Files
settled-reach/client/tests/unit/test_locomotion_math.gd
T
jpmschweitzerandClaude Fable 5 a7801a942a feat(client): 3D locomotion sandbox — character walks the live Gauntlet (T-1088)
New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world
derived from server snapshots. Per-leg constant-velocity interpolation keyed
to the stance throttle, 'server feet / client eyes' facing (wire octant while
moving, client aim octant idle), cadence-synced gait state machine on
AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg
default, T-cycle presets), sim-space grid shader, camera-side wall cutaway,
accumulating never-evict tile store with four-state visibility tint.

Additive seams only: InputMapper.facing_angle_provider (2D path unchanged),
CharacterVisual.play_animation blend_time param + get_animation_player().
Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting.
210 new gdUnit assertions across five suites; verified live (230/230 total,
clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:23:16 +02:00

379 lines
16 KiB
GDScript

## LocomotionRig math (T-1088 design §4, §5, §10.4): per-leg constant-velocity
## derivation (cardinal 1.25 m/s / diagonal 1.77 m/s at Walk 400 ms), the 3x
## catch-up clamp, the 2.5 m snap threshold, idle hysteresis timing, paused-tick
## idempotence, blocked-move zero-motion (Q-020 bump-to-turn), and shortest-arc
## yaw easing under the per-stance TURN_BUDGET_DEG clamps.
##
## Pure static-core functions are tested directly; wrapper tests instantiate the
## rig out-of-tree (never added as a child, so the engine never drives _process —
## frames are stepped manually for deterministic timing).
class_name TestLocomotionMath
extends GdUnitTestSuite
const EPS := 0.000001
## 60 fps frame used by the yaw tests.
const DT := 1.0 / 60.0
func _make_rig() -> LocomotionRig:
var rig: LocomotionRig = auto_free(LocomotionRig.new())
rig.model_root = auto_free(Node3D.new())
return rig
# -- leg-speed derivation (§4.1: dist / interval, clamped) --------------------------
func test_leg_speed_cardinal_walk() -> void:
# One cardinal subtile at Walk: 0.5 m / 0.4 s = 1.25 m/s.
assert_float(LocomotionRig.derive_leg_speed(0.5, 0.4)).is_equal_approx(1.25, EPS)
func test_leg_speed_diagonal_walk() -> void:
# Diagonal step costs the same interval (no sqrt(2) on the wire, D-053):
# 0.5 * sqrt(2) / 0.4 = ~1.77 m/s — arrives exactly when the next step lands.
var diag := 0.5 * sqrt(2.0)
assert_float(LocomotionRig.derive_leg_speed(diag, 0.4)).is_equal_approx(1.767767, 0.0001)
func test_leg_speed_sprint_cardinal() -> void:
# Sprint window 200 ms: 0.5 / 0.2 = 2.5 m/s.
assert_float(LocomotionRig.derive_leg_speed(0.5, 0.2)).is_equal_approx(2.5, EPS)
func test_leg_speed_catchup_clamped_at_3x() -> void:
# 4-tile latest-wins delta at Walk: raw 2.0/0.4 = 5.0 m/s, clamped to
# CATCHUP_MAX_FACTOR (3.0) * base = 3.75 m/s — feet speed up, never blur.
assert_float(LocomotionRig.derive_leg_speed(2.0, 0.4)).is_equal_approx(3.75, EPS)
func test_leg_speed_floored_at_base() -> void:
# Sub-subtile residue (late arrival) still closes at least at base speed —
# the tail of a leg never crawls.
assert_float(LocomotionRig.derive_leg_speed(0.1, 0.4)).is_equal_approx(1.25, EPS)
# -- target classification (§4.1: first snap / ignore / teleport / step) -------------
func test_classify_first_snapshot_snaps() -> void:
var got := LocomotionRig.classify_target(
false, Vector3.ZERO, Vector3.ZERO, Vector3(25.25, 0.0, 29.25)
)
assert_int(got).is_equal(LocomotionRig.TargetAction.SNAP_FIRST)
func test_classify_same_target_ignored() -> void:
# Paused ticks keep delivering identical positions (§4.3) — idempotent.
var target := Vector3(1.25, 0.0, 2.25)
var got := LocomotionRig.classify_target(true, target, target, target)
assert_int(got).is_equal(LocomotionRig.TargetAction.IGNORE)
func test_classify_same_target_ignored_mid_leg() -> void:
# A repeat while still chasing keeps the current leg speed (no re-derivation
# from the shrinking remaining distance).
var target := Vector3(1.75, 0.0, 2.25)
var render := Vector3(1.5, 0.0, 2.25)
var got := LocomotionRig.classify_target(true, render, target, target)
assert_int(got).is_equal(LocomotionRig.TargetAction.IGNORE)
func test_classify_step_within_snap_dist() -> void:
var render := Vector3(1.25, 0.0, 2.25)
var got := LocomotionRig.classify_target(
true, render, render, render + Vector3(0.5, 0.0, 0.0)
)
assert_int(got).is_equal(LocomotionRig.TargetAction.STEP)
func test_classify_snap_threshold_boundary() -> void:
# SNAP_DIST_M is strict: exactly 2.5 m (5 subtiles) still glides — matches
# the 2D TELEPORT_DISTANCE_THRESHOLD semantics; beyond it teleports.
var render := Vector3.ZERO
var at_limit := LocomotionRig.classify_target(
true, render, render, Vector3(2.5, 0.0, 0.0)
)
assert_int(at_limit).is_equal(LocomotionRig.TargetAction.STEP)
var beyond := LocomotionRig.classify_target(
true, render, render, Vector3(2.51, 0.0, 0.0)
)
assert_int(beyond).is_equal(LocomotionRig.TargetAction.TELEPORT)
func test_classify_teleport_measured_from_render_pos() -> void:
# Teleport distance is render-pos -> new target (§4.1), not target -> target.
var render := Vector3.ZERO
var old_target := Vector3(2.0, 0.0, 0.0)
var got := LocomotionRig.classify_target(
true, render, old_target, Vector3(2.0, 0.0, 2.0) # 2.83 m from render
)
assert_int(got).is_equal(LocomotionRig.TargetAction.TELEPORT)
# -- idle hysteresis (§4.2: IDLE_ENTER_DELAY_S = 0.18) --------------------------------
func test_moving_while_not_at_target() -> void:
assert_bool(LocomotionRig.is_moving_state(false, 99.0)).is_true()
func test_hysteresis_holds_moving_within_delay() -> void:
# Arrived, but only 0.1 s at target — still "moving" (covers 2-3 ticks of
# snapshot jitter at every stance).
assert_bool(LocomotionRig.is_moving_state(true, 0.1)).is_true()
func test_hysteresis_enters_idle_at_delay() -> void:
assert_bool(LocomotionRig.is_moving_state(true, 0.18)).is_false()
assert_bool(LocomotionRig.is_moving_state(true, 0.5)).is_false()
func test_idle_timer_accumulates_at_target() -> void:
assert_float(LocomotionRig.advance_idle_timer(0.1, true, 0.05)).is_equal_approx(0.15, EPS)
func test_idle_timer_resets_when_leg_starts() -> void:
assert_float(LocomotionRig.advance_idle_timer(0.5, false, 0.05)).is_equal_approx(0.0, EPS)
# -- yaw target selection (§5 authority table) -----------------------------------------
func test_facing_moving_uses_wire_octant() -> void:
# Moving: server Facing IS the motion direction — mouse aim is visually ignored.
var got := LocomotionRig.select_yaw_target(true, "East", "North", false, 0.0)
assert_float(got).is_equal_approx(PI / 2.0, EPS)
func test_facing_moving_ignores_suppression() -> void:
var got := LocomotionRig.select_yaw_target(true, "East", "North", true, 0.0)
assert_float(got).is_equal_approx(PI / 2.0, EPS)
func test_facing_idle_uses_provider_octant() -> void:
var got := LocomotionRig.select_yaw_target(false, "East", "North", false, 0.0)
assert_float(got).is_equal_approx(PI, EPS)
func test_facing_idle_without_provider_falls_back_to_wire() -> void:
# NPC path: no provider installed -> pure wire facing, single code path.
var got := LocomotionRig.select_yaw_target(false, "East", "", false, 0.0)
assert_float(got).is_equal_approx(PI / 2.0, EPS)
func test_facing_frozen_under_suppression_when_idle() -> void:
# Dialogue / free camera: InputMapper stops sending octants — hold the last
# target instead of showing an octant the server was never told.
var held := 0.123
var got := LocomotionRig.select_yaw_target(false, "East", "North", true, held)
assert_float(got).is_equal_approx(held, EPS)
# -- yaw stepping (§5: shortest-arc lerp_angle under TURN_BUDGET_DEG) --------------------
func test_shortest_arc_wraps_through_pi() -> void:
# -170 deg -> +170 deg is -20 deg through the seam, never +340 deg.
var arc := LocomotionRig.shortest_arc(deg_to_rad(-170.0), deg_to_rad(170.0))
assert_float(arc).is_equal_approx(deg_to_rad(-20.0), EPS)
func test_yaw_step_unclamped_matches_ease() -> void:
# Small 5 deg correction at Walk: eased step (~1 deg at 60 fps) is far under
# the 12 deg/frame budget — pure lerp_angle ease, no clamp.
var weight := 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * DT)
var expected := weight * deg_to_rad(5.0)
var got := LocomotionRig.step_yaw(0.0, deg_to_rad(5.0), "Walk", DT)
assert_float(got).is_equal_approx(expected, EPS)
func test_yaw_budget_clamps_walk_reversal() -> void:
# Near-180 reversal at Walk: eased step (~37 deg) hits the 720 deg/s budget
# -> exactly 12 deg this frame (reversal completes in ~0.25 s, half a step).
var got := LocomotionRig.step_yaw(0.0, deg_to_rad(179.0), "Walk", DT)
assert_float(got).is_equal_approx(deg_to_rad(720.0) * DT, EPS)
func test_yaw_budget_per_stance() -> void:
# Same reversal, different stances: Sprint 1080 -> 18 deg/frame,
# Crouch 420 -> 7 deg/frame.
var target := deg_to_rad(179.0)
var sprint := LocomotionRig.step_yaw(0.0, target, "Sprint", DT)
assert_float(sprint).is_equal_approx(deg_to_rad(1080.0) * DT, EPS)
var crouch := LocomotionRig.step_yaw(0.0, target, "Crouch", DT)
assert_float(crouch).is_equal_approx(deg_to_rad(420.0) * DT, EPS)
func test_yaw_budget_idle_and_unknown_key() -> void:
# Idle budget 600 -> 10 deg/frame; unknown stance keys fall back to Idle.
var target := deg_to_rad(179.0)
var idle := LocomotionRig.step_yaw(0.0, target, "Idle", DT)
assert_float(idle).is_equal_approx(deg_to_rad(600.0) * DT, EPS)
var unknown := LocomotionRig.step_yaw(0.0, target, "Prone", DT)
assert_float(unknown).is_equal_approx(deg_to_rad(600.0) * DT, EPS)
func test_yaw_step_takes_shortest_arc() -> void:
# From -170 deg toward +170 deg: the step is negative (through the seam).
var start := deg_to_rad(-170.0)
var weight := 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * DT)
var expected := start + weight * deg_to_rad(-20.0)
var got := LocomotionRig.step_yaw(start, deg_to_rad(170.0), "Walk", DT)
assert_float(got).is_equal_approx(expected, EPS)
func test_yaw_step_wraps_result_across_seam() -> void:
# Budget-clamped turn crossing -PI: -175 deg - 12 deg wraps to +173 deg.
var got := LocomotionRig.step_yaw(deg_to_rad(-175.0), deg_to_rad(90.0), "Walk", DT)
assert_float(got).is_equal_approx(deg_to_rad(173.0), EPS)
# -- wrapper behavior (out-of-tree rig, manually stepped frames) --------------------------
func test_first_wire_target_snaps_without_teleport_signal() -> void:
var rig := _make_rig()
var emissions: Array = []
rig.teleported.connect(func(p: Vector3) -> void: emissions.append(p))
rig.set_wire_target(Vector3(25.25, 0.0, 29.25), "East", "Walk", 100)
assert_float(rig.position.x).is_equal_approx(25.25, EPS)
assert_float(rig.position.z).is_equal_approx(29.25, EPS)
assert_bool(rig.is_moving).is_false()
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
# Yaw snapped straight to the wire octant (East = +90 deg), no ease-in.
assert_float(rig.model_root.rotation.y).is_equal_approx(PI / 2.0, EPS)
assert_int(emissions.size()).is_equal(0)
func test_paused_repeat_of_same_target_is_idempotent() -> void:
# Paused server keeps sending identical-position frames (§4.3): no motion
# re-trigger, no drift, rig settles idle through normal hysteresis.
var rig := _make_rig()
var target := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(target, "North", "Walk", 10)
for i in 5:
rig.set_wire_target(target, "North", "Walk", 10)
rig._process(0.05)
assert_bool(rig.is_moving).is_false()
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
assert_float(rig.velocity.length()).is_equal_approx(0.0, EPS)
assert_float(rig.position.distance_to(target)).is_equal_approx(0.0, EPS)
func test_blocked_move_turns_in_place_without_motion() -> void:
# Q-020 bump-to-turn (§4.3): the server updates Facing on a blocked move but
# not position — the rig turns to face the wall and stands, zero motion.
var rig := _make_rig()
var target := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(target, "North", "Walk", 1) # snap: yaw = PI (North)
rig.set_wire_target(target, "West", "Walk", 2) # blocked: same tile, new facing
rig._process(DT)
assert_float(rig.position.distance_to(target)).is_equal_approx(0.0, EPS)
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
assert_bool(rig.is_moving).is_false()
# Idle without a provider -> wire facing (West = -90 deg).
assert_float(rig.yaw_target).is_equal_approx(-PI / 2.0, EPS)
# One Idle-budget frame (600 deg/s -> 10 deg) from North toward West,
# shortest arc through the +PI seam: PI + 10 deg wraps to -PI + 10 deg.
assert_float(rig.model_root.rotation.y).is_equal_approx(-PI + deg_to_rad(10.0), EPS)
func test_step_moves_at_constant_leg_speed() -> void:
# Equal displacement per frame — the M2 milestone's numeric core.
var rig := _make_rig()
var start := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(start, "East", "Walk", 1)
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Walk", 2)
rig._process(0.1)
assert_bool(rig.is_moving).is_true()
assert_float(rig.current_speed).is_equal_approx(1.25, 0.001)
assert_float(rig.velocity.x).is_equal_approx(1.25, 0.001)
assert_float(rig.position.x).is_equal_approx(1.375, 0.001)
rig._process(0.1)
assert_float(rig.position.x).is_equal_approx(1.5, 0.001)
func test_diagonal_step_velocity_components() -> void:
# Diagonal leg at Walk: 1.77 m/s along the diagonal = 1.25 m/s per axis —
# both axes arrive exactly when a cardinal step would.
var rig := _make_rig()
var start := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(start, "Southeast", "Walk", 1)
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.5), "Southeast", "Walk", 2)
rig._process(0.1)
assert_float(rig.current_speed).is_equal_approx(1.767767, 0.0001)
assert_float(rig.velocity.x).is_equal_approx(1.25, 0.001)
assert_float(rig.velocity.z).is_equal_approx(1.25, 0.001)
func test_arrival_holds_moving_through_hysteresis_window() -> void:
# Arrive after 0.4 s, then stay "moving" until 0.18 s at target (§4.2).
var rig := _make_rig()
var start := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(start, "East", "Walk", 1)
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Walk", 2)
for i in 4: # 4 x 0.1 s = exactly one Walk window -> arrival
rig._process(0.1)
assert_float(rig.position.x).is_equal_approx(1.75, 0.001)
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
assert_bool(rig.is_moving).is_true() # at target 0.1 s < 0.18 s — held
rig._process(0.1) # at target 0.2 s >= 0.18 s — idle
assert_bool(rig.is_moving).is_false()
func test_teleport_snaps_all_channels_and_emits() -> void:
var rig := _make_rig()
var emissions: Array = []
rig.teleported.connect(func(p: Vector3) -> void: emissions.append(p))
rig.set_wire_target(Vector3(1.25, 0.0, 1.25), "North", "Walk", 1)
var far := Vector3(25.25, 0.0, 29.25) # Home-key Hub return — cross-map jump
rig.set_wire_target(far, "South", "Walk", 2)
assert_int(emissions.size()).is_equal(1)
assert_float((emissions[0] as Vector3).x).is_equal_approx(far.x, EPS)
assert_float(rig.position.distance_to(far)).is_equal_approx(0.0, EPS)
assert_bool(rig.is_moving).is_false()
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
# Yaw hard-snapped to the post-teleport wire octant (South = 0), no glide.
assert_float(rig.model_root.rotation.y).is_equal_approx(0.0, EPS)
func test_step_window_provider_overrides_default() -> void:
# The player adapter injects InputMapper.MOVE_INTERVAL_MS — the rig itself
# never reads the autoload (§4.0).
var rig := _make_rig()
rig.step_window_ms_provider = func(for_stance: String) -> float:
return 200.0 if for_stance == "Sprint" else 400.0
var start := Vector3(1.25, 0.0, 1.25)
rig.set_wire_target(start, "East", "Sprint", 1)
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Sprint", 2)
rig._process(0.1)
assert_float(rig.current_speed).is_equal_approx(2.5, 0.001)
func test_idle_facing_provider_steers_idle_yaw() -> void:
# Idle aim: the provider's octant (client-local, already sent as SetFacing)
# becomes the yaw target — mouse-responsive idle facing without wire lag.
var rig := _make_rig()
rig.idle_facing_provider = func() -> String: return "East"
rig.set_wire_target(Vector3(1.25, 0.0, 2.25), "North", "Walk", 1)
rig._process(DT)
assert_bool(rig.is_moving).is_false()
assert_float(rig.yaw_target).is_equal_approx(PI / 2.0, EPS)
func test_suppression_freezes_idle_yaw_target() -> void:
# Dialogue / free camera (§5 row 3): the yaw target holds even though the
# idle provider says otherwise.
var rig := _make_rig()
rig.idle_facing_provider = func() -> String: return "East"
rig.suppression_provider = func() -> bool: return true
rig.set_wire_target(Vector3(1.25, 0.0, 2.25), "North", "Walk", 1)
rig._process(DT)
assert_float(rig.yaw_target).is_equal_approx(PI, EPS) # frozen at North
# Node3D.rotation is float32: written +PI reads back a hair above double PI,
# so wrap_yaw lands on the equivalent -PI. Assert angular identity, not sign.
var arc := LocomotionRig.shortest_arc(PI, rig.model_root.rotation.y)
assert_float(arc).is_equal_approx(0.0, EPS)