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

521 lines
21 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 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 &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;
// 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<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();
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 -------------------------------------------------
/// <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). 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<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 (&lt; 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.");
}
// 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;
}
}
}
}