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:
@@ -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
|
||||
|
||||
@@ -36,6 +36,14 @@ var _anim: LocomotionAnim = null
|
||||
## Mouse-aim facing provider (design §9) — the installed Callable keeps it alive;
|
||||
## referenced here too so the seam's owner is greppable. Cleared in _exit_tree().
|
||||
var _aim_provider: SandboxMouseAimProvider = null
|
||||
## T-1088 click-to-move (Live-session feedback item 2): the mouse-over path
|
||||
## preview (Node3D under WorldRoot) and the path follower (RefCounted, ticked
|
||||
## per frame). Installed in _ready(); the RMB gesture handling is in _unhandled_input.
|
||||
var _path_preview: PathPreview = null
|
||||
var _path_follower: PathFollower = null
|
||||
## Time (ms) a non-double RMB press began, -1 = none pending. Resolves click vs
|
||||
## long-press on RELEASE (RMB_LONG_PRESS_MS threshold).
|
||||
var _rmb_press_msec: int = -1
|
||||
var _first_snapshot_seen: bool = false
|
||||
var _gameplay_paused: bool = false # D-170: implant fullscreen occludes gameplay
|
||||
var _autopilot_spec: String = "" # design §10.2: raw SR_AUTOPILOT string (kept for debugging)
|
||||
@@ -92,6 +100,10 @@ func _ready() -> void:
|
||||
# (4) InputMapper facing provider (design §9).
|
||||
_install_facing_provider()
|
||||
|
||||
# (4b) T-1088 click-to-move (Live-session feedback item 2): hover path preview
|
||||
# + follower. See _install_click_to_move.
|
||||
_install_click_to_move()
|
||||
|
||||
# (5) D-170: the occlusion *mechanism* is CanvasItem-only, the signal is not —
|
||||
# a 3D scene connects it directly to a pause flag.
|
||||
HudGroups.gameplay_occluded.connect(_on_gameplay_occluded)
|
||||
@@ -245,6 +257,12 @@ func _input_suppressed() -> bool:
|
||||
# 0.0-blend reset rides its own connection (LocomotionAnim.setup).
|
||||
func _on_rig_teleported(_pos_m: Vector3) -> void:
|
||||
camera_rig.snap_to_target()
|
||||
# T-1088: a teleport invalidates any in-flight click-to-move and stale preview
|
||||
# (design item 2 cancel condition — the planned route no longer applies).
|
||||
if _path_follower != null:
|
||||
_path_follower.cancel()
|
||||
if _path_preview != null:
|
||||
_path_preview.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -263,6 +281,84 @@ func _install_facing_provider() -> void:
|
||||
InputMapper.facing_angle_provider = Callable(_aim_provider, "get_facing_angle")
|
||||
|
||||
|
||||
## Click-to-move seam (T-1088 Live-session feedback item 2): the mouse-over path
|
||||
## preview — a Node3D under WorldRoot so it inherits the 45° map rotation for free —
|
||||
## and the path follower (RefCounted, ticked from _per_frame_update). Both plan over
|
||||
## the greybox KNOWN store via _is_floor and honour the information boundary (fog is
|
||||
## unpathable). The RMB commit is handled in _unhandled_input; nothing crosses a new
|
||||
## wire — the follower streams ordinary Move* steps through InputMapper's throttle.
|
||||
func _install_click_to_move() -> void:
|
||||
_path_preview = PathPreview.new()
|
||||
_path_preview.name = "PathPreview"
|
||||
world_root.add_child(_path_preview)
|
||||
_path_preview.setup(camera_rig.get_camera(), world_root)
|
||||
_path_follower = PathFollower.new()
|
||||
|
||||
|
||||
## RMB gesture vocabulary for click-to-move (design item 2 + sprint/crouch additions).
|
||||
## RMB, not LMB: no mouse button is bound in the input map (movement is WASD, interact
|
||||
## is E), so both are free — RMB is the tactical-genre move-here idiom and leaves LMB
|
||||
## open for the future click-to-interact/select verb. Read raw here (no project.godot
|
||||
## action) so the binding stays sandbox-local and the shared input map is untouched.
|
||||
## Single click (press, quick release) → walk the path at the current stance.
|
||||
## Double click (double_click press) → sprint-there: UPGRADE the in-flight
|
||||
## single-click follow in place (Godot delivers the double's first press as an
|
||||
## ordinary click that already started it), or start a fresh sprint follow.
|
||||
## Long press (held ≥ RMB_LONG_PRESS_MS, committed on RELEASE) → go there, then
|
||||
## Crouch on arrival ("take cover" input-vocabulary prototype only — real cover
|
||||
## mechanics are future combat design).
|
||||
## Click vs long-press can only be told apart on release, so the single click commits
|
||||
## on RELEASE (a few ms later — imperceptible); the double still commits on its press.
|
||||
## The path used is always the preview's current path (the hover tile at that instant).
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not (event is InputEventMouseButton):
|
||||
return
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.button_index != MOUSE_BUTTON_RIGHT:
|
||||
return
|
||||
# Any state where a commit is invalid drops the pending press — a gesture
|
||||
# interrupted by pause / dialogue / free-camera is abandoned.
|
||||
var can_commit := (
|
||||
_first_snapshot_seen
|
||||
and not _gameplay_paused
|
||||
and not _input_suppressed()
|
||||
and _path_preview != null
|
||||
and _path_follower != null
|
||||
)
|
||||
if not can_commit:
|
||||
_rmb_press_msec = -1
|
||||
return
|
||||
|
||||
if mb.pressed:
|
||||
if mb.double_click:
|
||||
_rmb_press_msec = -1 # this gesture resolved as a double — no click/long-press
|
||||
if _path_follower.is_active():
|
||||
_path_follower.upgrade_to_sprint(GameState.player_stance)
|
||||
else:
|
||||
_path_follower.start_sprint(
|
||||
_path_preview.get_current_path(), GameState.player_stance
|
||||
)
|
||||
else:
|
||||
_rmb_press_msec = Time.get_ticks_msec() # defer: decide on release
|
||||
else:
|
||||
if _rmb_press_msec < 0:
|
||||
return # release of a double's press (or nothing pending) — ignore
|
||||
var held := Time.get_ticks_msec() - _rmb_press_msec
|
||||
_rmb_press_msec = -1
|
||||
if held >= SandboxConstants.RMB_LONG_PRESS_MS:
|
||||
_path_follower.start_crouch(_path_preview.get_current_path())
|
||||
else:
|
||||
_path_follower.start(_path_preview.get_current_path())
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
## Greybox known-floor lookup (the PathFinder / follower / preview substrate):
|
||||
## true only for tiles the store KNOWS to be floor (doors collapse to FLOOR on the
|
||||
## wire). Unknown / never-seen / wall are impassable — fog is unpathable (D-010).
|
||||
func _is_floor(tile: Vector3i) -> bool:
|
||||
return greybox.store.has_tile(tile) and greybox.store.kind_of(tile) == GreyboxWorld.Store.Kind.FLOOR
|
||||
|
||||
|
||||
## Per-frame cross-component work owned by the root, gated on the first snapshot
|
||||
## and the D-170 pause flag: the cutaway anchor (design §8 — the rig's INTERPOLATED
|
||||
## world XZ, so the cut zone glides and spatial smoothstep becomes temporal
|
||||
@@ -280,6 +376,20 @@ func _per_frame_update(delta: float) -> void:
|
||||
greybox.set_cutaway_char_pos(player_rig.global_position, reach)
|
||||
_anim.update(delta)
|
||||
|
||||
# T-1088 click-to-move (design item 2): hover preview + path follower. Both plan
|
||||
# from the server-confirmed character tile so a plan starts from ground truth,
|
||||
# not an optimistic guess. Under input suppression (dialogue / free camera) the
|
||||
# preview hides and any walk is cancelled — the same condition that freezes idle
|
||||
# facing. Recompute inside the preview is edge-triggered, so this is cheap.
|
||||
var char_tile := SandboxSpace.wire_to_tile(GameState.player_position.x, GameState.player_position.y)
|
||||
var is_floor := Callable(self, "_is_floor")
|
||||
if _input_suppressed():
|
||||
_path_preview.clear()
|
||||
_path_follower.cancel()
|
||||
else:
|
||||
_path_preview.update(char_tile, is_floor, greybox.store.size())
|
||||
_path_follower.tick(char_tile, is_floor)
|
||||
|
||||
|
||||
## Hard camera-pivot snap (design §7) — first-snapshot latch + teleport fan-out.
|
||||
func _snap_camera_to_player() -> void:
|
||||
|
||||
@@ -79,20 +79,34 @@ static func compute_facing_angle(
|
||||
rig_local_pos: Vector3,
|
||||
deadzone_m: float
|
||||
) -> float:
|
||||
# Ray -> y=0 ground plane, world space.
|
||||
if absf(ray_dir.y) < _RAY_PARALLEL_EPS:
|
||||
var hit_local: Variant = ground_hit_local(ray_origin, ray_dir, world_root_transform)
|
||||
if hit_local == null:
|
||||
return NAN
|
||||
var t := -ray_origin.y / ray_dir.y
|
||||
if t < 0.0:
|
||||
return NAN
|
||||
var hit_world := ray_origin + ray_dir * t
|
||||
# Undo the 45° map rotation: WorldRoot-local axes are sim axes (SandboxSpace §2:
|
||||
# local +X = sim East, local +Z = sim South).
|
||||
var hit_local := world_root_transform.affine_inverse() * hit_world
|
||||
var delta := hit_local - rig_local_pos
|
||||
var delta: Vector3 = (hit_local as Vector3) - rig_local_pos
|
||||
var planar := Vector2(delta.x, delta.z)
|
||||
if planar.length_squared() < deadzone_m * deadzone_m:
|
||||
return NAN
|
||||
# atan2(z, x) IS the sim convention (0 = East, +PI/2 = South) because local
|
||||
# +Z = sim +y (South, Y-down radians — server vision_cone.rs).
|
||||
return atan2(delta.z, delta.x)
|
||||
|
||||
|
||||
## Pure ground-plane pick (headless-testable): a world-space mouse ray -> its y=0
|
||||
## ground-plane intersection expressed in WorldRoot-LOCAL metres (undoing the 45°
|
||||
## map rotation, so the result is sim-aligned space per SandboxSpace §2). Shared by
|
||||
## the facing provider above and the T-1088 hover-tile picker (path_preview.gd),
|
||||
## which feeds the returned point straight into SandboxSpace.world_to_tile — one
|
||||
## unproject, one place. Returns null (not a Vector3) when the ray is parallel to
|
||||
## the ground or the plane sits behind the ray origin.
|
||||
static func ground_hit_local(
|
||||
ray_origin: Vector3, ray_dir: Vector3, world_root_transform: Transform3D
|
||||
) -> Variant:
|
||||
if absf(ray_dir.y) < _RAY_PARALLEL_EPS:
|
||||
return null
|
||||
var t := -ray_origin.y / ray_dir.y
|
||||
if t < 0.0:
|
||||
return null
|
||||
var hit_world := ray_origin + ray_dir * t
|
||||
# Undo the 45° map rotation: WorldRoot-local axes are sim axes (SandboxSpace §2:
|
||||
# local +X = sim East, local +Z = sim South).
|
||||
return world_root_transform.affine_inverse() * hit_world
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
class_name PathFinder
|
||||
extends RefCounted
|
||||
## T-1088 (Live-session feedback item 2; D-248/D-053) — pure static 8-directional
|
||||
## A* over the greybox KNOWN-tile store, feeding the mouse-over path preview
|
||||
## (path_preview.gd) and the click-to-move follower (path_follower.gd).
|
||||
##
|
||||
## Headless-testable by construction: no scene, autoload, or GameState access —
|
||||
## the caller injects tile knowledge as Callables, so the whole search is covered
|
||||
## in test_path_finder.gd with synthetic stores.
|
||||
##
|
||||
## INFORMATION BOUNDARY (D-010): the character plans only through tiles it KNOWS
|
||||
## to be walkable. `is_floor(tile)` returns true ONLY for known floor/door tiles;
|
||||
## unknown/void/wall are impassable. Fog is unpathable — you cannot route the
|
||||
## character through space it has never observed. This is honest to the sim's
|
||||
## asymmetric-information model, not a UI convenience: the same never-evict store
|
||||
## that dims remembered tiles (§3) is the pathing substrate.
|
||||
##
|
||||
## COST MODEL (D-248/D-053): uniform step cost for cardinal AND diagonal — the
|
||||
## wire charges a diagonal the same movement cadence as a cardinal (no sqrt(2)),
|
||||
## so the time-optimal path is the one with the FEWEST STEPS and diagonals are
|
||||
## "free" length. Chebyshev distance (max(|dx|, |dy|)) is therefore the exact
|
||||
## obstacle-free cost and an admissible + consistent A* heuristic. Per-tile entry
|
||||
## cost is scaled by `terrain_cost(tile)` (default uniform 1.0) — the Phase-4 seam
|
||||
## for mud/rubble/slope. terrain_cost is expected >= 1.0 (a difficulty multiplier);
|
||||
## values below 1.0 still return a valid path but can defeat heuristic optimality.
|
||||
##
|
||||
## NO CORNER-CUTTING: a diagonal step is legal only when BOTH shared-edge cardinal
|
||||
## neighbours are also passable — the character never slips through a wall corner.
|
||||
|
||||
## Neighbour offsets in a fixed order (cardinals first, then diagonals). The order
|
||||
## is load-bearing for determinism: with the tie-break in _entry_less it fixes
|
||||
## which of several equal-cost paths is returned.
|
||||
const _DIRS: Array[Vector2i] = [
|
||||
Vector2i(1, 0), # East
|
||||
Vector2i(-1, 0), # West
|
||||
Vector2i(0, 1), # South (sim +y is South, Y-down — SandboxSpace §2)
|
||||
Vector2i(0, -1), # North
|
||||
Vector2i(1, 1), # Southeast
|
||||
Vector2i(1, -1), # Northeast
|
||||
Vector2i(-1, 1), # Southwest
|
||||
Vector2i(-1, -1), # Northwest
|
||||
]
|
||||
|
||||
|
||||
## Find the time-optimal path from `start` to `goal` over the known-tile substrate.
|
||||
## Returns an Array[Vector3i] of tile coords INCLUDING both endpoints, or an empty
|
||||
## array when: goal (or start) is not known-floor, or the goal is unreachable
|
||||
## through known tiles. start == goal returns a one-element path (already there —
|
||||
## the follower treats a path shorter than 2 as "nothing to walk").
|
||||
##
|
||||
## is_floor: (Vector3i) -> bool — true only for known floor/door tiles.
|
||||
## terrain_cost: (Vector3i) -> float — per-tile entry cost (default uniform 1.0).
|
||||
static func find_path(
|
||||
start: Vector3i, goal: Vector3i, is_floor: Callable, terrain_cost: Callable = Callable()
|
||||
) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
if not _passable(start, is_floor) or not _passable(goal, is_floor):
|
||||
return result
|
||||
if start == goal:
|
||||
result.append(start)
|
||||
return result
|
||||
|
||||
var open_heap: Array[Dictionary] = []
|
||||
var g_score: Dictionary = {} # Vector3i -> float (best known cost from start)
|
||||
var came_from: Dictionary = {} # Vector3i -> Vector3i
|
||||
var closed: Dictionary = {} # Vector3i -> true (expanded — never re-opened)
|
||||
var seq := 0 # monotonic insertion counter — the final determinism tiebreak
|
||||
|
||||
g_score[start] = 0.0
|
||||
var h0 := _heuristic(start, goal)
|
||||
_heap_push(open_heap, {"pos": start, "f": h0, "h": h0, "seq": seq})
|
||||
seq += 1
|
||||
|
||||
while not open_heap.is_empty():
|
||||
var current := _heap_pop(open_heap)
|
||||
var cpos: Vector3i = current["pos"]
|
||||
if cpos == goal:
|
||||
return _reconstruct(came_from, goal, start)
|
||||
if closed.has(cpos):
|
||||
continue # stale heap entry (lazy deletion) — already expanded
|
||||
closed[cpos] = true
|
||||
var cg: float = g_score[cpos]
|
||||
for dir in _DIRS:
|
||||
var npos := Vector3i(cpos.x + dir.x, cpos.y + dir.y, cpos.z)
|
||||
if closed.has(npos):
|
||||
continue
|
||||
if not _passable(npos, is_floor):
|
||||
continue
|
||||
# No corner-cutting: a diagonal needs BOTH shared cardinals passable.
|
||||
if dir.x != 0 and dir.y != 0:
|
||||
if not _passable(Vector3i(cpos.x + dir.x, cpos.y, cpos.z), is_floor):
|
||||
continue
|
||||
if not _passable(Vector3i(cpos.x, cpos.y + dir.y, cpos.z), is_floor):
|
||||
continue
|
||||
var tentative_g: float = cg + _cost(npos, terrain_cost)
|
||||
if tentative_g < float(g_score.get(npos, INF)):
|
||||
came_from[npos] = cpos
|
||||
g_score[npos] = tentative_g
|
||||
var h := _heuristic(npos, goal)
|
||||
_heap_push(open_heap, {"pos": npos, "f": tentative_g + h, "h": h, "seq": seq})
|
||||
seq += 1
|
||||
return result # open set drained — goal unreachable through known tiles
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Passable iff the lookup is installed AND reports the tile as known-floor.
|
||||
## An unset/invalid Callable makes everything impassable (fail-closed — never
|
||||
## route through space with no knowledge source).
|
||||
static func _passable(tile: Vector3i, is_floor: Callable) -> bool:
|
||||
if not is_floor.is_valid():
|
||||
return false
|
||||
return bool(is_floor.call(tile))
|
||||
|
||||
|
||||
## Per-tile entry cost. Default 1.0 (uniform, D-248); the terrain provider raises
|
||||
## it for difficult terrain in Phase 4. Non-positive returns fall back to 1.0 so a
|
||||
## misbehaving provider can never zero out or invert step cost (Dijkstra safety).
|
||||
static func _cost(tile: Vector3i, terrain_cost: Callable) -> float:
|
||||
if terrain_cost.is_valid():
|
||||
var c := float(terrain_cost.call(tile))
|
||||
if c > 0.0:
|
||||
return c
|
||||
return 1.0
|
||||
|
||||
|
||||
## Chebyshev distance — exact obstacle-free cost under the uniform 8-dir step cost
|
||||
## (diagonal == cardinal, D-248); admissible + consistent when terrain_cost >= 1.
|
||||
static func _heuristic(a: Vector3i, b: Vector3i) -> float:
|
||||
return float(maxi(absi(a.x - b.x), absi(a.y - b.y)))
|
||||
|
||||
|
||||
## Walk came_from back from goal to start and reverse — path includes both ends.
|
||||
static func _reconstruct(came_from: Dictionary, goal: Vector3i, start: Vector3i) -> Array[Vector3i]:
|
||||
var path: Array[Vector3i] = [goal]
|
||||
var cur := goal
|
||||
while cur != start:
|
||||
cur = came_from[cur]
|
||||
path.append(cur)
|
||||
path.reverse()
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary min-heap (open set). GDScript has no priority queue; this keeps A*
|
||||
# expansion O(log n) and, with _entry_less, fully deterministic.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Total order on frontier entries: lowest f first; ties broken toward the goal
|
||||
## (lower h — the standard A* tiebreak that also speeds convergence); remaining
|
||||
## ties broken by insertion order (seq) so the result never depends on Dictionary
|
||||
## hash iteration order.
|
||||
static func _entry_less(a: Dictionary, b: Dictionary) -> bool:
|
||||
if a["f"] != b["f"]:
|
||||
return a["f"] < b["f"]
|
||||
if a["h"] != b["h"]:
|
||||
return a["h"] < b["h"]
|
||||
return a["seq"] < b["seq"]
|
||||
|
||||
|
||||
static func _heap_push(heap: Array[Dictionary], entry: Dictionary) -> void:
|
||||
heap.append(entry)
|
||||
var i := heap.size() - 1
|
||||
while i > 0:
|
||||
var parent := (i - 1) >> 1
|
||||
if not _entry_less(heap[i], heap[parent]):
|
||||
break
|
||||
var tmp: Dictionary = heap[parent]
|
||||
heap[parent] = heap[i]
|
||||
heap[i] = tmp
|
||||
i = parent
|
||||
|
||||
|
||||
static func _heap_pop(heap: Array[Dictionary]) -> Dictionary:
|
||||
var top: Dictionary = heap[0]
|
||||
var last: Dictionary = heap.pop_back()
|
||||
if heap.is_empty():
|
||||
return top
|
||||
heap[0] = last
|
||||
var n := heap.size()
|
||||
var i := 0
|
||||
while true:
|
||||
var smallest := i
|
||||
var l := 2 * i + 1
|
||||
var r := 2 * i + 2
|
||||
if l < n and _entry_less(heap[l], heap[smallest]):
|
||||
smallest = l
|
||||
if r < n and _entry_less(heap[r], heap[smallest]):
|
||||
smallest = r
|
||||
if smallest == i:
|
||||
break
|
||||
var tmp: Dictionary = heap[i]
|
||||
heap[i] = heap[smallest]
|
||||
heap[smallest] = tmp
|
||||
i = smallest
|
||||
return top
|
||||
@@ -0,0 +1,236 @@
|
||||
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")
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
class_name PathPreview
|
||||
extends Node3D
|
||||
## T-1088 (Live-session feedback item 2) — mouse-over move-here marker + optimal
|
||||
## path line. A Node3D placed UNDER WorldRoot by the sandbox root, so it inherits
|
||||
## the single D-148 45° map rotation for free: its children live in the same
|
||||
## WorldRoot-local (sim-aligned) metric space the greybox tiles do, and
|
||||
## SandboxSpace.tile_to_world positions them directly.
|
||||
##
|
||||
## Two visuals, both children built in setup():
|
||||
## - marker: a flat PlaneMesh quad on the hovered destination subtile, lifted a
|
||||
## hair above the floor, in a distinct accent colour.
|
||||
## - line: an ImmediateMesh line strip through the tile centers of the planned
|
||||
## path, lifted slightly higher so it reads on top of the marker.
|
||||
##
|
||||
## The A* runs over the greybox KNOWN-tile store via the injected is_floor
|
||||
## Callable (PathFinder honours the information boundary — fog is unpathable).
|
||||
## Recompute is edge-triggered — on hovered-tile change, character-tile change, or
|
||||
## knowledge growth — NEVER per frame (A* every frame over ~14k tiles is waste).
|
||||
## Hidden whenever there is no valid path: hovering unknown/void/wall, or a
|
||||
## known-floor tile with no known route.
|
||||
##
|
||||
## Note on knowledge_version: the root passes store.size() (monotonic — the store
|
||||
## never evicts). A revealed wall REPLACING a known floor (a rekind, no size
|
||||
## change) can leave the drawn line momentarily stale until the next hover/step,
|
||||
## but the EXECUTED route is always safe — path_follower.gd revalidates every
|
||||
## remaining tile per step and replans. Preview is advisory; the follower is
|
||||
## authoritative.
|
||||
|
||||
## Hover-pick ray sources, injected in setup().
|
||||
var _camera: Camera3D = null
|
||||
var _world_root: Node3D = null
|
||||
|
||||
var _marker: MeshInstance3D = null
|
||||
var _line: MeshInstance3D = null
|
||||
var _line_mesh: ImmediateMesh = null
|
||||
|
||||
## Latest computed path (character tile -> hovered tile, inclusive). Empty when
|
||||
## the hovered tile is unpathable. Read by the root's click handler.
|
||||
var _current_path: Array[Vector3i] = []
|
||||
|
||||
## Edge-trigger memory — recompute only when one of these changes.
|
||||
var _last_hover: Vector3i = Vector3i(2147483647, 0, 0) # sentinel: forces first pass
|
||||
var _last_char_tile: Vector3i = Vector3i(2147483647, 0, 0)
|
||||
var _last_version: int = -1
|
||||
|
||||
|
||||
## Inject the ray-pick nodes and build the two child visuals. Called by the
|
||||
## sandbox root right after the preview is added under WorldRoot.
|
||||
func setup(camera: Camera3D, world_root: Node3D) -> void:
|
||||
_camera = camera
|
||||
_world_root = world_root
|
||||
|
||||
# Move-here marker — PlaneMesh faces +Y natively (like the floor tiles); a
|
||||
# double-sided unshaded material keeps it readable from every camera preset.
|
||||
var quad := PlaneMesh.new()
|
||||
quad.size = Vector2(SandboxConstants.SUBTILE_M, SandboxConstants.SUBTILE_M)
|
||||
_marker = MeshInstance3D.new()
|
||||
_marker.name = "MoveHereMarker"
|
||||
_marker.mesh = quad
|
||||
_marker.material_override = _flat_material(SandboxConstants.PATH_MARKER_COLOR)
|
||||
_marker.visible = false
|
||||
add_child(_marker)
|
||||
|
||||
# Path line — an ImmediateMesh rebuilt on recompute.
|
||||
_line_mesh = ImmediateMesh.new()
|
||||
_line = MeshInstance3D.new()
|
||||
_line.name = "PathLine"
|
||||
_line.mesh = _line_mesh
|
||||
_line.material_override = _flat_material(SandboxConstants.PATH_LINE_COLOR)
|
||||
_line.visible = false
|
||||
add_child(_line)
|
||||
|
||||
|
||||
## One frame of preview maintenance (called by the root while gameplay is live).
|
||||
## char_tile: the server-confirmed character tile (path origin).
|
||||
## is_floor: greybox known-floor lookup (Vector3i) -> bool.
|
||||
## knowledge_version: store.size() — bumps when new tiles are learned.
|
||||
func update(char_tile: Vector3i, is_floor: Callable, knowledge_version: int) -> void:
|
||||
var hover: Variant = _hovered_tile()
|
||||
if hover == null:
|
||||
# Mouse off the ground plane (pointing at sky / degenerate ray).
|
||||
_clear_visuals()
|
||||
_last_hover = Vector3i(2147483647, 0, 0)
|
||||
return
|
||||
var hover_tile: Vector3i = hover
|
||||
if (
|
||||
hover_tile == _last_hover
|
||||
and char_tile == _last_char_tile
|
||||
and knowledge_version == _last_version
|
||||
):
|
||||
return # nothing that affects the path changed — leave visuals as-is
|
||||
_last_hover = hover_tile
|
||||
_last_char_tile = char_tile
|
||||
_last_version = knowledge_version
|
||||
|
||||
_current_path = PathFinder.find_path(char_tile, hover_tile, is_floor)
|
||||
_redraw()
|
||||
|
||||
|
||||
## The planned path (character -> hovered), inclusive of both ends; empty when the
|
||||
## hovered tile is unpathable. The root hands this to the follower on click.
|
||||
func get_current_path() -> Array[Vector3i]:
|
||||
return _current_path
|
||||
|
||||
|
||||
## Hide both visuals and forget the current path (root calls on teleport / when
|
||||
## gameplay is suppressed).
|
||||
func clear() -> void:
|
||||
_current_path = []
|
||||
_clear_visuals()
|
||||
_last_hover = Vector3i(2147483647, 0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## The tile currently under the mouse, or null if the ray misses the ground. Reuses
|
||||
## the facing provider's ground-plane unproject (one unproject, one place), then
|
||||
## SandboxSpace.world_to_tile — WorldRoot-local metres are sim-aligned space.
|
||||
func _hovered_tile() -> Variant:
|
||||
if not is_instance_valid(_camera) or not is_instance_valid(_world_root):
|
||||
return null
|
||||
if not _camera.is_inside_tree():
|
||||
return null
|
||||
var viewport := _camera.get_viewport()
|
||||
if viewport == null:
|
||||
return null
|
||||
var mouse := viewport.get_mouse_position()
|
||||
var hit_local: Variant = SandboxMouseAimProvider.ground_hit_local(
|
||||
_camera.project_ray_origin(mouse),
|
||||
_camera.project_ray_normal(mouse),
|
||||
_world_root.global_transform
|
||||
)
|
||||
if hit_local == null:
|
||||
return null
|
||||
return SandboxSpace.world_to_tile(hit_local as Vector3)
|
||||
|
||||
|
||||
## Repaint marker + line from _current_path. Marker on the destination when a path
|
||||
## exists; line only for a real (>= 2 tile) move.
|
||||
func _redraw() -> void:
|
||||
if _current_path.is_empty():
|
||||
_clear_visuals()
|
||||
return
|
||||
var dest: Vector3i = _current_path[_current_path.size() - 1]
|
||||
var dest_world := SandboxSpace.tile_to_world(dest)
|
||||
_marker.position = Vector3(dest_world.x, SandboxConstants.PATH_MARKER_Y, dest_world.z)
|
||||
_marker.visible = true
|
||||
|
||||
_line_mesh.clear_surfaces()
|
||||
if _current_path.size() >= 2:
|
||||
_line_mesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
|
||||
for tile in _current_path:
|
||||
var c := SandboxSpace.tile_to_world(tile)
|
||||
_line_mesh.surface_add_vertex(Vector3(c.x, SandboxConstants.PATH_LINE_Y, c.z))
|
||||
_line_mesh.surface_end()
|
||||
_line.visible = true
|
||||
else:
|
||||
_line.visible = false
|
||||
|
||||
|
||||
func _clear_visuals() -> void:
|
||||
if _marker != null:
|
||||
_marker.visible = false
|
||||
if _line != null:
|
||||
_line.visible = false
|
||||
if _line_mesh != null:
|
||||
_line_mesh.clear_surfaces()
|
||||
|
||||
|
||||
## Unshaded, double-sided, alpha-blended material — a flat overlay swatch that
|
||||
## ignores scene lighting so the accent colour stays constant at any camera pitch.
|
||||
func _flat_material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat.albedo_color = color
|
||||
mat.vertex_color_use_as_albedo = false
|
||||
return mat
|
||||
@@ -0,0 +1,173 @@
|
||||
## PathFinder A* (T-1088 Live-session feedback item 2): 8-directional search over
|
||||
## the greybox KNOWN-tile store, exercised on synthetic floor sets. Covers the
|
||||
## uniform cardinal/diagonal cost (no sqrt(2), D-248), no-corner-cutting, the
|
||||
## information boundary (unknown tiles impassable), the terrain-cost seam,
|
||||
## unreachable-returns-empty, and deterministic tie-breaking.
|
||||
##
|
||||
## Pure static: no scene, autoload, or GameState — floor knowledge is injected as
|
||||
## a Callable, so the whole search is headless (design §10.4).
|
||||
class_name TestPathFinder
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
## A floor lookup over an explicit set of known-floor tiles — everything else is
|
||||
## impassable (unknown/void/wall), mirroring GreyboxWorld.Store.kind_of.
|
||||
func _lookup(floors: Array) -> Callable:
|
||||
var known := {}
|
||||
for t: Vector3i in floors:
|
||||
known[t] = true
|
||||
return func(tile: Vector3i) -> bool: return known.has(tile)
|
||||
|
||||
|
||||
## Every floor tile in an inclusive rectangle (z=0).
|
||||
func _rect(x0: int, x1: int, y0: int, y1: int) -> Array:
|
||||
var out: Array = []
|
||||
for x in range(x0, x1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
out.append(Vector3i(x, y, 0))
|
||||
return out
|
||||
|
||||
|
||||
## True if every consecutive pair in the path differs by a king-move (adjacent
|
||||
## incl. diagonal) — a well-formed contiguous path.
|
||||
func _is_contiguous(path: Array[Vector3i]) -> bool:
|
||||
for i in range(1, path.size()):
|
||||
var d: Vector3i = path[i] - path[i - 1]
|
||||
if absi(d.x) > 1 or absi(d.y) > 1 or d.z != 0 or d == Vector3i.ZERO:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
# -- shortest path: straight (cardinal) ---------------------------------------------
|
||||
|
||||
|
||||
func test_straight_cardinal_shortest() -> void:
|
||||
var lookup := _lookup(_rect(0, 5, 0, 0)) # one row, y=0
|
||||
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(5, 0, 0), lookup)
|
||||
# 5 steps east -> 6 tiles, endpoints inclusive, contiguous.
|
||||
assert_int(path.size()).is_equal(6)
|
||||
assert_object(path[0]).is_equal(Vector3i(0, 0, 0))
|
||||
assert_object(path[path.size() - 1]).is_equal(Vector3i(5, 0, 0))
|
||||
assert_bool(_is_contiguous(path)).is_true()
|
||||
|
||||
|
||||
# -- shortest path: diagonal (uniform cost — no sqrt(2), D-248) -----------------------
|
||||
|
||||
|
||||
func test_diagonal_shortest_beats_cardinal() -> void:
|
||||
var lookup := _lookup(_rect(0, 3, 0, 3))
|
||||
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(3, 3, 0), lookup)
|
||||
# Chebyshev distance 3 -> 4 tiles all-diagonal; a diagonal costs the same as a
|
||||
# cardinal, so 3 diagonal steps (cost 3) beat 6 cardinal steps (cost 6).
|
||||
assert_int(path.size()).is_equal(4)
|
||||
assert_bool(_is_contiguous(path)).is_true()
|
||||
# Every leg is a true diagonal.
|
||||
for i in range(1, path.size()):
|
||||
var d: Vector3i = path[i] - path[i - 1]
|
||||
assert_int(absi(d.x)).is_equal(1)
|
||||
assert_int(absi(d.y)).is_equal(1)
|
||||
|
||||
|
||||
# -- no corner-cutting ---------------------------------------------------------------
|
||||
|
||||
|
||||
func test_no_corner_cut_blocks_bare_diagonal() -> void:
|
||||
# Only the two diagonal-opposite tiles are floor; both shared cardinals are
|
||||
# missing, so the diagonal is illegal and there is no other route.
|
||||
var lookup := _lookup([Vector3i(0, 0, 0), Vector3i(1, 1, 0)])
|
||||
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(1, 1, 0), lookup)
|
||||
assert_array(path).is_empty()
|
||||
|
||||
|
||||
func test_no_corner_cut_takes_legal_l_route() -> void:
|
||||
# One shared cardinal present -> the bare diagonal is still illegal (needs
|
||||
# BOTH), but an L via the open cardinal is legal: 2 cardinal steps.
|
||||
var lookup := _lookup([Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(1, 1, 0)])
|
||||
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(1, 1, 0), lookup)
|
||||
assert_array(path).is_equal(
|
||||
[Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(1, 1, 0)] as Array[Vector3i]
|
||||
)
|
||||
|
||||
|
||||
func test_diagonal_allowed_when_both_cardinals_open() -> void:
|
||||
# Full 2x2 -> the diagonal is legal and cheaper than the L, so it wins.
|
||||
var lookup := _lookup(_rect(0, 1, 0, 1))
|
||||
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(1, 1, 0), lookup)
|
||||
assert_array(path).is_equal([Vector3i(0, 0, 0), Vector3i(1, 1, 0)] as Array[Vector3i])
|
||||
|
||||
|
||||
# -- information boundary: unknown tiles impassable (fog is unpathable) ----------------
|
||||
|
||||
|
||||
func test_unknown_tile_blocks_route() -> void:
|
||||
# A gap in the row (tile (2,0) never observed) severs the only path.
|
||||
var lookup := _lookup([Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(3, 0, 0)])
|
||||
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(3, 0, 0), lookup)
|
||||
assert_array(path).is_empty()
|
||||
|
||||
|
||||
func test_goal_not_floor_returns_empty() -> void:
|
||||
# Hovering an unknown/void/wall tile: no path, ever.
|
||||
var lookup := _lookup(_rect(0, 3, 0, 0))
|
||||
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(9, 9, 0), lookup)
|
||||
assert_array(path).is_empty()
|
||||
|
||||
|
||||
func test_start_equals_goal_is_single_tile() -> void:
|
||||
var lookup := _lookup([Vector3i(5, 5, 0)])
|
||||
var path := PathFinder.find_path(Vector3i(5, 5, 0), Vector3i(5, 5, 0), lookup)
|
||||
assert_array(path).is_equal([Vector3i(5, 5, 0)] as Array[Vector3i])
|
||||
|
||||
|
||||
# -- terrain-cost seam ----------------------------------------------------------------
|
||||
|
||||
|
||||
func test_terrain_cost_forces_detour() -> void:
|
||||
# 3x3 open. Straight (0,1)->(1,1)->(2,1) is 2 steps, but (1,1) costs 100, so
|
||||
# A* detours through the cheap corner (1,0) instead — proving the seam steers
|
||||
# the search without touching passability.
|
||||
var lookup := _lookup(_rect(0, 2, 0, 2))
|
||||
var costly := Vector3i(1, 1, 0)
|
||||
var terrain := func(tile: Vector3i) -> float: return 100.0 if tile == costly else 1.0
|
||||
var path := PathFinder.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0), lookup, terrain)
|
||||
assert_bool(path.has(costly)).is_false()
|
||||
assert_int(path.size()).is_equal(3)
|
||||
assert_bool(_is_contiguous(path)).is_true()
|
||||
|
||||
|
||||
func test_uniform_default_takes_straight_line() -> void:
|
||||
# No terrain provider -> uniform cost 1: the same query goes straight through
|
||||
# the middle (the contrast case for the detour above).
|
||||
var lookup := _lookup(_rect(0, 2, 0, 2))
|
||||
var path := PathFinder.find_path(Vector3i(0, 1, 0), Vector3i(2, 1, 0), lookup)
|
||||
assert_array(path).is_equal(
|
||||
[Vector3i(0, 1, 0), Vector3i(1, 1, 0), Vector3i(2, 1, 0)] as Array[Vector3i]
|
||||
)
|
||||
|
||||
|
||||
# -- unreachable ----------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_unreachable_returns_empty() -> void:
|
||||
# Goal is known-floor but on a disconnected island — the open set drains.
|
||||
var lookup := _lookup([Vector3i(0, 0, 0), Vector3i(1, 0, 0), Vector3i(9, 9, 0)])
|
||||
var path := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(9, 9, 0), lookup)
|
||||
assert_array(path).is_empty()
|
||||
|
||||
|
||||
# -- determinism ----------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_tie_break_is_deterministic() -> void:
|
||||
# 3x3 open, (0,0)->(2,0): the straight cardinal route and the via-(1,1) route
|
||||
# both cost 2 (uniform diagonal). The fixed neighbour order + (f, h, seq)
|
||||
# tiebreak must return the SAME path every call, never Dictionary hash order.
|
||||
var lookup := _lookup(_rect(0, 2, 0, 2))
|
||||
var a := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(2, 0, 0), lookup)
|
||||
var b := PathFinder.find_path(Vector3i(0, 0, 0), Vector3i(2, 0, 0), lookup)
|
||||
assert_array(a).is_equal(b)
|
||||
# And it is a valid shortest path (3 tiles, contiguous, correct endpoints).
|
||||
assert_int(a.size()).is_equal(3)
|
||||
assert_object(a[0]).is_equal(Vector3i(0, 0, 0))
|
||||
assert_object(a[a.size() - 1]).is_equal(Vector3i(2, 0, 0))
|
||||
assert_bool(_is_contiguous(a)).is_true()
|
||||
@@ -0,0 +1,65 @@
|
||||
## PathFollower stance-ladder math (T-1088 double-RMB sprint-there): the number of
|
||||
## ToggleStanceUp actions to reach Sprint from each stance, and the net-zero restore
|
||||
## property. Pure static — the ladder helpers touch no autoloads, so they run
|
||||
## headless (the instance follow/step behaviour is live-only, verified in the smoke).
|
||||
class_name TestPathFollower
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func test_toggles_to_sprint_from_each_stance() -> void:
|
||||
# Ladder: Crouch < Careful < Walk < Sprint (server MovementStance step_up).
|
||||
assert_int(PathFollower.toggles_to_sprint("Sprint")).is_equal(0)
|
||||
assert_int(PathFollower.toggles_to_sprint("Walk")).is_equal(1)
|
||||
assert_int(PathFollower.toggles_to_sprint("Careful")).is_equal(2)
|
||||
assert_int(PathFollower.toggles_to_sprint("Crouch")).is_equal(3)
|
||||
|
||||
|
||||
func test_stance_rung_matches_ladder() -> void:
|
||||
assert_int(PathFollower.stance_rung("Sprint")).is_equal(0)
|
||||
assert_int(PathFollower.stance_rung("Walk")).is_equal(1)
|
||||
assert_int(PathFollower.stance_rung("Careful")).is_equal(2)
|
||||
assert_int(PathFollower.stance_rung("Crouch")).is_equal(3)
|
||||
|
||||
|
||||
func test_unknown_stance_defaults_to_walk() -> void:
|
||||
# Defensive: an unrecognized / empty stance is treated as Walk (GameState default).
|
||||
assert_int(PathFollower.stance_rung("Bogus")).is_equal(1)
|
||||
assert_int(PathFollower.toggles_to_sprint("")).is_equal(1)
|
||||
|
||||
|
||||
func test_restore_count_equals_raise_count_net_zero() -> void:
|
||||
# Restore bursts the SAME number of ToggleStanceDown as the raise bursted
|
||||
# ToggleStanceUp. Since Sprint is rung 0, raising from a stance to Sprint climbs
|
||||
# `rung` steps and restoring drops `rung` steps → back to the exact start rung,
|
||||
# regardless of snapshot RTT. The single source of truth is toggles_to_sprint.
|
||||
for stance: String in ["Sprint", "Walk", "Careful", "Crouch"]:
|
||||
var raise_toggles := PathFollower.toggles_to_sprint(stance)
|
||||
var restore_toggles := PathFollower.toggles_to_sprint(stance)
|
||||
assert_int(restore_toggles).is_equal(raise_toggles)
|
||||
# Net-zero: start rung, up to Sprint (0), then down `raise_toggles` == start.
|
||||
assert_int(0 + restore_toggles).is_equal(PathFollower.stance_rung(stance))
|
||||
|
||||
|
||||
# -- long-press crouch-on-arrival ------------------------------------------------------
|
||||
|
||||
|
||||
func test_toggles_to_crouch_from_each_stance() -> void:
|
||||
# Long-press lowers to Crouch on arrival: ToggleStanceDown from the arrival stance
|
||||
# down to Crouch (rung 3). No restore.
|
||||
assert_int(PathFollower.toggles_to_crouch("Sprint")).is_equal(3)
|
||||
assert_int(PathFollower.toggles_to_crouch("Walk")).is_equal(2)
|
||||
assert_int(PathFollower.toggles_to_crouch("Careful")).is_equal(1)
|
||||
assert_int(PathFollower.toggles_to_crouch("Crouch")).is_equal(0)
|
||||
|
||||
|
||||
func test_toggles_to_crouch_unknown_defaults_to_walk() -> void:
|
||||
# Unknown/empty stance is treated as Walk (rung 1) → 2 down-toggles to Crouch.
|
||||
assert_int(PathFollower.toggles_to_crouch("Bogus")).is_equal(2)
|
||||
assert_int(PathFollower.toggles_to_crouch("")).is_equal(2)
|
||||
|
||||
|
||||
func test_crouch_lands_on_bottom_rung() -> void:
|
||||
# After the crouch burst the character sits exactly on Crouch's rung from any start.
|
||||
for stance: String in ["Sprint", "Walk", "Careful", "Crouch"]:
|
||||
var down := PathFollower.toggles_to_crouch(stance)
|
||||
assert_int(PathFollower.stance_rung(stance) + down).is_equal(PathFollower.stance_rung("Crouch"))
|
||||
Reference in New Issue
Block a user