Files
settled-reach/client/scripts/sandbox/path_follower.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

237 lines
9.5 KiB
GDScript

class_name PathFollower
extends RefCounted
## T-1088 (Live-session feedback item 2 + the RMB gesture vocabulary) — click-to-move
## follower. Walks a client-planned path (PathFinder) by emitting ONE ordinary Move*
## step per throttle window through InputMapper.queue_move_step — the server validates
## and drives every step, exactly as if the player were holding WASD. No client
## prediction, no protocol change (D-010/D-248).
##
## RMB gesture vocabulary (resolved by the sandbox root; this class is the executor):
## NORMAL (single click) — walk the path at the current stance.
## SPRINT (double click) — raise stance to Sprint, walk, RESTORE the pre-sprint
## stance on ARRIVAL.
## CROUCH (long press) — walk at the current stance, then lower stance to Crouch
## on ARRIVAL. NO restore — crouching was the point. NB:
## real "take cover" mechanics (wall adjacency, directional
## protection) are FUTURE combat design; this gesture is the
## input-vocabulary prototype only.
##
## Stance is server-authoritative and a ladder (Crouch < Careful < Walk < Sprint —
## server/src/bridge/types.rs step_up/step_down). We move along it by bursting the
## exact number of ordinary ToggleStanceUp/Down actions through the same queue path as
## movement (InputMapper.queue_stance_toggle): the server's handle_toggle_stance has NO
## cooldown and drops nothing (server/src/simulation/stance.rs), so N queued toggles
## deterministically climb/drop N rungs and the ends saturate (an over-toggle is a
## no-op). SPRINT restore bursts the SAME count it raised, so the round-trip is
## NET-ZERO regardless of snapshot RTT — no observe-and-gate loop, no overshoot.
##
## Stance changes fire ONLY on the follow's terminal event (arrival). Every
## CANCELLATION — WASD override (spec: the player took over deliberately, leaving
## them in Sprint is correct), path invalidation, teleport, input suppression —
## leaves the stance as-is: an interruption must not trail a stance-toggle burst
## (and under suppression the queue is blocked anyway).
##
## RefCounted, not a Node: it owns no visuals and needs no tree presence — the
## sandbox root ticks it once per frame from _per_frame_update, passing the
## server-confirmed player tile so every decision is made from ground truth, never
## from an optimistic guess. A blocked/dropped step re-emits next window
## (bump-to-turn, Q-020) instead of desyncing the path.
## Stance ladder, fastest (top) → slowest. Index = rung; Sprint is rung 0, Crouch 3.
## Order mirrors the server MovementStance ladder (step_up/step_down).
const STANCE_LADDER: Array[String] = ["Sprint", "Walk", "Careful", "Crouch"]
## Terminal stance behaviour of a follow.
enum Mode { NORMAL, SPRINT, CROUCH }
## Remaining planned path INCLUDING the current tile at some index; the goal is the
## last element. Empty while inactive.
var _path: Array[Vector3i] = []
var _goal: Vector3i = Vector3i.ZERO
var _active: bool = false
var _mode: int = Mode.NORMAL
## SPRINT only: the pre-sprint stance (kept for readability/debug) and the exact
## number of ToggleStanceUp bursted — the same ToggleStanceDown count restores it.
var _saved_stance: String = "Walk"
var _raise_count: int = 0
## True while walking a path.
func is_active() -> bool:
return _active
## True if this follow was raised to sprint-there.
func is_sprinting() -> bool:
return _mode == Mode.SPRINT
## Begin a NORMAL follow at the current stance (single RMB). A path shorter than two
## tiles is nothing to walk (clicking the current tile / an unpathable tile).
func start(path: Array[Vector3i]) -> void:
if not _adopt_path(path):
return
_mode = Mode.NORMAL
_raise_count = 0
## Begin a SPRINT-there follow (double RMB with no active follow to upgrade): adopt
## the path, remember the pre-sprint stance, and burst the raise toggles.
func start_sprint(path: Array[Vector3i], current_stance: String) -> void:
if not _adopt_path(path):
return
_begin_sprint(current_stance)
## Begin a CROUCH-on-arrival follow (long-press RMB): walk at the current stance,
## drop to Crouch on arrival. No raise at start, no restore.
func start_crouch(path: Array[Vector3i]) -> void:
if not _adopt_path(path):
return
_mode = Mode.CROUCH
_raise_count = 0
## Upgrade an ALREADY-ACTIVE NORMAL follow to sprint-there — the double-click's
## second press, after the single-click commit already started walking. Keeps the
## path and progress (an UPGRADE, not a restart); just raises the stance. No-op if
## no follow is active or the follow is already sprint/crouch.
func upgrade_to_sprint(current_stance: String) -> void:
if not _active or _mode != Mode.NORMAL:
return
_begin_sprint(current_stance)
## Stop following with NO stance change — every interruption that is not a clean
## arrival (WASD override / path invalidation / teleport / suppression).
func cancel() -> void:
_active = false
_path = []
_mode = Mode.NORMAL
_raise_count = 0
## One frame of following, called by the sandbox root while active.
## current_tile: the server-confirmed player tile (SandboxSpace.wire_to_tile of
## GameState.player_position) — the single source of truth.
## is_floor: the greybox known-floor lookup (Vector3i) -> bool.
## Emits at most one throttled step; may finish (arrival stance change) or cancel.
func tick(current_tile: Vector3i, is_floor: Callable) -> void:
if not _active:
return
# Cancel (no stance change): input suppression — dialogue / free camera.
if GameState.dialogue_active or GameState.free_camera_mode:
cancel()
return
# Cancel (no stance change): any WASD held — the player is steering manually.
if _wasd_held():
cancel()
return
# Arrival — the sole terminal event: apply the mode's arrival stance change, stop.
if current_tile == _goal:
_finish()
return
# Revalidate: replan if the player drifted off the path (desync) or a still-ahead
# tile became impassable (a revealed wall). A dead route cancels (no stance change).
var idx := _path.find(current_tile)
if idx == -1 or not _remaining_passable(idx, is_floor):
var replan := PathFinder.find_path(current_tile, _goal, is_floor)
if replan.size() < 2:
cancel()
return
_path = replan
idx = 0
# Step toward the next planned tile; the shared stance throttle paces it (for a
# sprint follow, as the confirmed stance climbs to Sprint queue_move_step picks up
# the 200 ms cadence automatically — the character accelerates into the sprint).
var next_tile: Vector3i = _path[idx + 1]
var delta := Vector2i(next_tile.x - current_tile.x, next_tile.y - current_tile.y)
InputMapper.queue_move_step(delta)
# ---------------------------------------------------------------------------
# Pure ladder math (headless-testable — test_path_follower.gd)
# ---------------------------------------------------------------------------
## Rung of a stance on the ladder (Sprint 0 .. Crouch 3). Unknown → Walk's rung,
## the GameState default.
static func stance_rung(stance: String) -> int:
var i := STANCE_LADDER.find(stance)
return i if i >= 0 else STANCE_LADDER.find("Walk")
## ToggleStanceUp actions to climb from `stance` to Sprint = its rung. Also the exact
## ToggleStanceDown count that restores back afterwards (net-zero).
static func toggles_to_sprint(stance: String) -> int:
return stance_rung(stance)
## ToggleStanceDown actions to descend from `stance` to Crouch = Crouch's rung minus
## the stance's rung (0 when already crouched).
static func toggles_to_crouch(stance: String) -> int:
return STANCE_LADDER.find("Crouch") - stance_rung(stance)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
## Adopt a path (shared by start / start_sprint / start_crouch); false if too short.
func _adopt_path(path: Array[Vector3i]) -> bool:
if path.size() < 2:
cancel()
return false
_path = path.duplicate()
_goal = _path[_path.size() - 1]
_active = true
return true
## Enter sprint-there: remember the pre-sprint stance and burst the raise toggles.
func _begin_sprint(current_stance: String) -> void:
_mode = Mode.SPRINT
_saved_stance = current_stance
_raise_count = toggles_to_sprint(current_stance)
for _i in _raise_count:
InputMapper.queue_stance_toggle(true) # burst up toward Sprint
## Arrival stance change per mode, then stop:
## SPRINT — burst the inverse toggles to restore the pre-sprint stance (net-zero).
## CROUCH — burst stance_down from the observed arrival stance to Crouch (no restore).
## NORMAL — nothing.
func _finish() -> void:
match _mode:
Mode.SPRINT:
for _i in _raise_count:
InputMapper.queue_stance_toggle(false)
Mode.CROUCH:
# Observed stance at arrival (no toggles were emitted mid-walk, so this is
# the true current stance) → exact ToggleStanceDown count to reach Crouch.
var n := toggles_to_crouch(GameState.player_stance)
for _i in n:
InputMapper.queue_stance_toggle(false)
cancel()
## Every remaining path tile (from idx onward) is still known-floor. A revealed wall
## on the route triggers a replan.
func _remaining_passable(idx: int, is_floor: Callable) -> bool:
for i in range(idx, _path.size()):
if not bool(is_floor.call(_path[i])):
return false
return true
## Any of the four movement actions currently held.
func _wasd_held() -> bool:
return (
Input.is_action_pressed("move_north")
or Input.is_action_pressed("move_south")
or Input.is_action_pressed("move_east")
or Input.is_action_pressed("move_west")
)