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>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user