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") )