OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / procedural / grapple_rope.gd

136 lines · gd ref:
class_name GrappleRope extends RefCounted
## Verlet particle chain for grapple rope simulation.
## Anchor end (index 0) is fixed. Player end (last index) is pinned to player.
## Constraint forces on the player particle are exposed as the swing force.

# Configuration
var particle_count: int = 10
var rest_length: float = 1.0         ## Per-segment rest length (total = rest_length * (particle_count-1))
var damping: float = 0.98
var gravity: Vector3 = Vector3(0, -2.0, 0)  ## Light sag only — this is a taut cable, not a loose rope
var constraint_iterations: int = 8           ## 8 iterations for tight constraint convergence
var reel_speed: float = 5.0
var min_segment_length: float = 0.3  ## Minimum rest_length when reeling in

# State
var positions: PackedVector3Array = PackedVector3Array()
var old_positions: PackedVector3Array = PackedVector3Array()
var anchor_position: Vector3 = Vector3.ZERO
var active: bool = false

# Output: force applied to player from rope constraint
var player_constraint_force: Vector3 = Vector3.ZERO


func start(anchor: Vector3, player_pos: Vector3, segments: int = 10) -> void:
	particle_count = segments
	var total_dist: float = anchor.distance_to(player_pos)
	rest_length = total_dist / float(particle_count - 1)

	positions.resize(particle_count)
	old_positions.resize(particle_count)

	# Distribute particles linearly from anchor to player
	for i in range(particle_count):
		var t: float = float(i) / float(particle_count - 1)
		var pos: Vector3 = anchor.lerp(player_pos, t)
		positions[i] = pos
		old_positions[i] = pos

	anchor_position = anchor
	active = true
	player_constraint_force = Vector3.ZERO


func stop() -> void:
	active = false
	positions.resize(0)
	old_positions.resize(0)
	player_constraint_force = Vector3.ZERO


func get_total_length() -> float:
	return rest_length * float(particle_count - 1)


func reel_in(delta: float) -> void:
	rest_length = maxf(rest_length - reel_speed * delta / float(particle_count - 1), min_segment_length)


func reel_out(delta: float) -> void:
	rest_length += reel_speed * delta / float(particle_count - 1)


func update(delta: float, player_pos: Vector3, anchor_pos: Vector3) -> void:
	if not active or particle_count < 2:
		return

	anchor_position = anchor_pos

	# Verlet integration (skip anchor at 0 and player at last)
	for i in range(1, particle_count - 1):
		var current: Vector3 = positions[i]
		var old: Vector3 = old_positions[i]
		var vel: Vector3 = (current - old) * damping
		old_positions[i] = current
		positions[i] = current + vel + gravity * delta * delta

	# Pin endpoints
	old_positions[0] = positions[0]
	positions[0] = anchor_pos

	old_positions[particle_count - 1] = positions[particle_count - 1]
	positions[particle_count - 1] = player_pos

	# Pin player end before constraints
	positions[particle_count - 1] = player_pos

	# Distance constraints (Jakobsen relaxation)
	for _iter in range(constraint_iterations):
		# Pin endpoints
		positions[0] = anchor_pos
		positions[particle_count - 1] = player_pos

		for i in range(particle_count - 1):
			var a: Vector3 = positions[i]
			var b: Vector3 = positions[i + 1]
			var delta_v: Vector3 = b - a
			var dist: float = delta_v.length()
			if dist < 0.0001:
				continue
			var error: float = dist - rest_length
			if error <= 0.0:
				continue  # Only enforce max length (cable can't stretch, can compress)
			var correction: Vector3 = delta_v.normalized() * error

			if i == 0:
				# Anchor is fixed — full correction to b
				positions[i + 1] -= correction
			elif i + 1 == particle_count - 1:
				# Player is fixed — full correction to a
				positions[i] += correction
			else:
				# Both free — split correction
				positions[i] += correction * 0.5
				positions[i + 1] -= correction * 0.5

		# Re-pin endpoints after constraints
		positions[0] = anchor_pos
		positions[particle_count - 1] = player_pos

	# Capture the constraint force on the player particle
	# This is the displacement the constraints WANTED to apply to the player end
	var player_idx: int = particle_count - 1
	var neighbor: Vector3 = positions[player_idx - 1]
	var to_neighbor: Vector3 = neighbor - player_pos
	var neighbor_dist: float = to_neighbor.length()
	if neighbor_dist > rest_length and neighbor_dist > 0.0001:
		# Rope is taut — compute pull force
		player_constraint_force = to_neighbor.normalized() * (neighbor_dist - rest_length) / delta
	else:
		player_constraint_force = Vector3.ZERO


func get_render_points() -> PackedVector3Array:
	return positions