Update README.md to include project description, developer documentation links, and license information.
CI / build-test (push) Successful in 1m6s

This commit is contained in:
Leonid Pershin
2026-06-11 04:03:07 +03:00
parent 31aba3aeee
commit ff2231a8ab
72 changed files with 4113 additions and 0 deletions
@@ -0,0 +1,83 @@
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
using Xunit;
namespace MrGameEng.Graphics.Tests;
public class CameraMathTests
{
private static void AssertVector(Vector2 expected, Vector2 actual, float tolerance = 0.001f)
{
Assert.InRange(actual.X, expected.X - tolerance, expected.X + tolerance);
Assert.InRange(actual.Y, expected.Y - tolerance, expected.Y + tolerance);
}
[Fact]
public void CameraPosition_MapsToScreenCenter()
{
var camera = new Camera(new Vector2(500f, 300f), zoom: 2f, rotation: 0.7f);
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
AssertVector(new Vector2(400f, 300f), state.WorldToScreen(camera.Position));
}
[Fact]
public void ScreenToWorld_RoundTripsWithWorldToScreen()
{
var camera = new Camera(new Vector2(123f, -45f), zoom: 1.5f, rotation: 0.3f);
var state = CameraMath.Compute(camera, 1280, 720, new ViewportMapping(new Vector2(0f, 60f), 1.5f));
var screen = new Vector2(200f, 500f);
var world = state.ScreenToWorld(screen);
AssertVector(screen, state.WorldToScreen(world));
}
[Fact]
public void Zoom_ShrinksCullRect()
{
var camera = new Camera(Vector2.Zero, zoom: 2f);
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
Assert.Equal(400f, state.CullRect.Width, 1);
Assert.Equal(300f, state.CullRect.Height, 1);
AssertVector(Vector2.Zero, state.CullRect.Center);
}
[Fact]
public void Rotation_ExpandsCullRectToCoverRotatedView()
{
var straight = CameraMath.Compute(new Camera(Vector2.Zero), 800, 600, ViewportMapping.Identity);
var rotated = CameraMath.Compute(new Camera(Vector2.Zero, rotation: MathF.PI / 4f), 800, 600, ViewportMapping.Identity);
Assert.True(rotated.CullRect.Width > straight.CullRect.Width);
Assert.True(rotated.CullRect.Height > straight.CullRect.Height);
}
[Fact]
public void Bounds_ClampCameraToWorldEdges()
{
var bounds = new RectF(0f, 0f, 2000f, 1000f);
var camera = new Camera(new Vector2(-500f, 500f), bounds: bounds);
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
// Camera should be clamped so the view's left edge sits at the world's left edge.
AssertVector(new Vector2(0f, 200f), state.ScreenToWorld(Vector2.Zero));
}
[Fact]
public void Mapping_CentersVirtualResolutionInWiderWindow()
{
var mapping = CameraMath.ComputeMapping(1920, 1080, 640, 360);
Assert.Equal(3f, mapping.Scale);
Assert.Equal(Vector2.Zero, mapping.Offset);
var letterboxed = CameraMath.ComputeMapping(1920, 1200, 640, 360);
Assert.Equal(3f, letterboxed.Scale);
Assert.Equal(new Vector2(0f, 60f), letterboxed.Offset);
}
}
@@ -0,0 +1,51 @@
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
using Xunit;
namespace MrGameEng.Graphics.Tests;
public class CullingTests
{
[Fact]
public void BoundingCircle_UnrotatedTopLeftOrigin_CenterIsRegionCenter()
{
var transform = Transform2D.At(new Vector2(100f, 200f));
var (center, radius) = CullingMath.SpriteBoundingCircle(transform, 32f, 32f, Vector2.Zero);
Assert.Equal(new Vector2(116f, 216f), center);
Assert.Equal(0.5f * MathF.Sqrt(32f * 32f * 2f), radius, 3);
}
[Fact]
public void BoundingCircle_CenterOrigin_CenterIsPosition()
{
var transform = Transform2D.At(new Vector2(50f, 50f));
var (center, _) = CullingMath.SpriteBoundingCircle(transform, 64f, 32f, new Vector2(32f, 16f));
Assert.Equal(new Vector2(50f, 50f), center);
}
[Fact]
public void BoundingCircle_Scale_GrowsRadius()
{
var transform = new Transform2D(Vector2.Zero, scale: new Vector2(2f, 2f));
var (_, radius) = CullingMath.SpriteBoundingCircle(transform, 10f, 10f, Vector2.Zero);
Assert.Equal(0.5f * MathF.Sqrt(800f), radius, 3);
}
[Theory]
[InlineData(50f, 50f, true)] // inside
[InlineData(-4f, 50f, true)] // touching from the left (radius 5)
[InlineData(-20f, 50f, false)] // far left
[InlineData(50f, 130f, false)] // far below
public void CircleIntersectsRect_DetectsOverlap(float x, float y, bool expected)
{
var rect = new RectF(0f, 0f, 100f, 100f);
Assert.Equal(expected, CullingMath.CircleIntersectsRect(new Vector2(x, y), 5f, rect));
}
}
@@ -0,0 +1,33 @@
using MrGameEng.Graphics;
using Xunit;
namespace MrGameEng.Graphics.Tests;
public class LayerRegistryTests
{
[Fact]
public void Registry_StartsWithDefaultWorldLayer()
{
var registry = new LayerRegistry();
Assert.Equal(1, registry.Count);
var layer = registry[LayerId.Default];
Assert.Equal("Default", layer.Name);
Assert.Equal(LayerSpace.World, layer.Space);
Assert.Equal(LayerSortMode.Depth, layer.SortMode);
}
[Fact]
public void Register_AssignsSequentialIds_InDrawOrder()
{
var registry = new LayerRegistry();
var world = registry.Register("World", LayerSpace.World, LayerSortMode.YSort);
var ui = registry.Register("UI", LayerSpace.Screen);
Assert.Equal(1, world.Value);
Assert.Equal(2, ui.Value);
Assert.Equal(LayerSortMode.YSort, registry[world].SortMode);
Assert.Equal(LayerSpace.Screen, registry[ui].Space);
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
using MrGameEng.Graphics;
using Xunit;
namespace MrGameEng.Graphics.Tests;
public class SortKeyTests
{
[Fact]
public void LayerDominatesDepthAndTexture()
{
var lowLayer = SpriteSortKey.Make(0, 1000f, textureKey: 5);
var highLayer = SpriteSortKey.Make(1, -1000f, textureKey: 1);
Assert.True(lowLayer < highLayer);
}
[Fact]
public void DepthDominatesTexture_WithinLayer()
{
var behind = SpriteSortKey.Make(3, -5f, textureKey: 999);
var inFront = SpriteSortKey.Make(3, 5f, textureKey: 1);
Assert.True(behind < inFront);
}
[Theory]
[InlineData(-100f, -1f)]
[InlineData(-1f, 0f)]
[InlineData(0f, 1f)]
[InlineData(1f, 100f)]
[InlineData(-0.5f, 0.5f)]
public void DepthBits_PreserveFloatOrder(float smaller, float larger)
{
Assert.True(SpriteSortKey.DepthToSortableBits(smaller) < SpriteSortKey.DepthToSortableBits(larger));
}
[Fact]
public void EqualLayerAndDepth_GroupByTexture()
{
var a1 = SpriteSortKey.Make(2, 1f, textureKey: 7);
var b = SpriteSortKey.Make(2, 1f, textureKey: 9);
var a2 = SpriteSortKey.Make(2, 1f, textureKey: 7);
Assert.Equal(a1, a2);
Assert.NotEqual(a1, b);
}
}
@@ -0,0 +1,65 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
using Xunit;
namespace MrGameEng.Graphics.Tests;
public class SpriteAnimationTests
{
private static Texture2DRegion Region(int x) => new(null!, new Rectangle(x, 0, 16, 16));
private static SpriteAnimationClip Clip(bool loop, params int[] xs) =>
new(Array.ConvertAll(xs, Region), framesPerSecond: 10f, loop);
[Fact]
public void FrameAt_LoopingClip_WrapsAround()
{
var clip = Clip(loop: true, 0, 1, 2);
Assert.Equal(0, clip.FrameAt(0.00f).Bounds.X);
Assert.Equal(1, clip.FrameAt(0.10f).Bounds.X);
Assert.Equal(2, clip.FrameAt(0.25f).Bounds.X);
Assert.Equal(0, clip.FrameAt(0.30f).Bounds.X); // wrapped
}
[Fact]
public void FrameAt_NonLoopingClip_ClampsToLastFrame()
{
var clip = Clip(loop: false, 0, 1);
Assert.Equal(1, clip.FrameAt(10f).Bounds.X);
}
private sealed class AnimScene : Scene
{
public Entity Animated;
public SpriteAnimationClip Clip = SpriteAnimationTests.Clip(loop: false, 0, 1, 2);
protected override void OnLoad()
{
this.UseSpriteAnimation();
Animated = Store.CreateEntity(
new Sprite { Color = Color.White },
new SpriteAnimator(Clip));
}
}
[Fact]
public void AnimationSystem_AdvancesFrames_AndStopsAtEnd()
{
var context = new EngineContext();
var scene = new AnimScene();
context.Scenes.Switch(scene);
context.Clock.Advance(0.15f); // 10 fps → frame 1
context.Scenes.Update(context.Clock);
Assert.Equal(1, scene.Animated.GetComponent<Sprite>().Region!.Bounds.X);
context.Clock.Advance(1.0f); // far past the end of a non-looping clip
context.Scenes.Update(context.Clock);
Assert.Equal(2, scene.Animated.GetComponent<Sprite>().Region!.Bounds.X);
Assert.False(scene.Animated.GetComponent<SpriteAnimator>().Playing);
}
}
@@ -0,0 +1,54 @@
using MrGameEng.Graphics;
using Xunit;
namespace MrGameEng.Graphics.Tests;
public class SpriteBatcherTests
{
private static SpriteInstance Instance(byte layer) => new() { Layer = layer };
[Fact]
public void Sort_OrdersByKey_ReturningOriginalIndices()
{
var batcher = new SpriteBatcher(initialCapacity: 2);
batcher.Submit(Instance(2), SpriteSortKey.Make(2, 0f, 0));
batcher.Submit(Instance(0), SpriteSortKey.Make(0, 0f, 0));
batcher.Submit(Instance(1), SpriteSortKey.Make(1, 0f, 0));
var order = batcher.Sort();
Assert.Equal(3, order.Length);
Assert.Equal(0, batcher[order[0]].Layer);
Assert.Equal(1, batcher[order[1]].Layer);
Assert.Equal(2, batcher[order[2]].Layer);
}
[Fact]
public void Submit_GrowsBeyondInitialCapacity()
{
var batcher = new SpriteBatcher(initialCapacity: 1);
for (var i = 0; i < 100; i++)
{
batcher.Submit(Instance(0), (ulong)(100 - i));
}
Assert.Equal(100, batcher.Count);
var order = batcher.Sort();
Assert.Equal(99, order[0]); // последний сабмит имеет наименьший ключ
}
[Fact]
public void Clear_ResetsCount_KeepsWorking()
{
var batcher = new SpriteBatcher();
batcher.Submit(Instance(0), 5);
batcher.Clear();
Assert.Equal(0, batcher.Count);
batcher.Submit(Instance(1), 1);
Assert.Equal(1, batcher.Count);
Assert.Equal(1, batcher[batcher.Sort()[0]].Layer);
}
}