Files
settled-reach/client/scripts/sandbox/path_finder.gd
T
jpmschweitzerandClaude Fable 5 49c8994943 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>
2026-07-06 19:52:03 +02:00

201 lines
7.8 KiB
GDScript

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