Everything related to chat functionality, and updating the projectile prefab to rotate properly

This commit is contained in:
Matt F 2026-05-15 13:40:13 -07:00
parent d92d00c83f
commit 66f84652dc
14 changed files with 1133 additions and 121 deletions

View file

@ -11,9 +11,17 @@ namespace TD.Gameplay
/// 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>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">
@ -55,8 +63,9 @@ namespace TD.Gameplay
// 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, int> gScore = new Dictionary<Vector2Int, int>();
private readonly Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>();
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
// ----- Lifecycle --------------------------------------------------
@ -114,9 +123,50 @@ namespace TD.Gameplay
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)
@ -125,7 +175,7 @@ namespace TD.Gameplay
gScore.Clear();
openSet.Clear();
gScore[start] = 0;
gScore[start] = 0f;
openSet.Enqueue(start, Heuristic(start));
while (openSet.Count > 0)
@ -133,21 +183,36 @@ namespace TD.Gameplay
Vector2Int current = openSet.Dequeue();
if (goalTiles.Contains(current))
return ReconstructPath(start, current);
{
var tilePath = ReconstructPath(start, current);
return SmoothPath(start, tilePath, loader);
}
int currentG = gScore.TryGetValue(current, out int g) ? g : int.MaxValue;
float currentG = gScore.TryGetValue(current, out float g) ? g : float.MaxValue;
foreach (var neighbor in GridCoordinates.GetNeighbors(current))
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
{
if (!loader.IsWalkable(neighbor)) continue;
int tentativeG = currentG + 1;
if (gScore.TryGetValue(neighbor, out int existingG)
// 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;
int f = tentativeG + Heuristic(neighbor);
float f = tentativeG + Heuristic(neighbor);
// Re-enqueue with updated priority. SimplePriorityQueue handles
// duplicate entries by ignoring higher-cost duplicates on dequeue.
@ -155,20 +220,23 @@ namespace TD.Gameplay
}
}
// No path found — TowerPlacementManager should have prevented this.
Debug.LogWarning($"[PathfindingService] A* found no path from {start}. " +
"Check that TowerPlacementManager BFS is validating correctly.");
// 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>();
}
// Manhattan distance to the nearest goal tile. Admissible heuristic for
// a 4-connected uniform-cost grid.
private int Heuristic(Vector2Int tile)
// 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)
{
int best = int.MaxValue;
float best = float.MaxValue;
foreach (var goal in goalTiles)
{
int d = GridCoordinates.ManhattanDistance(tile, goal);
float d = GridCoordinates.OctileDistance(tile, goal);
if (d < best) best = d;
}
return best;
@ -190,6 +258,105 @@ namespace TD.Gameplay
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()
@ -226,14 +393,15 @@ namespace TD.Gameplay
// -------------------------------------------------------------------------
internal sealed class SimplePriorityQueue
{
private readonly List<(int priority, Vector2Int tile)> heap
= new List<(int, Vector2Int)>();
// 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, int priority)
public void Enqueue(Vector2Int tile, float priority)
{
heap.Add((priority, tile));
SiftUp(heap.Count - 1);