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>
201 lines
7.8 KiB
GDScript
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
|