We've got enemies and movement!!
This commit is contained in:
parent
42ee0bf65d
commit
3287e8ea43
26 changed files with 1409 additions and 161 deletions
279
Assets/_Project/Scripts/Gameplay/PathfindingService.cs
Normal file
279
Assets/_Project/Scripts/Gameplay/PathfindingService.cs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
// 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 <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue