Files
settled-reach/client/scripts/sandbox/locomotion_anim.gd
T
jpmschweitzerandClaude Fable 5 a7801a942a feat(client): 3D locomotion sandbox — character walks the live Gauntlet (T-1088)
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>
2026-07-06 13:23:16 +02:00

183 lines
7.7 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).
##
## 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)