diff --git a/assets/Level1_v1.fbx b/assets/Level1_v1.fbx new file mode 100644 index 0000000..db36708 Binary files /dev/null and b/assets/Level1_v1.fbx differ diff --git a/assets/Level1_v1.fbx.import b/assets/Level1_v1.fbx.import new file mode 100644 index 0000000..6d859ab --- /dev/null +++ b/assets/Level1_v1.fbx.import @@ -0,0 +1,46 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://cvu5f82j7vxdl" +path="res://.godot/imported/Level1_v1.fbx-6541db1786b04595d703452dfc5c0724.scn" + +[deps] + +source_file="res://assets/Level1_v1.fbx" +dest_files=["res://.godot/imported/Level1_v1.fbx-6541db1786b04595d703452dfc5c0724.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=true +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +fbx/importer=0 +fbx/allow_geometry_helper_nodes=false +fbx/embedded_image_handling=1 +fbx/naming_version=2 diff --git a/rope_diag.gd b/rope_diag.gd new file mode 100644 index 0000000..16c57d0 --- /dev/null +++ b/rope_diag.gd @@ -0,0 +1,78 @@ +extends SceneTree + +# Drives the players apart at a set speed and measures actual droop: how much +# longer the rope's real path is than the straight line between the anchors. +# droop ~0 means visually taut. That is the thing the fix has to deliver. + +var rope +var p1 +var p2 +var t := 0.0 +var speed := 0.0 +var trial := 0 +var speeds := [1.0, 3.0, 6.0] +var worst_droop_while_taut := 0.0 +var saw_taut := false + + +func _initialize() -> void: + var level = load("res://scenes/game_scene/levels/3dlevel.tscn").instantiate() + root.add_child(level) + rope = level.get_node("Path3DRope") + p1 = level.get_node("Player1") + p2 = level.get_node("Player2") + rope.can_snap = false + speed = speeds[0] + print("max_length=%.1f reel_speed=%.1f taut_margin=%.2f" % [ + rope.max_length, rope.reel_speed, rope.taut_margin]) + + +# Length of the rope's actual polyline through every segment. +func path_len() -> float: + var total := 0.0 + var pts := [] + for s in rope.segments: + var h: float = s.get_child(0).shape.height * 0.5 + pts.append(s.position + s.transform.basis.y * h) + var last = rope.segments[-1] + var lh: float = last.get_child(0).shape.height * 0.5 + pts.append(last.position - last.transform.basis.y * lh) + for i in range(pts.size() - 1): + total += pts[i].distance_to(pts[i + 1]) + return total + + +func _physics_process(d: float) -> bool: + t += d + if t < 0.4: + return false + # Walk them apart at a controlled rate. + p1.global_position.x -= speed * d * 0.5 + p2.global_position.x += speed * d * 0.5 + p1.velocity.x = 0.0 + p2.velocity.x = 0.0 + + var gap: float = p1.global_position.distance_to(p2.global_position) + var droop: float = path_len() - gap + if rope.taut: + saw_taut = true + worst_droop_while_taut = max(worst_droop_while_taut, droop) + + if fmod(t, 0.6) < d: + print(" sep=%.0fm/s gap=%5.2f rope=%5.2f path=%5.2f droop=%5.2f taut=%s" % [ + speed, gap, rope._rope_length, path_len(), droop, str(rope.taut)]) + + if gap > rope.max_length * 1.3: + print("trial sep=%.0fm/s -> saw_taut=%s worst droop while taut=%.3f\n" % [ + speed, str(saw_taut), worst_droop_while_taut]) + trial += 1 + if trial >= speeds.size(): + quit(0) + return true + speed = speeds[trial] + saw_taut = false + worst_droop_while_taut = 0.0 + p1.global_position.x = -1.0 + p2.global_position.x = 1.0 + t = 0.0 + return false diff --git a/rope_diag.gd.uid b/rope_diag.gd.uid new file mode 100644 index 0000000..f2c6c0b --- /dev/null +++ b/rope_diag.gd.uid @@ -0,0 +1 @@ +uid://bgk47b00v2uvm diff --git a/scenes/game_scene/levels/3dlevel.tscn b/scenes/game_scene/levels/3dlevel.tscn index 1c1f3ea..6955dc8 100644 --- a/scenes/game_scene/levels/3dlevel.tscn +++ b/scenes/game_scene/levels/3dlevel.tscn @@ -39,16 +39,20 @@ transform = Transform3D(1, 0, 0, 0, 0.25881907, 0.9659258, 0, -0.9659258, 0.2588 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.1742709, 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.182826, 1, 0) player_id = 2 [node name="Path3DRope" parent="." unique_id=301387047 instance=ExtResource("2_jetiq")] curve = SubResource("Curve3D_jetiq") attach_start_path = NodePath("../Player1") attach_end_path = NodePath("../Player2") -number_of_segments = 32 +max_length = 6.0 +reel_speed = 2.0 +tension_start = 4.8 +snap_length = 10.0 +number_of_segments = 12 cable_thickness = 0.05 fixed_start_point = false diff --git a/scripts/rope_tether.gd b/scripts/rope_tether.gd index 2d3910f..1637a89 100644 --- a/scripts/rope_tether.gd +++ b/scripts/rope_tether.gd @@ -35,7 +35,7 @@ var attach_end : PhysicsBody3D ## 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 +@export var reel_speed := 8.0 ## Constrain segments to the X-Y plane (linear Z, angular X and Y). @export var plane_lock_z := true @@ -43,6 +43,79 @@ var attach_end : PhysicsBody3D @export_flags_3d_physics var segment_collision_layer := 4 @export_flags_3d_physics var segment_collision_mask := 1 + +# --- Pull / tension / snap --------------------------------------------------- +# +# Ported from the 2D Verlet rope. That rope integrated its own points, so it +# could resolve stretch by moving them directly; here the rope body is solved by +# the physics server, so only the *anchor* half of that logic carries over: the +# players get pulled, damped and finally launched. `max_length` is shared with +# the reel, which is exactly right — the reel stops paying out rope there, so +# that is the gap at which the rope runs out and starts pulling back. + +## Note: the reference script's `reel_speed` is this — how fast the rope hauls +## the players together. Ours was already taken by the rope's own length change, +## so the anchor-pulling rate is `pull_speed`. +@export_group("Tension") + +## Gap at which strain starts registering for visuals. Below max_length, so the +## rope reads as straining before it actually starts pulling. +@export var tension_start := 16.0 + +## Fraction of its nominal length the rope gives before it is genuinely straight, +## soaked up by compliant pin joints and the two end anchors. +## +## 0.12 is the measured sweet spot on a 6m/12-segment rope: sag at the moment +## pull engages falls from 0.73m at 0.02 to 0.41m here. Past ~0.18 it gets worse +## again — the extra span opens the joints further, which adds back the path +## length it was meant to take up. Retune if segment count or joint softness +## changes; those move the curve. +@export_range(0.0, 0.5, 0.005) var taut_margin := 0.12 + +## Metres per second the rope hauls the players together while over-stretched. +@export var pull_speed := 6.0 + +## How the pull is split. 0.5 moves both equally; 0.0 moves only attach_end. +@export_range(0.0, 1.0, 0.01) var pull_bias := 0.5 + +## Fraction of velocity-away-from-the-rope killed per tick while taut. +@export_range(0.0, 1.0, 0.01) var damping := 0.2 + +## Hard break distance, and how fast sustained over-stretch accumulates toward a +## break. The rope snaps on whichever arrives first. +@export var snap_length := 34.0 +@export var snap_tension := 1.0 +@export var tension_rise := 1.5 +@export var tension_fall := 2.0 + +## Speed kicked into each player when the rope lets go. +@export var snap_impulse := 19.0 + +## Seconds the two dead halves dangle and fade before being freed. +@export var snap_fade := 0.6 + +## Off makes the rope an unbreakable leash: it still pulls, never snaps. +@export var can_snap := true + +signal became_taut +signal became_slack +signal rope_snapped +signal tension_changed(amount) + +## True while the gap exceeds max_length and the rope is actively hauling. +var taut := false +## True once the rope has broken. It stays broken until reset(). +var snapped := false +## Accumulated over-stretch. Reaching snap_tension breaks the rope. +var tension := 0.0 +## 0..1 strain for visuals and camera shake. Driven by whichever of raw distance +## or accumulated tension is further along. +var strain_amt := 0.0 + +var _emitted_strain := -1.0 +var _break_time := 0.0 +var _halves : Array[Path3D] = [] + # 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 @@ -63,23 +136,135 @@ func _ready() -> void: func _physics_process(delta: float) -> void: + if snapped: + _update_break(delta) + return if dynamic_length: _reel(delta) + _constrain(delta) # Base script redraws the CSG curve from the segment transforms and capsule # heights, so it has to run after the resize. super(delta) +## Pull the players together while the gap exceeds the rope, accumulate strain, +## and break the rope when it has taken too much. +func _constrain(delta: float) -> void: + if attach_start == null or attach_end == null: + return + var v := attach_end.global_position - attach_start.global_position + if plane_lock_z: + v.z = 0.0 + var dist := v.length() + if dist < 0.001: + return + var stretch := dist - _taut_distance() + + var now_taut := stretch > 0.0 + if now_taut != taut: + taut = now_taut + if taut: + became_taut.emit() + else: + became_slack.emit() + + # Tension only builds while genuinely over-stretched, and bleeds off the rest + # of the time, so a rope that is repeatedly yanked breaks but one held at a + # steady hard stretch does not break instantly. + if stretch > 0.0: + var s := clampf(stretch / maxf(snap_length - max_length, 0.001), 0.0, 1.0) + tension += tension_rise * s * delta + else: + tension = move_toward(tension, 0.0, tension_fall * delta) + + var dist_strain := clampf( + (dist - tension_start) / maxf(snap_length - tension_start, 0.001), 0.0, 1.0) + strain_amt = maxf(dist_strain, tension / maxf(snap_tension, 0.001)) + _emit_strain() + + if now_taut: + var dir := v / dist + var pull := minf(stretch, pull_speed * delta) + _haul(attach_start, dir * pull * pull_bias) + _haul(attach_end, -dir * pull * (1.0 - pull_bias)) + if damping > 0.0: + _damp(attach_start, -dir) + _damp(attach_end, dir) + + if can_snap and (tension >= snap_tension or dist >= snap_length): + _snap() + + +# How far apart the rope can actually hold the players, which is *not* the +# length the reel commands. Pin joints are compliant: measured on the stock +# settings the interior joints open ~0.29m and the two end anchors another +# ~0.14m on a 6m rope, so the rope keeps roughly `taut_margin` in hand past its +# nominal length. Pulling at nominal starts hauling while the rope still droops. +# +# This deliberately does not measure the rope's live path. That was tried and it +# feeds back: compliant joints stretch to meet whatever the gap is, so measured +# reach chases the span, `stretch` never turns positive and the rope never pulls +# at all. The allowance has to be a value the current stretch cannot influence. +func _taut_distance() -> float: + return _rope_length * (1.0 + taut_margin) + + +func _emit_strain() -> void: + if absf(strain_amt - _emitted_strain) > 0.01: + _emitted_strain = strain_amt + tension_changed.emit(strain_amt) + + +# Position is moved rather than velocity added, matching the reference: the pull +# has to survive the player's own _physics_process, which rewrites velocity.x +# from input every tick and would swallow a velocity nudge. +func _haul(body: PhysicsBody3D, delta_pos: Vector3) -> void: + var rigid := body as RigidBody3D + if rigid != null: + rigid.apply_central_impulse(delta_pos / maxf(get_physics_process_delta_time(), 0.001)) + else: + body.global_position += delta_pos + + +# Kill only the component of velocity heading away from the rope, so the rope +# resists being stretched without deadening movement along it. +func _damp(body: PhysicsBody3D, away: Vector3) -> void: + var character := body as CharacterBody3D + if character != null: + var along := character.velocity.dot(away) + if along > 0.0: + character.velocity -= away * along * damping + return + var rigid := body as RigidBody3D + if rigid != null: + var along := rigid.linear_velocity.dot(away) + if along > 0.0: + rigid.linear_velocity -= away * along * damping + + ## Length the rope wants to be for the current endpoint gap. +## +## Divided by the compliance allowance rather than set to the gap directly. The +## chain can reach `taut_margin` past whatever length it is told to be, so +## commanding exactly the gap leaves that surplus hanging as droop at every +## distance — which is what made the rope look slack however hard it was pulled. +## Commanding the gap *minus* the stretch it is going to gain means its real +## reach lands on the gap, and the rope hangs straight. 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) + return clampf(span / (1.0 + taut_margin), min_length, max_length) func _reel(delta: float) -> void: var previous := _rope_length + # Both directions are rate limited. Paying out instantly was what made the + # rope feel permanently slack: the commanded length would sit exactly on the + # gap, and since the chain can reach `taut_margin` beyond what it is told to + # be, that surplus had nowhere to go and hung as droop no matter how hard the + # players pulled. Trailing the gap instead means a pull faster than + # `reel_speed` eats the surplus and the rope comes up taut. _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)) @@ -180,5 +365,164 @@ 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 + +## Break the rope: kick the players apart and split the chain in two. +## +## The reference had to hand-integrate the two dead halves after a break. Here +## the halves are still pin-jointed rigid bodies with one loose end, so the +## physics server drapes them for free — all that is needed is to drop the +## middle joint and give each half its own tube to draw into. +func _snap() -> void: + var dir := attach_end.global_position - attach_start.global_position + if plane_lock_z: + dir.z = 0.0 + dir = dir.normalized() if dir.length() > 0.001 else Vector3.RIGHT + _kick(attach_start, -dir * snap_impulse) + _kick(attach_end, dir * snap_impulse) + + _split_chain() + + snapped = true + taut = false + tension = 0.0 + strain_amt = 0.0 + _emit_strain() + rope_snapped.emit() + + +func _kick(body: PhysicsBody3D, impulse: Vector3) -> void: + var character := body as CharacterBody3D + if character != null: + character.velocity += impulse + return + var rigid := body as RigidBody3D + if rigid != null: + rigid.apply_central_impulse(impulse * rigid.mass) + + +# Free the middle joint so the chain parts, then hand each half its own path and +# tube. The single shared tube cannot be reused: its curve would still run +# through both halves and stretch a band of geometry across the break. +func _split_chain() -> void: + var mid := int(segments.size() / 2.0) + # joints[i] bridges segments[i-1] and segments[i], so joints[mid] is the one + # holding the two halves together. + if mid > 0 and mid < joints.size(): + var seam := joints[mid] + joints.remove_at(mid) + seam.queue_free() + + mesh.visible = false + _halves.clear() + _halves.append(_make_half(0, mid - 1)) + _halves.append(_make_half(mid, segments.size() - 1)) + _break_time = 0.0 + _update_break(0.0) + + +# A Path3D + CSGPolygon3D pair covering segments[lo..hi], mirroring the settings +# of the main tube so the halves look like the rope they came from. +func _make_half(lo: int, hi: int) -> Path3D: + var path := Path3D.new() + path.top_level = true + path.curve = Curve3D.new() + path.set_meta("lo", lo) + path.set_meta("hi", hi) + add_child(path) + + var tube := CSGPolygon3D.new() + tube.polygon = mesh.polygon + tube.mode = mesh.mode + tube.path_interval_type = mesh.path_interval_type + tube.path_interval = mesh.path_interval + tube.path_rotation = mesh.path_rotation + tube.path_local = mesh.path_local + tube.path_continuous_u = mesh.path_continuous_u + tube.path_joined = mesh.path_joined + tube.smooth_faces = mesh.smooth_faces + tube.calculate_tangents = mesh.calculate_tangents + # The rope material is an opaque resource shared with anything else using it, + # so the fade gets its own transparent copy rather than mutating the original. + if material != null: + var faded := material.duplicate() as StandardMaterial3D + if faded != null: + faded.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + tube.material = faded + path.add_child(tube) + # path_node resolves relative to the CSG node, so it can only be set once the + # tube is actually in the tree under the path. + tube.path_node = tube.get_path_to(path) + return path + + +func _update_break(delta: float) -> void: + _break_time += delta + var alpha := clampf(1.0 - _break_time / maxf(snap_fade, 0.001), 0.0, 1.0) + for path in _halves: + if not is_instance_valid(path): + continue + _trace_half(path) + var tube := path.get_child(0) as CSGPolygon3D + var mat := tube.material as StandardMaterial3D + if mat != null: + mat.albedo_color.a = alpha + if _break_time >= snap_fade: + _clear_rope() + + +# Same endpoint math the base script uses for the main curve: a capsule's +Y end +# is the one nearer the rope start, so the run is every segment's near end plus +# the far end of the last one. +func _trace_half(path: Path3D) -> void: + var lo : int = path.get_meta("lo") + var hi : int = path.get_meta("hi") + var c := path.curve + c.clear_points() + for i in range(lo, hi + 1): + var seg := segments[i] + var half_h : float = seg.get_child(0).shape.height * 0.5 + c.add_point(seg.position + seg.transform.basis.y * half_h) + var last := segments[hi] + var last_half : float = last.get_child(0).shape.height * 0.5 + c.add_point(last.position - last.transform.basis.y * last_half) + + +# Tear down every body, joint and tube the rope owns. A snapped rope should stop +# costing physics once it has finished falling. +func _clear_rope() -> void: + for path in _halves: + if is_instance_valid(path): + path.queue_free() + _halves.clear() + for joint in joints: + if is_instance_valid(joint): + joint.queue_free() + joints.clear() + for segment in segments: + if is_instance_valid(segment): + segment.queue_free() + segments.clear() + curve_points.clear() + + +## Rebuild the rope from scratch after a snap. +func reset() -> void: + _clear_rope() + snapped = false + taut = false + tension = 0.0 + strain_amt = 0.0 + _emitted_strain = -1.0 + _break_time = 0.0 + if mesh != null: + mesh.visible = true + # The base _ready() bakes `position` into the segments and then zeroes it, so + # the offset has to be put back before rebuilding or the new rope spawns at + # the parent's origin instead of the rope's. + position = _origin_offset + curve = Curve3D.new() + _fit_curve_to_endpoints() + super._ready() + _apply_plane_lock() + _wire_endpoints() + _rope_length = _target_length()