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