diff --git a/scenes/game_scene/levels/3dlevel.tscn b/scenes/game_scene/levels/3dlevel.tscn index 1c1f3ea..8b7b5fc 100644 --- a/scenes/game_scene/levels/3dlevel.tscn +++ b/scenes/game_scene/levels/3dlevel.tscn @@ -1,6 +1,7 @@ [gd_scene format=3 uid="uid://4ajccsn6o10n"] [ext_resource type="PackedScene" uid="uid://cbual4qbgv2fc" path="res://scenes/player.tscn" id="1_e2xum"] +[ext_resource type="Script" uid="uid://bvgm53qsm13tt" path="res://scripts/couch_camera.gd" id="1_epadm"] [ext_resource type="PackedScene" uid="uid://x4xnc85khpl5" path="res://scenes/path_3d_rope.tscn" id="2_jetiq"] [sub_resource type="BoxShape3D" id="BoxShape3D_xcqlg"] @@ -9,7 +10,7 @@ size = Vector3(40, 2, 5) [sub_resource type="BoxMesh" id="BoxMesh_xcqlg"] size = Vector3(40, 2, 5) -[sub_resource type="Curve3D" id="Curve3D_jetiq"] +[sub_resource type="Curve3D" id="Curve3D_epadm"] resource_local_to_scene = true bake_interval = 512.0 _data = { @@ -29,26 +30,41 @@ shape = SubResource("BoxShape3D_xcqlg") [node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D" unique_id=336650218] mesh = SubResource("BoxMesh_xcqlg") -[node name="Camera3D" type="Camera3D" parent="." unique_id=2114757393] +[node name="CouchCamera" type="Camera3D" parent="." unique_id=2114757393] transform = Transform3D(1, 0, 0, 0, 0.9961947, 0.08715574, 0, -0.08715574, 0.9961947, 0, 9, 23) fov = 48.4 size = 50.0 +script = ExtResource("1_epadm") +min_distance = 20.0 +max_distance = 50.0 +framing_offset = Vector3(0, 5, 0) +tether_path = NodePath("../Path3DRope") +tension_offset = 0.075 +tension_roll = 0.008 +tension_ramp = 6.0 [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=716627405] transform = Transform3D(1, 0, 0, 0, 0.25881907, 0.9659258, 0, -0.9659258, 0.25881907, 0, 0, 0) shadow_enabled = true [node name="Player1" parent="." unique_id=68487004 instance=ExtResource("1_e2xum")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 1, 0) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.0015082, 1, 0) [node name="Player2" parent="." unique_id=432105976 instance=ExtResource("1_e2xum")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 1, 0) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.154851, 1, 0) player_id = 2 [node name="Path3DRope" parent="." unique_id=301387047 instance=ExtResource("2_jetiq")] -curve = SubResource("Curve3D_jetiq") +curve = SubResource("Curve3D_epadm") attach_start_path = NodePath("../Player1") attach_end_path = NodePath("../Player2") -number_of_segments = 32 +max_length = 4.0 +elastic_stiffness = 20.0 +haul_drag = 20.0 +reel_speed = 2.0 +number_of_segments = 16 cable_thickness = 0.05 fixed_start_point = false + +[node name="SnapTimer" type="Timer" parent="Path3DRope" unique_id=1395149861] +one_shot = true diff --git a/scripts/couch_camera.gd b/scripts/couch_camera.gd new file mode 100644 index 0000000..9b59617 --- /dev/null +++ b/scripts/couch_camera.gd @@ -0,0 +1,167 @@ +class_name CouchCamera +extends Camera3D + +## Shared-screen camera: keeps every target framed, and shakes. +## +## The camera holds the orientation it was authored with in the scene. Only its +## distance along that view direction changes, so the shot keeps the angle and +## the lens it was composed with, and the players are what moves in frame. + +@export var targets: Array[Node3D] = [] + +## Padding kept outside the outermost target, in metres. Not pixels: everything +## this camera measures is world space. +@export var margin := Vector2(2.0, 1.5) + +## How close in and how far back the camera may pull to fit the group, in metres. +@export var min_distance := 4.0 +@export var max_distance := 40.0 + +## Follow and zoom response, as exponential rates. Higher is snappier. +@export var fspeed := 6.0 +@export var zspeed := 4.0 + +@export var auto_find := true + +## Shifts the framed point, in metres, for composition. Zero centres the group. +@export var framing_offset := Vector3.ZERO + +## Rope to take the sustained buzz from. Anything with a tension_ratio() -> float +## method will do. +@export var tether_path : NodePath + +# Snap punch: an impulse that decays fast and sharp. Offsets are metres. +@export var punch_decay := 2.6 +@export var punch_offset := 0.8 +@export var punch_roll := 0.05 + +# Tension buzz: sustained, tracks the current tension, ramps in. +@export var tension_offset := 0.15 +@export var tension_roll := 0.01 +@export var tension_ramp := 12.0 + +var trauma := 0.0 +var tension_shake := 0.0 + +var _tension_target := 0.0 +var _tether : Node +# Authored view direction and roll. Framing moves the camera along _view_dir and +# nothing else, so the authored angle survives. +var _view_dir := Vector3.FORWARD +var _rest_roll := 0.0 +# Smoothed framing. Seeded from the first solve rather than from the authored +# transform, so the level opens on the right shot instead of easing into it. +var _center := Vector3.ZERO +var _distance := 0.0 +var _framed := false + + +func _ready() -> void: + make_current() + _view_dir = -global_transform.basis.z + _rest_roll = rotation.z + _tether = get_node_or_null(tether_path) + # Deferred: the camera is an earlier sibling than the players, so its _ready + # runs first, and joining the "players" group is something their _ready does. + # Searching the group here finds nothing at all. + _find_targets.call_deferred() + + +func _find_targets() -> void: + if not auto_find or not targets.is_empty(): + return + for n in get_tree().get_nodes_in_group("players"): + if n is Node3D: + targets.append(n) + # Solve once right away, or the first frame is drawn from the authored + # transform and the shot pops as soon as _physics_process catches up. + _frame_targets(0.0) + + +## Add an impulse shake, 0-1. For hits, landings, deaths — anything that is a +## moment rather than a duration. Callers drive this; nothing does by default. +func add_trauma(amount: float) -> void: + trauma = clampf(trauma + amount, 0.0, 1.0) + + +## Set the sustained shake level, 0-1. Driven from the tether every frame when +## tether_path is set. +func set_tension(amount: float) -> void: + _tension_target = clampf(amount, 0.0, 1.0) + + +func _physics_process(d: float) -> void: + if _tether != null and _tether.has_method("tension_ratio"): + set_tension(_tether.tension_ratio()) + _frame_targets(d) + _apply_shake(d) + + +func _frame_targets(d: float) -> void: + var live : Array[Vector3] = [] + for t in targets: + if is_instance_valid(t): + live.append(t.global_position) + if live.is_empty(): + return + + var center := Vector3.ZERO + for p in live: + center += p + center = center / float(live.size()) + framing_offset + + # Measure the group along the camera's own screen axes, so an authored tilt + # is accounted for instead of assuming the group spreads along world X-Y. + var right := global_transform.basis.x + var up := global_transform.basis.y + var half_w := margin.x + var half_h := margin.y + for p in live: + var v := p - center + half_w = maxf(half_w, absf(v.dot(right)) + margin.x) + half_h = maxf(half_h, absf(v.dot(up)) + margin.y) + + # Solve for distance, not fov. fov is the authored look of the shot, and + # zooming by changing it warps the perspective as the players move. Godot's + # default keep_aspect is KEEP_HEIGHT, so fov is the vertical angle and the + # horizontal one follows from the viewport aspect. + var half_fov := tan(deg_to_rad(fov) * 0.5) + var vp := get_viewport().get_visible_rect().size + var aspect := vp.x / maxf(vp.y, 1.0) + var distance := clampf( + maxf(half_h / half_fov, half_w / (half_fov * aspect)), + min_distance, + max_distance + ) + + if _framed: + _center = _center.lerp(center, 1.0 - exp(-fspeed * d)) + _distance = lerpf(_distance, distance, 1.0 - exp(-zspeed * d)) + else: + _center = center + _distance = distance + _framed = true + global_position = _center - _view_dir * _distance + + +func _apply_shake(d: float) -> void: + var off := Vector2.ZERO + var roll := 0.0 + + tension_shake = move_toward(tension_shake, _tension_target, tension_ramp * d) + if tension_shake > 0.001: + var ts := tension_shake * tension_shake + off += Vector2(randf_range(-1, 1), randf_range(-1, 1)) * tension_offset * ts + roll += randf_range(-1, 1) * tension_roll * ts + + if trauma > 0.0: + trauma = maxf(trauma - punch_decay * d, 0.0) + var s := trauma * trauma + off += Vector2(randf_range(-1, 1), randf_range(-1, 1)) * punch_offset * s + roll += randf_range(-1, 1) * punch_roll * s + + # h_offset/v_offset shift the frustum, in metres, so they shake the image + # without disturbing the framing solved above. + h_offset = off.x + v_offset = off.y + rotation.z = _rest_roll + roll diff --git a/scripts/couch_camera.gd.uid b/scripts/couch_camera.gd.uid new file mode 100644 index 0000000..31d28c0 --- /dev/null +++ b/scripts/couch_camera.gd.uid @@ -0,0 +1 @@ +uid://bvgm53qsm13tt diff --git a/scripts/rope_tether.gd b/scripts/rope_tether.gd index 2d3910f..7f39a49 100644 --- a/scripts/rope_tether.gd +++ b/scripts/rope_tether.gd @@ -21,20 +21,69 @@ 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 +@export 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. +## Stops the rope collapsing into a stub when the players stand on top of each +## other. @export var min_length := 2.0 + +## Rest length of the leash, in metres. Past this the tether pulls the endpoints +## back together with a spring. +## +## This is deliberately NOT a cap on the rope geometry. The endpoints are +## CharacterBody3D, which is kinematic and therefore infinitely massive to the +## solver: a pin joint anchored to one pulls on the rope and never on the player. +## Cap the chain and the terminal segment gets whipped by correction impulses +## nothing ever absorbs, which is the violent wiggle. So the chain is kept slack +## at all times and the leash is an explicit force instead. @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. +## Leash spring: metres per second squared of pull per metre of stretch, plus +## damping on the separation speed. Applied as acceleration, so it behaves the +## same whether an endpoint is a CharacterBody3D or a RigidBody3D. +## Stiffness has to beat Player.friction (which zeroes horizontal velocity when +## there is no input) before a grounded player will slide at all. +@export var elastic_stiffness := 60.0 +@export var elastic_damping := 8.0 + +## Safety valve so a runaway stretch cannot fling a body across the level. +@export var max_pull_accel := 200.0 + +## Drag on the rate the two endpoints separate at while the rope is taut, per +## second, so towing the other player feels heavy. Ramps in over the same +## taut_range as the tautening. This is the knob for how much a player is slowed +## by dragging their partner; elastic_stiffness is the leash itself. +@export var haul_drag := 10.0 + +## How hard the rope is drawn straight once the leash engages. 0 leaves it +## hanging; 1 snaps it onto the line between the endpoints. +## +## Needed because the chain can never be dead taut on its own. Its length has to +## stay above the gap or the pin joints are over-constrained, and a hanging chain +## sags by roughly L*sqrt(3*excess/8) — even a 2% excess drapes ~9% of the span, +## which reads as a slack rope no matter how hard the players pull. So tension is +## faked: the segments are moved onto the straight line directly. That +## configuration satisfies every pin joint exactly, so unlike a real tension load +## it costs the solver nothing. +@export_range(0.0, 1.0, 0.01) var taut_pull := 0.5 + +## Stretch past max_length, in metres, at which the rope is drawn fully taut. +@export var taut_range := 1.0 + +## Hard backstop, as a multiple of max_length. The spring cannot win against code +## that writes velocity outright — a launcher, a moving platform, a bug — and an +## endpoint dragged far enough turns the rope into a handful of enormous capsules +## that thrash. Past this distance the endpoints get moved back directly. Normal +## play never reaches it. +@export var hard_stretch := 2.0 + +## How fast the rope reels *in*, in metres per second. Low values feel like a +## winch, high values like the rope is weightless. Reeling out is not rate +## limited — see _reel. @export var reel_speed := 6.0 ## Constrain segments to the X-Y plane (linear Z, angular X and Y). @@ -43,13 +92,28 @@ var attach_end : PhysicsBody3D @export_flags_3d_physics var segment_collision_layer := 4 @export_flags_3d_physics var segment_collision_mask := 1 +# Fraction of the endpoint gap the rope keeps in hand at minimum. `slack` is the +# resting sag; this is only the floor that keeps the chain off dead taut when +# slack is turned down to nothing. +const TAUT_MARGIN := 0.02 + # 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. +# Total rope length right now: snaps up to _target_length(), eases down to it. var _rope_length := 0.0 +## Fired once, when the rope parts. Carries the world position of the break, for +## whoever wants to put a sound or a puff of frayed cable there. +signal snapped(at_position: Vector3) + +var _snapped := false +# Index of the first segment of the tail half, once the rope has parted. +var _split_index := -1 +# The tail half gets its own Path3D to draw along. See _split_mesh. +var _tail_path : Path3D + func _ready() -> void: attach_start = get_node_or_null(attach_start_path) as PhysicsBody3D @@ -60,31 +124,320 @@ func _ready() -> void: _apply_plane_lock() _wire_endpoints() _rope_length = _target_length() + var snap_timer := get_node_or_null("SnapTimer") as Timer + # Guarded: the signal may also have been wired up in the editor. + if snap_timer != null and not snap_timer.timeout.is_connected(_on_snap_timer_timeout): + snap_timer.timeout.connect(_on_snap_timer_timeout) func _physics_process(delta: float) -> void: + if _snapped: + # Nothing left to reel, tension or leash. Both halves just hang off their + # player, so all that is left is drawing them. + _redraw_halves() + return if dynamic_length: _reel(delta) + _apply_elastic(delta) + _apply_tautness(delta) + _update_timer() # 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 _on_snap_timer_timeout() -> void: + snap() + + +## Break the rope at its midpoint. Each half stays pinned to its own player and +## falls slack; the leash, the reel and the tautening all stop, so from here the +## players are untethered. Idempotent. +func snap() -> void: + if _snapped or segments.size() < 2: + return + _snapped = true + + # joints[0] pins segment 0 to attach_start, and joints[i] for i in 1..N-1 + # bridges segments i-1 and i. So the joint in the middle of the chain is + # joints[N/2], and freeing it is what actually parts the rope. + @warning_ignore("integer_division") + _split_index = segments.size() / 2 + var broken := joints[_split_index] + var break_point := broken.global_position + joints.remove_at(_split_index) + broken.queue_free() + + # Hand the weight back. _apply_tautness may have left the segments weightless + # to hold a straight line, and it is never going to run again to undo that. + for segment in segments: + segment.gravity_scale = 1.0 + + _split_mesh() + snapped.emit(break_point) + + +# One Path3D drew the whole chain. Left alone, the CSG would keep bridging the +# two halves with a length of rope stretched across the gap, so the tail gets a +# path of its own and the original curve is cut back to the head. +func _split_mesh() -> void: + var tail_curve := Curve3D.new() + for i in segments.size() - _split_index + 1: + tail_curve.add_point(Vector3.ZERO) + _tail_path = Path3D.new() + _tail_path.curve = tail_curve + add_child(_tail_path) + + # Duplicated rather than built from scratch: the polygon is generated in the + # base script's _ready, and the CSG carries a dozen path_* settings off the + # scene that all have to match for the two halves to look like one rope. + var tail_mesh := mesh.duplicate() as CSGPolygon3D + _tail_path.add_child(tail_mesh) + tail_mesh.path_node = tail_mesh.get_path_to(_tail_path) + + # Cut the head back. Curves run one point per segment plus one to cap the end. + while curve.point_count > _split_index + 1: + curve.remove_point(curve.point_count - 1) + _redraw_halves() + + +func _redraw_halves() -> void: + _write_curve(curve, 0, _split_index - 1) + if _tail_path != null: + _write_curve(_tail_path.curve, _split_index, segments.size() - 1) + + +# The base script's curve update, over a range instead of the whole chain: a +# point at each segment's +Y cap, plus one more for the last segment's -Y cap. +func _write_curve(target: Curve3D, first: int, last: int) -> void: + for i in last - first + 1: + var segment := segments[first + i] + var half := (segment.get_child(0).shape as CapsuleShape3D).height * 0.5 + target.set_point_position(i, segment.position + segment.transform.basis.y * half) + var final := segments[last] + var final_half := (final.get_child(0).shape as CapsuleShape3D).height * 0.5 + target.set_point_position( + last - first + 1, final.position - final.transform.basis.y * final_half + ) + + +## Length the rope wants to be for the current endpoint gap. Unbounded above: +## the geometry follows the players wherever they go and _apply_elastic is what +## stops them going far. 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) + # Give up the resting sag as the leash engages. Straightening the segments is + # not enough on its own: while the chain is longer than the gap the joints have + # real error to correct and they push it right back off the line. + var effective_slack := lerpf(slack, TAUT_MARGIN, _taut_ramp()) + return maxf(_endpoint_span() * (1.0 + effective_slack), min_length) func _reel(delta: float) -> void: var previous := _rope_length - _rope_length = move_toward(_rope_length, _target_length(), reel_speed * delta) + var target := _target_length() + if target > _rope_length: + # Out is instant. Rate-limiting this direction is what lets the gap outrun + # the chain, and a chain shorter than the gap is the whole failure mode. + _rope_length = target + else: + # In is the winch, so the rope visibly takes up its own slack. + _rope_length = move_toward(_rope_length, target, reel_speed * delta) + # Hard floor. Instant reel-out already covers this while `slack` is positive; + # it is here so that a slack of 0 still cannot produce a chain shorter than the + # straight-line gap. + _rope_length = maxf(_rope_length, _endpoint_span() * (1.0 + TAUT_MARGIN)) if absf(_rope_length - previous) > 0.0001: _set_segment_length(_rope_length / float(number_of_segments)) +# The leash. See max_length for why this is a force and not a joint constraint. +func _apply_elastic(delta: float) -> void: + if attach_start == null or attach_end == null: + return + var to_end := attach_end.global_position - attach_start.global_position + var span := to_end.length() + if span <= max_length or span < 0.001: + return + var axis := to_end / span + # Damp separation only. Damping the closing speed too would fight the spring + # on the way back in and leave the players stuck at full stretch. + var separation := maxf(_separation_speed(), 0.0) + var accel := clampf( + elastic_stiffness * (span - max_length) + elastic_damping * separation, + 0.0, + max_pull_accel + ) + var pull := axis * accel * delta + _add_velocity(attach_start, pull) + _add_velocity(attach_end, -pull) + + _apply_haul_drag(axis, delta) + + var limit := max_length * hard_stretch + if span > limit: + _clamp_span(axis, span - limit, separation) + + +# Weight. The spring alone does not read as hauling: Player re-accelerates to +# move_speed every frame at `accel`, so a player towing a planted partner still +# runs at full speed and the rope looks weightless. Drag on the outward part of +# their velocity is what makes the load felt. +# +# Only the outward part, so being towed is never slowed and neither is running +# back toward the other player. And no scaling by who is heavier: the resistance +# comes out of the ramp, which only stays high while the far end is actually +# refusing to follow. +func _apply_haul_drag(axis: Vector3, delta: float) -> void: + if haul_drag <= 0.0: + return + var drag := clampf(haul_drag * _stretch_ramp() * delta, 0.0, 1.0) + if drag <= 0.0: + return + # Braked on their own outward speed, not on the separation rate. Separation is + # the wrong signal: a hauler towing a partner who is keeping up has no + # separation at all, and that is the case this exists for. Stretch is the right + # signal, because stretch is what the rope's tension is proportional to. + # + # axis runs start -> end, so outward is -axis for the start and +axis for the + # end. Only whoever is moving outward pays, which sorts out who is hauling and + # who is being towed without having to ask. + var out_start := _velocity_of(attach_start).dot(-axis) + if out_start > 0.0: + _add_velocity(attach_start, axis * (out_start * drag)) + var out_end := _velocity_of(attach_end).dot(axis) + if out_end > 0.0: + _add_velocity(attach_end, -axis * (out_end * drag)) + + +# Draw the chain onto the straight line between the endpoints as the leash takes +# up. See taut_pull for why this is done by hand rather than by the solver. +func _apply_tautness(delta: float) -> void: + if attach_start == null or attach_end == null: + return + var ramp := _taut_ramp() + + # Take the weight off as the rope goes taut. Gravity is the only thing pulling + # it off the line, and a weightless chain holds the pose for free instead of + # being dragged back down between frames. Written unconditionally so the rope + # gets its weight back the moment the leash lets go. + for segment in segments: + segment.gravity_scale = 1.0 - ramp + if ramp <= 0.0: + return + + var a := attach_start.global_position + var b := attach_end.global_position + var span := a.distance_to(b) + # Per-frame lerp weight, corrected so the pull feels the same off 60 Hz. + var blend := 1.0 - pow(1.0 - ramp * taut_pull, delta * 60.0) + + # Capsules run along local Y and +Y is the end nearer the rope's start, so the + # straight-line pose points each segment's local Y from the end back to the + # start. Only that axis is aimed: the addon bakes a PI/2 X-rotation into the + # segments at spawn, so their local Z is not world Z, and building a fresh + # basis instead of swinging the existing one puts the segment in a pose + # axis_lock_angular_x/y forbid and the solver spends the frame undoing it. + var y_axis := (a - b) / span + + var count := segments.size() + for i in count: + var segment := segments[i] + var current := Quaternion(segment.global_basis.orthonormalized()) + # Shortest arc onto the line. Both vectors lie in the X-Y plane, so the + # rotation is about world Z, which is the one axis left unlocked. + var swing := Quaternion(segment.global_basis.y.normalized(), y_axis) + # Segment i owns the slice [i, i+1] of the line and sits at its midpoint. + var ideal := a.lerp(b, (float(i) + 0.5) / float(count)) + segment.global_transform = Transform3D( + Basis(current.slerp(swing * current, blend)), + segment.global_position.lerp(ideal, blend) + ) + # Spin is the segment's own momentum fighting the pose it was just put in. + segment.angular_velocity *= 1.0 - blend + + +# Positional backstop. Moves the bodies rather than shortening the chain: a chain +# shorter than the gap is the over-constrained case this whole design exists to +# avoid, so the rope is never the thing that gives. +func _clamp_span(axis: Vector3, excess: float, separation: float) -> void: + var half := axis * (excess * 0.5) + _move_endpoint(attach_start, half) + _move_endpoint(attach_end, -half) + # Cancel the outward velocity too, or they grind against the backstop and it + # has to fire again every single frame. + if separation > 0.0: + var kill := axis * (separation * 0.5) + _add_velocity(attach_start, kill) + _add_velocity(attach_end, -kill) + + +func _move_endpoint(body: PhysicsBody3D, motion: Vector3) -> void: + if body is CharacterBody3D: + # Swept, so the backstop cannot shove a player inside level geometry. + (body as CharacterBody3D).move_and_collide(motion) + else: + body.global_position += motion + + +## How loaded the tether is, 0 slack to 1 at full stretch. For camera shake, UI +## and audio; read every frame, so keep it cheap. +func tension_ratio() -> float: + return (clampf(_stretch_ramp(), 0.8, 1) - 0.8) * 5 + + +## 0 while the rope hangs free, reaching 1 once the leash is stretched taut_range +## past max_length. Drives both the tautening and the haul drag. +func _stretch_ramp() -> float: + # A parted rope pulls on nothing, so it reports no load either — otherwise the + # players walk away and the camera buzzes forever off a rope that is gone. + if _snapped or attach_start == null or attach_end == null: + return 0.0 + var span := _endpoint_span() + if span <= max_length: + return 0.0 + return clampf((span - max_length) / maxf(taut_range, 0.001), 0.0, 1.0) + + +## _stretch_ramp gated on the tautening being switched on at all. +func _taut_ramp() -> float: + if taut_pull <= 0.0: + return 0.0 + return _stretch_ramp() + + +func _endpoint_span() -> float: + if attach_start == null or attach_end == null: + return 0.0 + return attach_start.global_position.distance_to(attach_end.global_position) + + +## Rate the gap is opening at, in metres per second. Negative while closing. +func _separation_speed() -> float: + if attach_start == null or attach_end == null: + return 0.0 + var to_end := attach_end.global_position - attach_start.global_position + if to_end.length() < 0.001: + return 0.0 + return (_velocity_of(attach_end) - _velocity_of(attach_start)).dot(to_end.normalized()) + + +func _velocity_of(body: PhysicsBody3D) -> Vector3: + if body is CharacterBody3D: + return (body as CharacterBody3D).velocity + if body is RigidBody3D: + return (body as RigidBody3D).linear_velocity + return Vector3.ZERO + + +func _add_velocity(body: PhysicsBody3D, delta_v: Vector3) -> void: + if body is CharacterBody3D: + (body as CharacterBody3D).velocity += delta_v + elif body is RigidBody3D: + (body as RigidBody3D).linear_velocity += delta_v + + # Resize every capsule and re-anchor the pin joints bracketing it. # # Joint3D only derives its anchor points from the node transforms when the joint @@ -180,5 +533,9 @@ func _wire_endpoints() -> void: 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 +func _update_timer() -> void: + var stretched = is_equal_approx(tension_ratio(), 1.0) + if stretched and $SnapTimer.is_stopped(): + $SnapTimer.start() + elif !stretched: + $SnapTimer.stop()