class_name LocomotionAnim extends RefCounted ## T-1088 gait state machine (design §6) — proto-production. ## Slaves animation to the rig's motion channel: clip selection keys off render ## velocity (never input), transitions are edge-triggered crossfades, and playback ## rate is cadence-synced so foot fall matches actual ground speed (§6.2). ## ## Wiring (sandbox root, design §14 group C): ## var anim := LocomotionAnim.new() ## anim.setup(player_rig, character_visual) # after both exist in-tree ## anim.update(delta) # each frame, after the rig moved ## Teleports: setup() auto-connects the rig's `teleported` signal to notify_teleport() ## when the signal exists; otherwise call notify_teleport() alongside the rig snap. ## ## The rig ref is duck-typed against the §4.0 contract getters — `stance: String`, ## `is_moving: bool`, `current_speed: float` (m/s, constant per leg) — so the machine ## needs no rig class and a test stub drives it headless. The visual ref is duck-typed ## against the §6.3 additive CharacterVisual API: play_animation(name, blend_time) ## and get_animation_player(). ## ## Clip strings live ONLY in SandboxConstants.GAIT_CLIP (§6.1) — never here. ## Q-063 seam (footstep audio): emitted once per edge-triggered gait change with the ## clip now playing (the clip identifies stance + motion; Idle clips = no footsteps). ## Clip phase for footstep timing is available via CharacterVisual.get_animation_player(). signal gait_changed(clip: StringName) ## §6.3 pre-flagged caveat (the SKIP_PHASE_SEEK flag): in Godot 4.6, seek() during a ## custom_blend crossfade may cancel the blend (unverified against the live scene). ## If gait<->gait transitions visibly pop instead of crossfading, set this true to ## drop the phase-preserving seek — the 0.15 s blend masks the leg-beat resync ## acceptably (tune-by-eye; record the outcome on T-1088 per design §11.9). var skip_phase_seek: bool = false var _rig = null # duck-typed rig (locomotion_rig.gd): stance / is_moving / current_speed var _visual = null # duck-typed CharacterVisual: play_animation() / get_animation_player() var _current_clip: StringName = &"" # empty = nothing played yet (first play hard-cuts) var _current_moving: bool = false ## Pure gait table lookup (§6.1). Unknown stances warn and fall back to the Walk row ## (mirrors GameState.player_stance's wire default). static func gait(stance: String, is_moving: bool) -> StringName: if not SandboxConstants.GAIT_CLIP.has(stance): push_warning("LocomotionAnim: unknown stance '%s' — defaulting to Walk" % stance) stance = "Walk" var row: Dictionary = SandboxConstants.GAIT_CLIP[stance] return StringName(row["moving"]) if is_moving else StringName(row["idle"]) ## Pure blend-table selection (§6.3). Any transition into or out of a Crouch_* clip ## takes the crouch blend; otherwise the idle/gait edge decides. static func blend_for( from_clip: StringName, to_clip: StringName, from_moving: bool, to_moving: bool ) -> float: var blends: Dictionary = SandboxConstants.BLEND if String(from_clip).begins_with("Crouch") or String(to_clip).begins_with("Crouch"): return float(blends["crouch"]) if not from_moving and to_moving: return float(blends["idle_to_gait"]) if from_moving and not to_moving: return float(blends["gait_to_idle"]) return float(blends["gait_to_gait"]) ## The clip currently driven (empty StringName until the first play) — surfaced by ## the sandbox root for the DebugHud "clip" readout. func get_current_clip() -> StringName: return _current_clip ## Store the rig + visual refs. Auto-connects the rig's `teleported(pos_m)` signal ## (if it exposes one) to the hard reset. func setup(rig, visual) -> void: _rig = rig _visual = visual if rig != null and rig.has_signal("teleported"): rig.teleported.connect(_on_rig_teleported) # Signal adapter — locomotion_rig.gd emits teleported(pos_m: Vector3); the anim # reset doesn't need the position, only the rig's post-snap state. func _on_rig_teleported(_pos_m: Vector3) -> void: notify_teleport() ## Per-frame drive — call after the rig's own interpolation for the frame. func update(_delta: float) -> void: if _rig == null or _visual == null: return _apply_state(_rig.stance, _rig.is_moving, _rig.current_speed) ## Teleport hard reset (§6.3): a cross-map jump must not smear — replay the correct ## clip for the rig's post-snap state with the 0.0 teleport blend, rewound to phase 0. func notify_teleport() -> void: if _rig == null or _visual == null: return var moving: bool = _rig.is_moving var clip := gait(_rig.stance, moving) var changed := clip != _current_clip _visual.play_animation(clip, float(SandboxConstants.BLEND["teleport"])) var player = _visual.get_animation_player() if player != null: # play() on the already-current clip does not rewind — force the hard reset. player.seek(0.0, false) _current_clip = clip _current_moving = moving _update_speed_scale(clip, moving, _rig.current_speed) if changed: gait_changed.emit(clip) # State application, separated from the rig read so tests drive it directly. func _apply_state(stance: String, moving: bool, speed: float) -> void: var clip := gait(stance, moving) if clip != _current_clip: _transition_to(clip, moving) _update_speed_scale(clip, moving, speed) # Edge-triggered transition (§6.3): fires only on clip change, so loops never # restart mid-cycle (a stance toggle between Walk/Careful/Sprint idle keeps the # shared Idle clip untouched). func _transition_to(clip: StringName, moving: bool) -> void: var first_play := _current_clip == &"" var blend := -1.0 # first-ever play: hard cut (the character just appeared) if not first_play: blend = blend_for(_current_clip, clip, _current_moving, moving) # gait<->gait phase preservation (§6.3): capture the leg beat before switching, # then re-seek into the new clip so feet keep their rhythm across the blend. var phase := -1.0 if not first_play and _current_moving and moving and not skip_phase_seek: phase = _capture_phase() _visual.play_animation(clip, blend) if phase >= 0.0: _seek_phase(clip, phase) _current_clip = clip _current_moving = moving gait_changed.emit(clip) # Cadence sync (§6.2): while moving, speed_scale = clamp(speed / NATIVE_MPS[clip], # 0.6, 1.8). Rig speed is constant per leg (dist/interval, §4.1), so the scale is # constant per leg — no within-step wobble; catch-up bursts push it up so feet chase # instead of skating. Idle clips always run at 1.0. func _update_speed_scale(clip: StringName, moving: bool, speed: float) -> void: var player = _visual.get_animation_player() if player == null: return if not moving: player.speed_scale = 1.0 return var native: float = SandboxConstants.NATIVE_MPS.get(String(clip), 0.0) if native <= 0.0: player.speed_scale = 1.0 # moving clip missing from NATIVE_MPS — table drift return var limits: Vector2 = SandboxConstants.SPEED_SCALE_CLAMP player.speed_scale = clampf(speed / native, limits.x, limits.y) # Normalized phase [0,1) of the currently playing clip, or -1.0 when unavailable. func _capture_phase() -> float: var player = _visual.get_animation_player() if player == null or String(player.current_animation).is_empty(): return -1.0 var length: float = player.current_animation_length if length <= 0.0: return -1.0 return fposmod(player.current_animation_position, length) / length # Seek the new clip to the preserved phase without flushing the pose (update=false) # so the crossfade started by play() keeps blending. See skip_phase_seek caveat. func _seek_phase(clip: StringName, phase: float) -> void: var player = _visual.get_animation_player() if player == null: return var anim: Animation = player.get_animation(clip) if anim == null: return player.seek(phase * anim.length, false)