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>
437 lines
16 KiB
GDScript
437 lines
16 KiB
GDScript
## 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
|
|
|
|
|
|
class StubAnimPlayer:
|
|
extends RefCounted
|
|
## Clip lengths mirror the verified import dump (design-input §2.2) — values only
|
|
## matter for phase arithmetic, not for clip existence (layer 1 covers that).
|
|
const LENGTHS := {
|
|
"Idle": 2.5, "Walk": 1.33, "Walk_Formal": 1.33,
|
|
"Sprint": 0.67, "Crouch_Idle": 2.93, "Crouch_Fwd": 2.0,
|
|
}
|
|
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 ual_standard.glb 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 — ual_standard.glb library copy failed"
|
|
).is_false()
|
|
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; library"
|
|
+ " '' bare names, '_Loop' stripped by the importer). List: %s"
|
|
) % [stance, cell, 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("Idle")
|
|
assert_str(String(LocomotionAnim.gait("Walk", true))).is_equal("Walk")
|
|
assert_str(String(LocomotionAnim.gait("Careful", true))).is_equal("Walk_Formal")
|
|
assert_str(String(LocomotionAnim.gait("Sprint", true))).is_equal("Sprint")
|
|
assert_str(String(LocomotionAnim.gait("Crouch", false))).is_equal("Crouch_Idle")
|
|
assert_str(String(LocomotionAnim.gait("Crouch", true))).is_equal("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("Walk")
|
|
assert_str(String(LocomotionAnim.gait("Prone", false))).is_equal("Idle")
|
|
|
|
|
|
# =============================================================================
|
|
# 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.
|
|
var crouch: float = SandboxConstants.BLEND["crouch"]
|
|
assert_float(LocomotionAnim.blend_for(&"Walk", &"Crouch_Fwd", true, true)) \
|
|
.is_equal_approx(crouch, EPS)
|
|
assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Walk", true, true)) \
|
|
.is_equal_approx(crouch, EPS)
|
|
assert_float(LocomotionAnim.blend_for(&"Idle", &"Crouch_Idle", false, false)) \
|
|
.is_equal_approx(crouch, EPS)
|
|
assert_float(LocomotionAnim.blend_for(&"Crouch_Idle", &"Crouch_Fwd", false, true)) \
|
|
.is_equal_approx(crouch, EPS)
|
|
assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"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("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("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("Idle")
|
|
assert_float(_last_play(visual)["blend"]) \
|
|
.is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS)
|
|
|
|
|
|
func test_stance_toggle_while_idle_keeps_shared_idle_clip() -> void:
|
|
# Walk-idle and Sprint-idle share the Idle cell — the clip identity is the edge,
|
|
# so no re-play and no gait_changed re-emit on the stance flip.
|
|
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 = "Sprint"
|
|
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(["Idle", "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:
|
|
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 = "Sprint"
|
|
rig.current_speed = 2.5
|
|
anim.update(0.016) # -> Sprint, gait<->gait
|
|
assert_str(_last_play(visual)["name"]).is_equal("Sprint")
|
|
assert_int(visual.player.seeks.size()).is_equal(1)
|
|
# phase 0.5 into Sprint's 0.67 s loop; update=false keeps the crossfade pose.
|
|
assert_float(visual.player.seeks[0][0]) \
|
|
.is_equal_approx(0.5 * StubAnimPlayer.LENGTHS["Sprint"], 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 = "Sprint"
|
|
rig.current_speed = 2.5
|
|
anim.update(0.016)
|
|
assert_str(_last_play(visual)["name"]).is_equal("Sprint")
|
|
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("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("Idle")
|
|
assert_float(_last_play(visual)["blend"]) \
|
|
.is_equal_approx(SandboxConstants.BLEND["teleport"], EPS)
|
|
|
|
|
|
class SignallingStubRig:
|
|
extends RefCounted
|
|
signal teleported(pos_m: Vector3)
|
|
var stance: String = "Walk"
|
|
var is_moving: bool = false
|
|
var current_speed: float = 0.0
|