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().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 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 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 tiny = new Entity[1]; Assert.Equal(1, scene.World.QueryAabb(area, tiny)); // обрезан по размеру буфера Span 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); // как и у касающихся кругов } }