// 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 the octile-distance heuristic on an 8-connected grid. /// Cardinal steps cost 1.0, diagonal steps cost √2. Octile distance is the admissible /// heuristic for this cost model and yields optimal paths. Diagonal moves require /// both "shoulder" cardinals to be walkable (corner-cut prevention) — preserves /// maze-building, since a 1-tile diagonal gap between two walls won't admit enemies. /// /// Path smoothing: After A* produces a tile-by-tile path, /// greedily collapses intermediate waypoints whenever a grid line-of-sight is clear /// between the current anchor and a later waypoint. This produces the any-angle /// straight-line movement seen in WC3/Wintermaul — enemies walk directly across open /// space rather than hugging 45° grid steps. /// /// 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; // Precomputed octile-distance-to-nearest-goal, indexed by [y * gridWidth + x] in // grid-local space (subtract GridOriginTile to convert world-tile → array index). // Computed ONCE on Start. Without this, the A* heuristic scanned every goal tile // (~48 tiles for the 9-player map) on every node visit — making the heuristic // O(node visits * goal count) per A* run. With this table, it's O(1) per node. // Tiles outside the grid use Heuristic's fallback path (per-goal scan); they are // rare so the table isn't extended to cover them. private float[] heuristicTable; private Vector2Int heuristicOrigin; private Vector2Int heuristicSize; // A* scratch collections — allocated once and cleared per run to avoid GC. // PathfindingService is a singleton, so single-instance scratch is safe. // gScore is float to support diagonal cost √2; the priority queue matches. 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(); BuildHeuristicTable(); 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(); } // If the requested start is non-walkable, the enemy was caught // standing inside a freshly-stamped tower footprint (or any tile // that just became blocked). Nudge to the nearest walkable tile so // the enemy can resume routing instead of repeatedly failing. // Search outward in Chebyshev rings — closest 8-neighbor wins. if (!loader.IsWalkable(startTile)) { if (TryFindNearestWalkable(startTile, loader, maxRadius: 4, out var nudged)) startTile = nudged; else return new List(); } return RunAStar(startTile, loader); } // Expanding ring search (Chebyshev / 8-connected) for the nearest // walkable tile around . Caps at maxRadius to // keep this O(maxRadius²) in the worst case. Used by ComputePath when // an enemy's start tile becomes non-walkable mid-flight. private static bool TryFindNearestWalkable(Vector2Int origin, LevelLoader loader, int maxRadius, out Vector2Int found) { for (int r = 1; r <= maxRadius; r++) { for (int dy = -r; dy <= r; dy++) { for (int dx = -r; dx <= r; dx++) { // Only walk the OUTER ring at distance r (skip interior — already checked at smaller r). if (Mathf.Abs(dx) != r && Mathf.Abs(dy) != r) continue; var tile = new Vector2Int(origin.x + dx, origin.y + dy); if (loader.IsWalkable(tile)) { found = tile; return true; } } } } found = default; return false; } // ----- A* implementation ------------------------------------------ private List RunAStar(Vector2Int start, LevelLoader loader) { cameFrom.Clear(); gScore.Clear(); openSet.Clear(); gScore[start] = 0f; openSet.Enqueue(start, Heuristic(start)); while (openSet.Count > 0) { Vector2Int current = openSet.Dequeue(); if (goalTiles.Contains(current)) { var tilePath = ReconstructPath(start, current); return SmoothPath(start, tilePath, loader); } float currentG = gScore.TryGetValue(current, out float g) ? g : float.MaxValue; foreach (var neighbor in GridCoordinates.GetNeighbors8(current)) { if (!loader.IsWalkable(neighbor)) continue; // Corner-cut prevention: for a diagonal step, both cardinal // shoulder tiles must also be walkable. Otherwise enemies could // squeeze through 1-tile diagonal gaps between walls — that // would break the maze-building design intent. if (GridCoordinates.IsDiagonal(current, neighbor)) { GridCoordinates.GetCornerShoulders(current, neighbor, out var shoulderA, out var shoulderB); if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB)) continue; } float tentativeG = currentG + GridCoordinates.StepCost(current, neighbor); if (gScore.TryGetValue(neighbor, out float existingG) && tentativeG >= existingG) continue; cameFrom[neighbor] = current; gScore[neighbor] = tentativeG; float 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. This can legitimately happen during normal play // when an enemy is in a disconnected pocket created by a tower // placement (the placement BFS only verifies spawner→exit, not // every enemy's current tile). The EnemyMovement caller dedupes // its own warning so this doesn't spam the console on every // walkability change while an enemy is stuck. return new List(); } // Octile distance to the nearest goal tile. Admissible heuristic for an // 8-connected uniform-cost grid (cardinal 1, diagonal √2). Hot path: served // from heuristicTable (O(1)) when the tile is in the grid bounds, otherwise // falls back to scanning every goal (O(goalCount)). private float Heuristic(Vector2Int tile) { if (heuristicTable != null) { int x = tile.x - heuristicOrigin.x; int y = tile.y - heuristicOrigin.y; if (x >= 0 && x < heuristicSize.x && y >= 0 && y < heuristicSize.y) { return heuristicTable[y * heuristicSize.x + x]; } } // Out-of-grid fallback. A* shouldn't usually visit these (it expands only // from walkable tiles, which are in-bounds), but we keep correctness for // any callers that hand us a stray tile. float best = float.MaxValue; foreach (var goal in goalTiles) { float d = GridCoordinates.OctileDistance(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; } // ----- Path smoothing --------------------------------------------- /// /// Greedy line-of-sight path simplification. Walks the input tile path and /// collapses runs of tiles into single waypoints whenever a straight grid /// line-of-sight is clear. Produces any-angle paths from the otherwise /// 45°-stepped 8-connected A* output. /// /// /// Algorithm: maintain an "anchor" (initially the start tile). For each /// position in the path, find the FARTHEST subsequent waypoint that has clear /// LOS from the anchor. Add it to the smoothed result and make it the new /// anchor. Repeat. O(n²) in path length, acceptable for typical TD path /// lengths (< 100 tiles). /// /// LOS check uses Bresenham line walk with the same corner-cut rules as A* /// — a smoothed segment never crosses through a non-walkable tile and never /// squeezes through a diagonal corner. /// private List SmoothPath(Vector2Int start, List path, LevelLoader loader) { if (path.Count <= 1) return path; var result = new List(path.Count); Vector2Int anchor = start; int i = 0; while (i < path.Count) { // Find the farthest waypoint we can directly reach from the anchor. int farthest = i; for (int j = path.Count - 1; j > i; j--) { if (HasLineOfSight(anchor, path[j], loader)) { farthest = j; break; } } result.Add(path[farthest]); anchor = path[farthest]; i = farthest + 1; } return result; } /// /// Grid line-of-sight test from to . /// Returns true if a straight Bresenham line between the two tiles only /// crosses walkable tiles, with no diagonal corner-cuts. Used by /// to decide whether two waypoints can be collapsed. /// private static bool HasLineOfSight(Vector2Int a, Vector2Int b, LevelLoader loader) { int x0 = a.x, y0 = a.y; int x1 = b.x, y1 = b.y; int dx = Mathf.Abs(x1 - x0); int dy = Mathf.Abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; int sy = (y0 < y1) ? 1 : -1; int err = dx - dy; int x = x0, y = y0; while (x != x1 || y != y1) { int e2 = 2 * err; bool steppedX = false, steppedY = false; if (e2 > -dy) { err -= dy; x += sx; steppedX = true; } if (e2 < dx) { err += dx; y += sy; steppedY = true; } // Diagonal step: enforce corner-cut prevention against the two // shoulder tiles (same rule A* uses). if (steppedX && steppedY) { if (!loader.IsWalkable(new Vector2Int(x - sx, y))) return false; if (!loader.IsWalkable(new Vector2Int(x, y - sy))) return false; } if (!loader.IsWalkable(new Vector2Int(x, y))) return false; } return true; } // ----- 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."); } // Precomputes the octile-distance-to-nearest-goal for every tile in the grid. // Runs once on Start (cheap: ~3000 tiles × ~50 goals = 150K octile evaluations on // the 9-player map, well under a millisecond). The output is a flat float[] keyed // by grid-local index, hot for the A* heuristic. private void BuildHeuristicTable() { var loader = LevelLoader.Instance; if (loader == null || loader.LevelData == null) { heuristicTable = null; return; } var levelData = loader.LevelData; heuristicOrigin = levelData.GridOriginTile; heuristicSize = levelData.GridSize; int total = heuristicSize.x * heuristicSize.y; if (total <= 0 || goalTiles == null || goalTiles.Count == 0) { heuristicTable = null; return; } heuristicTable = new float[total]; for (int y = 0; y < heuristicSize.y; y++) { for (int x = 0; x < heuristicSize.x; x++) { Vector2Int worldTile = new Vector2Int( heuristicOrigin.x + x, heuristicOrigin.y + y); float best = float.MaxValue; foreach (var goal in goalTiles) { float d = GridCoordinates.OctileDistance(worldTile, goal); if (d < best) best = d; } heuristicTable[y * heuristicSize.x + x] = best; } } Debug.Log($"[PathfindingService] Heuristic table built: {total} tiles, " + $"origin={heuristicOrigin}, size={heuristicSize}."); } 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 { // Float priority to support diagonal cost (√2) in 8-connected A*. private readonly List<(float priority, Vector2Int tile)> heap = new List<(float, Vector2Int)>(); public int Count => heap.Count; public void Clear() => heap.Clear(); public void Enqueue(Vector2Int tile, float 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; } } } }