feat(client): move-here path preview + RMB gesture vocabulary (T-1088, Q-084)

Hover marker + optimal path line over the KNOWN tile store only — the
character plans through what they know; fog is unpathable (info boundary at
the planning layer; the follower additionally revalidates every remaining
tile per step). Pure static 8-dir A*: uniform cost 1 incl. diagonals (D-248
time-optimal, no sqrt2), no corner-cutting, terrain-cost provider seam for
Phase-4 terrain. Execution streams ordinary Move* steps through the existing
throttle — zero protocol change, server validates every step.

RMB vocabulary (live-session spec): click = walk there at current stance;
double-click = sprint there (ToggleStanceUp burst — server toggle handler
verified cooldown-free so bursts climb deterministically — with net-zero
restore on arrival; a double upgrades the active follow in place);
long-press >=400ms = go there then Crouch on arrival, no restore (input-
vocabulary prototype; real cover mechanics are future combat design).
Cancellation: WASD override (stance kept), invalidation, teleport,
suppression. Two additive InputMapper seams (queue_move_step,
queue_stance_toggle); mouse unproject shared with the facing provider
(ground_hit_local). 44 new gdUnit tests across finder + follower ladders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 19:52:03 +02:00
co-authored by Claude Fable 5
parent 98032e9d5a
commit 49c8994943
8 changed files with 1034 additions and 10 deletions
+44
View File
@@ -175,6 +175,50 @@ func flush_queue() -> Array[Dictionary]:
return queue
# T-1088 click-to-move seam (design §9 / D-248): queue ONE 8-direction step from a
# sim tile delta (e.g. Vector2i(1, 0) = East, Vector2i(1, 1) = Southeast), gated by
# the SAME per-stance throttle held-WASD uses (_last_move_msec + MOVE_INTERVAL_MS).
# The sandbox path follower calls this once per frame while walking a client-planned
# path; the shared throttle paces it to the exact stance cadence (no duplicated
# interval table) and interleaves cleanly with WASD — a held key and a queued step
# can never both fire inside one window. Returns true when the step was queued,
# false when throttled or input-suppressed. Ordinary Move* actions only: zero
# protocol change, no client prediction, the server validates every step (D-010).
func queue_move_step(tile_delta: Vector2i) -> bool:
if GameState.dialogue_active or GameState.free_camera_mode:
return false
if tile_delta == Vector2i.ZERO:
return false
var now := Time.get_ticks_msec()
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
if now - _last_move_msec < interval:
return false
_last_move_msec = now
input_queue.append(
{
"action": _dir_to_action(tile_delta),
"timestamp_msec": now,
}
)
return true
# T-1088 double-RMB sprint-there seam: queue ONE ordinary stance toggle (up = toward
# Sprint, down = toward Crouch) through the same input_queue movement rides. Discrete,
# so no throttle — the server's handle_toggle_stance has no cooldown, so the sandbox
# follower can burst the exact ladder distance and trust it (server validates). No-op
# while input is suppressed (dialogue / free camera), matching movement.
func queue_stance_toggle(up: bool) -> void:
if GameState.dialogue_active or GameState.free_camera_mode:
return
input_queue.append(
{
"action": Action.TOGGLE_STANCE_UP if up else Action.TOGGLE_STANCE_DOWN,
"timestamp_msec": Time.get_ticks_msec(),
}
)
## Reset facing state to default (North). Use in tests per D-030 testability.
func reset_facing_state() -> void:
facing_angle = -PI / 2.0