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>
183 lines
7.7 KiB
GDScript
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)
|