CI / build-test (push) Failing after 1m5s
Pathfinding (depends on Core only): GridPathfinder with A* (octile/
Manhattan heuristic), Dijkstra and BFS over a game-implemented
IPathGrid; 4/8 connectivity, diagonals never cut corners. FlowField +
FlowFieldBuilder (multi-source Dijkstra) give crowds O(1) steering per
agent per frame. All buffers are grid-sized once and invalidated by a
generation stamp - repeated queries allocate nothing and clear nothing.
Collisions (Graphics exception: Transform2D, RectF): Collider component
(circle/AABB, offset, two-way layer masks), CollisionWorld - a uniform
spatial hash on flat arrays rebuilt from scratch each tick (O(n) for
movers, zero alloc after warm-up, deterministic pair order), pair
collection, QueryAabb and closest-hit Raycast. scene.UseCollisions()
registers CollisionSystem after movement systems.
30 new tests (string-map mazes, cost weighting, corner cutting, flow
descent; pair/mask/query/raycast). Sample gains a PathfindingScene
('path' console command): click to set the goal, 250 agents follow the
flow field, the A* path is highlighted, colliding agents flash red.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
245 lines
7.8 KiB
C#
245 lines
7.8 KiB
C#
using Microsoft.Xna.Framework;
|
|
|
|
namespace MrGameEng.Pathfinding;
|
|
|
|
/// <summary>
|
|
/// A flow field: per-cell distance to the nearest goal and a normalized direction to follow.
|
|
/// Built once per goal change by <see cref="FlowFieldBuilder"/>, then any number of agents
|
|
/// steer by an O(1) lookup per frame — the tool of choice for crowds heading to shared targets.
|
|
/// </summary>
|
|
public sealed class FlowField
|
|
{
|
|
/// <summary>Grid width in cells.</summary>
|
|
public int Width { get; private set; }
|
|
|
|
/// <summary>Grid height in cells.</summary>
|
|
public int Height { get; private set; }
|
|
|
|
internal float[] Distances = [];
|
|
internal Vector2[] Directions = [];
|
|
|
|
/// <summary>Cost-weighted distance to the nearest goal; <see cref="float.PositiveInfinity"/> when unreachable.</summary>
|
|
public float DistanceAt(int x, int y) => Distances[y * Width + x];
|
|
|
|
/// <summary>Normalized direction toward the nearest goal; <see cref="Vector2.Zero"/> at goals and unreachable cells.</summary>
|
|
public Vector2 DirectionAt(int x, int y) => Directions[y * Width + x];
|
|
|
|
/// <summary>True when a path to a goal exists from this cell.</summary>
|
|
public bool IsReachable(int x, int y) => !float.IsPositiveInfinity(Distances[y * Width + x]);
|
|
|
|
internal void EnsureSize(int width, int height)
|
|
{
|
|
Width = width;
|
|
Height = height;
|
|
var size = width * height;
|
|
if (Distances.Length < size)
|
|
{
|
|
Distances = new float[size];
|
|
Directions = new Vector2[size];
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds <see cref="FlowField"/>s with a multi-source Dijkstra over an <see cref="IPathGrid"/>.
|
|
/// Buffers are reused between builds (no allocations after warm-up). One instance per system.
|
|
/// </summary>
|
|
public sealed class FlowFieldBuilder
|
|
{
|
|
private static readonly int[] OffsetX = [1, -1, 0, 0, 1, 1, -1, -1];
|
|
private static readonly int[] OffsetY = [0, 0, 1, -1, 1, -1, 1, -1];
|
|
private const float DiagonalCost = 1.4142135f;
|
|
|
|
private readonly IPathGrid _grid;
|
|
private readonly GridConnectivity _connectivity;
|
|
private readonly int _width;
|
|
private readonly int _height;
|
|
private readonly int[] _closedStamp;
|
|
private int[] _heapNodes;
|
|
private float[] _heapPriorities;
|
|
private int _generation;
|
|
private int _heapCount;
|
|
|
|
/// <summary>Creates a builder bound to <paramref name="grid"/>.</summary>
|
|
public FlowFieldBuilder(IPathGrid grid, GridConnectivity connectivity = GridConnectivity.Eight)
|
|
{
|
|
_grid = grid;
|
|
_connectivity = connectivity;
|
|
_width = grid.Width;
|
|
_height = grid.Height;
|
|
var size = _width * _height;
|
|
_closedStamp = new int[size];
|
|
_heapNodes = new int[size + 1];
|
|
_heapPriorities = new float[size + 1];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fills <paramref name="field"/> with distances and directions toward the nearest of
|
|
/// <paramref name="goals"/>. Impassable goals are ignored; with no valid goal the whole
|
|
/// field is unreachable.
|
|
/// </summary>
|
|
public void Build(ReadOnlySpan<Point> goals, FlowField field)
|
|
{
|
|
field.EnsureSize(_width, _height);
|
|
var distances = field.Distances;
|
|
var directions = field.Directions;
|
|
Array.Fill(distances, float.PositiveInfinity, 0, _width * _height);
|
|
|
|
_generation++;
|
|
_heapCount = 0;
|
|
|
|
foreach (var goal in goals)
|
|
{
|
|
if (goal.X >= 0 && goal.X < _width && goal.Y >= 0 && goal.Y < _height &&
|
|
_grid.IsPassable(goal.X, goal.Y))
|
|
{
|
|
var index = goal.Y * _width + goal.X;
|
|
distances[index] = 0f;
|
|
HeapPush(index, 0f);
|
|
}
|
|
}
|
|
|
|
var directionCount = (int)_connectivity;
|
|
while (_heapCount > 0)
|
|
{
|
|
var current = HeapPop();
|
|
if (_closedStamp[current] == _generation)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_closedStamp[current] = _generation;
|
|
var cx = current % _width;
|
|
var cy = current / _width;
|
|
|
|
for (var d = 0; d < directionCount; d++)
|
|
{
|
|
var nx = cx + OffsetX[d];
|
|
var ny = cy + OffsetY[d];
|
|
if (!Walkable(cx, cy, nx, ny, d))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var neighbor = ny * _width + nx;
|
|
var tentative = distances[current] + (d < 4 ? 1f : DiagonalCost) * _grid.Cost(nx, ny);
|
|
if (tentative < distances[neighbor])
|
|
{
|
|
distances[neighbor] = tentative;
|
|
HeapPush(neighbor, tentative);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Направление — к соседу с минимальной дистанцией (с учётом запрета срезать углы).
|
|
for (var y = 0; y < _height; y++)
|
|
{
|
|
for (var x = 0; x < _width; x++)
|
|
{
|
|
var index = y * _width + x;
|
|
directions[index] = Vector2.Zero;
|
|
if (float.IsPositiveInfinity(distances[index]) || distances[index] == 0f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var best = distances[index];
|
|
var bestDx = 0;
|
|
var bestDy = 0;
|
|
for (var d = 0; d < directionCount; d++)
|
|
{
|
|
var nx = x + OffsetX[d];
|
|
var ny = y + OffsetY[d];
|
|
if (!Walkable(x, y, nx, ny, d))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var distance = distances[ny * _width + nx];
|
|
if (distance < best)
|
|
{
|
|
best = distance;
|
|
bestDx = OffsetX[d];
|
|
bestDy = OffsetY[d];
|
|
}
|
|
}
|
|
|
|
if (bestDx != 0 || bestDy != 0)
|
|
{
|
|
directions[index] = Vector2.Normalize(new Vector2(bestDx, bestDy));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool Walkable(int fromX, int fromY, int toX, int toY, int direction)
|
|
{
|
|
if (toX < 0 || toX >= _width || toY < 0 || toY >= _height || !_grid.IsPassable(toX, toY))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return direction < 4 || (_grid.IsPassable(toX, fromY) && _grid.IsPassable(fromX, toY));
|
|
}
|
|
|
|
private void HeapPush(int node, float priority)
|
|
{
|
|
if (_heapCount + 1 == _heapNodes.Length)
|
|
{
|
|
Array.Resize(ref _heapNodes, _heapNodes.Length * 2);
|
|
Array.Resize(ref _heapPriorities, _heapPriorities.Length * 2);
|
|
}
|
|
|
|
var i = ++_heapCount;
|
|
while (i > 1 && _heapPriorities[i >> 1] > priority)
|
|
{
|
|
_heapNodes[i] = _heapNodes[i >> 1];
|
|
_heapPriorities[i] = _heapPriorities[i >> 1];
|
|
i >>= 1;
|
|
}
|
|
|
|
_heapNodes[i] = node;
|
|
_heapPriorities[i] = priority;
|
|
}
|
|
|
|
private int HeapPop()
|
|
{
|
|
var top = _heapNodes[1];
|
|
var lastNode = _heapNodes[_heapCount];
|
|
var lastPriority = _heapPriorities[_heapCount];
|
|
_heapCount--;
|
|
|
|
var i = 1;
|
|
while (true)
|
|
{
|
|
var child = i << 1;
|
|
if (child > _heapCount)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (child < _heapCount && _heapPriorities[child + 1] < _heapPriorities[child])
|
|
{
|
|
child++;
|
|
}
|
|
|
|
if (_heapPriorities[child] >= lastPriority)
|
|
{
|
|
break;
|
|
}
|
|
|
|
_heapNodes[i] = _heapNodes[child];
|
|
_heapPriorities[i] = _heapPriorities[child];
|
|
i = child;
|
|
}
|
|
|
|
if (_heapCount > 0)
|
|
{
|
|
_heapNodes[i] = lastNode;
|
|
_heapPriorities[i] = lastPriority;
|
|
}
|
|
|
|
return top;
|
|
}
|
|
}
|