// Assets/_Project/Scripts/Gameplay/PathfindingService.cs using System.Collections.Generic; using UnityEngine; using TD.Core; using TD.Levels; namespace TD.Gameplay { /// /// Scene singleton that computes shortest-path routes from any tile to the /// nearest goal tile using A* on the runtime walkability grid. /// /// /// Algorithm: A* with Manhattan-distance heuristic. Grid cost is uniform /// (1 per step, 4-connected, no diagonals), so A* is optimal and significantly /// faster than plain BFS on large grids thanks to the heuristic. /// /// Who calls this: /// /// calls once on /// spawn and again whenever fires. /// /// /// Invalidation: Subscribes to . /// When a tower is placed or sold, LevelLoader.SetWalkable fires that event /// and is relayed to all active enemies, which /// each recompute their own path from their current tile. /// /// Goal tile set: Built once on Start from /// LevelLoader.LevelData.Goals[].TileArea. Goal tiles never change at /// runtime (they are baked into the level), so there is no need to rebuild the set. /// /// No caching: Paths are computed on demand per enemy. On typical TD grid /// sizes (50×50 or smaller) a single A* run takes <1 ms. If profiling shows /// otherwise, add a per-startTile cache here. /// public class PathfindingService : MonoBehaviour { // ----- Singleton -------------------------------------------------- public static PathfindingService Instance { get; private set; } // ----- Events ----------------------------------------------------- /// /// Fired on every peer when the walkability grid changes (tower placed/sold). /// subscribes per-instance to recompute its path. /// public event System.Action OnPathsInvalidated; // ----- State ------------------------------------------------------ // Built once on Start from LevelData.Goals[].TileArea. private HashSet goalTiles; // A* scratch collections — allocated once and cleared per run to avoid GC. // PathfindingService is a singleton, so single-instance scratch is safe. private readonly Dictionary cameFrom = new Dictionary(); private readonly Dictionary gScore = new Dictionary(); private readonly SimplePriorityQueue openSet = new SimplePriorityQueue(); // ----- Lifecycle -------------------------------------------------- private void Awake() { if (Instance != null && Instance != this) { Debug.LogError("[PathfindingService] Duplicate instance detected. " + "Only one PathfindingService should exist per scene."); Destroy(gameObject); return; } Instance = this; } private void Start() { BuildGoalTileSet(); var loader = LevelLoader.Instance; if (loader != null) loader.OnWalkabilityChanged += HandleWalkabilityChanged; else Debug.LogWarning("[PathfindingService] LevelLoader not found in Start. " + "Path invalidation will not work."); } private void OnDestroy() { if (Instance == this) Instance = null; var loader = LevelLoader.Instance; if (loader != null) loader.OnWalkabilityChanged -= HandleWalkabilityChanged; } // ----- Public API ------------------------------------------------- /// /// Computes the shortest walkable path from to /// the nearest goal tile. Returns an ordered list of tiles to visit, starting /// with the first step AFTER and ending with the /// reached goal tile. Returns an empty list if no path exists or LevelLoader /// is unavailable (should not occur — TowerPlacementManager guarantees a path /// always exists after any placement). /// public List ComputePath(Vector2Int startTile) { var loader = LevelLoader.Instance; if (loader == null || !loader.IsLoaded || goalTiles == null || goalTiles.Count == 0) { Debug.LogWarning("[PathfindingService] Cannot compute path: " + "LevelLoader unavailable or no goal tiles baked."); return new List(); } return RunAStar(startTile, loader); } // ----- A* implementation ------------------------------------------ private List RunAStar(Vector2Int start, LevelLoader loader) { cameFrom.Clear(); gScore.Clear(); openSet.Clear(); gScore[start] = 0; openSet.Enqueue(start, Heuristic(start)); while (openSet.Count > 0) { Vector2Int current = openSet.Dequeue(); if (goalTiles.Contains(current)) return ReconstructPath(start, current); int currentG = gScore.TryGetValue(current, out int g) ? g : int.MaxValue; foreach (var neighbor in GridCoordinates.GetNeighbors(current)) { if (!loader.IsWalkable(neighbor)) continue; int tentativeG = currentG + 1; if (gScore.TryGetValue(neighbor, out int existingG) && tentativeG >= existingG) continue; cameFrom[neighbor] = current; gScore[neighbor] = tentativeG; int f = tentativeG + Heuristic(neighbor); // Re-enqueue with updated priority. SimplePriorityQueue handles // duplicate entries by ignoring higher-cost duplicates on dequeue. openSet.Enqueue(neighbor, f); } } // No path found — TowerPlacementManager should have prevented this. Debug.LogWarning($"[PathfindingService] A* found no path from {start}. " + "Check that TowerPlacementManager BFS is validating correctly."); return new List(); } // Manhattan distance to the nearest goal tile. Admissible heuristic for // a 4-connected uniform-cost grid. private int Heuristic(Vector2Int tile) { int best = int.MaxValue; foreach (var goal in goalTiles) { int d = GridCoordinates.ManhattanDistance(tile, goal); if (d < best) best = d; } return best; } private List ReconstructPath(Vector2Int start, Vector2Int goal) { var path = new List(); Vector2Int current = goal; while (current != start) { path.Add(current); if (!cameFrom.TryGetValue(current, out current)) break; // shouldn't happen } path.Reverse(); return path; } // ----- Helpers ---------------------------------------------------- private void BuildGoalTileSet() { goalTiles = new HashSet(); var loader = LevelLoader.Instance; if (loader == null || loader.LevelData == null || loader.LevelData.Goals == null) { Debug.LogWarning("[PathfindingService] No LevelData or Goals found. " + "Enemies will have no destination."); return; } foreach (var goal in loader.LevelData.Goals) { if (goal.TileArea == null) continue; foreach (var tile in goal.TileArea) goalTiles.Add(tile); } Debug.Log($"[PathfindingService] Goal tile set built: {goalTiles.Count} tiles."); } private void HandleWalkabilityChanged() { OnPathsInvalidated?.Invoke(); } } // ------------------------------------------------------------------------- // Minimal priority queue for A*. Uses a sorted list of (priority, tile) pairs. // Suitable for the grid sizes in this project (typically < 10,000 tiles). // Replace with a binary heap if profiling shows this as a bottleneck. // ------------------------------------------------------------------------- internal sealed class SimplePriorityQueue { private readonly List<(int priority, Vector2Int tile)> heap = new List<(int, Vector2Int)>(); public int Count => heap.Count; public void Clear() => heap.Clear(); public void Enqueue(Vector2Int tile, int priority) { heap.Add((priority, tile)); SiftUp(heap.Count - 1); } public Vector2Int Dequeue() { Vector2Int result = heap[0].tile; int last = heap.Count - 1; heap[0] = heap[last]; heap.RemoveAt(last); if (heap.Count > 0) SiftDown(0); return result; } private void SiftUp(int i) { while (i > 0) { int parent = (i - 1) / 2; if (heap[parent].priority <= heap[i].priority) break; (heap[i], heap[parent]) = (heap[parent], heap[i]); i = parent; } } private void SiftDown(int i) { int count = heap.Count; while (true) { int smallest = i; int left = 2 * i + 1; int right = 2 * i + 2; if (left < count && heap[left].priority < heap[smallest].priority) smallest = left; if (right < count && heap[right].priority < heap[smallest].priority) smallest = right; if (smallest == i) break; (heap[i], heap[smallest]) = (heap[smallest], heap[i]); i = smallest; } } } }