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>
237 lines
9.5 KiB
GDScript
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")
|
|
)
|