Three playtest-reported bugs fixed out-of-phase before Phase 18: * Furniture build-queue gap: Torch / Bed / Crate / Workbench / CremationPyre were missing World.register_build_site(self) in _ready, so newly-painted designations never entered ConstructionProvider's iteration. The seeded cabin pre-built everything via _spawn_complete_* helpers, masking the gap until a player painted a fresh furniture designation. * Wall-trap regression for bystanders + walk-through pawns: Wall._complete now dislodges any pawn on the tile via new Pathfinder.find_nearest_walkable BFS helper; Pawn._advance_walk re-checks next tile walkability before stepping, aborts walk + cancels job + lets Decision reroute. Phase 6's adjacent-stand fix only protected the BUILDING pawn. * Floor / Pawn Y-sort ambiguity: Floor was anchored at tile-center (same Y as Pawn), so Y-sort tiebreak fell to scene-tree order and Floor (spawned later) drew over Pawn. Moved Floor origin to top-of-tile so Floor.y < Pawn.y under Y-sort; _draw rect offsets compensate. All three verified via MCP runtime: torch built end-to-end, all 3 pawns working on different jobs with no idle traps, pawn renders over floor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4.7 KiB
GDScript
136 lines
4.7 KiB
GDScript
class_name Pathfinder
|
|
extends Node
|
|
## AStarGrid2D wrapper for rimlike's 4-directional tile pathfinding.
|
|
##
|
|
## One grid covers the full map. Walkability is updated in O(1) per cell
|
|
## change (wall placed, door toggled, furniture added/removed). All path
|
|
## queries are sub-millisecond at 80² and within the 120² ceiling.
|
|
##
|
|
## Usage:
|
|
## pathfinder.setup(World.MAP_SIZE_TILES)
|
|
## pathfinder.set_cell_walkable(cell, false) # e.g. after wall placed
|
|
## var path := pathfinder.find_path(from_cell, to_cell)
|
|
##
|
|
## `find_path` returns tile coords EXCLUDING `from`, INCLUDING `to`.
|
|
## Returns an empty Array[Vector2i] when the destination is unreachable.
|
|
|
|
const TILE_SIZE_PX: int = 16
|
|
|
|
signal walkability_changed(cell: Vector2i)
|
|
|
|
var _astar: AStarGrid2D
|
|
var _map_size_tiles: Vector2i
|
|
|
|
|
|
## Configure the grid. Must be called once before any other method.
|
|
## Typically called by World._ready() after the map size is known.
|
|
func setup(map_size_tiles: Vector2i) -> void:
|
|
_map_size_tiles = map_size_tiles
|
|
|
|
_astar = AStarGrid2D.new()
|
|
_astar.region = Rect2i(0, 0, map_size_tiles.x, map_size_tiles.y)
|
|
_astar.cell_size = Vector2(TILE_SIZE_PX, TILE_SIZE_PX)
|
|
_astar.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_NEVER # 4-directional, Rimworld-like
|
|
_astar.default_compute_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
|
|
_astar.default_estimate_heuristic = AStarGrid2D.HEURISTIC_MANHATTAN
|
|
_astar.update()
|
|
|
|
Audit.log("pathfinder", "AStarGrid2D online for %s tiles (4-dir, Manhattan)" % map_size_tiles)
|
|
|
|
|
|
## Mark a tile as passable or impassable and emit walkability_changed.
|
|
## Called whenever a wall, door, or furniture state changes.
|
|
## Does not log — Phase 5 triggers many of these per tick.
|
|
func set_cell_walkable(cell: Vector2i, walkable: bool) -> void:
|
|
_astar.set_point_solid(cell, not walkable)
|
|
emit_signal("walkability_changed", cell)
|
|
|
|
|
|
## Returns true if `cell` is inside the configured region AND is not solid.
|
|
func is_walkable(cell: Vector2i) -> bool:
|
|
return _astar.is_in_boundsv(cell) and not _astar.is_point_solid(cell)
|
|
|
|
|
|
## BFS outward from `cell` for the nearest walkable tile. Used to dislodge a
|
|
## pawn trapped on a tile that just became impassable (e.g. another pawn built
|
|
## a wall on top of them). Returns `cell` itself if no walkable tile is found
|
|
## within `max_radius` (BFS-ring distance).
|
|
func find_nearest_walkable(cell: Vector2i, max_radius: int = 8) -> Vector2i:
|
|
if is_walkable(cell):
|
|
return cell
|
|
var visited := {cell: true}
|
|
var frontier: Array[Vector2i] = [cell]
|
|
var offsets: Array[Vector2i] = [
|
|
Vector2i(0, -1), Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0),
|
|
]
|
|
for _ring in max_radius:
|
|
var next: Array[Vector2i] = []
|
|
for c in frontier:
|
|
for off in offsets:
|
|
var n: Vector2i = c + off
|
|
if visited.has(n):
|
|
continue
|
|
visited[n] = true
|
|
if is_walkable(n):
|
|
return n
|
|
next.append(n)
|
|
frontier = next
|
|
return cell
|
|
|
|
|
|
## Returns the path from `from` to `to` as tile-coordinate steps.
|
|
## The returned array EXCLUDES `from` and INCLUDES `to`.
|
|
## Returns an empty Array[Vector2i] when:
|
|
## - either endpoint is outside the configured region
|
|
## - `to` is solid (impassable)
|
|
## - no path exists (area is disconnected)
|
|
func find_path(from: Vector2i, to: Vector2i) -> Array[Vector2i]:
|
|
if not _astar.is_in_boundsv(from) or not _astar.is_in_boundsv(to):
|
|
return [] as Array[Vector2i]
|
|
if _astar.is_point_solid(to):
|
|
return [] as Array[Vector2i]
|
|
|
|
var raw_path: Array[Vector2i] = _astar.get_id_path(from, to)
|
|
if raw_path.is_empty():
|
|
# Both endpoints are in-bounds and destination is walkable; the
|
|
# area must be disconnected. Log for debugging, not a caller-bug.
|
|
Audit.log("pathfinder", "no path: %s → %s" % [from, to])
|
|
return [] as Array[Vector2i]
|
|
|
|
# get_id_path includes the start tile at index 0; drop it per API contract.
|
|
raw_path.remove_at(0)
|
|
return raw_path
|
|
|
|
|
|
## Spike / debug utility. Times `find_path` over `pairs` repeated `iterations`
|
|
## times and returns timing statistics. Each entry in `pairs` is [Vector2i, Vector2i].
|
|
## Uses Time.get_ticks_usec() for microsecond resolution.
|
|
func benchmark(pairs: Array, iterations: int = 1) -> Dictionary:
|
|
var min_us: int = 9223372036854775807 # INT64_MAX
|
|
var max_us: int = 0
|
|
var total_us: int = 0
|
|
var total_paths: int = 0
|
|
|
|
for _i in iterations:
|
|
for pair in pairs:
|
|
var t_start: int = Time.get_ticks_usec()
|
|
find_path(pair[0], pair[1])
|
|
var elapsed: int = Time.get_ticks_usec() - t_start
|
|
|
|
if elapsed < min_us:
|
|
min_us = elapsed
|
|
if elapsed > max_us:
|
|
max_us = elapsed
|
|
total_us += elapsed
|
|
total_paths += 1
|
|
|
|
var avg_us: float = float(total_us) / float(total_paths) if total_paths > 0 else 0.0
|
|
|
|
Audit.log("pathfinder", "bench: %d paths, avg=%.1f us, max=%d us" % [total_paths, avg_us, max_us])
|
|
|
|
return {
|
|
"min_us": min_us,
|
|
"max_us": max_us,
|
|
"avg_us": avg_us,
|
|
"total_paths": total_paths,
|
|
}
|