UnityTowerDefense/Assets/_Project/Scripts/Gameplay/PathfindingService.cs

279 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/Gameplay/PathfindingService.cs
using System.Collections.Generic;
using UnityEngine;
using TD.Core;
using TD.Levels;
namespace TD.Gameplay
{
/// <summary>
/// Scene singleton that computes shortest-path routes from any tile to the
/// nearest goal tile using A* on the runtime walkability grid.
/// </summary>
/// <remarks>
/// <b>Algorithm:</b> 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.
///
/// <b>Who calls this:</b>
/// <list type="bullet">
/// <item><see cref="EnemyMovement"/> calls <see cref="ComputePath"/> once on
/// spawn and again whenever <see cref="OnPathsInvalidated"/> fires.</item>
/// </list>
///
/// <b>Invalidation:</b> Subscribes to <see cref="LevelLoader.OnWalkabilityChanged"/>.
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event
/// and <see cref="OnPathsInvalidated"/> is relayed to all active enemies, which
/// each recompute their own path from their current tile.
///
/// <b>Goal tile set:</b> Built once on <c>Start</c> from
/// <c>LevelLoader.LevelData.Goals[].TileArea</c>. Goal tiles never change at
/// runtime (they are baked into the level), so there is no need to rebuild the set.
///
/// <b>No caching:</b> Paths are computed on demand per enemy. On typical TD grid
/// sizes (50×50 or smaller) a single A* run takes &lt;1 ms. If profiling shows
/// otherwise, add a per-startTile cache here.
/// </remarks>
public class PathfindingService : MonoBehaviour
{
// ----- Singleton --------------------------------------------------
public static PathfindingService Instance { get; private set; }
// ----- Events -----------------------------------------------------
/// <summary>
/// Fired on every peer when the walkability grid changes (tower placed/sold).
/// <see cref="EnemyMovement"/> subscribes per-instance to recompute its path.
/// </summary>
public event System.Action OnPathsInvalidated;
// ----- State ------------------------------------------------------
// Built once on Start from LevelData.Goals[].TileArea.
private HashSet<Vector2Int> 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<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();
private readonly Dictionary<Vector2Int, int> gScore = new Dictionary<Vector2Int, int>();
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 -------------------------------------------------
/// <summary>
/// Computes the shortest walkable path from <paramref name="startTile"/> to
/// the nearest goal tile. Returns an ordered list of tiles to visit, starting
/// with the first step AFTER <paramref name="startTile"/> 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).
/// </summary>
public List<Vector2Int> 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<Vector2Int>();
}
return RunAStar(startTile, loader);
}
// ----- A* implementation ------------------------------------------
private List<Vector2Int> 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<Vector2Int>();
}
// 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<Vector2Int> ReconstructPath(Vector2Int start, Vector2Int goal)
{
var path = new List<Vector2Int>();
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<Vector2Int>();
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;
}
}
}
}