Add MrGameEng.Pathfinding and MrGameEng.Collisions modules
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>
This commit is contained in:
Leonid Pershin
2026-06-11 13:39:28 +03:00
co-authored by Claude Fable 5
parent b3415120c3
commit a395e58458
20 changed files with 2053 additions and 2 deletions
@@ -168,6 +168,13 @@ public sealed class MainScene : Scene
}
});
console.Register("beep", "play the beep sound", (_, _) => audio.Play(beep));
console.Register("path", "switch to the pathfinding & collisions demo", (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new PathfindingScene(), Transition.Fade(0.6f));
}
});
console.Register("atlas", "list texture atlas regions", (c, _) =>
{
c.WriteLine($"atlas '{atlas.Name}': {atlas.Pages.Count} page(s), {atlas.Regions.Count} region(s)");
@@ -0,0 +1,284 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Assets;
using MrGameEng.Collisions;
using MrGameEng.Core;
using MrGameEng.DevConsole;
using MrGameEng.Graphics;
using MrGameEng.Input;
using MrGameEng.Pathfinding;
using MrGameEng.UI;
namespace MrGameEng.Sample.Scenes;
/// <summary>
/// Демо поиска пути и коллизий: ЛКМ ставит цель — толпа агентов стекается к ней по
/// flow field, жёлтым подсвечен A*-путь из левого верхнего угла, столкнувшиеся агенты
/// вспыхивают красным. Tab/'main' — назад.
/// </summary>
public sealed class PathfindingScene : Scene
{
private const int CellsX = 64;
private const int CellsY = 36;
private const int CellSize = 20;
private const int AgentCount = 250;
private static readonly Point AStarStart = new(1, 1);
private readonly WallGrid _grid = new(CellsX, CellsY, seed: 99);
private readonly FlowField _field = new();
private readonly List<Point> _path = [];
private readonly List<Entity> _pathMarkers = [];
private FlowFieldBuilder _builder = null!;
private GridPathfinder _pathfinder = null!;
private Texture2DRegion _white = null!;
private Entity _goalMarker;
/// <summary>Состояние агента: время красной вспышки после столкновения.</summary>
private struct AgentState : IComponent
{
public float Flash;
}
protected override void OnLoad()
{
var assets = Context.Services.GetOrDefault<AssetManager>() ?? Context.UseAssets();
var input = this.UseInput();
var actions = SampleInput.CreateActions(input);
var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) });
SampleLayers.EnsureRegistered(renderer);
var shapesTexture = assets.Load(GameAssets.Textures.Shapes);
_white = new Texture2DRegion(shapesTexture, new Rectangle(40, 41, 14, 14)); // жёлтый квадрат как "белый" регион
_builder = new FlowFieldBuilder(_grid);
_pathfinder = new GridPathfinder(_grid);
// Стены.
for (var y = 0; y < CellsY; y++)
{
for (var x = 0; x < CellsX; x++)
{
if (!_grid.IsPassable(x, y))
{
var sprite = new Sprite(_white) { Color = new Color(70, 74, 84), Depth = 0f };
Store.CreateEntity(CellTransform(x, y), sprite);
}
}
}
// Агенты на проходимых клетках.
var random = new Random(7);
for (var i = 0; i < AgentCount; i++)
{
var cell = RandomPassableCell(random);
var sprite = new Sprite(_white) { Color = new Color(120, 180, 255), Depth = 1f };
sprite.CenterOrigin();
Store.CreateEntity(
new Transform2D(CellCenter(cell), scale: new Vector2(0.45f)),
sprite,
new AgentState(),
Collider.Circle(4f));
}
// Маркер цели.
var goalSprite = new Sprite(_white) { Color = new Color(80, 220, 120), Depth = 0.6f };
_goalMarker = Store.CreateEntity(CellTransform(0, 0), goalSprite);
var world = this.UseCollisions(cellSize: 16f);
UpdateSystems.Add(new ClickTargetSystem(this, input, renderer));
UpdateSystems.Add(new AgentSteerSystem(this));
UpdateSystems.Add(new CollisionFlashSystem(world));
UpdateSystems.Add(new FlashDecaySystem());
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Fade(0.6f)));
var desktop = this.UseUI();
var label = new Myra.Graphics2D.UI.Label
{
Text = "ЛКМ — цель: толпа идёт по flow field, жёлтое — путь A*.\nСтолкновения агентов подсвечиваются красным. Tab — назад.",
};
desktop.Root = new Myra.Graphics2D.UI.VerticalStackPanel { Left = 12, Top = 12 };
((Myra.Graphics2D.UI.VerticalStackPanel)desktop.Root).Widgets.Add(label);
this.UseDevConsole();
SetGoal(new Point(CellsX / 2, CellsY / 2));
}
private void SetGoal(Point goal)
{
if (!_grid.IsPassable(goal.X, goal.Y))
{
return;
}
_builder.Build([goal], _field);
_goalMarker.GetComponent<Transform2D>().Position = CellTopLeft(goal);
foreach (var marker in _pathMarkers)
{
marker.DeleteEntity();
}
_pathMarkers.Clear();
if (_pathfinder.FindPath(AStarStart, goal, _path))
{
foreach (var cell in _path)
{
var sprite = new Sprite(_white) { Color = new Color(240, 210, 60) * 0.55f, Depth = 0.5f };
_pathMarkers.Add(Store.CreateEntity(CellTransform(cell.X, cell.Y), sprite));
}
}
}
private static Transform2D CellTransform(int x, int y) =>
new(CellTopLeft(new Point(x, y)), scale: new Vector2(CellSize / 14f));
private static Vector2 CellTopLeft(Point cell) => new(cell.X * CellSize, cell.Y * CellSize);
private static Vector2 CellCenter(Point cell) =>
new(cell.X * CellSize + CellSize / 2f, cell.Y * CellSize + CellSize / 2f);
private Point RandomPassableCell(Random random)
{
while (true)
{
var cell = new Point(random.Next(CellsX), random.Next(CellsY));
if (_grid.IsPassable(cell.X, cell.Y))
{
return cell;
}
}
}
/// <summary>Случайные прямоугольные стены; старт A* и центр всегда свободны.</summary>
private sealed class WallGrid : IPathGrid
{
private readonly bool[,] _walls;
public WallGrid(int width, int height, int seed)
{
Width = width;
Height = height;
_walls = new bool[width, height];
var random = new Random(seed);
for (var i = 0; i < 70; i++)
{
var w = random.Next(1, 7);
var h = random.Next(1, 7);
var x0 = random.Next(width - w);
var y0 = random.Next(height - h);
for (var y = y0; y < y0 + h; y++)
{
for (var x = x0; x < x0 + w; x++)
{
_walls[x, y] = true;
}
}
}
for (var y = 0; y < 3; y++)
{
for (var x = 0; x < 3; x++)
{
_walls[AStarStart.X + x, AStarStart.Y + y] = false;
_walls[width / 2 + x - 1, height / 2 + y - 1] = false;
}
}
}
public int Width { get; }
public int Height { get; }
public bool IsPassable(int x, int y) => !_walls[x, y];
public float Cost(int x, int y) => 1f;
}
private sealed class ClickTargetSystem(PathfindingScene scene, InputManager input, Renderer2D renderer) : BaseSystem
{
protected override void OnUpdateGroup()
{
if (!input.IsMousePressed(MouseButton.Left))
{
return;
}
var world = renderer.ScreenToWorld(input.MousePosition.ToVector2());
var cell = new Point((int)(world.X / CellSize), (int)(world.Y / CellSize));
if (cell.X >= 0 && cell.X < CellsX && cell.Y >= 0 && cell.Y < CellsY)
{
scene.SetGoal(cell);
}
}
}
private sealed class AgentSteerSystem(PathfindingScene scene) : QuerySystem<Transform2D, AgentState>
{
private const float Speed = 70f;
protected override void OnUpdate()
{
var delta = Tick.deltaTime;
var field = scene._field;
foreach (var (transforms, _, _) in Query.Chunks)
{
var t = transforms.Span;
for (var i = 0; i < t.Length; i++)
{
ref var position = ref t[i].Position;
var cx = Math.Clamp((int)(position.X / CellSize), 0, CellsX - 1);
var cy = Math.Clamp((int)(position.Y / CellSize), 0, CellsY - 1);
if (!field.IsReachable(cx, cy) || field.DistanceAt(cx, cy) <= 0.6f)
{
continue; // недостижимо или уже у цели
}
position += field.DirectionAt(cx, cy) * (Speed * delta);
}
}
}
}
private sealed class CollisionFlashSystem(CollisionWorld world) : BaseSystem
{
protected override void OnUpdateGroup()
{
foreach (var pair in world.Pairs)
{
Flash(pair.A);
Flash(pair.B);
}
}
private static void Flash(Entity entity)
{
if (entity.HasComponent<AgentState>())
{
entity.GetComponent<AgentState>().Flash = 0.25f;
}
}
}
private sealed class FlashDecaySystem : QuerySystem<AgentState, Sprite>
{
private static readonly Color Calm = new(120, 180, 255);
private static readonly Color Hit = new(235, 70, 60);
protected override void OnUpdate()
{
var delta = Tick.deltaTime;
foreach (var (states, sprites, _) in Query.Chunks)
{
var a = states.Span;
var s = sprites.Span;
for (var i = 0; i < a.Length; i++)
{
a[i].Flash = Math.Max(0f, a[i].Flash - delta);
s[i].Color = a[i].Flash > 0f ? Hit : Calm;
}
}
}
}
}