CI / build-test (push) Failing after 1m8s
Add MrGameEng.AI utility-AI module; format codebase with CSharpier New MrGameEng.AI module (ResponseCurve, Consideration, UtilityAction, UtilityAi selector, Blackboard) plus CSharpier formatting applied across the whole engine. Documents the CSharpier convention in CLAUDE.md. @
243 lines
7.6 KiB
C#
243 lines
7.6 KiB
C#
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 _));
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryAabb_BeforeFirstRebuild_ReturnsZero()
|
|
{
|
|
// До первого EndRebuild хэш пуст; запрос не должен зависать и что-то находить.
|
|
var world = new CollisionWorld();
|
|
|
|
Span<Entity> results = new Entity[4];
|
|
Assert.Equal(0, world.QueryAabb(new RectF(-10f, -10f, 20f, 20f), results));
|
|
}
|
|
|
|
[Fact]
|
|
public void QueryAabb_TruncatedResults_DoNotPoisonNextQuery()
|
|
{
|
|
var (context, scene) = CreateScene();
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
Spawn(scene, new Vector2(i * 5f, 0f), Collider.Circle(4f));
|
|
}
|
|
|
|
Tick(context);
|
|
|
|
var area = new RectF(-10f, -10f, 40f, 20f);
|
|
Span<Entity> tiny = new Entity[1];
|
|
Assert.Equal(1, scene.World.QueryAabb(area, tiny)); // обрезан по размеру буфера
|
|
|
|
Span<Entity> all = new Entity[8];
|
|
Assert.Equal(3, scene.World.QueryAabb(area, all)); // повторный запрос видит всех
|
|
}
|
|
|
|
[Fact]
|
|
public void Raycast_FromInsideCircle_HitsAtOrigin()
|
|
{
|
|
var (context, scene) = CreateScene();
|
|
var entity = Spawn(scene, new Vector2(0f, 0f), Collider.Circle(10f));
|
|
|
|
Tick(context);
|
|
|
|
Assert.True(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out var hit));
|
|
Assert.Equal(entity, hit.Entity);
|
|
Assert.Equal(0f, hit.Fraction);
|
|
Assert.Equal(Vector2.Zero, hit.Point);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExactlyTouchingBoxes_ProducePair()
|
|
{
|
|
var (context, scene) = CreateScene();
|
|
Spawn(scene, new Vector2(0f, 0f), Collider.Box(20f, 20f));
|
|
Spawn(scene, new Vector2(20f, 0f), Collider.Box(20f, 20f)); // грани соприкасаются ровно
|
|
|
|
Tick(context);
|
|
|
|
Assert.Equal(1, scene.World.Pairs.Length); // как и у касающихся кругов
|
|
}
|
|
}
|