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
@@ -0,0 +1,185 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
using Xunit;
namespace MrGameEng.Collisions.Tests;
public class CollisionWorldTests
{
private sealed class CollisionScene : Scene
{
public CollisionWorld World = null!;
protected override void OnLoad() => World = this.UseCollisions(cellSize: 32f);
}
private static (EngineContext Context, CollisionScene Scene) CreateScene()
{
var context = new EngineContext();
var scene = new CollisionScene();
context.Scenes.Switch(scene);
context.Scenes.Update(context.Clock); // применяет переключение и первый тик
return (context, scene);
}
private static Entity Spawn(Scene scene, Vector2 position, in Collider collider) =>
scene.Store.CreateEntity(Transform2D.At(position), collider);
private static void Tick(EngineContext context)
{
context.Clock.Advance(0.016f);
context.Scenes.Update(context.Clock);
}
[Fact]
public void OverlappingCircles_ProduceOnePair()
{
var (context, scene) = CreateScene();
var a = Spawn(scene, new Vector2(0f, 0f), Collider.Circle(10f));
var b = Spawn(scene, new Vector2(15f, 0f), Collider.Circle(10f));
Spawn(scene, new Vector2(100f, 100f), Collider.Circle(10f)); // далёкий — без пар
Tick(context);
var pair = Assert.Single(scene.World.Pairs.ToArray());
Assert.True((pair.A == a && pair.B == b) || (pair.A == b && pair.B == a));
}
[Fact]
public void SeparatedCircles_NoPairs()
{
var (context, scene) = CreateScene();
Spawn(scene, new Vector2(0f, 0f), Collider.Circle(5f));
Spawn(scene, new Vector2(11f, 0f), Collider.Circle(5f));
Tick(context);
Assert.Equal(0, scene.World.Pairs.Length);
}
[Fact]
public void CircleAndBox_Overlap_DetectedBothWays()
{
var (context, scene) = CreateScene();
Spawn(scene, new Vector2(0f, 0f), Collider.Box(20f, 20f));
Spawn(scene, new Vector2(14f, 0f), Collider.Circle(5f)); // касается правой грани
Tick(context);
Assert.Equal(1, scene.World.Pairs.Length);
}
[Fact]
public void CornerCircle_DoesNotTouchBox()
{
var (context, scene) = CreateScene();
Spawn(scene, new Vector2(0f, 0f), Collider.Box(20f, 20f));
// Угол бокса (10,10); круг r=5 в (16,16): расстояние до угла ~8.49 > 5.
Spawn(scene, new Vector2(16f, 16f), Collider.Circle(5f));
Tick(context);
Assert.Equal(0, scene.World.Pairs.Length);
}
[Fact]
public void LayerMasks_FilterPairs()
{
var (context, scene) = CreateScene();
var ghost = Collider.Circle(10f);
ghost.Layer = 0b10;
ghost.CollidesWith = 0b10; // призраки сталкиваются только с призраками
var wall = Collider.Circle(10f);
wall.Layer = 0b01;
wall.CollidesWith = 0b01;
Spawn(scene, new Vector2(0f, 0f), ghost);
Spawn(scene, new Vector2(5f, 0f), wall);
Tick(context);
Assert.Equal(0, scene.World.Pairs.Length);
}
[Fact]
public void MovingApart_PairDisappearsNextTick()
{
var (context, scene) = CreateScene();
Spawn(scene, new Vector2(0f, 0f), Collider.Circle(10f));
var mover = Spawn(scene, new Vector2(5f, 0f), Collider.Circle(10f));
Tick(context);
Assert.Equal(1, scene.World.Pairs.Length);
mover.GetComponent<Transform2D>().Position = new Vector2(100f, 0f);
Tick(context);
Assert.Equal(0, scene.World.Pairs.Length);
}
[Fact]
public void LargeCluster_AllTouchingPairsFound_OnceEach()
{
var (context, scene) = CreateScene();
// Цепочка из 10 кругов: касаются только соседи → ровно 9 пар.
for (var i = 0; i < 10; i++)
{
Spawn(scene, new Vector2(i * 18f, 0f), Collider.Circle(10f));
}
Tick(context);
Assert.Equal(9, scene.World.Pairs.Length);
}
[Fact]
public void QueryAabb_ReturnsOnlyEntitiesInArea()
{
var (context, scene) = CreateScene();
var inside = Spawn(scene, new Vector2(10f, 10f), Collider.Circle(5f));
Spawn(scene, new Vector2(200f, 200f), Collider.Circle(5f));
Tick(context);
Span<Entity> results = new Entity[8];
var found = scene.World.QueryAabb(new RectF(0f, 0f, 50f, 50f), results);
Assert.Equal(1, found);
Assert.Equal(inside, results[0]);
}
[Fact]
public void Raycast_HitsClosestCollider_AndRespectsMask()
{
var (context, scene) = CreateScene();
var near = Collider.Circle(5f);
near.Layer = 0b01;
var far = Collider.Circle(5f);
far.Layer = 0b10;
var nearEntity = Spawn(scene, new Vector2(30f, 0f), near);
var farEntity = Spawn(scene, new Vector2(60f, 0f), far);
Tick(context);
Assert.True(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out var hit));
Assert.Equal(nearEntity, hit.Entity);
Assert.Equal(25f, hit.Point.X, 1);
Assert.True(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out hit, mask: 0b10));
Assert.Equal(farEntity, hit.Entity);
}
[Fact]
public void Raycast_Miss_ReturnsFalse()
{
var (context, scene) = CreateScene();
Spawn(scene, new Vector2(0f, 50f), Collider.Box(10f, 10f));
Tick(context);
Assert.False(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out _));
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Collisions\MrGameEng.Collisions.csproj" />
</ItemGroup>
</Project>