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) ## 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, }