Files
settled-reach/client/scripts/sandbox/locomotion_sandbox.gd
T
jpmschweitzerandClaude Fable 5 629c0a9b1e feat(client): D-252 view/movement split — body from leg velocity, view is the mouse (T-1093)
D-252 (new record, amends D-054/D-249, resolves Q-084's walk-vs-aim split):
Facing is view-only; movement no longer writes it. Client side: the rig's
moving-body yaw now always derives from leg velocity (the wire octant is the
VIEW and must never rotate the body — the follow-only commit flag
generalizes and disappears); the layered head/torso look-at runs during any
movement, WASD included; the ~100ms post-step re-assert mitigation is
removed as dead (server-side facing_from_delta removal lands separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:59:06 +02:00

572 lines
24 KiB
GDScript

extends Node3D
## T-1088 3D locomotion sandbox root — boot, connect, poll/pump, local descriptor,
## first-snapshot latch, component wiring (design §1.3). Sandbox-only file.
##
## The session-driver boilerplate below is a deliberate ~40-line copy from main.gd:
## D-166 freezes main.gd until Phase 5, so each copied block carries a "Pattern:"
## comment naming its source for the T-962 shared-driver extraction.
##
## Launch recipe (design §10.1):
## T1: cd server && cargo run --bin settled-reach-server -- --test-mode
## T2: SR_LIVE=1 ~/bin/godot4 --path client res://scenes/locomotion_sandbox.tscn
const MANIFEST_PATH := "res://assets/characters/manifest.json"
## Autopilot (design §10.2): sim-space heading per movement token (0 = East,
## +PI/2 = South — the InputMapper.facing_angle convention, Y-down radians).
const AUTOPILOT_HEADINGS := {
"east": 0.0,
"south": PI / 2.0,
"west": PI,
"north": -PI / 2.0,
}
## Hold time for pulsed discrete actions (stance_up/stance_down) — long enough
## for the buffered InputEventAction press to reach InputMapper._unhandled_input.
const AUTOPILOT_PULSE_S := 0.1
@onready var world_root: Node3D = $WorldRoot
@onready var greybox: GreyboxWorld = $WorldRoot/Greybox
@onready var player_rig: LocomotionRig = $WorldRoot/PlayerRig
@onready var model_root: Node3D = $WorldRoot/PlayerRig/ModelRoot
@onready var camera_rig: FollowCamera3D = $CameraRig
var character_visual: CharacterVisual = null
## Gait state machine (design §6) — RefCounted, owned and driven by this root.
var _anim: LocomotionAnim = null
## Mouse-aim facing provider (design §9) — the installed Callable keeps it alive;
## referenced here too so the seam's owner is greppable. Cleared in _exit_tree().
var _aim_provider: SandboxMouseAimProvider = null
## T-1088 click-to-move (Live-session feedback item 2): the mouse-over path
## preview (Node3D under WorldRoot) and the path follower (RefCounted, ticked
## per frame). Installed in _ready(); the RMB gesture handling is in _unhandled_input.
var _path_preview: PathPreview = null
var _path_follower: PathFollower = null
## T-1088 follow-facing: layered head/torso look-at (active during follows) and
## its shared aim-target node (WorldRoot-local, positioned at the mouse ground
## point each frame).
var _head_look: SandboxHeadLook = null
var _head_aim_target: Node3D = null
## Time (ms) a non-double RMB press began, -1 = none pending. Resolves click vs
## long-press on RELEASE (RMB_LONG_PRESS_MS threshold).
var _rmb_press_msec: int = -1
var _first_snapshot_seen: bool = false
var _gameplay_paused: bool = false # D-170: implant fullscreen occludes gameplay
var _autopilot_spec: String = "" # design §10.2: raw SR_AUTOPILOT string (kept for debugging)
var _autopilot_steps: Array[Dictionary] = [] # parsed segments, consumed FIFO by _autopilot_tick
var _autopilot_active: bool = false # a segment is running (its timer is live)
var _autopilot_timer: float = 0.0 # seconds left in the active segment (fixed-fps deltas)
var _autopilot_action: String = "" # Input action held/pulsed by the active segment
var _autopilot_kind: String = "" # "move" | "pulse" | "wait"
var _autopilot_heading: float = NAN # scripted sim-space facing angle (NAN until first move)
func _ready() -> void:
# (1) SR_PORT honor — pattern: visual_capture.gd:79-88. A plain boot dials the
# default 9876; the live harness starts the server with --port 0 and passes the
# parsed port through SR_PORT.
var port_env := OS.get_environment("SR_PORT")
if not port_env.is_empty():
SimBridge.server_port = int(port_env)
# Guarded connect — pattern: main.gd:61-62. Handshake (D-192) + StartupMessage
# are automatic in SimBridge's state machine.
if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED:
SimBridge.connect_to_sim()
# (2)+(3) Local descriptor (GameState.character_visual_descriptor is never
# populated — the sandbox builds its own from manifest.json), then CharacterVisual
# IN-TREE BEFORE load_descriptor: its _ready() loads the toon/outline shaders;
# out-of-tree the materials get null shaders. Pattern: character_creation.gd:386-388.
character_visual = CharacterVisual.new()
character_visual.name = "CharacterVisual"
model_root.add_child(character_visual)
character_visual.load_descriptor(_build_descriptor())
# TRAP (design §2): never call character_visual.set_facing() — its octant table
# assumes the 2D renderer's mirrored axis mapping (East -> -X). ModelRoot.rotation.y,
# always via SandboxSpace.octant_to_yaw(), is the only facing authority here;
# CharacterVisual's own rotation.y stays 0. The rig enforces this (locomotion_rig.gd).
# PlayerRig stays hidden (.tscn visible=false) until the first-snapshot latch.
# (3b) Rig provider adapters (design §4.0, §5): the rig reads zero autoloads —
# these Callables are its only view of InputMapper/GameState. NPC adapters later
# leave all three unset.
player_rig.step_window_ms_provider = _step_window_ms
player_rig.idle_facing_provider = _idle_facing_octant
player_rig.suppression_provider = _input_suppressed
# Teleport fan-out (design §4.1): camera hard-snaps its pivot; the anim machine
# connects itself in setup() below (0.0-blend hard cut).
player_rig.teleported.connect(_on_rig_teleported)
# (3c) Gait machine (design §6): slaved to the rig's motion channel, driving
# CharacterVisual via the additive play_animation(name, blend) API.
_anim = LocomotionAnim.new()
_anim.setup(player_rig, character_visual)
# (3d) Follow-facing look-at (T-1088): aim target under WorldRoot (its local
# space IS the sim-aligned space ground_hit_local returns) + the modifier
# pair on the freshly-built skeleton.
_head_aim_target = Node3D.new()
_head_aim_target.name = "HeadAimTarget"
world_root.add_child(_head_aim_target)
_head_look = SandboxHeadLook.new()
_head_look.setup(character_visual.get_skeleton(), _head_aim_target)
# (4) InputMapper facing provider (design §9).
_install_facing_provider()
# (4b) T-1088 click-to-move (Live-session feedback item 2): hover path preview
# + follower. See _install_click_to_move.
_install_click_to_move()
# (5) D-170: the occlusion *mechanism* is CanvasItem-only, the signal is not —
# a 3D scene connects it directly to a pause flag.
HudGroups.gameplay_occluded.connect(_on_gameplay_occluded)
# (6) SR_AUTOPILOT (design §10.2) — deterministic capture input. Parsed here,
# ticked per-frame after the first snapshot. While a script is loaded, a scripted
# heading replaces the mouse-aim provider ON THE SAME SEAM (the mouse position is
# nondeterministic under xvfb), so the genuine InputMapper octant-snap / SetFacing /
# mouse-relative-WASD / throttle path still runs end-to-end.
_autopilot_spec = OS.get_environment("SR_AUTOPILOT")
_autopilot_steps = _parse_autopilot(_autopilot_spec)
if not _autopilot_steps.is_empty():
InputMapper.facing_angle_provider = Callable(self, "_autopilot_facing_angle")
func _exit_tree() -> void:
# Clear the §9 seam so the 2D canvas-transform path resumes for any scene loaded
# after this one; the provider's own guards make a stale install safe (NAN).
InputMapper.facing_angle_provider = Callable()
# Release any autopilot-held action so pressed state never leaks past the scene.
if _autopilot_active:
_autopilot_end_segment()
func _process(delta: float) -> void:
# Snapshot poll — pattern: main.gd:251-268; extract to a shared session driver in
# Phase 5 (T-962). The first snapshot arrives here, never in _ready. Dispatch runs
# BEFORE the latch so the rig's SNAP_FIRST has placed the player when the camera snaps.
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
GameState.apply_snapshot(snapshot)
_dispatch_snapshot()
if not _first_snapshot_seen:
_first_snapshot_latch()
# Input pump — pattern: main.gd:295-341. The only pump in the codebase lives in
# main.gd; without this the sandbox connects but never moves (design §1.3).
for entry in InputMapper.flush_queue():
var action: int = entry.get("action", -1)
if (
action == InputMapper.Action.BUG_REPORT
or action == InputMapper.Action.OPEN_JOURNAL
or action == InputMapper.Action.OPEN_MENU
):
continue # client-only actions — no wire mapping (sim_bridge.gd:536-539)
SimBridge.send_input(entry)
if _gameplay_paused:
return # D-170: skip per-frame visual work while an implant app occludes
_per_frame_update(delta)
_autopilot_tick(delta)
# First-snapshot latch (design §1.3 step 3): the rig's SNAP_FIRST already placed
# position + yaw (dispatch precedes the latch, and the rig snaps on its first
# set_wire_target — entity_renderer.gd:133-140 first-appearance precedent); the
# latch owns visibility and the camera snap.
func _first_snapshot_latch() -> void:
_first_snapshot_seen = true
player_rig.visible = true
_snap_camera_to_player()
# Snapshot fan-out — direct consumer calls, no SnapshotEventRouter (design §13.7:
# the router's registration lives in main.gd; two consumers don't justify touching it).
func _dispatch_snapshot() -> void:
greybox.on_snapshot()
var pos := GameState.player_position
player_rig.set_wire_target(
SandboxSpace.wire_to_world(pos.x, pos.y),
GameState.player_facing,
GameState.player_stance,
GameState.current_tick
)
# Local CharacterVisualDescriptor from the first valid manifest.json id per category
# — no hardcoded id strings (design §1.3 step 2). Body type and skin tone keep
# descriptor defaults; facial hair stays empty (empty string = none is a valid value,
# not a manifest id).
func _build_descriptor() -> CharacterVisualDescriptor:
var manifest := _load_manifest()
var descriptor := CharacterVisualDescriptor.new()
var heads: Array = manifest.get("heads", [])
if not heads.is_empty():
descriptor.head_id = str(heads[0])
var hair: Array = manifest.get("hair", [])
if not hair.is_empty():
descriptor.hair_id = str(hair[0])
# Clothing manifest maps item_id -> {slot}; wear the first item declared per slot.
var clothing: Variant = manifest.get("clothing", {})
if clothing is Dictionary:
for item_id: String in clothing:
var slot := str((clothing[item_id] as Dictionary).get("slot", ""))
if not slot.is_empty() and not descriptor.clothing_slots.has(slot):
descriptor.clothing_slots[slot] = item_id
return descriptor
# Pattern: character_creation.gd:324-338 (manifest load).
func _load_manifest() -> Dictionary:
var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
if file == null:
push_warning("locomotion_sandbox: manifest not found at %s" % MANIFEST_PATH)
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if parsed is Dictionary:
return parsed as Dictionary
push_warning("locomotion_sandbox: manifest parse failed — using empty defaults")
return {}
func _on_gameplay_occluded(occluded: bool) -> void:
_gameplay_paused = occluded
## True while an implant app occludes gameplay (D-170) — components consult this
## instead of connecting to HudGroups themselves.
func is_gameplay_paused() -> bool:
return _gameplay_paused
# ---------------------------------------------------------------------------
# Rig provider adapters (design §4.0, §5) — the player-side Callables installed
# in _ready(). Each is the exact adapter shape the rig's doc comments name.
# ---------------------------------------------------------------------------
# Step window for one confirmed step, ms — reads InputMapper.MOVE_INTERVAL_MS at
# runtime (single source, never copied — design §13.8). 400 = the Walk default,
# matching the rig's own fallback.
func _step_window_ms(for_stance: String) -> int:
return int(InputMapper.MOVE_INTERVAL_MS.get(for_stance, 400))
# Idle facing (§5): the SAME snapped octant that rides the SetFacing wire, so the
# model never shows an octant the server wasn't told.
func _idle_facing_octant() -> String:
return InputMapper.facing_octant
# Suppression (§5): the exact condition under which InputMapper computes but does
# not send octants (input_mapper.gd:78) — idle yaw freezes on it.
func _input_suppressed() -> bool:
return GameState.dialogue_active or GameState.free_camera_mode
# Teleport (design §4.1/§7): hard camera-pivot snap, no glide. The anim machine's
# 0.0-blend reset rides its own connection (LocomotionAnim.setup).
func _on_rig_teleported(_pos_m: Vector3) -> void:
camera_rig.snap_to_target()
# T-1088: a teleport invalidates any in-flight click-to-move and stale preview
# (design item 2 cancel condition — the planned route no longer applies).
if _path_follower != null:
_path_follower.cancel()
if _path_preview != null:
_path_preview.clear()
# ---------------------------------------------------------------------------
# Wiring (design §14 groups B/C/D) — one function per attach point.
# ---------------------------------------------------------------------------
## Input seam (design §9): install InputMapper.facing_angle_provider — unprojects
## the mouse onto the y=0 plane, WorldRoot.to_local() (undoing the 45 degree map
## rotation), delta from the rig's local position, atan2(delta.z, delta.x) sim
## radians; NAN inside MOUSE_AIM_DEADZONE_M. Without it, 2D-canvas mouse math
## makes facing garbage in a 3D scene and WASD unsteerable. CameraRig is a child,
## so its _ready() (which adopts the Camera3D) has already run.
func _install_facing_provider() -> void:
_aim_provider = SandboxMouseAimProvider.new(camera_rig.get_camera(), world_root, player_rig)
InputMapper.facing_angle_provider = Callable(_aim_provider, "get_facing_angle")
## Click-to-move seam (T-1088 Live-session feedback item 2): the mouse-over path
## preview — a Node3D under WorldRoot so it inherits the 45° map rotation for free —
## and the path follower (RefCounted, ticked from _per_frame_update). Both plan over
## the greybox KNOWN store via _is_floor and honour the information boundary (fog is
## unpathable). The RMB commit is handled in _unhandled_input; nothing crosses a new
## wire — the follower streams ordinary Move* steps through InputMapper's throttle.
func _install_click_to_move() -> void:
_path_preview = PathPreview.new()
_path_preview.name = "PathPreview"
world_root.add_child(_path_preview)
_path_preview.setup(camera_rig.get_camera(), world_root)
_path_follower = PathFollower.new()
## RMB gesture vocabulary for click-to-move (design item 2 + sprint/crouch additions).
## RMB, not LMB: no mouse button is bound in the input map (movement is WASD, interact
## is E), so both are free — RMB is the tactical-genre move-here idiom and leaves LMB
## open for the future click-to-interact/select verb. Read raw here (no project.godot
## action) so the binding stays sandbox-local and the shared input map is untouched.
## Single click (press, quick release) → walk the path at the current stance.
## Double click (double_click press) → sprint-there: UPGRADE the in-flight
## single-click follow in place (Godot delivers the double's first press as an
## ordinary click that already started it), or start a fresh sprint follow.
## Long press (held ≥ RMB_LONG_PRESS_MS, committed on RELEASE) → go there, then
## Crouch on arrival ("take cover" input-vocabulary prototype only — real cover
## mechanics are future combat design).
## Click vs long-press can only be told apart on release, so the single click commits
## on RELEASE (a few ms later — imperceptible); the double still commits on its press.
## The path used is always the preview's current path (the hover tile at that instant).
func _unhandled_input(event: InputEvent) -> void:
if not (event is InputEventMouseButton):
return
var mb := event as InputEventMouseButton
if mb.button_index != MOUSE_BUTTON_RIGHT:
return
# Any state where a commit is invalid drops the pending press — a gesture
# interrupted by pause / dialogue / free-camera is abandoned.
var can_commit := (
_first_snapshot_seen
and not _gameplay_paused
and not _input_suppressed()
and _path_preview != null
and _path_follower != null
)
if not can_commit:
_rmb_press_msec = -1
return
if mb.pressed:
if mb.double_click:
_rmb_press_msec = -1 # this gesture resolved as a double — no click/long-press
if _path_follower.is_active():
_path_follower.upgrade_to_sprint(GameState.player_stance)
else:
_path_follower.start_sprint(
_path_preview.get_current_path(), GameState.player_stance
)
else:
_rmb_press_msec = Time.get_ticks_msec() # defer: decide on release
else:
if _rmb_press_msec < 0:
return # release of a double's press (or nothing pending) — ignore
var held := Time.get_ticks_msec() - _rmb_press_msec
_rmb_press_msec = -1
if held >= SandboxConstants.RMB_LONG_PRESS_MS:
_path_follower.start_crouch(_path_preview.get_current_path())
else:
_path_follower.start(_path_preview.get_current_path())
get_viewport().set_input_as_handled()
## Greybox known-floor lookup (the PathFinder / follower / preview substrate):
## true only for tiles the store KNOWS to be floor (doors collapse to FLOOR on the
## wire). Unknown / never-seen / wall are impassable — fog is unpathable (D-010).
func _is_floor(tile: Vector3i) -> bool:
return greybox.store.has_tile(tile) and greybox.store.kind_of(tile) == GreyboxWorld.Store.Kind.FLOOR
## Per-frame cross-component work owned by the root, gated on the first snapshot
## and the D-170 pause flag: the cutaway anchor (design §8 — the rig's INTERPOLATED
## world XZ, so the cut zone glides and spatial smoothstep becomes temporal
## smoothness) and the gait machine (after the rig's own _process interpolation).
func _per_frame_update(delta: float) -> void:
if not _first_snapshot_seen:
return
# Corridor reach: how far camera-side a WALL_H wall can still occlude the
# character in ortho at the current (smoothed) pitch. Clamped: the -5 deg
# frontal preset would otherwise ask for a ~29 m corridor.
var pitch_abs: float = maxf(absf(camera_rig.pitch_deg()), 5.0)
var reach: float = clampf(
SandboxConstants.WALL_H / tan(deg_to_rad(pitch_abs)), 0.5, 16.0
)
greybox.set_cutaway_char_pos(player_rig.global_position, reach)
_anim.update(delta)
# T-1088 click-to-move (design item 2): hover preview + path follower. Both plan
# from the server-confirmed character tile so a plan starts from ground truth,
# not an optimistic guess. Under input suppression (dialogue / free camera) the
# preview hides and any walk is cancelled — the same condition that freezes idle
# facing. Recompute inside the preview is edge-triggered, so this is cheap.
var char_tile := SandboxSpace.wire_to_tile(GameState.player_position.x, GameState.player_position.y)
var is_floor := Callable(self, "_is_floor")
if _input_suppressed():
_path_preview.clear()
_path_follower.cancel()
else:
_path_preview.update(char_tile, is_floor, greybox.store.size())
_path_follower.tick(char_tile, is_floor)
# D-252 view/movement split: the body always walks the leg direction (rig);
# the mouse is the VIEW — server cone via SetFacing, and the layered
# head/torso look-at whenever the character is in motion (WASD or follow).
var looking := (_path_follower.is_active() or player_rig.is_moving)
_head_look.set_active(looking and not _input_suppressed())
_update_head_aim_target()
_head_look.update(delta)
## Position the shared look-at target at the mouse's ground point (WorldRoot-
## local — the same sim-aligned space the aim provider returns) at eye height.
func _update_head_aim_target() -> void:
var cam := get_viewport().get_camera_3d()
if cam == null or _head_aim_target == null:
return
var mouse := get_viewport().get_mouse_position()
var hit: Variant = SandboxMouseAimProvider.ground_hit_local(
cam.project_ray_origin(mouse), cam.project_ray_normal(mouse),
world_root.global_transform
)
if hit != null:
var p: Vector3 = hit
p.y = SandboxConstants.HEAD_LOOK_EYE_HEIGHT_M
_head_aim_target.position = p
## Hard camera-pivot snap (design §7) — first-snapshot latch + teleport fan-out.
func _snap_camera_to_player() -> void:
camera_rig.snap_to_target()
# ---------------------------------------------------------------------------
# Autopilot (S8, design §10.2) — deterministic capture input. SR_AUTOPILOT
# (e.g. "east:2.0,south:1.5,stance_up,east:1.0") drives timed presses of the
# REAL input actions — no SimBridge bypass, no test-only paths in the rig.
# Movement tokens aim the token's heading through the facing seam and hold
# "move_north" (W = toward aim under D-054 mouse-relative WASD); stance tokens
# pulse an InputEventAction through the normal event pipeline.
# ---------------------------------------------------------------------------
# Segment machine, ticked from _process. Gated on the first snapshot so
# capture-time connection jitter never eats the schedule. Durations count
# process deltas — deterministic frame counts under --fixed-fps.
func _autopilot_tick(delta: float) -> void:
if not _first_snapshot_seen:
return
if _autopilot_active:
_autopilot_timer -= delta
if _autopilot_timer > 0.0:
return
_autopilot_end_segment()
if not _autopilot_steps.is_empty():
_autopilot_begin_segment(_autopilot_steps.pop_front())
# Parse "east:2.0,south:1.5,stance_up,east:1.0" into segment dicts. Movement
# tokens (north/east/south/west:SECONDS) walk that sim direction; stance_up/
# stance_down pulse once (optional :SECONDS hold); "wait:SECONDS" idles.
# Unknown tokens warn and are skipped.
func _parse_autopilot(spec: String) -> Array[Dictionary]:
var steps: Array[Dictionary] = []
for raw_token in spec.split(",", false):
var parts := raw_token.strip_edges().split(":")
var token := parts[0].strip_edges()
var duration := parts[1].to_float() if parts.size() > 1 else 0.0
if AUTOPILOT_HEADINGS.has(token):
(
steps
. append(
{
"kind": "move",
"action": "move_north", # W = forward = toward the scripted aim (D-054)
"duration": maxf(duration, 0.0),
"heading": AUTOPILOT_HEADINGS[token],
}
)
)
elif token == "stance_up" or token == "stance_down":
(
steps
. append(
{
"kind": "pulse",
"action": token,
"duration": maxf(duration, AUTOPILOT_PULSE_S),
"heading": NAN,
}
)
)
elif token == "wait":
steps.append(
{"kind": "wait", "action": "", "duration": maxf(duration, 0.0), "heading": NAN}
)
elif not token.is_empty():
push_warning("locomotion_sandbox: unknown SR_AUTOPILOT token '%s'" % token)
return steps
func _autopilot_begin_segment(step: Dictionary) -> void:
_autopilot_active = true
_autopilot_timer = step["duration"]
_autopilot_action = step["action"]
_autopilot_kind = step["kind"]
var heading: float = step["heading"]
if is_finite(heading):
_autopilot_heading = heading
match _autopilot_kind:
"move":
# Held action state — InputMapper polls Input.is_action_pressed each frame.
Input.action_press(_autopilot_action)
"pulse":
_autopilot_send_event(_autopilot_action, true)
func _autopilot_end_segment() -> void:
match _autopilot_kind:
"move":
Input.action_release(_autopilot_action)
"pulse":
_autopilot_send_event(_autopilot_action, false)
_autopilot_active = false
_autopilot_action = ""
_autopilot_kind = ""
# InputEventAction through the buffered input pipeline — reaches InputMapper's
# _unhandled_input exactly like a keypress (Input.action_press only feeds the
# polled is_action_pressed path, which discrete actions never read).
func _autopilot_send_event(action_name: String, pressed: bool) -> void:
var event := InputEventAction.new()
event.action = action_name
event.pressed = pressed
Input.parse_input_event(event)
# Scripted facing (installed on InputMapper.facing_angle_provider while an
# autopilot script is loaded): the heading of the current/last movement segment,
# NAN before the first one (facing keeps InputMapper's default). Everything
# downstream — octant snap, SetFacing-on-change, WASD rotation — is unchanged.
func _autopilot_facing_angle() -> float:
return _autopilot_heading
# ---------------------------------------------------------------------------
# DebugHud duck contract (sandbox_debug_hud.gd) — the HUD probes this root for
# the anim readouts; rig and greybox answer their own.
# ---------------------------------------------------------------------------
## Clip currently driven by the gait machine; null until one plays (HUD shows "—").
func get_current_clip() -> Variant:
if _anim == null:
return null
var clip: StringName = _anim.get_current_clip()
return null if clip == &"" else clip
## Live AnimationPlayer speed_scale (cadence sync, design §6.2); null until the
## CharacterVisual exists. Re-fetched every call — load_descriptor() recreates the player.
func get_anim_speed_scale() -> Variant:
if character_visual == null:
return null
var player := character_visual.get_animation_player()
return null if player == null else player.speed_scale