447 lines
18 KiB
C#
447 lines
18 KiB
C#
// 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 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.
|
||
///
|
||
/// <b>Path smoothing:</b> After A* produces a tile-by-tile path, <see cref="SmoothPath"/>
|
||
/// 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.
|
||
///
|
||
/// <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.
|
||
// gScore is float to support diagonal cost √2; the priority queue matches.
|
||
private readonly Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();
|
||
private readonly Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>();
|
||
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>();
|
||
}
|
||
|
||
// 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<Vector2Int>();
|
||
}
|
||
|
||
return RunAStar(startTile, loader);
|
||
}
|
||
|
||
// Expanding ring search (Chebyshev / 8-connected) for the nearest
|
||
// walkable tile around <paramref name="origin"/>. 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<Vector2Int> 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<Vector2Int>();
|
||
}
|
||
|
||
// Octile distance to the nearest goal tile. Admissible heuristic for an
|
||
// 8-connected uniform-cost grid (cardinal 1, diagonal √2).
|
||
private float Heuristic(Vector2Int 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<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;
|
||
}
|
||
|
||
// ----- Path smoothing ---------------------------------------------
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// 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.
|
||
/// </remarks>
|
||
private List<Vector2Int> SmoothPath(Vector2Int start, List<Vector2Int> path,
|
||
LevelLoader loader)
|
||
{
|
||
if (path.Count <= 1) return path;
|
||
|
||
var result = new List<Vector2Int>(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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Grid line-of-sight test from <paramref name="a"/> to <paramref name="b"/>.
|
||
/// Returns true if a straight Bresenham line between the two tiles only
|
||
/// crosses walkable tiles, with no diagonal corner-cuts. Used by
|
||
/// <see cref="SmoothPath"/> to decide whether two waypoints can be collapsed.
|
||
/// </summary>
|
||
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<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
|
||
{
|
||
// 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;
|
||
}
|
||
}
|
||
}
|
||
}
|