## LocomotionAnim gait machine tests (T-1088 design §6, §10.4). ## ## Two layers: ## 1. END-TO-END CLIP GUARD — every SandboxConstants.GAIT_CLIP cell must exist in a ## real headless CharacterVisual's get_animation_list(). play_animation() is a ## case-sensitive exact-name search that fails with only a warning, so a typo'd ## or renamed clip is a SILENT miss in production — this test is the tripwire, ## end-to-end against the imported GLB, not against a constant copy. ## 2. State-machine logic on stubs — edge-triggered transitions, the §6.3 blend ## table, §6.2 cadence sync, phase preservation + skip_phase_seek, teleport ## hard reset, and the gait_changed (Q-063) signal. class_name TestGaitTable extends GdUnitTestSuite const EPS := 0.000001 # ============================================================================= # Stubs — duck-typed against the §4.0 rig getters and §6.3 CharacterVisual API # ============================================================================= class StubRig: extends RefCounted var stance: String = "Walk" var is_moving: bool = false var current_speed: float = 0.0 var yaw_target: float = 0.0 # idle turn-jump detector input (rad) class StubAnimPlayer: extends RefCounted ## Clip lengths mirror the verified import dump (2026-07-06), keyed by the explicit ## "lib/Clip" address (D-248). Values only matter for phase/one-shot-timer arithmetic, ## not for clip existence (layer 1 covers that against the real GLB). const LENGTHS := { "ual1/Idle": 2.5, "ual1/Walk": 1.33, "ual1/Walk_Formal": 1.33, "ual1/Sprint": 0.67, "ual1/Crouch_Idle": 2.93, "ual1/Crouch_Fwd": 2.0, # stance-transition + turn one-shots "ual1/Sprint_Enter": 0.87, "ual1/Sprint_Exit": 1.67, "ual1/Crouch_Enter": 0.83, "ual1/Crouch_Exit": 0.83, "ual1/Turn90_L": 2.0, "ual1/Turn90_R": 2.0, "ual2/Turn180_L": 1.67, "ual2/Turn180_R": 1.67, } var speed_scale: float = 1.0 var current_animation: String = "" var current_animation_position: float = 0.0 var current_animation_length: float = 0.0 var seeks: Array = [] # [seconds, update] per seek() call func get_animation(clip_name: StringName) -> Animation: var anim := Animation.new() anim.length = float(LENGTHS.get(String(clip_name), 1.0)) return anim func seek(seconds: float, update: bool = false, _update_only: bool = false) -> void: seeks.append([seconds, update]) current_animation_position = seconds class StubVisual: extends RefCounted var player := StubAnimPlayer.new() var plays: Array = [] # [{"name": String, "blend": float}] per play_animation call func play_animation(anim_name: String, blend_time: float = -1.0) -> void: plays.append({"name": anim_name, "blend": blend_time}) player.current_animation = anim_name player.current_animation_length = float(StubAnimPlayer.LENGTHS.get(anim_name, 1.0)) player.current_animation_position = 0.0 func get_animation_player() -> StubAnimPlayer: return player ## Machine wired to fresh stubs; returns [anim, rig, visual]. func _make_machine() -> Array: var rig := StubRig.new() var visual := StubVisual.new() var anim := LocomotionAnim.new() anim.setup(rig, visual) return [anim, rig, visual] func _last_play(visual: StubVisual) -> Dictionary: return {} if visual.plays.is_empty() else visual.plays[-1] # ============================================================================= # 1. End-to-end clip guard — real headless CharacterVisual (§6.1) # ============================================================================= func test_every_gait_cell_exists_in_imported_animation_list() -> void: # §2.1 order is load-bearing: .new() -> add_child() -> load_descriptor(). # In-tree first, because _ready() loads the toon/outline shaders. A default # descriptor is enough — only the mandatory skeleton + animation library matter. var visual: CharacterVisual = auto_free(CharacterVisual.new()) add_child(visual) visual.load_descriptor(CharacterVisualDescriptor.new()) var player: AnimationPlayer = visual.get_animation_player() assert_object(player).override_failure_message( "get_animation_player() must return the AnimPlayer after load_descriptor()" + " — null means the skeleton or the ual1/ual2 GLBs failed to load" ).is_not_null() if player == null: return var clips := player.get_animation_list() assert_bool(clips.is_empty()).override_failure_message( "imported animation list is empty — ual1.glb/ual2.glb library copy failed" ).is_false() # Both explicit libraries must have registered (D-248 lib/Clip addressing). var libs := player.get_animation_library_list() assert_bool(libs.has(&"ual1") and libs.has(&"ual2")).override_failure_message( "expected explicit libraries 'ual1' and 'ual2' — got %s" % [libs] ).is_true() for stance: String in SandboxConstants.GAIT_CLIP: var row: Dictionary = SandboxConstants.GAIT_CLIP[stance] for cell: String in row: var clip := String(row[cell]) assert_bool(clips.has(clip)).override_failure_message( ( "GAIT_CLIP[%s][%s] = '%s' not in the imported animation list —" + " play_animation() would miss SILENTLY (case-sensitive; explicit" + " 'lib/Clip' addressing, '_Loop' stripped by the importer). List: %s" ) % [stance, cell, clip, clips] ).is_true() func test_transition_and_turn_clips_exist_in_imported_animation_list() -> void: # The stance-transition (Sprint/Crouch Enter/Exit) and turn-in-place (Turn90/Turn180 # L/R) name tables must resolve in the real headless CharacterVisual — same silent-miss # tripwire as the gait table, for the one-shot overlays (T-1088). var visual: CharacterVisual = auto_free(CharacterVisual.new()) add_child(visual) visual.load_descriptor(CharacterVisualDescriptor.new()) var player: AnimationPlayer = visual.get_animation_player() assert_object(player).is_not_null() if player == null: return var clips := player.get_animation_list() var expected: Array = [] expected.append_array(SandboxConstants.STANCE_ENTER_CLIP.values()) expected.append_array(SandboxConstants.STANCE_EXIT_CLIP.values()) expected.append_array(SandboxConstants.TURN_CLIP.values()) for clip: StringName in expected: assert_bool(clips.has(String(clip))).override_failure_message( "one-shot clip '%s' not in the imported animation list — %s" % [clip, clips] ).is_true() func test_gait_table_covers_all_four_stances() -> void: # The wire stance enum (D-053/D-055): every variant must have both cells. for stance: String in ["Sprint", "Walk", "Careful", "Crouch"]: assert_bool(SandboxConstants.GAIT_CLIP.has(stance)).override_failure_message( "GAIT_CLIP missing wire stance '%s'" % stance ).is_true() var row: Dictionary = SandboxConstants.GAIT_CLIP[stance] assert_bool(row.has("idle") and row.has("moving")).override_failure_message( "GAIT_CLIP[%s] must have both 'idle' and 'moving' cells" % stance ).is_true() # ============================================================================= # 2. Pure gait() lookup (§6.1) # ============================================================================= func test_gait_returns_table_cells() -> void: assert_str(String(LocomotionAnim.gait("Walk", false))).is_equal("ual1/Idle") assert_str(String(LocomotionAnim.gait("Walk", true))).is_equal("ual1/Walk") assert_str(String(LocomotionAnim.gait("Careful", true))).is_equal("ual1/Walk_Formal") assert_str(String(LocomotionAnim.gait("Sprint", true))).is_equal("ual1/Sprint") assert_str(String(LocomotionAnim.gait("Crouch", false))).is_equal("ual1/Crouch_Idle") assert_str(String(LocomotionAnim.gait("Crouch", true))).is_equal("ual1/Crouch_Fwd") func test_gait_unknown_stance_falls_back_to_walk_row() -> void: # Mirrors the wire default (player_stance serde-defaults to Walk). assert_str(String(LocomotionAnim.gait("Prone", true))).is_equal("ual1/Walk") assert_str(String(LocomotionAnim.gait("Prone", false))).is_equal("ual1/Idle") # ============================================================================= # 2b. bare() basename helper + one-shot name tables (§ T-1088) # ============================================================================= func test_bare_strips_library_prefix() -> void: assert_str(LocomotionAnim.bare(&"ual1/Walk")).is_equal("Walk") assert_str(LocomotionAnim.bare(&"ual2/Turn180_L")).is_equal("Turn180_L") # Unqualified names pass through unchanged (back-compat). assert_str(LocomotionAnim.bare(&"Crouch_Fwd")).is_equal("Crouch_Fwd") func test_transition_clip_table() -> void: # Destination Enter / source Exit, verified imported names (UAL1). assert_str(String(LocomotionAnim.transition_clip_for("Walk", "Sprint"))).is_equal("ual1/Sprint_Enter") assert_str(String(LocomotionAnim.transition_clip_for("Sprint", "Walk"))).is_equal("ual1/Sprint_Exit") assert_str(String(LocomotionAnim.transition_clip_for("Walk", "Crouch"))).is_equal("ual1/Crouch_Enter") assert_str(String(LocomotionAnim.transition_clip_for("Crouch", "Walk"))).is_equal("ual1/Crouch_Exit") # Walk<->Careful have no Enter/Exit clips — plain gait blend, no one-shot. assert_str(String(LocomotionAnim.transition_clip_for("Walk", "Careful"))).is_equal("") assert_str(String(LocomotionAnim.transition_clip_for("Careful", "Walk"))).is_equal("") # Same stance -> no transition. assert_str(String(LocomotionAnim.transition_clip_for("Sprint", "Sprint"))).is_equal("") # Both apply (Crouch<->Sprint) -> destination Enter wins. assert_str(String(LocomotionAnim.transition_clip_for("Crouch", "Sprint"))).is_equal("ual1/Sprint_Enter") assert_str(String(LocomotionAnim.transition_clip_for("Sprint", "Crouch"))).is_equal("ual1/Crouch_Enter") func test_turn_clip_table() -> void: # Below TURN_TRIGGER_DEG -> no clip (the rig's yaw ease handles it). assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(45.0)))).is_equal("") # 90 deg: sign picks L (positive) / R (negative), Turn90 lives in UAL1. assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(90.0)))).is_equal("ual1/Turn90_L") assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(-90.0)))).is_equal("ual1/Turn90_R") # >= TURN_180_DEG: Turn180 lives in UAL2. assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(180.0)))).is_equal("ual2/Turn180_L") assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(-135.0)))).is_equal("ual2/Turn180_R") # ============================================================================= # 3. Blend table (§6.3) — pure blend_for # ============================================================================= func test_blend_idle_to_gait() -> void: var b := LocomotionAnim.blend_for(&"Idle", &"Walk", false, true) assert_float(b).is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS) func test_blend_gait_to_idle() -> void: var b := LocomotionAnim.blend_for(&"Walk", &"Idle", true, false) assert_float(b).is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS) func test_blend_gait_to_gait() -> void: var b := LocomotionAnim.blend_for(&"Walk", &"Sprint", true, true) assert_float(b).is_equal_approx(SandboxConstants.BLEND["gait_to_gait"], EPS) func test_blend_crouch_overrides_all_edges() -> void: # "<->Crouch_*" takes the crouch blend regardless of the idle/gait edge kind. # Uses explicit "lib/Clip" names to prove the crouch detection is addressing-agnostic # (blend_for() strips the library prefix via bare()). var crouch: float = SandboxConstants.BLEND["crouch"] assert_float(LocomotionAnim.blend_for(&"ual1/Walk", &"ual1/Crouch_Fwd", true, true)) \ .is_equal_approx(crouch, EPS) assert_float(LocomotionAnim.blend_for(&"ual1/Crouch_Fwd", &"ual1/Walk", true, true)) \ .is_equal_approx(crouch, EPS) assert_float(LocomotionAnim.blend_for(&"ual1/Idle", &"ual1/Crouch_Idle", false, false)) \ .is_equal_approx(crouch, EPS) assert_float(LocomotionAnim.blend_for(&"ual1/Crouch_Idle", &"ual1/Crouch_Fwd", false, true)) \ .is_equal_approx(crouch, EPS) assert_float(LocomotionAnim.blend_for(&"ual1/Crouch_Fwd", &"ual1/Crouch_Idle", true, false)) \ .is_equal_approx(crouch, EPS) # ============================================================================= # 4. Edge-triggered transitions on the stubbed player # ============================================================================= func test_first_update_plays_idle_with_hard_cut() -> void: var m := _make_machine() var anim: LocomotionAnim = m[0] var visual: StubVisual = m[2] anim.update(0.016) assert_int(visual.plays.size()).is_equal(1) assert_str(_last_play(visual)["name"]).is_equal("ual1/Idle") # First-ever play: -1.0 rides play_animation's default hard-cut path. assert_float(_last_play(visual)["blend"]).is_equal_approx(-1.0, EPS) func test_unchanged_state_never_retriggers_play() -> void: # Loops never restart mid-cycle: same state across frames = exactly one play call. var m := _make_machine() var anim: LocomotionAnim = m[0] var visual: StubVisual = m[2] for i in 5: anim.update(0.016) assert_int(visual.plays.size()).is_equal(1) func test_idle_to_walk_uses_idle_to_gait_blend() -> void: var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) # settle into Idle rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS) func test_walk_to_idle_uses_gait_to_idle_blend() -> void: var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) rig.is_moving = false rig.current_speed = 0.0 anim.update(0.016) assert_str(_last_play(visual)["name"]).is_equal("ual1/Idle") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS) func test_stance_toggle_between_transitionless_stances_keeps_shared_idle_clip() -> void: # Walk-idle and Careful-idle share the Idle cell AND have no Enter/Exit clip — the # clip identity is the edge, so no re-play and no gait_changed re-emit on the flip. # (Sprint/Crouch now DO fire an Enter/Exit one-shot — see the transition tests.) var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) rig.stance = "Careful" anim.update(0.016) assert_int(visual.plays.size()).is_equal(1) func test_gait_changed_emitted_once_per_edge() -> void: # Q-063 seam: one emission per transition, carrying the clip now playing. var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var emitted: Array = [] anim.gait_changed.connect(func(clip: StringName) -> void: emitted.append(String(clip))) anim.update(0.016) # -> Idle anim.update(0.016) # no edge rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) # -> Walk anim.update(0.016) # no edge assert_array(emitted).is_equal(["ual1/Idle", "ual1/Walk"]) # ============================================================================= # 5. Cadence sync (§6.2) # ============================================================================= func test_speed_scale_matches_speed_over_native_mps() -> void: var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 1.25 # Walk cardinal leg: 0.5 m / 0.4 s anim.update(0.016) assert_float(visual.player.speed_scale) \ .is_equal_approx(1.25 / SandboxConstants.NATIVE_MPS["Walk"], EPS) func test_speed_scale_clamped_at_catchup_burst() -> void: var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 10.0 # 3x catch-up burst far beyond native anim.update(0.016) assert_float(visual.player.speed_scale) \ .is_equal_approx(SandboxConstants.SPEED_SCALE_CLAMP.y, EPS) func test_speed_scale_clamped_at_floor() -> void: # Hysteresis window: at-target (speed 0) but still "moving" — clamp floor, not 0. var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 0.0 anim.update(0.016) assert_float(visual.player.speed_scale) \ .is_equal_approx(SandboxConstants.SPEED_SCALE_CLAMP.x, EPS) func test_idle_runs_at_native_rate() -> void: var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 2.5 anim.update(0.016) rig.is_moving = false rig.current_speed = 0.0 anim.update(0.016) assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS) # ============================================================================= # 6. gait<->gait phase preservation (§6.3) # ============================================================================= func test_gait_to_gait_preserves_phase() -> void: # Walk -> Careful (Walk_Formal) is the transitionless gait<->gait pair (no Enter/Exit # clip intervenes); Walk -> Sprint now routes through Sprint_Enter (see transitions). var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) # -> Walk # Mid-stride: half way through the Walk loop. visual.player.current_animation_position = 0.5 * visual.player.current_animation_length rig.stance = "Careful" rig.current_speed = 1.2 anim.update(0.016) # -> Walk_Formal, gait<->gait assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk_Formal") assert_int(visual.player.seeks.size()).is_equal(1) # phase 0.5 into Walk_Formal's 1.33 s loop; update=false keeps the crossfade pose. assert_float(visual.player.seeks[0][0]) \ .is_equal_approx(0.5 * StubAnimPlayer.LENGTHS["ual1/Walk_Formal"], EPS) assert_bool(visual.player.seeks[0][1]).is_false() func test_skip_phase_seek_flag_disables_the_seek() -> void: # §6.3 caveat fallback: if seek-during-blend cancels the crossfade in the live # scene, the flag drops the seek and the 0.15 s blend masks the resync. var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.skip_phase_seek = true rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) visual.player.current_animation_position = 0.5 * visual.player.current_animation_length rig.stance = "Careful" rig.current_speed = 1.2 anim.update(0.016) assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk_Formal") assert_int(visual.player.seeks.size()).is_equal(0) func test_idle_transitions_do_not_phase_seek() -> void: # Phase preservation is gait<->gait only — an idle edge starts the clip normally. var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) # -> Idle visual.player.current_animation_position = 1.0 rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) # Idle -> Walk assert_int(visual.player.seeks.size()).is_equal(0) # ============================================================================= # 7. Teleport hard reset (§6.3) # ============================================================================= func test_teleport_replays_with_zero_blend_and_rewinds() -> void: var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) # -> Walk, mid-session visual.player.current_animation_position = 0.7 rig.is_moving = false # rig snapped at the teleport target rig.current_speed = 0.0 anim.notify_teleport() assert_str(_last_play(visual)["name"]).is_equal("ual1/Idle") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.BLEND["teleport"], EPS) # play() on an already-current clip does not rewind — the reset must seek 0. assert_int(visual.player.seeks.size()).is_equal(1) assert_float(visual.player.seeks[0][0]).is_equal_approx(0.0, EPS) assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS) func test_setup_connects_rig_teleported_signal_when_present() -> void: # The rig contract (§4.0/§7) emits `teleported(pos_m)`; setup() auto-wires the # reset. The stub signal signature mirrors locomotion_rig.gd:42. var rig := SignallingStubRig.new() var visual := StubVisual.new() var anim := LocomotionAnim.new() anim.setup(rig, visual) rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) rig.is_moving = false rig.current_speed = 0.0 rig.teleported.emit(Vector3(25.25, 0.0, 29.25)) assert_str(_last_play(visual)["name"]).is_equal("ual1/Idle") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.BLEND["teleport"], EPS) # ============================================================================= # 8. Stance-transition one-shots (T-1088) # ============================================================================= func test_stance_change_triggers_transition_oneshot() -> void: # Idle Walk -> Crouch fires the Crouch_Enter one-shot (loop=0) with the transition # blend, while the rig keeps interpolating independently. var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) # seed baselines, idle rig.stance = "Crouch" anim.update(0.016) assert_str(_last_play(visual)["name"]).is_equal("ual1/Crouch_Enter") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.STANCE_TRANSITION_BLEND, EPS) # One-shots play at native rate (no cadence scaling). assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS) func test_transition_holds_then_blends_into_target_loop() -> void: # The one-shot owns the clip for its length (Crouch_Enter = 0.83 s @ 1.0x), then the # next frame blends into the destination loop (idle -> Crouch_Idle). var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) # seed rig.stance = "Crouch" anim.update(0.016) # -> Crouch_Enter var plays_at_trigger := visual.plays.size() # Within the 0.83 s window: the clip is held, no new play. anim.update(0.2) assert_int(visual.plays.size()).is_equal(plays_at_trigger) # Advance past the window: elapses and blends into Crouch_Idle the same frame. anim.update(0.8) assert_str(_last_play(visual)["name"]).is_equal("ual1/Crouch_Idle") func test_transition_does_not_stall_movement_flag() -> void: # Movement independence: even mid-transition the machine never blocks the rig — it # only owns the CLIP. If movement is underway when the transition ends, it lands on # the moving loop. var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) # seed -> Walk rig.stance = "Sprint" rig.current_speed = 3.0 anim.update(0.016) # -> Sprint_Enter one-shot assert_str(_last_play(visual)["name"]).is_equal("ual1/Sprint_Enter") anim.update(1.0) # elapse (Sprint_Enter 0.87 s) -> Sprint moving loop assert_str(_last_play(visual)["name"]).is_equal("ual1/Sprint") # ============================================================================= # 9. Turn-in-place one-shots (T-1088) # ============================================================================= func test_idle_yaw_jump_triggers_turn() -> void: # A >= 60 deg idle yaw-target jump plays a Turn90 one-shot; +delta -> L. var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) # seed, yaw_target 0 rig.yaw_target = deg_to_rad(90.0) anim.update(0.016) assert_str(_last_play(visual)["name"]).is_equal("ual1/Turn90_L") assert_float(_last_play(visual)["blend"]).is_equal_approx(SandboxConstants.TURN_BLEND, EPS) assert_float(visual.player.speed_scale) \ .is_equal_approx(SandboxConstants.TURN_SPEED_SCALE, EPS) func test_small_idle_yaw_change_does_not_turn() -> void: # A 45 deg reface stays under TURN_TRIGGER_DEG — the rig's yaw ease handles it, no clip. var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) # seed -> Idle rig.yaw_target = deg_to_rad(45.0) anim.update(0.016) assert_int(visual.plays.size()).is_equal(1) # only the seed idle play func test_moving_yaw_change_does_not_turn() -> void: # Turns are idle-only — while moving, the body yaw follows the legs (D-252). var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) # seed -> Walk rig.yaw_target = deg_to_rad(120.0) anim.update(0.016) assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk") # no turn clip func test_turn_retrigger_guarded_while_active() -> void: # A second jump during an active turn does not start a new turn (retrigger guard). var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) # seed rig.yaw_target = deg_to_rad(90.0) anim.update(0.016) # -> Turn90_L (2.0 s / 2.0x = 1.0 s window) var plays_at_turn := visual.plays.size() rig.yaw_target = deg_to_rad(180.0) # another jump mid-turn anim.update(0.1) # still within the window assert_int(visual.plays.size()).is_equal(plays_at_turn) # guarded, no new play func test_turn_abandoned_when_movement_starts() -> void: # A turn is idle-only: if the rig starts moving mid-turn, abandon it and show the # leg gait immediately (movement never stalls). var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) # seed idle rig.yaw_target = deg_to_rad(90.0) anim.update(0.016) # -> Turn90_L active rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) # movement -> abandon turn -> Walk assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk") class SignallingStubRig: extends RefCounted signal teleported(pos_m: Vector3) var stance: String = "Walk" var is_moving: bool = false var current_speed: float = 0.0 var yaw_target: float = 0.0