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). ## ## Two one-shot overlays sit on top of the gait loop (T-1088, UAL Enter/Exit + Turn ## clips): ## - STANCE TRANSITIONS: on a stance change with a Sprint/Crouch Enter/Exit clip, ## play it once (loop=0) then blend into the target loop. The rig position channel ## is independent, so the character keeps gliding — movement never stalls. ## - TURN-IN-PLACE: when IDLE and the yaw TARGET jumps >= TURN_TRIGGER_DEG (a fast ## mouse reface), play Turn90/Turn180 L/R once while the rig's lerp_angle does the ## actual yaw work, then fall back to the idle clip. Retrigger is guarded by the ## active-one-shot window. ## Both overlays use a clip-length timer (decremented in update(delta)) rather than the ## AnimationPlayer.animation_finished signal: it fits the existing pure-polling drive, ## needs no signal lifecycle on a RefCounted across load_descriptor() player rebuilds, ## and is directly headless-testable by advancing update() calls. ## ## 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 / yaw_target 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 var _current_is_oneshot: bool = false # true while _current_clip is an Enter/Exit/Turn one-shot # One-shot overlay state (stance transitions + turn-in-place). var _last_stance: String = "" # stance-change edge detector (empty = not yet seeded) var _turn_ref_yaw: float = 0.0 # yaw-target reference for idle turn-jump detection (rad) var _oneshot_clip: StringName = &"" # active one-shot clip (empty = none) var _oneshot_remaining_s: float = 0.0 # seconds left on the active one-shot (<= 0 = none) var _oneshot_is_turn: bool = false # true if the active one-shot is a turn (idle-only) ## 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"]) ## Clip basename — the part after "lib/", or the whole name when unqualified. Lets the ## name-based logic (crouch detection, NATIVE_MPS lookup) stay addressing-agnostic now ## that gait clips carry explicit "ual1/..."-style library prefixes (D-248). static func bare(clip: StringName) -> String: var s := String(clip) var slash := s.rfind("/") return s.substr(slash + 1) if slash >= 0 else s ## 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 bare(from_clip).begins_with("Crouch") or bare(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"]) ## Signed shortest arc from one yaw to another, in [-PI, PI). Local copy so the machine ## stays free of any rig-class dependency (it is duck-typed against the rig). static func shortest_arc(from_yaw: float, to_yaw: float) -> float: return wrapf(to_yaw - from_yaw, -PI, PI) ## Stance-transition one-shot for a stance change, or &"" if none applies (§ T-1088). ## Destination Enter wins over source Exit — entering the new stance is the salient ## action; the only case both apply is Crouch<->Sprint. Same stance in/out -> no clip. static func transition_clip_for(from_stance: String, to_stance: String) -> StringName: if from_stance == to_stance: return &"" if SandboxConstants.STANCE_ENTER_CLIP.has(to_stance): return StringName(SandboxConstants.STANCE_ENTER_CLIP[to_stance]) if SandboxConstants.STANCE_EXIT_CLIP.has(from_stance): return StringName(SandboxConstants.STANCE_EXIT_CLIP[from_stance]) return &"" ## Turn-in-place one-shot for a signed idle yaw-target jump (rad), or &"" if the jump is ## below TURN_TRIGGER_DEG. >= TURN_180_DEG selects the 180 clip (UAL2); the sign picks ## L (positive) vs R (negative) — see TURN_CLIP for the live-verify handedness note. static func turn_clip_for(delta_rad: float) -> StringName: var mag := absf(delta_rad) if mag < deg_to_rad(SandboxConstants.TURN_TRIGGER_DEG): return &"" var arc := "180" if mag >= deg_to_rad(SandboxConstants.TURN_180_DEG) else "90" var hand := "L" if delta_rad > 0.0 else "R" return StringName(SandboxConstants.TURN_CLIP["%s_%s" % [arc, hand]]) ## 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. ## Order: (1) first-frame seed, (2) drive an active one-shot to completion, (3) detect a ## stance change or an idle yaw-jump and start a new one-shot, (4) otherwise normal gait. func update(delta: float) -> void: if _rig == null or _visual == null: return var stance: String = _rig.stance var moving: bool = _rig.is_moving var speed: float = _rig.current_speed # (1) First-ever frame: hard-cut to the current gait and seed the edge baselines. if _current_clip == &"": _apply_state(stance, moving, speed) _last_stance = stance _turn_ref_yaw = _read_yaw_target() return # (2) An active stance-transition / turn one-shot owns the clip until it elapses. if _oneshot_remaining_s > 0.0: _oneshot_remaining_s -= delta # Keep the turn reference fresh so a mouse sweep DURING the one-shot doesn't # burst into a turn the instant it ends. _turn_ref_yaw = _read_yaw_target() # A turn is idle-only: if the rig started moving, abandon it so the leg gait # shows without stalling. if _oneshot_is_turn and moving: _oneshot_remaining_s = 0.0 if _oneshot_remaining_s > 0.0: return # Just elapsed — clear and fall through so _apply_state blends into the loop. _oneshot_clip = &"" _oneshot_is_turn = false # (3a) Stance change -> maybe a transition one-shot (Sprint/Crouch Enter/Exit). if stance != _last_stance: var trans := transition_clip_for(_last_stance, stance) _last_stance = stance if not String(trans).is_empty(): _play_oneshot(trans, SandboxConstants.STANCE_TRANSITION_BLEND, false, moving) return # (3b) Idle yaw-target jump -> maybe a turn-in-place one-shot. var yaw_target := _read_yaw_target() if not moving: var d := shortest_arc(_turn_ref_yaw, yaw_target) _turn_ref_yaw = yaw_target if absf(d) >= deg_to_rad(SandboxConstants.TURN_TRIGGER_DEG): var turn := turn_clip_for(d) if not String(turn).is_empty(): _play_oneshot(turn, SandboxConstants.TURN_BLEND, true, false) return else: # Moving: body yaw follows the legs (D-252); keep the reference current so a # post-stop reface measures only the jump that happens AFTER arrival. _turn_ref_yaw = yaw_target # (4) Normal gait selection. _apply_state(stance, moving, speed) # Rig yaw target (rad, WorldRoot-local) for idle turn-jump detection, duck-typed so a # rig/stub without a yaw_target simply never triggers turns (delta stays 0). func _read_yaw_target() -> float: if _rig != null and "yaw_target" in _rig: return float(_rig.yaw_target) return _turn_ref_yaw # Play a one-shot overlay (stance transition or turn): crossfade in, hold for the clip's # (speed-scaled) duration, then let update() blend into the target loop. Turns play at # TURN_SPEED_SCALE so the deliberate UAL turn clip doesn't drag far past the rig's snappy # idle yaw ease; transitions play at native rate. func _play_oneshot(clip: StringName, blend: float, is_turn: bool, moving: bool) -> void: var scale := SandboxConstants.TURN_SPEED_SCALE if is_turn else 1.0 _visual.play_animation(clip, blend) var player = _visual.get_animation_player() if player != null: player.speed_scale = scale _oneshot_clip = clip _oneshot_is_turn = is_turn _oneshot_remaining_s = _clip_length(clip) / maxf(scale, 0.01) _current_clip = clip _current_moving = moving _current_is_oneshot = true gait_changed.emit(clip) # Clip length (s) from the real (or stubbed) AnimationPlayer, 0.0 when unavailable. func _clip_length(clip: StringName) -> float: var player = _visual.get_animation_player() if player == null: return 0.0 var anim: Animation = player.get_animation(clip) if anim == null: return 0.0 return anim.length ## 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 # A teleport aborts any in-flight one-shot overlay — the destination pose is a hard # cut, not a continuation of an Enter/Exit/Turn. _oneshot_clip = &"" _oneshot_remaining_s = 0.0 _oneshot_is_turn = false _current_is_oneshot = false 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 # Re-seed the edge baselines so the post-teleport state does not fire a spurious # stance transition or turn on the next frame. _last_stance = _rig.stance _turn_ref_yaw = _read_yaw_target() _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. Skipped # when leaving a one-shot (Enter/Exit/Turn) — its phase does not map to a gait loop. var phase := -1.0 if not first_play and _current_moving and moving and not skip_phase_seek and not _current_is_oneshot: phase = _capture_phase() _visual.play_animation(clip, blend) if phase >= 0.0: _seek_phase(clip, phase) _current_clip = clip _current_moving = moving _current_is_oneshot = false 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(bare(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)