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