Files
settled-reach/client/scripts/sandbox/locomotion_rig.gd
T
jpmschweitzerandClaude Fable 5 cb6b39331d feat(client): follow-facing — body commits to path, mouse drives layered head/torso look-at (T-1088)
During an RMB path-follow the body yaw locks to the leg direction (rig
commit_body_to_motion, from interpolated velocity — immune to SetFacing
interleaving between throttled steps). The mouse instead drives a layered
look-at: LookAtModifier3D pair on Head (±70°) + spine_02 (±30° torso twist
for looking far lateral/behind — past their sum the character physically
cannot look further without turning). Forward axis measured from the
armature rest pose (+Z), not guessed. Influence fades in/out on follow
start/end. Pure client presentation per D-249; SetFacing still rides the
wire, so the server vision cone follows the mouse while walking — the
character looks where the player points.

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

328 lines
15 KiB
GDScript

class_name LocomotionRig
extends Node3D
## T-1088 locomotion rig (design §4, §5) — the position and yaw channels of the
## feel thesis (§0): each channel gets exactly ONE smoothing layer, crisp -> soft.
## Thin Node3D wrapper (this node = PlayerRig; local position = interpolated sim
## position in WorldRoot-local metres) over a pure static math core, so every
## rule is headless-testable (test_locomotion_math.gd, §10.4). Proto-production:
## an NPC adapter later feeds set_wire_target() from VisibleEntity rows with no
## rig changes.
##
## Position (§4.1): per-leg constant velocity sized to the step — on a changed
## target, cover the distance in exactly one stance interval (dist/interval),
## clamped to [base, CATCHUP_MAX_FACTOR * base]. No easing on the character
## itself: easing a repeating 0.4 s step reads as scooting; the camera (S5) is
## deliberately the only soft position layer.
##
## Yaw (§5, "server feet, client eyes"): moving (incl. the idle-hysteresis
## window) follows the wire octant — the server sets Facing from the move delta,
## so it IS the motion direction; idle follows idle_facing_provider (the same
## snapped octant that rides the SetFacing wire); frozen while suppressed
## (dialogue / free camera). Shortest-arc lerp_angle ease (TURN_SHARPNESS) under
## per-stance deg/s budgets (TURN_BUDGET_DEG), written to ModelRoot.rotation.y in
## WorldRoot-LOCAL space so octant->yaw composes with the D-148 map rotation
## exactly once.
##
## TRAP (design §2, §5): NEVER call CharacterVisual.set_facing() from this rig
## or anything near it. Its internal table (west=+90, east=-90 —
## character_visual.gd:184-193) assumes a mirrored axis mapping (East -> -X)
## incompatible with THE convention (SandboxSpace: East -> +X); using it makes
## the model face west while walking east. This rig owns ModelRoot.rotation.y
## exclusively, always through SandboxSpace.octant_to_yaw(); CharacterVisual's
## own rotation.y stays 0.
##
## The rig never reads GameState, InputMapper, or any autoload itself (§4.0) —
## the sandbox root adapts snapshot fields into set_wire_target() and installs
## the provider Callables below. NPC adapters leave the providers unset.
## Emitted when a wire target lands beyond SNAP_DIST_M (Home-key / cross-map
## teleport, §4.1): all rig channels have already snapped; the camera (S5)
## hard-sets its pivot on this, the anim machine (S6) hard-cuts (BLEND.teleport
## = 0.0). NOT emitted for the first-ever snapshot (the root's latch owns that).
signal teleported(pos_m: Vector3)
## set_wire_target() outcome — a pure decision, exposed for tests (§10.4).
enum TargetAction { SNAP_FIRST, IGNORE, TELEPORT, STEP }
## Fallback step window when step_window_ms_provider is unset (headless tests,
## future NPC adapters before MovementSpeed wiring). Mirrors the Walk default
## stance only — the player adapter MUST install a provider reading
## InputMapper.MOVE_INTERVAL_MS at runtime: that table is the single source and
## is deliberately never copied (design §13.8).
const DEFAULT_STEP_WINDOW_MS := 400.0
## (stance: String) -> float|int step window in ms for one confirmed step.
## Player adapter: func(stance): return InputMapper.MOVE_INTERVAL_MS.get(stance, 400).
## Called once per accepted step (per-leg recompute, §4.1) with the stance that
## arrived in the same wire target.
var step_window_ms_provider: Callable = Callable()
## () -> octant String for idle facing. The player adapter returns
## InputMapper.facing_octant — the SAME snapped octant that rides the SetFacing
## wire, so the model never shows an octant the server wasn't told (§5). Leave
## unset (NPCs) to collapse the facing split to pure wire facing — single code
## path by construction.
var idle_facing_provider: Callable = Callable()
## T-1088 follow-facing: while a client-initiated path-follow is active the
## body yaw commits to the leg direction (mouse -> head_look, not body).
## Set/cleared by the sandbox root from PathFollower.is_active().
var commit_body_to_motion: bool = false
## () -> bool, true while input is suppressed. The player adapter returns
## GameState.dialogue_active or GameState.free_camera_mode — the exact condition
## under which InputMapper computes but does not send octants (input_mapper.gd:71).
## While idle + suppressed the yaw target freezes (last target held, §5). Leave
## unset for never-suppressed (NPCs).
var suppression_provider: Callable = Callable()
## Latest wire facing octant (snapshot player_facing) — the moving yaw source.
## Updates on EVERY wire target, including ignored ones: a blocked move (Q-020)
## still changes Facing server-side, so bump-to-turn falls out for free (§4.3).
var wire_octant: String = "North"
## Latest wire stance — selects the step window and the moving turn budget.
var stance: String = "Walk"
## Tick of the latest consumed wire target (DebugHud "snapshot age" input).
var last_wire_tick: int = -1
## True until at-target for IDLE_ENTER_DELAY_S (§4.2 hysteresis — kills the
## walk<->idle flicker from snapshot jitter). Gait selection keys off this and
## current_speed, never off input (§0).
var is_moving: bool = false
## Constant per-leg ground speed (m/s), zero on arrival — cadence-sync input
## (§6.2: speed_scale = current_speed / NATIVE_MPS, constant per leg).
var current_speed: float = 0.0
## Render velocity (m/s), zero on arrival — gait-selection input (§0).
var velocity: Vector3 = Vector3.ZERO
## Current yaw target (rad, WorldRoot-local) — held while frozen. DebugHud pairs
## it with model_root.rotation.y as "yaw target / actual".
var yaw_target: float = 0.0
## Yaw channel output node — resolved from the ModelRoot scene child in _ready();
## injectable beforehand for headless tests.
var model_root: Node3D = null
var _has_target: bool = false
var _target_pos: Vector3 = Vector3.ZERO
var _leg_speed: float = 0.0
var _idle_timer_s: float = 0.0
func _ready() -> void:
if model_root == null:
model_root = get_node_or_null("ModelRoot") as Node3D
## The single rig input (§4.0): the sandbox root (or a future NPC adapter) calls
## this once per consumed snapshot with the wire position converted to
## WorldRoot-local metres (SandboxSpace.wire_to_world), the wire facing octant,
## stance, and tick.
func set_wire_target(pos_m: Vector3, facing_octant: String, new_stance: String, tick: int) -> void:
wire_octant = facing_octant
stance = new_stance
last_wire_tick = tick
match classify_target(_has_target, position, _target_pos, pos_m):
TargetAction.SNAP_FIRST:
# First-ever snapshot snaps, no signal (2D precedent
# entity_renderer.gd:133-140; the root's first-snapshot latch
# handles the camera).
_snap_all_channels(pos_m)
TargetAction.IGNORE:
# Paused-tick identical frames and blocked moves (Q-020) land here:
# target-chasing is idempotent, nothing re-triggers. Facing already
# updated above — bump-to-turn is the visible blocked-move behavior.
pass
TargetAction.TELEPORT:
_snap_all_channels(pos_m)
teleported.emit(pos_m)
TargetAction.STEP:
_target_pos = pos_m
_leg_speed = derive_leg_speed(position.distance_to(pos_m), _step_interval_s())
func _process(delta: float) -> void:
if not _has_target:
return
# Position channel: constant-velocity chase, exact arrival (§4.1).
position = step_position(position, _target_pos, _leg_speed, delta)
var at_target := position.is_equal_approx(_target_pos)
if at_target:
position = _target_pos # kill sub-epsilon residue
velocity = Vector3.ZERO
current_speed = 0.0
else:
velocity = (_target_pos - position).normalized() * _leg_speed
current_speed = _leg_speed
_idle_timer_s = advance_idle_timer(_idle_timer_s, at_target, delta)
is_moving = is_moving_state(at_target, _idle_timer_s)
# Yaw channel (§5).
_update_facing(delta)
## Remaining distance (m) on the current leg — DebugHud "leg distance" readout.
func leg_remaining_m() -> float:
return position.distance_to(_target_pos)
## DebugHud duck contract (sandbox_debug_hud.gd): render speed in m/s — constant
## per leg, zero on arrival.
func get_render_speed() -> float:
return current_speed
## DebugHud duck contract (sandbox_debug_hud.gd): current yaw target (rad,
## WorldRoot-local), paired on screen with ModelRoot.rotation.y as "target/actual".
func get_yaw_target_rad() -> float:
return yaw_target
## Snap every rig channel to the target (first snapshot / teleport, §4.1):
## position, velocity, yaw target AND actual (no ease across a jump), and the
## idle hysteresis (pre-expired so the rig is instantly idle).
func _snap_all_channels(pos_m: Vector3) -> void:
_has_target = true
position = pos_m
_target_pos = pos_m
_leg_speed = 0.0
velocity = Vector3.ZERO
current_speed = 0.0
_idle_timer_s = SandboxConstants.IDLE_ENTER_DELAY_S
is_moving = false
yaw_target = SandboxSpace.octant_to_yaw(wire_octant)
if model_root != null:
model_root.rotation.y = yaw_target
func _step_interval_s() -> float:
var window_ms := DEFAULT_STEP_WINDOW_MS
if step_window_ms_provider.is_valid():
window_ms = float(step_window_ms_provider.call(stance))
return window_ms / 1000.0
func _update_facing(delta: float) -> void:
var suppressed := false
if suppression_provider.is_valid():
suppressed = bool(suppression_provider.call())
if commit_body_to_motion and is_moving and velocity.length_squared() > 0.0001:
# Path-follow (T-1088 follow-facing refinement): the body is committed to
# the leg direction — the mouse drives the head/torso look-at instead
# (head_look.gd), and SetFacing still steers the server vision cone.
# Leg direction in WorldRoot-local space -> yaw via the same convention
# as SandboxSpace (local +X = sim East, +Z = sim South).
yaw_target = SandboxSpace.sim_angle_to_yaw(atan2(velocity.z, velocity.x))
else:
var idle_octant := ""
if idle_facing_provider.is_valid():
idle_octant = str(idle_facing_provider.call())
yaw_target = select_yaw_target(is_moving, wire_octant, idle_octant, suppressed, yaw_target)
if model_root == null:
return
var budget_key := stance if is_moving else "Idle"
model_root.rotation.y = step_yaw(model_root.rotation.y, yaw_target, budget_key, delta)
# ---------------------------------------------------------------------------
# Pure static core — all locomotion math lives below, stateless and
# headless-testable (§10.4). The wrapper above only shuttles state.
# ---------------------------------------------------------------------------
## Classify an incoming wire target (§4.1): the first-ever snapshot snaps; an
## unchanged target is ignored — paused-tick frames (§4.3) and blocked moves
## (Q-020) re-trigger nothing by construction, and a mid-leg repeat keeps the
## current leg speed (no tail-slowdown re-derivation); beyond SNAP_DIST_M
## (5 subtiles — matches the 2D TELEPORT_DISTANCE_THRESHOLD) is a teleport;
## otherwise a normal step.
static func classify_target(
has_target: bool, render_pos: Vector3, current_target: Vector3, new_target: Vector3
) -> TargetAction:
if not has_target:
return TargetAction.SNAP_FIRST
if new_target.is_equal_approx(current_target):
return TargetAction.IGNORE
if render_pos.distance_to(new_target) > SandboxConstants.SNAP_DIST_M:
return TargetAction.TELEPORT
return TargetAction.STEP
## Per-leg constant velocity (§4.1): cover dist in exactly one stance interval,
## clamped to [base, CATCHUP_MAX_FACTOR * base] where base is the cardinal
## one-subtile speed (SUBTILE_M / interval). One rule handles everything:
## cardinal Walk 0.5/0.4 = 1.25 m/s; diagonal 0.707/0.4 = 1.77 m/s — arrives
## exactly on time (no sqrt(2) on the wire, D-053; a fixed speed would lag on
## held diagonals); multi-tile latest-wins deltas close within ~one interval
## with feet speeding up to match (§6.2).
static func derive_leg_speed(dist_m: float, interval_s: float) -> float:
var safe_interval := maxf(interval_s, 0.001)
var base := SandboxConstants.SUBTILE_M / safe_interval
return clampf(dist_m / safe_interval, base, SandboxConstants.CATCHUP_MAX_FACTOR * base)
## One frame of constant-velocity chase — move_toward clamps at the target, so
## arrival is exact and velocity is a clean square wave, never a sawtooth (§4.1).
static func step_position(
render_pos: Vector3, target: Vector3, leg_speed: float, delta: float
) -> Vector3:
return render_pos.move_toward(target, leg_speed * delta)
## Idle-hysteresis timer (§4.2): accumulates while at-target, resets the moment
## a new leg starts.
static func advance_idle_timer(timer_s: float, at_target: bool, delta: float) -> float:
return timer_s + delta if at_target else 0.0
## is_moving with hysteresis (§4.2): snapshot arrival jitters +-1-2 ticks against
## the client throttle, so the rig frequently arrives a few frames early —
## staying "moving" until at-target for IDLE_ENTER_DELAY_S kills the walk<->idle
## flicker; a real stop still reaches Idle inside the human ~0.3 s settle window.
static func is_moving_state(at_target: bool, timer_s: float) -> bool:
if not at_target:
return true
return timer_s < SandboxConstants.IDLE_ENTER_DELAY_S
## Yaw target source per the §5 authority table ("server feet, client eyes"):
## moving -> wire octant (server Facing IS the motion direction); idle +
## suppressed -> held target frozen; idle with a provider -> the client-local
## aim octant; idle without one (NPCs) -> pure wire facing. Empty idle_octant
## means "no provider installed".
static func select_yaw_target(
moving: bool, wire_oct: String, idle_octant: String, suppressed: bool, held_target: float
) -> float:
if moving:
return SandboxSpace.octant_to_yaw(wire_oct)
if suppressed:
return held_target
if idle_octant.is_empty():
return SandboxSpace.octant_to_yaw(wire_oct)
return SandboxSpace.octant_to_yaw(idle_octant)
## Signed shortest arc from one yaw to another, in [-PI, PI).
static func shortest_arc(from_yaw: float, to_yaw: float) -> float:
return wrapf(to_yaw - from_yaw, -PI, PI)
## Wrap a yaw into (-PI, PI] — same convention as SandboxSpace (North stays +PI).
static func wrap_yaw(yaw: float) -> float:
var wrapped := yaw
while wrapped > PI:
wrapped -= TAU
while wrapped <= -PI:
wrapped += TAU
return wrapped
## One frame of yaw smoothing (§5): shortest-arc lerp_angle ease-out at
## TURN_SHARPNESS, with the per-frame change clamped to the per-stance
## TURN_BUDGET_DEG (budget_key = stance while moving, "Idle" otherwise; unknown
## keys fall back to Idle). A 180 deg reversal at Walk completes in ~0.25 s
## (about half a step); a 45 deg corner resolves in ~60 ms.
static func step_yaw(current_yaw: float, target_yaw: float, budget_key: String, delta: float) -> float:
var eased := lerp_angle(current_yaw, target_yaw, 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * delta))
var budget_deg: float = SandboxConstants.TURN_BUDGET_DEG.get(
budget_key, SandboxConstants.TURN_BUDGET_DEG["Idle"]
)
var max_step := deg_to_rad(budget_deg) * delta
var step := clampf(shortest_arc(current_yaw, eased), -max_step, max_step)
return wrap_yaw(current_yaw + step)