Files
gamejamgame/scripts/rope_tether.gd
T
ASOwnerYTandClaude Opus 5 d66570169a feat(rope): reel tether length to track player distance
Rope length was baked once at load, so it sagged when players closed in
and the pin joints fought when they separated.

Total length now eases toward clamp(gap * (1 + slack), min, max) at
reel_speed. Joint3D only derives anchors from node transforms when the
joint is configured on tree entry, so anchors are pushed straight to
PhysicsServer3D.pin_joint_set_local_a/_b after resizing the capsules.

Also instances the rope in 3dlevel and solves the spawn arc's bow height
from the same target length, so the rope no longer lurches on frame one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-25 17:37:06 +12:00

185 lines
7.3 KiB
GDScript

extends "res://addons/pinjoint-ropephysics/path_3d_rope.gd"
## Tether variant of the pinjoint rope.
##
## Adds two things the base addon does not do:
## - endpoints can be any PhysicsBody3D (the addon only exports RigidBody3D,
## and its `rigidbody_attached_to_start` path overwrites node_b instead of
## setting node_a, which pins the body to world space rather than to the rope)
## - every segment is locked to the X-Y plane so the rope behaves as a 2D rope
##
## Expects to be a direct child of an untransformed parent: the base addon bakes
## its own local `position` into the segment/joint positions and zeroes the node.
## Bodies the two rope ends pin to. NodePath rather than a typed node export so
## the value resolves reliably when set from a .tscn instance override.
@export var attach_start_path : NodePath
@export var attach_end_path : NodePath
var attach_start : PhysicsBody3D
var attach_end : PhysicsBody3D
## Extra rope length as a fraction of the gap between the two endpoints.
## 0.0 is dead taut (pin joints will fight); ~0.15 gives a natural sag.
@export_range(0.0, 1.0, 0.01) var slack := 0.15
## Reel the rope in and out so its length keeps tracking the endpoint gap,
## instead of staying fixed at whatever it was when the level loaded.
@export var dynamic_length := true
## Bounds on total rope length, in metres. min_length stops the rope collapsing
## into a stub when the players stand on top of each other; max_length is the
## leash that eventually drags them back together.
@export var min_length := 2.0
@export var max_length := 20.0
## How fast total length changes, in metres per second. Low values feel like a
## winch, high values like the rope is weightless.
@export var reel_speed := 6.0
## Constrain segments to the X-Y plane (linear Z, angular X and Y).
@export var plane_lock_z := true
@export_flags_3d_physics var segment_collision_layer := 4
@export_flags_3d_physics var segment_collision_mask := 1
# The base script zeroes `position` and bakes it into child positions, so keep
# our own copy to place the end joint we add.
var _origin_offset := Vector3.ZERO
# Total rope length right now, eased toward _target_length() at reel_speed.
var _rope_length := 0.0
func _ready() -> void:
attach_start = get_node_or_null(attach_start_path) as PhysicsBody3D
attach_end = get_node_or_null(attach_end_path) as PhysicsBody3D
_origin_offset = position
_fit_curve_to_endpoints()
super()
_apply_plane_lock()
_wire_endpoints()
_rope_length = _target_length()
func _physics_process(delta: float) -> void:
if dynamic_length:
_reel(delta)
# Base script redraws the CSG curve from the segment transforms and capsule
# heights, so it has to run after the resize.
super(delta)
## Length the rope wants to be for the current endpoint gap.
func _target_length() -> float:
if attach_start == null or attach_end == null:
return maxf(distance, min_length)
var span := attach_start.global_position.distance_to(attach_end.global_position)
return clampf(span * (1.0 + slack), min_length, max_length)
func _reel(delta: float) -> void:
var previous := _rope_length
_rope_length = move_toward(_rope_length, _target_length(), reel_speed * delta)
if absf(_rope_length - previous) > 0.0001:
_set_segment_length(_rope_length / float(number_of_segments))
# Resize every capsule and re-anchor the pin joints bracketing it.
#
# Joint3D only derives its anchor points from the node transforms when the joint
# is (re)configured on tree entry, so moving the joint nodes here would do
# nothing — the anchors go straight to the physics server instead.
#
# Capsules run along local Y, and the base script's curve update shows +Y is the
# end nearer the rope's start, so joint i sits at -Y on segment i-1 and +Y on
# segment i.
func _set_segment_length(segment_length: float) -> void:
# CapsuleShape3D silently clamps height to its diameter; clamp here too so
# the joint anchors match the shape the physics server actually has.
var clamped := maxf(segment_length, cable_thickness * 2.0)
var half := clamped * 0.5
for segment in segments:
var shape := segment.get_child(0).shape as CapsuleShape3D
shape.height = clamped
for i in joints.size():
var rid := joints[i].get_rid()
if i == 0:
# node_b is the attached body, whose anchor must not move.
PhysicsServer3D.pin_joint_set_local_a(rid, Vector3(0, half, 0))
elif i < segments.size():
PhysicsServer3D.pin_joint_set_local_a(rid, Vector3(0, -half, 0))
PhysicsServer3D.pin_joint_set_local_b(rid, Vector3(0, half, 0))
else:
# Tail joint: node_a is the last segment, node_b the attached body.
PhysicsServer3D.pin_joint_set_local_a(rid, Vector3(0, -half, 0))
# Rebuild the curve as a sagging arc between the two endpoints so the rope
# always spans wherever the players actually spawn.
func _fit_curve_to_endpoints() -> void:
if attach_start == null or attach_end == null:
return
var a := to_local(attach_start.global_position)
var b := to_local(attach_end.global_position)
var span := a.distance_to(b)
if span < 0.001:
return
# Bow the spawn arc *upward*. A downward sag can start inside level geometry
# (a rope between two grounded players dips below the floor), and segments
# that spawn embedded tunnel straight through it. Starting high is always
# clear, and gravity drapes the rope into place within a few frames.
#
# Curve points get zero tangents, so the baked path is the straight pair
# a->mid->b: bowing by h gives a length of 2*sqrt((span/2)^2 + h^2). Solve
# that for the length the reel is going to ask for anyway, so the rope does
# not lurch on the first frame.
var target := _target_length()
var bow := 0.5 * sqrt(maxf(target * target - span * span, 0.0))
var mid := (a + b) * 0.5 + Vector3(0, bow, 0)
var fitted := Curve3D.new()
# The scene's curve ships with a huge bake_interval, which would make
# sample_baked() miss the midpoint entirely.
fitted.bake_interval = maxf(span / float(number_of_segments) * 0.25, 0.05)
fitted.add_point(a)
fitted.add_point(mid)
fitted.add_point(b)
curve = fitted
# `distance` is an @onready in the base script; recompute it explicitly so
# the value is correct regardless of when that initializer runs.
distance = curve.get_baked_length()
func _apply_plane_lock() -> void:
for segment in segments:
segment.collision_layer = segment_collision_layer
segment.collision_mask = segment_collision_mask
segment.continuous_cd = true
if plane_lock_z:
segment.axis_lock_linear_z = true
segment.axis_lock_angular_x = true
segment.axis_lock_angular_y = true
# The base script offsets look_at by (0.001, 0, -0.001) to dodge a
# degenerate up vector, which nudges segments off the plane.
segment.position.z = 0.0
func _wire_endpoints() -> void:
if attach_start != null:
# node_b first: assigning node_a while node_b is still segments[0] would
# briefly join the body to itself.
joints[0].node_b = attach_start.get_path()
joints[0].node_a = segments[0].get_path()
if attach_end != null and not fixed_end_point:
var end_joint := PinJoint3D.new()
add_child(end_joint)
end_joint.position = curve_points[-1] + _origin_offset
end_joint.node_a = segments[-1].get_path()
end_joint.node_b = attach_end.get_path()
end_joint.set_param(PinJoint3D.PARAM_BIAS, joint_bias_or_stiffness)
end_joint.set_param(PinJoint3D.PARAM_IMPULSE_CLAMP, max_impulse)
joints.append(end_joint)
#func _physics_process(_delta: float) -> void:
#curve.bake_interval += 0.01