Remove obsolete project files for MrGameEng.AI, MrGameEng.Assets, MrGameEng.Atlases, and MrGameEng.Collisions modules. Introduce new AssetManager and related classes for asset loading and management, including support for texture atlases and mod definitions. Enhance mod loading capabilities with DefDatabase and LanguageManager for JSON-based definitions and localization. Implement a shelf packing algorithm for efficient texture atlas creation.

This commit is contained in:
Leonid Pershin
2026-06-12 07:47:04 +03:00
parent 1f87fb0b74
commit c30e2ce764
70 changed files with 0 additions and 233 deletions
@@ -0,0 +1,64 @@
using Microsoft.Xna.Framework;
using MrGameEng.AI;
using Xunit;
namespace MrGameEng.AI.Tests;
public class BlackboardTests
{
[Fact]
public void SetThenTryGet_RoundTripsTypedValues()
{
var board = new Blackboard();
board.Set("target", new Vector2(3f, 4f));
Assert.True(board.TryGet<Vector2>("target", out var value));
Assert.Equal(new Vector2(3f, 4f), value);
}
[Fact]
public void TryGet_ReturnsFalse_OnMissingKeyOrTypeMismatch()
{
var board = new Blackboard();
board.Set("count", 5);
Assert.False(board.TryGet<int>("missing", out _));
Assert.False(board.TryGet<string>("count", out _)); // wrong type
}
[Fact]
public void GetOrDefault_FallsBackWhenAbsent()
{
var board = new Blackboard();
Assert.Equal(42, board.GetOrDefault("hp", 42));
board.Set("hp", 7);
Assert.Equal(7, board.GetOrDefault("hp", 42));
}
[Fact]
public void Set_OverwritesExistingValue()
{
var board = new Blackboard();
board.Set("k", 1);
board.Set("k", 2);
Assert.Equal(2, board.GetOrDefault("k", 0));
Assert.Equal(1, board.Count);
}
[Fact]
public void RemoveAndClear_DropKeys()
{
var board = new Blackboard();
board.Set("a", 1);
board.Set("b", 2);
Assert.True(board.Remove("a"));
Assert.False(board.Remove("a"));
Assert.True(board.Has("b"));
board.Clear();
Assert.Equal(0, board.Count);
}
}
@@ -0,0 +1,75 @@
using MrGameEng.AI;
using Xunit;
namespace MrGameEng.AI.Tests;
public class ResponseCurveTests
{
[Fact]
public void Identity_ReturnsInputUnchanged()
{
var curve = ResponseCurve.Identity;
Assert.Equal(0f, curve.Evaluate(0f));
Assert.Equal(0.5f, curve.Evaluate(0.5f), 5);
Assert.Equal(1f, curve.Evaluate(1f));
}
[Fact]
public void Default_HasZeroSlope_SoIdentityMustBeUsedExplicitly()
{
// Guards the gotcha that default(ResponseCurve) is NOT the identity curve.
var zero = default(ResponseCurve);
Assert.Equal(0f, zero.Evaluate(0.5f));
}
[Fact]
public void Evaluate_ClampsInputAndOutputToUnitRange()
{
var curve = ResponseCurve.Linear();
Assert.Equal(0f, curve.Evaluate(-2f));
Assert.Equal(1f, curve.Evaluate(5f));
}
[Fact]
public void Linear_NegativeSlope_InvertsTheInput()
{
var curve = ResponseCurve.Linear(slope: -1f, yShift: 1f);
Assert.Equal(1f, curve.Evaluate(0f), 5);
Assert.Equal(0.75f, curve.Evaluate(0.25f), 5);
Assert.Equal(0f, curve.Evaluate(1f), 5);
}
[Fact]
public void Polynomial_Quadratic_EasesInFromZero()
{
var curve = ResponseCurve.Polynomial(exponent: 2f);
Assert.Equal(0.25f, curve.Evaluate(0.5f), 5);
Assert.True(curve.Evaluate(0.25f) < 0.25f); // below the linear line: slow start
}
[Fact]
public void Logistic_IsMonotonicAndCentredOnMidpoint()
{
var curve = ResponseCurve.Logistic(steepness: 12f, midpoint: 0.5f);
Assert.Equal(0.5f, curve.Evaluate(0.5f), 2);
Assert.True(curve.Evaluate(0.2f) < 0.5f);
Assert.True(curve.Evaluate(0.8f) > 0.5f);
Assert.True(curve.Evaluate(0.6f) > curve.Evaluate(0.4f));
}
[Fact]
public void SmoothStep_IsFlatAtTheEnds()
{
var curve = ResponseCurve.SmoothStep();
Assert.Equal(0f, curve.Evaluate(0f), 5);
Assert.Equal(1f, curve.Evaluate(1f), 5);
Assert.Equal(0.5f, curve.Evaluate(0.5f), 5);
}
}
@@ -0,0 +1,168 @@
using MrGameEng.AI;
using Xunit;
namespace MrGameEng.AI.Tests;
public class UtilityAiTests
{
// A minimal agent context: everything the considerations read.
private record struct Ctx(float Energy, float Hunger);
private static UtilityAi<Ctx> BuildBrain()
{
// Rest gets attractive as energy drops; wander as energy is high.
var rest = new UtilityAction<Ctx>(
"rest",
new Consideration<Ctx>(
"tired",
c => c.Energy,
0f,
1f,
ResponseCurve.Linear(slope: -1f, yShift: 1f)
)
);
var wander = new UtilityAction<Ctx>(
"wander",
new Consideration<Ctx>("rested", c => c.Energy)
);
return new UtilityAi<Ctx>(rest, wander);
}
[Fact]
public void Select_PicksTheHighestScoringAction()
{
var brain = BuildBrain();
Assert.Equal("rest", brain.Select(new Ctx(Energy: 0.1f, Hunger: 0f))!.Name);
Assert.Equal("wander", brain.Select(new Ctx(Energy: 0.9f, Hunger: 0f))!.Name);
}
[Fact]
public void Select_IsDeterministicAcrossRepeatedCalls()
{
var brain = BuildBrain();
var ctx = new Ctx(Energy: 0.3f, Hunger: 0.5f);
var first = brain.Select(ctx)!.Name;
for (var i = 0; i < 100; i++)
{
Assert.Equal(first, brain.Select(ctx)!.Name);
}
}
[Fact]
public void Select_TieResolvesToEarliestAction()
{
// Two actions that always score equally; the first declared must win.
var a = new UtilityAction<Ctx>("a", new Consideration<Ctx>("k", _ => 0.5f));
var b = new UtilityAction<Ctx>("b", new Consideration<Ctx>("k", _ => 0.5f));
var brain = new UtilityAi<Ctx>(a, b);
Assert.Equal("a", brain.Select(default)!.Name);
}
[Fact]
public void Select_ReturnsNull_WhenNothingBeatsThreshold()
{
var brain = BuildBrain();
Assert.Null(brain.Select(new Ctx(Energy: 0.5f, Hunger: 0f), threshold: 0.99f));
}
[Fact]
public void Select_PopulatesLastScoresAlignedWithActions()
{
var brain = BuildBrain();
brain.Select(new Ctx(Energy: 0.2f, Hunger: 0f));
Assert.Equal(2, brain.LastScores.Length);
Assert.True(brain.LastScores[0] > brain.LastScores[1]); // rest scores above wander
}
[Fact]
public void VetoConsideration_ZeroesTheAction()
{
var action = new UtilityAction<Ctx>(
"eat",
new Consideration<Ctx>("has-food", _ => 0f), // veto: no food
new Consideration<Ctx>("hungry", _ => 1f)
);
Assert.Equal(0f, action.Score(default));
}
[Fact]
public void Weight_ScalesTheActionScore()
{
var low = new UtilityAction<Ctx>("a", 0.5f, new Consideration<Ctx>("k", _ => 0.4f));
var high = new UtilityAction<Ctx>("a", 2f, new Consideration<Ctx>("k", _ => 0.4f));
Assert.True(high.Score(default) > low.Score(default));
}
[Fact]
public void SelectWeighted_IsReproducibleForTheSameSeed()
{
var brain = BuildBrain();
var ctx = new Ctx(Energy: 0.5f, Hunger: 0f);
var first = Run(new Random(1234));
var second = Run(new Random(1234));
Assert.Equal(first, second);
List<string> Run(Random random)
{
var picks = new List<string>();
for (var i = 0; i < 50; i++)
{
picks.Add(brain.SelectWeighted(ctx, random)!.Name);
}
return picks;
}
}
[Fact]
public void SelectWeighted_FavoursTheHigherScoreOverManyRolls()
{
var brain = BuildBrain();
var ctx = new Ctx(Energy: 0.1f, Hunger: 0f); // rest should dominate
var random = new Random(7);
var rest = 0;
for (var i = 0; i < 1000; i++)
{
if (brain.SelectWeighted(ctx, random)!.Name == "rest")
{
rest++;
}
}
Assert.True(rest > 800, $"expected rest to dominate, got {rest}/1000");
}
[Fact]
public void Constructor_Throws_WhenNoActions()
{
Assert.Throws<ArgumentException>(() => new UtilityAi<Ctx>());
}
[Fact]
public void Consideration_Throws_WhenRangeIsDegenerate()
{
Assert.Throws<ArgumentException>(() =>
new Consideration<Ctx>("bad", c => c.Energy, min: 1f, max: 1f)
);
}
[Fact]
public void Consideration_NormalizesRawValuesAgainstItsRange()
{
var c = new Consideration<Ctx>("hunger", x => x.Hunger, min: 0f, max: 200f);
Assert.Equal(0f, c.Score(new Ctx(0f, 0f)), 5);
Assert.Equal(0.5f, c.Score(new Ctx(0f, 100f)), 5);
Assert.Equal(1f, c.Score(new Ctx(0f, 9999f)), 5); // clamps above range
}
}
@@ -0,0 +1,242 @@
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); // как и у касающихся кругов
}
}
@@ -0,0 +1,101 @@
using Microsoft.Xna.Framework;
using Xunit;
namespace MrGameEng.Pathfinding.Tests;
public class FlowFieldTests
{
[Fact]
public void Build_DistancesGrowFromGoal_DirectionsDescend()
{
var grid = new TestGrid(".....", ".###.", ".....");
var builder = new FlowFieldBuilder(grid);
var field = new FlowField();
builder.Build([new Point(0, 0)], field);
Assert.Equal(0f, field.DistanceAt(0, 0));
Assert.True(field.DistanceAt(4, 2) > field.DistanceAt(1, 0));
// Из любой достижимой не-целевой клетки направление ведёт к клетке с меньшей дистанцией.
for (var y = 0; y < grid.Height; y++)
{
for (var x = 0; x < grid.Width; x++)
{
if (
!grid.IsPassable(x, y)
|| !field.IsReachable(x, y)
|| field.DistanceAt(x, y) == 0f
)
{
continue;
}
var direction = field.DirectionAt(x, y);
Assert.NotEqual(Vector2.Zero, direction);
var nx = x + Math.Sign(MathF.Round(direction.X * 10f));
var ny = y + Math.Sign(MathF.Round(direction.Y * 10f));
Assert.True(
field.DistanceAt(nx, ny) < field.DistanceAt(x, y),
$"direction at ({x},{y}) does not descend"
);
}
}
}
[Fact]
public void Build_UnreachablePocket_IsFlagged()
{
var grid = new TestGrid("..#..", "..#..", "..#..");
var builder = new FlowFieldBuilder(grid);
var field = new FlowField();
builder.Build([new Point(0, 1)], field);
Assert.False(field.IsReachable(4, 1));
Assert.Equal(Vector2.Zero, field.DirectionAt(4, 1));
Assert.True(field.IsReachable(1, 2));
}
[Fact]
public void Build_MultipleGoals_EachCellFlowsToNearest()
{
var grid = new TestGrid("..........");
var builder = new FlowFieldBuilder(grid);
var field = new FlowField();
builder.Build([new Point(0, 0), new Point(9, 0)], field);
Assert.True(field.DirectionAt(2, 0).X < 0f); // ближе к левой цели
Assert.True(field.DirectionAt(7, 0).X > 0f); // ближе к правой
Assert.Equal(0f, field.DistanceAt(9, 0));
}
[Fact]
public void Build_Rebuild_OverwritesPreviousField()
{
var grid = new TestGrid(".....");
var builder = new FlowFieldBuilder(grid);
var field = new FlowField();
builder.Build([new Point(0, 0)], field);
Assert.True(field.DirectionAt(4, 0).X < 0f);
builder.Build([new Point(4, 0)], field);
Assert.True(field.DirectionAt(0, 0).X > 0f);
Assert.Equal(0f, field.DistanceAt(4, 0));
}
[Fact]
public void Build_NoValidGoals_EverythingUnreachable()
{
var grid = new TestGrid(".#.");
var builder = new FlowFieldBuilder(grid);
var field = new FlowField();
builder.Build([new Point(1, 0)], field); // цель — стена
Assert.False(field.IsReachable(0, 0));
Assert.False(field.IsReachable(2, 0));
}
}
@@ -0,0 +1,183 @@
using Microsoft.Xna.Framework;
using Xunit;
namespace MrGameEng.Pathfinding.Tests;
public class GridPathfinderTests
{
private static List<Point> Path(
IPathGrid grid,
Point start,
Point goal,
PathAlgorithm algorithm = PathAlgorithm.AStar,
GridConnectivity connectivity = GridConnectivity.Eight
)
{
var pathfinder = new GridPathfinder(grid, connectivity);
var path = new List<Point>();
Assert.True(pathfinder.FindPath(start, goal, path, algorithm));
return path;
}
private static void AssertValidPath(IPathGrid grid, List<Point> path, Point start, Point goal)
{
Assert.Equal(start, path[0]);
Assert.Equal(goal, path[^1]);
for (var i = 0; i < path.Count; i++)
{
Assert.True(grid.IsPassable(path[i].X, path[i].Y), $"impassable cell {path[i]}");
if (i > 0)
{
var dx = Math.Abs(path[i].X - path[i - 1].X);
var dy = Math.Abs(path[i].Y - path[i - 1].Y);
Assert.True(
dx <= 1 && dy <= 1 && dx + dy > 0,
$"non-adjacent step {path[i - 1]} -> {path[i]}"
);
}
}
}
[Theory]
[InlineData(PathAlgorithm.AStar)]
[InlineData(PathAlgorithm.Dijkstra)]
[InlineData(PathAlgorithm.BreadthFirst)]
public void FindPath_OpenField_StraightLine(PathAlgorithm algorithm)
{
var grid = new TestGrid(".....", ".....", ".....");
var path = Path(grid, new Point(0, 1), new Point(4, 1), algorithm);
AssertValidPath(grid, path, new Point(0, 1), new Point(4, 1));
Assert.Equal(5, path.Count); // прямая, без лишних шагов
}
[Theory]
[InlineData(PathAlgorithm.AStar)]
[InlineData(PathAlgorithm.Dijkstra)]
[InlineData(PathAlgorithm.BreadthFirst)]
public void FindPath_WallsForceDetour(PathAlgorithm algorithm)
{
var grid = new TestGrid(".....", "####.", ".....");
var path = Path(grid, new Point(0, 0), new Point(0, 2), algorithm);
AssertValidPath(grid, path, new Point(0, 0), new Point(0, 2));
Assert.Contains(new Point(4, 1), path); // единственный проход
}
[Fact]
public void FindPath_NoRoute_ReturnsFalse()
{
var grid = new TestGrid(".#.", ".#.", ".#.");
var pathfinder = new GridPathfinder(grid);
var path = new List<Point>();
Assert.False(pathfinder.FindPath(new Point(0, 0), new Point(2, 0), path));
Assert.Empty(path);
}
[Fact]
public void FindPath_StartEqualsGoal_SinglePoint()
{
var grid = new TestGrid("...");
var path = Path(grid, new Point(1, 0), new Point(1, 0));
Assert.Equal([new Point(1, 0)], path);
}
[Fact]
public void FindPath_DiagonalNeverCutsCorners()
{
var grid = new TestGrid(".#", "#.");
var pathfinder = new GridPathfinder(grid, GridConnectivity.Eight);
var path = new List<Point>();
// Диагональ (0,0)->(1,1) зажата стенами — пути нет.
Assert.False(pathfinder.FindPath(new Point(0, 0), new Point(1, 1), path));
}
[Fact]
public void FindPath_FourConnectivity_NoDiagonalSteps()
{
var grid = new TestGrid("...", "...", "...");
var path = Path(
grid,
new Point(0, 0),
new Point(2, 2),
connectivity: GridConnectivity.Four
);
Assert.Equal(5, path.Count); // манхэттен: 4 шага
for (var i = 1; i < path.Count; i++)
{
var dx = Math.Abs(path[i].X - path[i - 1].X);
var dy = Math.Abs(path[i].Y - path[i - 1].Y);
Assert.Equal(1, dx + dy);
}
}
[Theory]
[InlineData(PathAlgorithm.AStar)]
[InlineData(PathAlgorithm.Dijkstra)]
public void FindPath_CostAware_AvoidsExpensiveTerrain(PathAlgorithm algorithm)
{
// Прямой путь через болото (цена 9) дороже обхода по краю.
var grid = new TestGrid(".....", ".999.", ".....");
var path = Path(grid, new Point(0, 1), new Point(4, 1), algorithm);
Assert.DoesNotContain(path, p => grid.Cost(p.X, p.Y) > 1f);
}
[Fact]
public void FindPath_BreadthFirst_IgnoresCosts()
{
var grid = new TestGrid(".....", ".999.", ".....");
var path = Path(grid, new Point(0, 1), new Point(4, 1), PathAlgorithm.BreadthFirst);
Assert.Equal(5, path.Count); // идёт напрямик через дорогие клетки
}
[Fact]
public void FindPath_ReusedInstance_GivesCleanResults()
{
var grid = new TestGrid(".....", ".###.", ".....");
var pathfinder = new GridPathfinder(grid);
var path = new List<Point>();
Assert.True(pathfinder.FindPath(new Point(0, 0), new Point(4, 2), path));
var first = path.ToArray();
Assert.True(pathfinder.FindPath(new Point(0, 0), new Point(4, 2), path));
Assert.Equal(first, path); // generation-сброс не оставляет мусора между запросами
}
[Fact]
public void FindPath_AStarMatchesDijkstraCost()
{
var grid = new TestGrid("..3..", ".#3#.", "..3..", ".###.", ".....");
var start = new Point(0, 0);
var goal = new Point(4, 4);
var aStar = Path(grid, start, goal, PathAlgorithm.AStar);
var dijkstra = Path(grid, start, goal, PathAlgorithm.Dijkstra);
Assert.Equal(PathCost(grid, dijkstra), PathCost(grid, aStar), 3);
}
private static float PathCost(IPathGrid grid, List<Point> path)
{
var total = 0f;
for (var i = 1; i < path.Count; i++)
{
var diagonal = path[i].X != path[i - 1].X && path[i].Y != path[i - 1].Y;
total += (diagonal ? 1.4142135f : 1f) * grid.Cost(path[i].X, path[i].Y);
}
return total;
}
}
@@ -0,0 +1,46 @@
using Microsoft.Xna.Framework;
using Xunit;
namespace MrGameEng.Pathfinding.Tests;
/// <summary>
/// Размеры грида фиксируются в конструкторе; «уехавший» грид должен давать
/// громкую ошибку, а не молчаливое чтение мимо границ.
/// </summary>
public class GridResizeTests
{
private sealed class ResizableGrid : IPathGrid
{
public int Width { get; set; } = 4;
public int Height { get; set; } = 4;
public bool IsPassable(int x, int y) => true;
public float Cost(int x, int y) => 1f;
}
[Fact]
public void FindPath_AfterGridResize_Throws()
{
var grid = new ResizableGrid();
var pathfinder = new GridPathfinder(grid);
grid.Width = 8;
Assert.Throws<InvalidOperationException>(() =>
pathfinder.FindPath(new Point(0, 0), new Point(1, 1), [])
);
}
[Fact]
public void FlowFieldBuild_AfterGridResize_Throws()
{
var grid = new ResizableGrid();
var builder = new FlowFieldBuilder(grid);
grid.Height = 8;
Assert.Throws<InvalidOperationException>(() =>
builder.Build([new Point(0, 0)], new FlowField())
);
}
}
@@ -0,0 +1,23 @@
using MrGameEng.Pathfinding;
namespace MrGameEng.Pathfinding.Tests;
/// <summary>Грид из строк: '#' — стена, '.' — клетка с ценой 1, '2'..'9' — клетка с этой ценой.</summary>
public sealed class TestGrid : IPathGrid
{
private readonly string[] _rows;
public TestGrid(params string[] rows) => _rows = rows;
public int Width => _rows[0].Length;
public int Height => _rows.Length;
public bool IsPassable(int x, int y) => _rows[y][x] != '#';
public float Cost(int x, int y)
{
var cell = _rows[y][x];
return cell is >= '2' and <= '9' ? cell - '0' : 1f;
}
}