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>
379 lines
16 KiB
GDScript
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)
|