Finally proper rope physics. Parity with 2D connectedplayers.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
[gd_scene format=3 uid="uid://4ajccsn6o10n"]
|
[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="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"]
|
[ext_resource type="PackedScene" uid="uid://x4xnc85khpl5" path="res://scenes/path_3d_rope.tscn" id="2_jetiq"]
|
||||||
|
|
||||||
[sub_resource type="BoxShape3D" id="BoxShape3D_xcqlg"]
|
[sub_resource type="BoxShape3D" id="BoxShape3D_xcqlg"]
|
||||||
@@ -9,7 +10,7 @@ size = Vector3(40, 2, 5)
|
|||||||
[sub_resource type="BoxMesh" id="BoxMesh_xcqlg"]
|
[sub_resource type="BoxMesh" id="BoxMesh_xcqlg"]
|
||||||
size = Vector3(40, 2, 5)
|
size = Vector3(40, 2, 5)
|
||||||
|
|
||||||
[sub_resource type="Curve3D" id="Curve3D_jetiq"]
|
[sub_resource type="Curve3D" id="Curve3D_epadm"]
|
||||||
resource_local_to_scene = true
|
resource_local_to_scene = true
|
||||||
bake_interval = 512.0
|
bake_interval = 512.0
|
||||||
_data = {
|
_data = {
|
||||||
@@ -29,26 +30,41 @@ shape = SubResource("BoxShape3D_xcqlg")
|
|||||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D" unique_id=336650218]
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D" unique_id=336650218]
|
||||||
mesh = SubResource("BoxMesh_xcqlg")
|
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)
|
transform = Transform3D(1, 0, 0, 0, 0.9961947, 0.08715574, 0, -0.08715574, 0.9961947, 0, 9, 23)
|
||||||
fov = 48.4
|
fov = 48.4
|
||||||
size = 50.0
|
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]
|
[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)
|
transform = Transform3D(1, 0, 0, 0, 0.25881907, 0.9659258, 0, -0.9659258, 0.25881907, 0, 0, 0)
|
||||||
shadow_enabled = true
|
shadow_enabled = true
|
||||||
|
|
||||||
[node name="Player1" parent="." unique_id=68487004 instance=ExtResource("1_e2xum")]
|
[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")]
|
[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
|
player_id = 2
|
||||||
|
|
||||||
[node name="Path3DRope" parent="." unique_id=301387047 instance=ExtResource("2_jetiq")]
|
[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_start_path = NodePath("../Player1")
|
||||||
attach_end_path = NodePath("../Player2")
|
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
|
cable_thickness = 0.05
|
||||||
fixed_start_point = false
|
fixed_start_point = false
|
||||||
|
|
||||||
|
[node name="SnapTimer" type="Timer" parent="Path3DRope" unique_id=1395149861]
|
||||||
|
one_shot = true
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bvgm53qsm13tt
|
||||||
+370
-13
@@ -21,20 +21,69 @@ var attach_end : PhysicsBody3D
|
|||||||
|
|
||||||
## Extra rope length as a fraction of the gap between the two endpoints.
|
## 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.
|
## 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,
|
## 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.
|
## instead of staying fixed at whatever it was when the level loaded.
|
||||||
@export var dynamic_length := true
|
@export var dynamic_length := true
|
||||||
|
|
||||||
## Bounds on total rope length, in metres. min_length stops the rope collapsing
|
## Stops the rope collapsing into a stub when the players stand on top of each
|
||||||
## into a stub when the players stand on top of each other; max_length is the
|
## other.
|
||||||
## leash that eventually drags them back together.
|
|
||||||
@export var min_length := 2.0
|
@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
|
@export var max_length := 20.0
|
||||||
|
|
||||||
## How fast total length changes, in metres per second. Low values feel like a
|
## Leash spring: metres per second squared of pull per metre of stretch, plus
|
||||||
## winch, high values like the rope is weightless.
|
## 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
|
@export var reel_speed := 6.0
|
||||||
|
|
||||||
## Constrain segments to the X-Y plane (linear Z, angular X and Y).
|
## 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_layer := 4
|
||||||
@export_flags_3d_physics var segment_collision_mask := 1
|
@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
|
# The base script zeroes `position` and bakes it into child positions, so keep
|
||||||
# our own copy to place the end joint we add.
|
# our own copy to place the end joint we add.
|
||||||
var _origin_offset := Vector3.ZERO
|
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
|
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:
|
func _ready() -> void:
|
||||||
attach_start = get_node_or_null(attach_start_path) as PhysicsBody3D
|
attach_start = get_node_or_null(attach_start_path) as PhysicsBody3D
|
||||||
@@ -60,31 +124,320 @@ func _ready() -> void:
|
|||||||
_apply_plane_lock()
|
_apply_plane_lock()
|
||||||
_wire_endpoints()
|
_wire_endpoints()
|
||||||
_rope_length = _target_length()
|
_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:
|
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:
|
if dynamic_length:
|
||||||
_reel(delta)
|
_reel(delta)
|
||||||
|
_apply_elastic(delta)
|
||||||
|
_apply_tautness(delta)
|
||||||
|
_update_timer()
|
||||||
# Base script redraws the CSG curve from the segment transforms and capsule
|
# Base script redraws the CSG curve from the segment transforms and capsule
|
||||||
# heights, so it has to run after the resize.
|
# heights, so it has to run after the resize.
|
||||||
super(delta)
|
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:
|
func _target_length() -> float:
|
||||||
if attach_start == null or attach_end == null:
|
if attach_start == null or attach_end == null:
|
||||||
return maxf(distance, min_length)
|
return maxf(distance, min_length)
|
||||||
var span := attach_start.global_position.distance_to(attach_end.global_position)
|
# Give up the resting sag as the leash engages. Straightening the segments is
|
||||||
return clampf(span * (1.0 + slack), min_length, max_length)
|
# 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:
|
func _reel(delta: float) -> void:
|
||||||
var previous := _rope_length
|
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:
|
if absf(_rope_length - previous) > 0.0001:
|
||||||
_set_segment_length(_rope_length / float(number_of_segments))
|
_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.
|
# Resize every capsule and re-anchor the pin joints bracketing it.
|
||||||
#
|
#
|
||||||
# Joint3D only derives its anchor points from the node transforms when the joint
|
# 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)
|
end_joint.set_param(PinJoint3D.PARAM_IMPULSE_CLAMP, max_impulse)
|
||||||
joints.append(end_joint)
|
joints.append(end_joint)
|
||||||
|
|
||||||
#func _physics_process(_delta: float) -> void:
|
func _update_timer() -> void:
|
||||||
#curve.bake_interval += 0.01
|
var stretched = is_equal_approx(tension_ratio(), 1.0)
|
||||||
|
if stretched and $SnapTimer.is_stopped():
|
||||||
|
$SnapTimer.start()
|
||||||
|
elif !stretched:
|
||||||
|
$SnapTimer.stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user