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,141 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
using MrGameEng.Assets.Generator;
using Xunit;
namespace MrGameEng.Assets.Generator.Tests;
public class AssetHandlesGeneratorTests
{
private sealed class FakeAdditionalText(string path) : AdditionalText
{
public override string Path { get; } = path;
public override SourceText GetText(CancellationToken cancellationToken = default) =>
SourceText.From(string.Empty);
}
private sealed class FakeOptions(Dictionary<string, string> values) : AnalyzerConfigOptions
{
public override bool TryGetValue(string key, out string value) =>
values.TryGetValue(key, out value!);
}
private sealed class FakeOptionsProvider(Dictionary<string, string> values) : AnalyzerConfigOptionsProvider
{
public override AnalyzerConfigOptions GlobalOptions { get; } = new FakeOptions(values);
public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions;
public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions;
}
private static string RunGenerator(string[] files, Dictionary<string, string>? options = null)
{
var driver = CSharpGeneratorDriver.Create(
[new AssetHandlesGenerator().AsSourceGenerator()],
additionalTexts: Array.ConvertAll(files, f => (AdditionalText)new FakeAdditionalText(f)),
optionsProvider: new FakeOptionsProvider(options ?? new Dictionary<string, string>
{
["build_property.RootNamespace"] = "MyGame",
}));
var compilation = CSharpCompilation.Create("test");
var result = driver.RunGenerators(compilation).GetRunResult();
return Assert.Single(Assert.Single(result.Results).GeneratedSources).SourceText.ToString();
}
[Fact]
public void GeneratesTypedHandles_ForKnownExtensions()
{
var source = RunGenerator(
[
@"D:\game\Assets\Textures\player.png",
@"D:\game\Assets\Sounds\jump.wav",
@"D:\game\Assets\Fonts\main.ttf",
@"D:\game\Assets\Music\theme.ogg",
]);
Assert.Contains("namespace MyGame;", source);
Assert.Contains("public static partial class GameAssets", source);
Assert.Contains("public static class Textures", source);
Assert.Contains(
"AssetRef<global::Microsoft.Xna.Framework.Graphics.Texture2D> Player = new(\"Textures/player.png\")",
source);
Assert.Contains(
"AssetRef<global::Microsoft.Xna.Framework.Audio.SoundEffect> Jump = new(\"Sounds/jump.wav\")",
source);
Assert.Contains("AssetRef<global::FontStashSharp.FontSystem> Main", source);
Assert.Contains("AssetRef<global::MrGameEng.Core.MusicTrack> Theme", source);
}
[Fact]
public void IgnoresUnknownExtensions_AndFilesOutsideAssets()
{
var source = RunGenerator(
[
@"D:\game\Assets\readme.md",
@"D:\game\Other\image.png",
@"D:\game\Assets\valid.png",
]);
Assert.Contains("Valid", source);
Assert.DoesNotContain("Readme", source);
Assert.DoesNotContain("Image", source);
}
[Fact]
public void NestedDirectories_BecomeNestedClasses()
{
var source = RunGenerator([@"C:\proj\Assets\UI\Icons\save-icon.png"]);
Assert.Contains("public static class UI", source);
Assert.Contains("public static class Icons", source);
Assert.Contains("SaveIcon = new(\"UI/Icons/save-icon.png\")", source);
}
[Fact]
public void GeneratedCode_Compiles()
{
var source = RunGenerator([@"D:\game\Assets\player.png"]);
// Подменяем внешние типы заглушками, чтобы скомпилировать сгенерированный код изолированно.
const string stubs = """
namespace MrGameEng.Assets { public readonly record struct AssetRef<T>(string Path) where T : class; }
namespace Microsoft.Xna.Framework.Graphics { public sealed class Texture2D; }
""";
var compilation = CSharpCompilation.Create(
"generated",
[CSharpSyntaxTree.ParseText(source), CSharpSyntaxTree.ParseText(stubs)],
[MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(System.Runtime.Loader.AssemblyLoadContext.Default
.LoadFromAssemblyName(new System.Reflection.AssemblyName("System.Runtime")).Location)],
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var errors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToList();
Assert.Empty(errors);
}
[Theory]
[InlineData("player", "Player")]
[InlineData("save-icon", "SaveIcon")]
[InlineData("8bit_font", "_8bitFont")]
[InlineData("...", "_")]
public void Identifier_SanitizesNames(string input, string expected)
{
Assert.Equal(expected, AssetHandlesGenerator.Identifier(input));
}
[Theory]
[InlineData(@"D:\game\Assets\a.png", "a.png")]
[InlineData("/home/user/game/Assets/sub/b.wav", "sub/b.wav")]
[InlineData(@"D:\game\NotAssets\c.png", null)]
[InlineData(@"D:\game\Assets\unknown.xyz", null)]
public void ToAssetPath_ExtractsRelativePath(string fullPath, string? expected)
{
Assert.Equal(expected, AssetHandlesGenerator.ToAssetPath(fullPath));
}
}
@@ -0,0 +1,19 @@
<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" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using MrGameEng.Core;
using Xunit;
namespace MrGameEng.Core.Tests;
public class GameClockTests
{
[Fact]
public void Advance_SingleFrame_UpdatesDeltaTotalAndFrameCount()
{
var clock = new GameClock();
clock.Advance(0.016f);
Assert.Equal(0.016f, clock.DeltaTime);
Assert.Equal(0.016f, clock.UnscaledDeltaTime);
Assert.Equal(0.016, clock.TotalTime, 3);
Assert.Equal(1, clock.FrameCount);
}
[Fact]
public void Advance_WithTimeScale_ScalesDeltaButNotUnscaled()
{
var clock = new GameClock { TimeScale = 0.5f };
clock.Advance(0.02f);
Assert.Equal(0.01f, clock.DeltaTime, 3);
Assert.Equal(0.02f, clock.UnscaledDeltaTime, 3);
Assert.Equal(0.01, clock.TotalTime, 3);
Assert.Equal(0.02, clock.UnscaledTotalTime, 3);
}
[Fact]
public void TimeScale_Zero_PausesScaledTime()
{
var clock = new GameClock { TimeScale = 0f };
clock.Advance(0.016f);
Assert.Equal(0f, clock.DeltaTime);
Assert.Equal(0.0, clock.TotalTime);
Assert.Equal(0.016, clock.UnscaledTotalTime, 3);
}
[Fact]
public void TimeScale_Negative_ClampsToZero()
{
var clock = new GameClock { TimeScale = -1f };
Assert.Equal(0f, clock.TimeScale);
}
}
@@ -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.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,87 @@
using MrGameEng.Core;
using Xunit;
namespace MrGameEng.Core.Tests;
public class SceneManagerTests
{
private sealed class TrackingScene : Scene
{
public int LoadCount;
public int UnloadCount;
public int UpdateCount;
protected override void OnLoad() => LoadCount++;
protected override void OnUnload() => UnloadCount++;
public override void Update(GameClock clock)
{
UpdateCount++;
base.Update(clock);
}
}
[Fact]
public void Switch_IsDeferred_UntilNextUpdate()
{
var context = new EngineContext();
var scene = new TrackingScene();
context.Scenes.Switch(scene);
Assert.Null(context.Scenes.Current);
Assert.Equal(0, scene.LoadCount);
context.Scenes.Update(context.Clock);
Assert.Same(scene, context.Scenes.Current);
Assert.Equal(1, scene.LoadCount);
Assert.Equal(1, scene.UpdateCount);
Assert.True(scene.IsLoaded);
}
[Fact]
public void Switch_UnloadsPreviousScene_AndLoadsNext()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
context.Scenes.Switch(first);
context.Scenes.Update(context.Clock);
context.Scenes.Switch(second);
context.Scenes.Update(context.Clock);
Assert.Equal(1, first.UnloadCount);
Assert.False(first.IsLoaded);
Assert.Same(second, context.Scenes.Current);
Assert.Equal(1, second.LoadCount);
}
[Fact]
public void Switch_ToNull_UnloadsCurrentScene()
{
var context = new EngineContext();
var scene = new TrackingScene();
context.Scenes.Switch(scene);
context.Scenes.Update(context.Clock);
context.Scenes.Switch(null);
context.Scenes.Update(context.Clock);
Assert.Null(context.Scenes.Current);
Assert.Equal(1, scene.UnloadCount);
}
[Fact]
public void Update_WithoutScene_DoesNothing()
{
var context = new EngineContext();
context.Scenes.Update(context.Clock);
context.Scenes.Draw(context.Clock);
Assert.Null(context.Scenes.Current);
}
}
@@ -0,0 +1,73 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using MrGameEng.Core;
using Xunit;
namespace MrGameEng.Core.Tests;
public class SceneSystemsTests
{
private struct Velocity : IComponent
{
public float X;
}
private struct Translation : IComponent
{
public float X;
}
private sealed class MoveSystem : QuerySystem<Translation, Velocity>
{
protected override void OnUpdate()
{
foreach (var (translations, velocities, _) in Query.Chunks)
{
var t = translations.Span;
var v = velocities.Span;
for (var i = 0; i < t.Length; i++)
{
t[i].X += v[i].X * Tick.deltaTime;
}
}
}
}
private sealed class MovingScene : Scene
{
public Entity Mover;
protected override void OnLoad()
{
Mover = Store.CreateEntity(new Translation { X = 0f }, new Velocity { X = 10f });
UpdateSystems.Add(new MoveSystem());
}
}
[Fact]
public void Update_RunsRegisteredQuerySystem_WithClockDelta()
{
var context = new EngineContext();
var scene = new MovingScene();
context.Scenes.Switch(scene);
context.Clock.Advance(0.5f);
context.Scenes.Update(context.Clock);
Assert.Equal(5f, scene.Mover.GetComponent<Translation>().X, 3);
}
[Fact]
public void TimeScale_AffectsSystemDelta()
{
var context = new EngineContext();
var scene = new MovingScene();
context.Scenes.Switch(scene);
context.Clock.TimeScale = 0f;
context.Clock.Advance(0.5f);
context.Scenes.Update(context.Clock);
Assert.Equal(0f, scene.Mover.GetComponent<Translation>().X);
}
}
@@ -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);
}
}
@@ -0,0 +1,75 @@
using Microsoft.Xna.Framework.Input;
using MrGameEng.Input;
using Xunit;
namespace MrGameEng.Input.Tests;
public class ActionMapTests
{
private enum GameAction
{
Jump,
MoveLeft,
MoveRight,
}
private static void Frame(InputManager input, params Keys[] keys) =>
input.Apply(new KeyboardState(keys), default, GamePadState.Default);
[Fact]
public void IsDown_TrueWhenAnyBindingIsHeld()
{
var input = new InputManager();
var map = new ActionMap<GameAction>(input)
.Bind(GameAction.Jump, Keys.Space)
.Bind(GameAction.Jump, Keys.W);
Frame(input, Keys.W);
Assert.True(map.IsDown(GameAction.Jump));
}
[Fact]
public void IsPressed_EdgeTriggered()
{
var input = new InputManager();
var map = new ActionMap<GameAction>(input).Bind(GameAction.Jump, Keys.Space);
Frame(input, Keys.Space);
Assert.True(map.IsPressed(GameAction.Jump));
Frame(input, Keys.Space);
Assert.False(map.IsPressed(GameAction.Jump));
Assert.True(map.IsDown(GameAction.Jump));
}
[Fact]
public void Unbind_RemovesAllBindings()
{
var input = new InputManager();
var map = new ActionMap<GameAction>(input).Bind(GameAction.Jump, Keys.Space);
map.Unbind(GameAction.Jump);
Frame(input, Keys.Space);
Assert.False(map.IsDown(GameAction.Jump));
}
[Fact]
public void GetAxis_CombinesTwoActions()
{
var input = new InputManager();
var map = new ActionMap<GameAction>(input)
.Bind(GameAction.MoveLeft, Keys.A)
.Bind(GameAction.MoveRight, Keys.D);
Frame(input, Keys.A);
Assert.Equal(-1f, map.GetAxis(GameAction.MoveLeft, GameAction.MoveRight));
Frame(input, Keys.A, Keys.D);
Assert.Equal(0f, map.GetAxis(GameAction.MoveLeft, GameAction.MoveRight));
Frame(input, Keys.D);
Assert.Equal(1f, map.GetAxis(GameAction.MoveLeft, GameAction.MoveRight));
}
}
@@ -0,0 +1,67 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Input;
using Xunit;
namespace MrGameEng.Input.Tests;
public class InputManagerTests
{
private static MouseState Mouse(int x = 0, int y = 0, int wheel = 0, ButtonState left = ButtonState.Released) =>
new(x, y, wheel, left, ButtonState.Released, ButtonState.Released, ButtonState.Released, ButtonState.Released);
private static void Frame(InputManager input, KeyboardState keyboard = default, MouseState mouse = default) =>
input.Apply(keyboard, mouse, GamePadState.Default);
[Fact]
public void KeyPressed_OnlyOnTheFrameItGoesDown()
{
var input = new InputManager();
Frame(input, new KeyboardState(Keys.Space));
Assert.True(input.IsKeyPressed(Keys.Space));
Assert.True(input.IsKeyDown(Keys.Space));
Frame(input, new KeyboardState(Keys.Space));
Assert.False(input.IsKeyPressed(Keys.Space));
Assert.True(input.IsKeyDown(Keys.Space));
}
[Fact]
public void KeyReleased_OnlyOnTheFrameItGoesUp()
{
var input = new InputManager();
Frame(input, new KeyboardState(Keys.A));
Frame(input);
Assert.True(input.IsKeyReleased(Keys.A));
Frame(input);
Assert.False(input.IsKeyReleased(Keys.A));
}
[Fact]
public void MouseDeltaAndWheelDelta_ComputedBetweenFrames()
{
var input = new InputManager();
Frame(input, mouse: Mouse(x: 10, y: 10, wheel: 0));
Frame(input, mouse: Mouse(x: 25, y: 5, wheel: 120));
Assert.Equal(new Point(15, -5), input.MouseDelta);
Assert.Equal(120, input.WheelDelta);
Assert.Equal(new Point(25, 5), input.MousePosition);
}
[Fact]
public void MousePressed_DetectsLeftButtonEdge()
{
var input = new InputManager();
Frame(input, mouse: Mouse());
Frame(input, mouse: Mouse(left: ButtonState.Pressed));
Assert.True(input.IsMousePressed(MouseButton.Left));
Assert.False(input.IsMousePressed(MouseButton.Right));
}
}
@@ -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.Input\MrGameEng.Input.csproj" />
</ItemGroup>
</Project>