ual1.glb (120 clips) + ual2.glb (134) replace the free-tier subsets; CharacterVisual loads both under explicit library names with lib/Clip exact addressing (bare names keep cross-library search). Gait table re-pointed; two new one-shot overlays in the gait machine: stance transitions (Sprint/Crouch Enter/Exit, destination-Enter priority, clip-length timer, movement never stalls) and turn-in-place (Turn90/180 L/R from shortest-arc sign, >=60/135 deg idle yaw jumps, retrigger-guarded). All clip names verified by dumping the imported GLBs. 70 gait tests + live smoke green (both libraries load with exact counts). S9 live-tune list: turn handedness (unverified headless — one-line swap), TURN_SPEED_SCALE=2.0 compromise vs the snappy yaw ease, turn-on-stop feel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
348 lines
15 KiB
GDScript
348 lines
15 KiB
GDScript
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)
|