From ff2231a8ab7b1373244c72e6e5ad13fe00d1a0ca Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 11 Jun 2026 04:03:07 +0300 Subject: [PATCH] Update README.md to include project description, developer documentation links, and license information. --- .editorconfig | 17 + .github/workflows/ci.yml | 19 + CLAUDE.md | 57 +++ Directory.Build.props | 17 + Directory.Packages.props | 20 ++ MrGameEng.sln | 193 +++++++++++ README.md | 8 + docs/architecture.md | 181 ++++++++++ docs/roadmap.md | 24 ++ .../MrGameEng.Sample/Assets/Music/theme.ogg | Bin 0 -> 14056 bytes .../MrGameEng.Sample/Assets/Sounds/beep.wav | Bin 0 -> 6660 bytes .../Assets/Textures/player.png | Bin 0 -> 760 bytes .../Assets/Textures/shapes.png | Bin 0 -> 933 bytes .../MrGameEng.Sample/MrGameEng.Sample.csproj | 23 ++ samples/MrGameEng.Sample/Program.cs | 15 + samples/MrGameEng.Sample/SampleShared.cs | 46 +++ samples/MrGameEng.Sample/SampleSystems.cs | 156 +++++++++ samples/MrGameEng.Sample/Scenes/MainScene.cs | 106 ++++++ .../MrGameEng.Sample/Scenes/StressScene.cs | 71 ++++ .../AssetHandlesGenerator.cs | 180 ++++++++++ .../MrGameEng.Assets.Generator.csproj | 18 + src/MrGameEng.Assets/AssetManager.cs | 131 +++++++ src/MrGameEng.Assets/AssetRef.cs | 14 + src/MrGameEng.Assets/MrGameEng.Assets.csproj | 15 + src/MrGameEng.Audio/AudioManager.cs | 46 +++ src/MrGameEng.Audio/MrGameEng.Audio.csproj | 15 + src/MrGameEng.Audio/MusicPlayer.cs | 113 ++++++ src/MrGameEng.Core/EngineContext.cs | 39 +++ src/MrGameEng.Core/GameClock.cs | 42 +++ src/MrGameEng.Core/GameHost.cs | 77 +++++ src/MrGameEng.Core/GameHostOptions.cs | 34 ++ src/MrGameEng.Core/MrGameEng.Core.csproj | 12 + src/MrGameEng.Core/MusicTrack.cs | 8 + src/MrGameEng.Core/Scene.cs | 62 ++++ src/MrGameEng.Core/SceneManager.cs | 51 +++ src/MrGameEng.Core/ServiceRegistry.cs | 33 ++ src/MrGameEng.Graphics/Camera.cs | 32 ++ src/MrGameEng.Graphics/CameraMath.cs | 120 +++++++ src/MrGameEng.Graphics/CullingMath.cs | 41 +++ src/MrGameEng.Graphics/Layers.cs | 62 ++++ .../MrGameEng.Graphics.csproj | 11 + src/MrGameEng.Graphics/RectF.cs | 34 ++ src/MrGameEng.Graphics/RenderSystems.cs | 66 ++++ src/MrGameEng.Graphics/Renderer2D.cs | 324 ++++++++++++++++++ src/MrGameEng.Graphics/Renderer2DOptions.cs | 20 ++ .../SceneGraphicsExtensions.cs | 40 +++ src/MrGameEng.Graphics/Sprite.cs | 66 ++++ src/MrGameEng.Graphics/SpriteAnimation.cs | 125 +++++++ src/MrGameEng.Graphics/SpriteBatcher.cs | 94 +++++ src/MrGameEng.Graphics/SpriteSortKey.cs | 23 ++ src/MrGameEng.Graphics/Texture2DRegion.cs | 40 +++ src/MrGameEng.Graphics/Transform2D.cs | 32 ++ src/MrGameEng.Input/ActionMap.cs | 86 +++++ src/MrGameEng.Input/InputManager.cs | 98 ++++++ src/MrGameEng.Input/InputSystem.cs | 39 +++ src/MrGameEng.Input/MrGameEng.Input.csproj | 15 + .../AssetHandlesGeneratorTests.cs | 141 ++++++++ .../MrGameEng.Assets.Generator.Tests.csproj | 19 + tests/MrGameEng.Core.Tests/GameClockTests.cs | 53 +++ .../MrGameEng.Core.Tests.csproj | 18 + .../MrGameEng.Core.Tests/SceneManagerTests.cs | 87 +++++ .../MrGameEng.Core.Tests/SceneSystemsTests.cs | 73 ++++ .../CameraMathTests.cs | 83 +++++ .../MrGameEng.Graphics.Tests/CullingTests.cs | 51 +++ .../LayerRegistryTests.cs | 33 ++ .../MrGameEng.Graphics.Tests.csproj | 18 + .../MrGameEng.Graphics.Tests/SortKeyTests.cs | 47 +++ .../SpriteAnimationTests.cs | 65 ++++ .../SpriteBatcherTests.cs | 54 +++ tests/MrGameEng.Input.Tests/ActionMapTests.cs | 75 ++++ .../InputManagerTests.cs | 67 ++++ .../MrGameEng.Input.Tests.csproj | 18 + 72 files changed, 4113 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 MrGameEng.sln create mode 100644 docs/architecture.md create mode 100644 docs/roadmap.md create mode 100644 samples/MrGameEng.Sample/Assets/Music/theme.ogg create mode 100644 samples/MrGameEng.Sample/Assets/Sounds/beep.wav create mode 100644 samples/MrGameEng.Sample/Assets/Textures/player.png create mode 100644 samples/MrGameEng.Sample/Assets/Textures/shapes.png create mode 100644 samples/MrGameEng.Sample/MrGameEng.Sample.csproj create mode 100644 samples/MrGameEng.Sample/Program.cs create mode 100644 samples/MrGameEng.Sample/SampleShared.cs create mode 100644 samples/MrGameEng.Sample/SampleSystems.cs create mode 100644 samples/MrGameEng.Sample/Scenes/MainScene.cs create mode 100644 samples/MrGameEng.Sample/Scenes/StressScene.cs create mode 100644 src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs create mode 100644 src/MrGameEng.Assets.Generator/MrGameEng.Assets.Generator.csproj create mode 100644 src/MrGameEng.Assets/AssetManager.cs create mode 100644 src/MrGameEng.Assets/AssetRef.cs create mode 100644 src/MrGameEng.Assets/MrGameEng.Assets.csproj create mode 100644 src/MrGameEng.Audio/AudioManager.cs create mode 100644 src/MrGameEng.Audio/MrGameEng.Audio.csproj create mode 100644 src/MrGameEng.Audio/MusicPlayer.cs create mode 100644 src/MrGameEng.Core/EngineContext.cs create mode 100644 src/MrGameEng.Core/GameClock.cs create mode 100644 src/MrGameEng.Core/GameHost.cs create mode 100644 src/MrGameEng.Core/GameHostOptions.cs create mode 100644 src/MrGameEng.Core/MrGameEng.Core.csproj create mode 100644 src/MrGameEng.Core/MusicTrack.cs create mode 100644 src/MrGameEng.Core/Scene.cs create mode 100644 src/MrGameEng.Core/SceneManager.cs create mode 100644 src/MrGameEng.Core/ServiceRegistry.cs create mode 100644 src/MrGameEng.Graphics/Camera.cs create mode 100644 src/MrGameEng.Graphics/CameraMath.cs create mode 100644 src/MrGameEng.Graphics/CullingMath.cs create mode 100644 src/MrGameEng.Graphics/Layers.cs create mode 100644 src/MrGameEng.Graphics/MrGameEng.Graphics.csproj create mode 100644 src/MrGameEng.Graphics/RectF.cs create mode 100644 src/MrGameEng.Graphics/RenderSystems.cs create mode 100644 src/MrGameEng.Graphics/Renderer2D.cs create mode 100644 src/MrGameEng.Graphics/Renderer2DOptions.cs create mode 100644 src/MrGameEng.Graphics/SceneGraphicsExtensions.cs create mode 100644 src/MrGameEng.Graphics/Sprite.cs create mode 100644 src/MrGameEng.Graphics/SpriteAnimation.cs create mode 100644 src/MrGameEng.Graphics/SpriteBatcher.cs create mode 100644 src/MrGameEng.Graphics/SpriteSortKey.cs create mode 100644 src/MrGameEng.Graphics/Texture2DRegion.cs create mode 100644 src/MrGameEng.Graphics/Transform2D.cs create mode 100644 src/MrGameEng.Input/ActionMap.cs create mode 100644 src/MrGameEng.Input/InputManager.cs create mode 100644 src/MrGameEng.Input/InputSystem.cs create mode 100644 src/MrGameEng.Input/MrGameEng.Input.csproj create mode 100644 tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs create mode 100644 tests/MrGameEng.Assets.Generator.Tests/MrGameEng.Assets.Generator.Tests.csproj create mode 100644 tests/MrGameEng.Core.Tests/GameClockTests.cs create mode 100644 tests/MrGameEng.Core.Tests/MrGameEng.Core.Tests.csproj create mode 100644 tests/MrGameEng.Core.Tests/SceneManagerTests.cs create mode 100644 tests/MrGameEng.Core.Tests/SceneSystemsTests.cs create mode 100644 tests/MrGameEng.Graphics.Tests/CameraMathTests.cs create mode 100644 tests/MrGameEng.Graphics.Tests/CullingTests.cs create mode 100644 tests/MrGameEng.Graphics.Tests/LayerRegistryTests.cs create mode 100644 tests/MrGameEng.Graphics.Tests/MrGameEng.Graphics.Tests.csproj create mode 100644 tests/MrGameEng.Graphics.Tests/SortKeyTests.cs create mode 100644 tests/MrGameEng.Graphics.Tests/SpriteAnimationTests.cs create mode 100644 tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs create mode 100644 tests/MrGameEng.Input.Tests/ActionMapTests.cs create mode 100644 tests/MrGameEng.Input.Tests/InputManagerTests.cs create mode 100644 tests/MrGameEng.Input.Tests/MrGameEng.Input.Tests.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f03edca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{csproj,props,targets,yml,yaml,json}] +indent_size = 2 + +[*.cs] +csharp_style_namespace_declarations = file_scoped:error +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true +dotnet_sort_system_directives_first = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d1fc747 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Build + run: dotnet build MrGameEng.sln --configuration Release + - name: Test + run: dotnet test MrGameEng.sln --configuration Release --no-build --verbosity normal diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..199d93a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# mrgameeng + +2D game engine built on MonoGame 3.8.4 (DesktopGL), .NET 8, C#. +ECS-first: Friflo.Engine.ECS 3.6 is tightly integrated into the core — all gameplay +state lives in components, all logic in systems. + +Design docs and developer documentation live in `docs/` and are written in **Russian**. +Keep them up to date when architecture or conventions change. + +## Solution layout + +``` +src/ MrGameEng.* engine libraries (one per functional area) +samples/ MrGameEng.Sample — demo game showcasing every engine feature +tests/ xUnit test projects, one per engine library +docs/ architecture, conventions, roadmap (Russian) +``` + +Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched +renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content +pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles). +Dependency rule: every module may depend only on `Core`; `Core` depends only on +MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer. + +## Commands + +``` +dotnet build MrGameEng.sln +dotnet test MrGameEng.sln +dotnet run --project samples/MrGameEng.Sample +``` + +## Architecture rules + +- ECS-first: components are plain data (`struct` implementing `IComponent`), + behavior goes into Friflo systems (`QuerySystem`), wired through `SystemRoot`. + No `Update()` methods on game objects, no inheritance-based entities. +- Hot paths (per-frame systems) must be allocation-free. +- Rendering: custom batcher in `Graphics` (vertex buffers, layer→depth→texture sort, + atlas support); `SpriteBatch` is not used in engine code. Draw systems write vertices + directly from Friflo chunk iteration. Orthographic camera (one active per scene), + registered render layers (World or Screen space, optional Y-sort), AABB culling + against the camera rect before vertices are written. +- No MGCB content pipeline. Assets are raw files under `Assets/`, loaded at runtime + (textures via `Texture2D.FromFile`, fonts via FontStashSharp, ogg via NVorbis, + shaders precompiled by `dotnet-mgfxc` at build time). Game code references assets + only through generated typed handles (`AssetRef`), never string paths. +- New engine functionality goes into the matching module, or a new + `MrGameEng.` library if it is a distinct area — never into `Core` by default. +- Every public engine feature must be demonstrated in `MrGameEng.Sample` + and covered by tests where logic is testable without a GPU. + +## Code conventions + +- Nullable reference types enabled, warnings as errors, file-scoped namespaces. +- Public engine API requires XML doc comments (English). +- Tests: xUnit, named `Method_Scenario_Expectation`. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..5292dfc --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + + latest + enable + enable + true + true + true + $(MSBuildProjectName) + + + + true + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..cb0521f --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/MrGameEng.sln b/MrGameEng.sln new file mode 100644 index 0000000..f0c85a7 --- /dev/null +++ b/MrGameEng.sln @@ -0,0 +1,193 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Core", "src\MrGameEng.Core\MrGameEng.Core.csproj", "{09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Sample", "samples\MrGameEng.Sample\MrGameEng.Sample.csproj", "{E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Core.Tests", "tests\MrGameEng.Core.Tests\MrGameEng.Core.Tests.csproj", "{2E9A054C-77A1-484B-BBFF-51FC40CD7D87}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Graphics", "src\MrGameEng.Graphics\MrGameEng.Graphics.csproj", "{1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Graphics.Tests", "tests\MrGameEng.Graphics.Tests\MrGameEng.Graphics.Tests.csproj", "{F3C7CE85-EF6B-4CEE-9042-50B8710EF656}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Assets", "src\MrGameEng.Assets\MrGameEng.Assets.csproj", "{8FDFB833-57DC-4013-8399-5B2F67C9B14E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Assets.Generator", "src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj", "{FA351009-2001-42A8-8091-54438111E2F1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Assets.Generator.Tests", "tests\MrGameEng.Assets.Generator.Tests\MrGameEng.Assets.Generator.Tests.csproj", "{234790A6-1705-48C3-BF31-3DC79721B1E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input", "src\MrGameEng.Input\MrGameEng.Input.csproj", "{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio", "src\MrGameEng.Audio\MrGameEng.Audio.csproj", "{D55753A2-4CC0-4BCC-80D5-01E919FF97BE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input.Tests", "tests\MrGameEng.Input.Tests\MrGameEng.Input.Tests.csproj", "{0E0710AB-6132-4E64-9AFC-03B0601F92C6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Debug|x64.ActiveCfg = Debug|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Debug|x64.Build.0 = Debug|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Debug|x86.ActiveCfg = Debug|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Debug|x86.Build.0 = Debug|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|Any CPU.Build.0 = Release|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|x64.ActiveCfg = Release|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|x64.Build.0 = Release|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|x86.ActiveCfg = Release|Any CPU + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|x86.Build.0 = Release|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|x64.Build.0 = Debug|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|x86.Build.0 = Debug|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|Any CPU.Build.0 = Release|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|x64.ActiveCfg = Release|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|x64.Build.0 = Release|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|x86.ActiveCfg = Release|Any CPU + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|x86.Build.0 = Release|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|x64.Build.0 = Debug|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|x86.Build.0 = Debug|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Release|Any CPU.Build.0 = Release|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Release|x64.ActiveCfg = Release|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Release|x64.Build.0 = Release|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Release|x86.ActiveCfg = Release|Any CPU + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Release|x86.Build.0 = Release|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Debug|x64.ActiveCfg = Debug|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Debug|x64.Build.0 = Debug|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Debug|x86.ActiveCfg = Debug|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Debug|x86.Build.0 = Debug|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Release|Any CPU.Build.0 = Release|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Release|x64.ActiveCfg = Release|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Release|x64.Build.0 = Release|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Release|x86.ActiveCfg = Release|Any CPU + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1}.Release|x86.Build.0 = Release|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Debug|x64.ActiveCfg = Debug|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Debug|x64.Build.0 = Debug|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Debug|x86.Build.0 = Debug|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|Any CPU.Build.0 = Release|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|x64.ActiveCfg = Release|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|x64.Build.0 = Release|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|x86.ActiveCfg = Release|Any CPU + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|x86.Build.0 = Release|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|x64.ActiveCfg = Debug|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|x64.Build.0 = Debug|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|x86.ActiveCfg = Debug|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|x86.Build.0 = Debug|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|Any CPU.Build.0 = Release|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|x64.ActiveCfg = Release|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|x64.Build.0 = Release|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|x86.ActiveCfg = Release|Any CPU + {8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|x86.Build.0 = Release|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Debug|x64.Build.0 = Debug|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Debug|x86.Build.0 = Debug|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Release|Any CPU.Build.0 = Release|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Release|x64.ActiveCfg = Release|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Release|x64.Build.0 = Release|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Release|x86.ActiveCfg = Release|Any CPU + {FA351009-2001-42A8-8091-54438111E2F1}.Release|x86.Build.0 = Release|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Debug|x64.Build.0 = Debug|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Debug|x86.Build.0 = Debug|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|Any CPU.Build.0 = Release|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|x64.ActiveCfg = Release|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|x64.Build.0 = Release|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|x86.ActiveCfg = Release|Any CPU + {234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|x86.Build.0 = Release|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|x64.Build.0 = Debug|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|x86.Build.0 = Debug|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|Any CPU.Build.0 = Release|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|x64.ActiveCfg = Release|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|x64.Build.0 = Release|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|x86.ActiveCfg = Release|Any CPU + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|x86.Build.0 = Release|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|x64.Build.0 = Debug|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|x86.Build.0 = Debug|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|Any CPU.Build.0 = Release|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|x64.ActiveCfg = Release|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|x64.Build.0 = Release|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|x86.ActiveCfg = Release|Any CPU + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|x86.Build.0 = Release|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|x64.Build.0 = Debug|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|x86.Build.0 = Debug|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|Any CPU.Build.0 = Release|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x64.ActiveCfg = Release|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x64.Build.0 = Release|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.ActiveCfg = Release|Any CPU + {0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {2E9A054C-77A1-484B-BBFF-51FC40CD7D87} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F3C7CE85-EF6B-4CEE-9042-50B8710EF656} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {8FDFB833-57DC-4013-8399-5B2F67C9B14E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {FA351009-2001-42A8-8091-54438111E2F1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {234790A6-1705-48C3-BF31-3DC79721B1E9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D55753A2-4CC0-4BCC-80D5-01E919FF97BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {0E0710AB-6132-4E64-9AFC-03B0601F92C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 763e1e5..dde80c1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # mrgameeng +Cross-platform 2D game engine built on [MonoGame](https://monogame.net) 3.8.4 (DesktopGL) +and [Friflo.Engine.ECS](https://github.com/friflo/Friflo.Engine.ECS) — ECS-first, .NET 8, C#. + +Developer documentation (Russian): [docs/architecture.md](docs/architecture.md), [docs/roadmap.md](docs/roadmap.md). + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b862a52 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,181 @@ +# Архитектура mrgameeng + +## Обзор + +**mrgameeng** — кроссплатформенный 2D игровой движок на базе MonoGame 3.8.4 +(DesktopGL: Windows, Linux, macOS), .NET 8, C#. + +Ключевое архитектурное решение — **ECS-first**: движок плотно интегрирует +[Friflo.Engine.ECS](https://github.com/friflo/Friflo.Engine.ECS) (v3.6) как ядро +модели объектов. Это самая производительная ECS-библиотека в .NET по результатам +[Ecs.CSharp.Benchmark](https://github.com/Doraku/Ecs.CSharp.Benchmark): ноль аллокаций +в горячих путях, archetype-хранилище, встроенные системы, события и индексы. + +## Принципы + +1. **Данные отдельно от логики.** Всё состояние игры — в компонентах + (`struct : IComponent`), вся логика — в системах (`QuerySystem`). + Никаких `Update()` у игровых объектов и иерархий наследования сущностей. +2. **Ноль аллокаций в кадре.** Системы, выполняющиеся каждый кадр, не должны + аллоцировать память. Проверяется бенчмарками и code review. +3. **Модульность.** Движок разбит на библиотеки по функциональным областям. + Игра подключает только то, что использует. +4. **Всё демонстрируется.** Каждая публичная возможность движка показана + в `MrGameEng.Sample` и покрыта тестами (где логика тестируема без GPU). + +## Модули + +| Библиотека | Ответственность | +|------------------------------|------------------------------------------------------------| +| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время, жизненный цикл | +| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои | +| `MrGameEng.Input` | Абстракция ввода: клавиатура, мышь, геймпад; action maps | +| `MrGameEng.Audio` | Звуковые эффекты и музыка | +| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef` | +| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов | + +Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`. + +### Правило зависимостей + +``` +MrGameEng.Graphics ─┐ +MrGameEng.Input ─┤ +MrGameEng.Audio ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL +MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS +``` + +Модули зависят **только от `Core`** и никогда друг от друга. `Core` зависит только +от MonoGame и Friflo. Если двум модулям нужен общий тип — он переезжает в `Core`. + +`MrGameEng.Assets.Generator` — особый случай: это анализатор (netstandard2.0), +он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит +от других модулей движка. + +## Загрузка ресурсов (без Content Pipeline) + +MGCB / Content Pipeline **не используется**. Все ресурсы лежат в папке `Assets/` +проекта игры как сырые файлы (копируются в output при сборке) и грузятся в рантайме: + +| Тип | Форматы | Как грузим | +|----------------|-----------|---------------------------------------------------------| +| Текстуры | png, jpg | `Texture2D.FromFile` | +| Шрифты | ttf | FontStashSharp (растеризация в рантайме, динамический атлас) | +| Звуки | wav | `SoundEffect.FromFile` | +| Музыка | ogg | Стриминг через NVorbis + `DynamicSoundEffectInstance` | +| Шейдеры | fx | Компиляция `dotnet-mgfxc` на этапе сборки (MSBuild target), в рантайме грузится байткод `.mgfx` | + +`AssetManager` (в `MrGameEng.Assets`) грузит ресурсы по типизированному хендлу +`AssetRef` (путь + тип), кэширует по пути и владеет временем жизни (Dispose +при выгрузке сцены/игры). + +### Кодогенератор хендлов + +`MrGameEng.Assets.Generator` — Roslyn **incremental source generator**. Файлы из +`Assets/` передаются ему через `AdditionalFiles` (один glob в `.csproj` игры). +По дереву папок он генерирует статический класс с типизированными хендлами, +тип выводится из расширения файла: + +```csharp +// Assets/Textures/player.png → +public static partial class GameAssets +{ + public static class Textures + { + public static readonly AssetRef Player = new("Textures/player.png"); + } +} + +// использование: Texture2D tex = assets.Load(GameAssets.Textures.Player); +``` + +Обращение к ресурсу по строковому пути в коде игры — запрещено соглашением; +строки существуют только внутри сгенерированного кода. + +## Рендеринг + +`SpriteBatch` в движке **не используется** — в `MrGameEng.Graphics` свой батчер, +спроектированный под ECS. + +### Камера + +Ортографическая 2D-камера — из коробки, отдельная сущность с компонентом `Camera`: + +- Позиция, зум, поворот, опциональные границы мира (clamp). +- Строит ортографическую view-projection матрицу; виртуальное разрешение + с letterbox/scale под размер окна. +- Активная камера — одна на сцену. Утилиты `ScreenToWorld` / `WorldToScreen`. + +### Слои (render layers) + +- Слои регистрируются явно при настройке рендерера: имя + порядок отрисовки + (например, `Background → World → Foreground → UI`). +- У каждого слоя — **пространство**: `World` (через трансформ камеры) или + `Screen` (screen-space, без камеры — HUD, UI). +- Сортировка внутри слоя — по `float depth` спрайта; слой можно переключить + в режим **Y-sort** (depth = позиция Y, для top-down игр). +- Спрайт ссылается на слой компактным id (`LayerId`), не строкой. + +### Culling + +- Перед записью вершин AABB спрайта (с учётом rotation/scale) проверяется против + world-прямоугольника камеры; невидимые сущности не попадают в батчер. +- Screen-space слои не куллятся по камере (они всегда на экране). +- Culling — простой broad-phase в draw-системе, без пространственных структур; + если профилирование покажет, что на больших мирах его не хватает, + добавим spatial hash отдельным этапом. + +### Батчер + +- Draw-системы итерируют чанки Friflo (`Chunks`) и пишут + вершины напрямую в CPU-буфер батчера — без промежуточных списков и аллокаций. +- Порядок сортировки: **слой → depth (или Y) → текстура**; спрайты с одной + текстурой сливаются в один draw call (динамический vertex buffer + общий + quad index buffer). +- Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа, + спрайты одного атласа батчатся автоматически. +- Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе, + 0 аллокаций на кадр. Контролируется бенчмарками (BenchmarkDotNet) и + стресс-сценой в Sample. + +## Интеграция ECS + +- На каждую сцену создаётся свой `EntityStore` (мир) и **два** `SystemRoot`: + `UpdateSystems` (логика) и `DrawSystems` (рендер). Порядок систем детерминирован + и задаётся явно при регистрации. +- Модули подключаются к сцене extension-методами из `OnLoad`: + `scene.UseRenderer2D()`, `scene.UseInput()`, `scene.UseSpriteAnimation()`, + `context.UseAssets()`, `context.UseAudio()`. Сервисы модулей живут в + `EngineContext.Services` (общие между сценами), системы — в сцене. +- Базовый трансформ — один компонент `Transform2D` (position + rotation + scale): + один массив на чанк дешевле трёх отдельных компонентов. +- Модули поставляют готовые компоненты и системы + (например, `Sprite` + `SpriteRenderSystem` из `Graphics`), игра добавляет свои. + +## Кадр (frame pipeline) + +``` +Game.Update ──► Scene.Update ──► SystemRoot (Update-фаза): input → логика игры → анимации +Game.Draw ──► Scene.Draw ──► SystemRoot (Draw-фаза): камера → culling → запись вершин в батчер → flush (draw calls) +``` + +## Структура репозитория + +``` +MrGameEng.sln +src/ библиотеки движка (MrGameEng.*) +samples/ MrGameEng.Sample — демо всех возможностей +tests/ xUnit-тесты, по проекту на библиотеку +docs/ документация (русский) +``` + +## Зафиксированные версии + +| Зависимость | Версия | Назначение | +|---------------------------------|----------|----------------------------------| +| .NET (target framework) | net8.0 | | +| MonoGame.Framework.DesktopGL | 3.8.4.1 | Базовый фреймворк | +| Friflo.Engine.ECS | 3.6.0 | ECS | +| FontStashSharp.MonoGame | 1.5.6 | Шрифты (ttf) в рантайме | +| NVorbis | 0.10.5 | Декодирование ogg | +| dotnet-mgfxc (dotnet tool) | 3.8.4.1 | Компиляция шейдеров при сборке | diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..942cd28 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,24 @@ +# Roadmap + +Базовый движок (ядро, рендер, ассеты с кодогенерацией, ввод, звук, демо) реализован — +текущие возможности описаны в [architecture.md](architecture.md). Здесь — только планы. + +Пункт считается завершённым, когда покрыт тестами и показан в `MrGameEng.Sample`; +после завершения он убирается отсюда и фиксируется в architecture.md. + +## Ближайшее + +- [ ] Текст: рендер шрифтов FontStashSharp через батчер движка + (новый модуль `MrGameEng.Text`, потребует зависимости от Graphics) +- [ ] MSBuild target для автоматической компиляции `.fx` → `.mgfx` через `dotnet-mgfxc` + (лоадер `.mgfx` в AssetManager уже готов; target — когда появится первый кастомный шейдер) + +## Бэклог + +- Physics2D (выбор библиотеки: Aether.Physics2D / своя) +- Tilemap (поддержка Tiled) +- Particles +- UI +- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой) +- Стабильная сортировка спрайтов с равным ключом (сейчас порядок не гарантирован между кадрами) +- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость) diff --git a/samples/MrGameEng.Sample/Assets/Music/theme.ogg b/samples/MrGameEng.Sample/Assets/Music/theme.ogg new file mode 100644 index 0000000000000000000000000000000000000000..e1a45a2c8399deef1bb4baab0ec18f3c78897187 GIT binary patch literal 14056 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 literal 0 HcmV?d00001 diff --git a/samples/MrGameEng.Sample/Assets/Sounds/beep.wav b/samples/MrGameEng.Sample/Assets/Sounds/beep.wav new file mode 100644 index 0000000000000000000000000000000000000000..2f2e70e56259ec8a955354406072344d2b0a855c GIT binary patch literal 6660 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 Di3prQ literal 0 HcmV?d00001 diff --git a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj new file mode 100644 index 0000000..f398dab --- /dev/null +++ b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj @@ -0,0 +1,23 @@ + + + + WinExe + net8.0 + + + + + + + + + + + + + + + + + diff --git a/samples/MrGameEng.Sample/Program.cs b/samples/MrGameEng.Sample/Program.cs new file mode 100644 index 0000000..d4009a1 --- /dev/null +++ b/samples/MrGameEng.Sample/Program.cs @@ -0,0 +1,15 @@ +using Microsoft.Xna.Framework; +using MrGameEng.Core; +using MrGameEng.Sample.Scenes; + +using var host = new GameHost( + new GameHostOptions + { + Title = "MrGameEng Sample", + Width = 1280, + Height = 720, + ClearColor = new Color(24, 26, 32), + }, + new MainScene()); + +host.Run(); diff --git a/samples/MrGameEng.Sample/SampleShared.cs b/samples/MrGameEng.Sample/SampleShared.cs new file mode 100644 index 0000000..43b0853 --- /dev/null +++ b/samples/MrGameEng.Sample/SampleShared.cs @@ -0,0 +1,46 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; +using MrGameEng.Graphics; + +namespace MrGameEng.Sample; + +/// Действия игрока, привязанные к клавишам через ActionMap. +public enum SampleAction +{ + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + Jump, + Pause, + ToggleMusic, + SwitchScene, +} + +/// Скорость для движущихся сущностей сэмпла. +public struct Velocity : IComponent +{ + public Vector2 Value; +} + +/// Слои рендера сэмпла; регистрируются один раз на общий Renderer2D. +public static class SampleLayers +{ + public static LayerId Actors { get; private set; } + + public static LayerId Ui { get; private set; } + + private static bool _registered; + + public static void EnsureRegistered(Renderer2D renderer) + { + if (_registered) + { + return; + } + + Actors = renderer.Layers.Register("Actors", LayerSpace.World, LayerSortMode.YSort); + Ui = renderer.Layers.Register("UI", LayerSpace.Screen); + _registered = true; + } +} diff --git a/samples/MrGameEng.Sample/SampleSystems.cs b/samples/MrGameEng.Sample/SampleSystems.cs new file mode 100644 index 0000000..3bb5b49 --- /dev/null +++ b/samples/MrGameEng.Sample/SampleSystems.cs @@ -0,0 +1,156 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Input; +using MrGameEng.Audio; +using MrGameEng.Core; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace MrGameEng.Sample; + +/// Управление игроком (WASD/стрелки) и прыжок-писк на пробел. +public sealed class PlayerControlSystem( + Entity player, ActionMap actions, AudioManager audio, SoundEffect beep) : BaseSystem +{ + protected override void OnUpdateGroup() + { + ref var transform = ref player.GetComponent(); + var move = new Vector2( + actions.GetAxis(SampleAction.MoveLeft, SampleAction.MoveRight), + actions.GetAxis(SampleAction.MoveUp, SampleAction.MoveDown)); + + if (move != Vector2.Zero) + { + move.Normalize(); + transform.Position += move * 260f * Tick.deltaTime; + } + + if (actions.IsPressed(SampleAction.Jump)) + { + audio.Play(beep); + } + } +} + +/// Камера следует за игроком; колесо — зум, Q/E — поворот. +public sealed class CameraControlSystem(Entity cameraEntity, Entity player, InputManager input) : BaseSystem +{ + protected override void OnUpdateGroup() + { + ref var camera = ref cameraEntity.GetComponent(); + var target = player.GetComponent().Position; + + camera.Position = Vector2.Lerp(camera.Position, target, Math.Min(1f, 6f * Tick.deltaTime)); + camera.Zoom = Math.Clamp(camera.Zoom * (1f + input.WheelDelta * 0.001f), 0.2f, 5f); + + var rotate = (input.IsKeyDown(Keys.E) ? 1f : 0f) - (input.IsKeyDown(Keys.Q) ? 1f : 0f); + camera.Rotation += rotate * 1.2f * Tick.deltaTime; + } +} + +/// Отскок сущностей со скоростью от границ мира. +public sealed class BounceSystem(RectF bounds) : QuerySystem +{ + protected override void OnUpdate() + { + var delta = Tick.deltaTime; + foreach (var (transforms, velocities, _) in Query.Chunks) + { + var t = transforms.Span; + var v = velocities.Span; + for (var i = 0; i < t.Length; i++) + { + ref var position = ref t[i].Position; + ref var velocity = ref v[i].Value; + position += velocity * delta; + + if (position.X < bounds.Left || position.X > bounds.Right) + { + velocity.X = -velocity.X; + position.X = Math.Clamp(position.X, bounds.Left, bounds.Right); + } + + if (position.Y < bounds.Top || position.Y > bounds.Bottom) + { + velocity.Y = -velocity.Y; + position.Y = Math.Clamp(position.Y, bounds.Top, bounds.Bottom); + } + } + } + } +} + +/// Пауза (P), музыка (M), переключение сцены (Tab). +public sealed class SceneHotkeysSystem( + EngineContext context, ActionMap actions, Func nextScene) : BaseSystem +{ + protected override void OnUpdateGroup() + { + if (actions.IsPressed(SampleAction.Pause)) + { + context.Clock.TimeScale = context.Clock.TimeScale > 0f ? 0f : 1f; + } + + if (actions.IsPressed(SampleAction.ToggleMusic)) + { + var music = context.Services.Get().Music; + if (music.IsPlaying) + { + music.Pause(); + } + else + { + music.Resume(); + } + } + + if (actions.IsPressed(SampleAction.SwitchScene)) + { + context.Scenes.Switch(nextScene()); + } + } +} + +/// FPS и статистика рендера в заголовке окна (обновляется 4 раза в секунду). +public sealed class TitleStatsSystem(EngineContext context, Renderer2D renderer, string sceneName) : BaseSystem +{ + private float _accumulated; + private int _frames; + + protected override void OnUpdateGroup() + { + _accumulated += context.Clock.UnscaledDeltaTime; + _frames++; + if (_accumulated < 0.25f) + { + return; + } + + var fps = _frames / _accumulated; + _accumulated = 0f; + _frames = 0; + context.Services.Get().Title = + $"MrGameEng Sample — {sceneName} | {fps:F0} FPS | sprites: {renderer.SubmittedSprites} | culled: {renderer.CulledSprites} | draw calls: {renderer.DrawCalls}"; + } +} + +/// Общая настройка ввода для сцен сэмпла. +public static class SampleInput +{ + public static ActionMap CreateActions(InputManager input) => + new ActionMap(input) + .Bind(SampleAction.MoveLeft, Keys.A) + .Bind(SampleAction.MoveLeft, Keys.Left) + .Bind(SampleAction.MoveRight, Keys.D) + .Bind(SampleAction.MoveRight, Keys.Right) + .Bind(SampleAction.MoveUp, Keys.W) + .Bind(SampleAction.MoveUp, Keys.Up) + .Bind(SampleAction.MoveDown, Keys.S) + .Bind(SampleAction.MoveDown, Keys.Down) + .Bind(SampleAction.Jump, Keys.Space) + .Bind(SampleAction.Pause, Keys.P) + .Bind(SampleAction.ToggleMusic, Keys.M) + .Bind(SampleAction.SwitchScene, Keys.Tab); +} diff --git a/samples/MrGameEng.Sample/Scenes/MainScene.cs b/samples/MrGameEng.Sample/Scenes/MainScene.cs new file mode 100644 index 0000000..7df09ac --- /dev/null +++ b/samples/MrGameEng.Sample/Scenes/MainScene.cs @@ -0,0 +1,106 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; +using MrGameEng.Assets; +using MrGameEng.Audio; +using MrGameEng.Core; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace MrGameEng.Sample.Scenes; + +/// +/// Интерактивная демо-сцена: ассеты через сгенерированные хендлы, анимация, слои +/// (мир + Y-sort + screen-space HUD), камера с зумом/поворотом, ввод, звук и музыка. +/// WASD — игрок, колесо — зум, Q/E — поворот, Space — звук, P — пауза, M — музыка, Tab — стресс-сцена. +/// +public sealed class MainScene : Scene +{ + private const int DecorCount = 1500; + private static readonly RectF WorldBounds = new(-2000f, -2000f, 4000f, 4000f); + + protected override void OnLoad() + { + var assets = Context.Services.GetOrDefault() ?? Context.UseAssets(); + var audio = Context.Services.GetOrDefault() ?? Context.UseAudio(); + var input = this.UseInput(); + var actions = SampleInput.CreateActions(input); + this.UseSpriteAnimation(); + + var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }); + SampleLayers.EnsureRegistered(renderer); + + var playerTexture = assets.Load(GameAssets.Textures.Player); + var shapesTexture = assets.Load(GameAssets.Textures.Shapes); + var beep = assets.Load(GameAssets.Sounds.Beep); + + var shapeRegions = new[] + { + new Texture2DRegion(shapesTexture, new Rectangle(0, 0, 32, 32)), + new Texture2DRegion(shapesTexture, new Rectangle(32, 0, 32, 32)), + new Texture2DRegion(shapesTexture, new Rectangle(0, 32, 32, 32)), + new Texture2DRegion(shapesTexture, new Rectangle(32, 32, 32, 32)), + }; + + // Декорации по всему миру: уезжаешь камерой — попадают под culling (см. заголовок окна). + var random = new Random(42); + for (var i = 0; i < DecorCount; i++) + { + var sprite = new Sprite(shapeRegions[random.Next(shapeRegions.Length)]); + sprite.CenterOrigin(); + sprite.Color = Color.White * 0.35f; + Store.CreateEntity( + new Transform2D( + new Vector2( + random.NextSingle() * WorldBounds.Width + WorldBounds.Left, + random.NextSingle() * WorldBounds.Height + WorldBounds.Top), + rotation: random.NextSingle() * MathF.Tau, + scale: new Vector2(0.5f + random.NextSingle())), + sprite); + } + + // Бродячие "существа" на Y-sort слое: кто ниже на экране — тот ближе. + for (var i = 0; i < 200; i++) + { + var sprite = new Sprite(shapeRegions[random.Next(shapeRegions.Length)], SampleLayers.Actors); + sprite.CenterOrigin(); + var angle = random.NextSingle() * MathF.Tau; + Store.CreateEntity( + new Transform2D(new Vector2(random.Next(-600, 600), random.Next(-400, 400))), + sprite, + new Velocity { Value = new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * (40f + random.Next(80)) }); + } + + // Игрок: анимированный спрайт (2 кадра из атласа player.png), Y-sort слой. + var playerSprite = new Sprite(new Texture2DRegion(playerTexture, new Rectangle(0, 0, 32, 32)), SampleLayers.Actors); + playerSprite.CenterOrigin(); + var blink = new SpriteAnimationClip( + [ + new Texture2DRegion(playerTexture, new Rectangle(0, 0, 32, 32)), + new Texture2DRegion(playerTexture, new Rectangle(32, 0, 32, 32)), + ], framesPerSecond: 3f); + + var player = Store.CreateEntity( + new Transform2D(Vector2.Zero, scale: new Vector2(2f)), + playerSprite, + new SpriteAnimator(blink)); + + var camera = Store.CreateEntity(new Camera(Vector2.Zero, zoom: 1f, bounds: WorldBounds)); + + // HUD: золотой квадрат в углу на screen-space слое — не двигается с камерой. + var hud = new Sprite(shapeRegions[3], SampleLayers.Ui); + Store.CreateEntity(new Transform2D(new Vector2(16f, 16f)), hud); + + UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep)); + UpdateSystems.Add(new BounceSystem(WorldBounds)); + UpdateSystems.Add(new CameraControlSystem(camera, player, input)); + UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene())); + UpdateSystems.Add(new TitleStatsSystem(Context, renderer, "Main")); + + var music = audio.Music; + if (!music.IsPlaying) + { + music.Volume = 0.4f; + music.Play(assets.Load(GameAssets.Music.Theme)); + } + } +} diff --git a/samples/MrGameEng.Sample/Scenes/StressScene.cs b/samples/MrGameEng.Sample/Scenes/StressScene.cs new file mode 100644 index 0000000..e875da9 --- /dev/null +++ b/samples/MrGameEng.Sample/Scenes/StressScene.cs @@ -0,0 +1,71 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; +using MrGameEng.Assets; +using MrGameEng.Core; +using MrGameEng.Graphics; +using MrGameEng.Input; + +namespace MrGameEng.Sample.Scenes; + +/// +/// Стресс-сцена: 100 000 спрайтов скачут в мире 4000×4000. Колесо — зум (чем дальше, +/// тем больше спрайтов в кадре), Tab — обратно в основную сцену. Цель: 60 FPS. +/// +public sealed class StressScene : Scene +{ + private const int SpriteCount = 100_000; + private static readonly RectF WorldBounds = new(-2000f, -2000f, 4000f, 4000f); + + protected override void OnLoad() + { + var assets = Context.Services.Get(); + var input = this.UseInput(); + var actions = SampleInput.CreateActions(input); + + var renderer = this.UseRenderer2D(); + SampleLayers.EnsureRegistered(renderer); + + var shapesTexture = assets.Load(GameAssets.Textures.Shapes); + var regions = new[] + { + new Texture2DRegion(shapesTexture, new Rectangle(0, 0, 32, 32)), + new Texture2DRegion(shapesTexture, new Rectangle(32, 0, 32, 32)), + new Texture2DRegion(shapesTexture, new Rectangle(0, 32, 32, 32)), + new Texture2DRegion(shapesTexture, new Rectangle(32, 32, 32, 32)), + }; + + var random = new Random(7); + for (var i = 0; i < SpriteCount; i++) + { + var sprite = new Sprite(regions[random.Next(regions.Length)]); + sprite.CenterOrigin(); + var angle = random.NextSingle() * MathF.Tau; + Store.CreateEntity( + new Transform2D( + new Vector2( + random.NextSingle() * WorldBounds.Width + WorldBounds.Left, + random.NextSingle() * WorldBounds.Height + WorldBounds.Top), + scale: new Vector2(0.4f + random.NextSingle() * 0.6f)), + sprite, + new Velocity { Value = new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * (30f + random.Next(150)) }); + } + + var camera = Store.CreateEntity(new Camera(Vector2.Zero, zoom: 0.3f)); + + UpdateSystems.Add(new BounceSystem(WorldBounds)); + UpdateSystems.Add(new StressCameraSystem(camera, input)); + UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene())); + UpdateSystems.Add(new TitleStatsSystem(Context, renderer, $"Stress {SpriteCount:N0}")); + } + + /// Только зум колесом — чтобы регулировать число видимых спрайтов. + private sealed class StressCameraSystem(Entity cameraEntity, InputManager input) + : Friflo.Engine.ECS.Systems.BaseSystem + { + protected override void OnUpdateGroup() + { + ref var camera = ref cameraEntity.GetComponent(); + camera.Zoom = Math.Clamp(camera.Zoom * (1f + input.WheelDelta * 0.001f), 0.05f, 5f); + } + } +} diff --git a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs new file mode 100644 index 0000000..6a9f27e --- /dev/null +++ b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs @@ -0,0 +1,180 @@ +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace MrGameEng.Assets.Generator; + +/// +/// Incremental source generator producing typed asset handles. Asset files are passed in as +/// AdditionalFiles (glob over the game's Assets/ directory); for each known file type a +/// static readonly AssetRef<T> field is emitted, nested in static classes +/// mirroring the directory tree. +/// +[Generator] +public sealed class AssetHandlesGenerator : IIncrementalGenerator +{ + private static readonly Dictionary TypeByExtension = new(StringComparer.OrdinalIgnoreCase) + { + [".png"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D", + [".jpg"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D", + [".jpeg"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D", + [".bmp"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D", + [".ttf"] = "global::FontStashSharp.FontSystem", + [".otf"] = "global::FontStashSharp.FontSystem", + [".wav"] = "global::Microsoft.Xna.Framework.Audio.SoundEffect", + [".ogg"] = "global::MrGameEng.Core.MusicTrack", + [".mgfx"] = "global::Microsoft.Xna.Framework.Graphics.Effect", + }; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var options = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) => + { + provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns); + provider.GlobalOptions.TryGetValue("build_property.MrGameEngAssetsClassName", out var className); + return ( + Namespace: string.IsNullOrEmpty(ns) ? "Game" : ns!, + ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!); + }); + + var assets = context.AdditionalTextsProvider + .Select(static (text, _) => ToAssetPath(text.Path)) + .Where(static path => path is not null) + .Collect(); + + context.RegisterSourceOutput(assets.Combine(options), static (production, input) => + production.AddSource("GameAssets.g.cs", SourceText.From(Emit(input.Left!, input.Right.Namespace, input.Right.ClassName), Encoding.UTF8))); + } + + /// + /// Extracts the path relative to the "Assets" directory (forward slashes), or null when + /// the file is outside an Assets directory or has an unknown extension. + /// + internal static string? ToAssetPath(string fullPath) + { + var normalized = fullPath.Replace('\\', '/'); + var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase); + if (marker < 0) + { + return null; + } + + var relative = normalized.Substring(marker + "/Assets/".Length); + var extension = Path.GetExtension(relative); + return TypeByExtension.ContainsKey(extension) ? relative : null; + } + + private static string Emit(ImmutableArray paths, string ns, string className) + { + var root = new Node(); + foreach (var path in paths.Sort(StringComparer.Ordinal)) + { + var segments = path!.Split('/'); + var node = root; + for (var i = 0; i < segments.Length - 1; i++) + { + node = node.Child(segments[i]); + } + + node.Files.Add((segments[segments.Length - 1], path!)); + } + + var source = new StringBuilder(); + source.AppendLine("// "); + source.AppendLine($"namespace {ns};"); + source.AppendLine(); + source.AppendLine("/// Typed handles for every file under the Assets directory."); + source.AppendLine($"public static partial class {className}"); + source.AppendLine("{"); + EmitNode(source, root, indent: 1); + source.AppendLine("}"); + return source.ToString(); + } + + private static void EmitNode(StringBuilder source, Node node, int indent) + { + var pad = new string(' ', indent * 4); + var usedNames = new HashSet(); + + foreach (var (fileName, relativePath) in node.Files) + { + var type = TypeByExtension[Path.GetExtension(fileName)]; + var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName))); + source.AppendLine($"{pad}/// {relativePath}"); + source.AppendLine( + $"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = new(\"{relativePath}\");"); + } + + foreach (var pair in node.Children) + { + var name = Unique(usedNames, Identifier(pair.Key)); + source.AppendLine($"{pad}/// {pair.Key}/"); + source.AppendLine($"{pad}public static class {name}"); + source.AppendLine($"{pad}{{"); + EmitNode(source, pair.Value, indent + 1); + source.AppendLine($"{pad}}}"); + } + } + + /// Converts an arbitrary file or directory name to a PascalCase C# identifier. + internal static string Identifier(string name) + { + var result = new StringBuilder(name.Length); + var upperNext = true; + foreach (var c in name) + { + if (char.IsLetterOrDigit(c)) + { + result.Append(upperNext ? char.ToUpperInvariant(c) : c); + upperNext = false; + } + else + { + upperNext = true; + } + } + + if (result.Length == 0) + { + return "_"; + } + + if (char.IsDigit(result[0])) + { + result.Insert(0, '_'); + } + + return result.ToString(); + } + + private static string Unique(HashSet used, string name) + { + var candidate = name; + var counter = 2; + while (!used.Add(candidate)) + { + candidate = name + counter++; + } + + return candidate; + } + + private sealed class Node + { + public readonly SortedDictionary Children = new(StringComparer.Ordinal); + public readonly List<(string FileName, string RelativePath)> Files = []; + + public Node Child(string name) + { + if (!Children.TryGetValue(name, out var child)) + { + child = new Node(); + Children.Add(name, child); + } + + return child; + } + } +} diff --git a/src/MrGameEng.Assets.Generator/MrGameEng.Assets.Generator.csproj b/src/MrGameEng.Assets.Generator/MrGameEng.Assets.Generator.csproj new file mode 100644 index 0000000..24ea496 --- /dev/null +++ b/src/MrGameEng.Assets.Generator/MrGameEng.Assets.Generator.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + true + true + + + + + + + + + + + + diff --git a/src/MrGameEng.Assets/AssetManager.cs b/src/MrGameEng.Assets/AssetManager.cs new file mode 100644 index 0000000..8cd0c19 --- /dev/null +++ b/src/MrGameEng.Assets/AssetManager.cs @@ -0,0 +1,131 @@ +using FontStashSharp; +using Microsoft.Xna.Framework.Audio; +using Microsoft.Xna.Framework.Graphics; +using MrGameEng.Core; + +namespace MrGameEng.Assets; + +/// +/// Loads raw asset files at runtime (no content pipeline) by typed +/// handles, caches them by path and owns their lifetime. Built-in loaders: +/// Texture2D (png/jpg, premultiplied), SoundEffect (wav), +/// FontSystem (ttf via FontStashSharp), Effect (precompiled .mgfx), +/// MusicTrack (ogg, streamed by the audio module). Register custom loaders +/// with . +/// +public sealed class AssetManager : IDisposable +{ + /// Absolute path of the asset root directory. + public string RootPath { get; } + + private readonly EngineContext _context; + private readonly Dictionary<(Type Type, string Path), object> _cache = new(); + private readonly Dictionary> _loaders = new(); + + /// + /// Creates a manager reading from (relative paths are resolved + /// against the executable directory; default "Assets"). + /// + public AssetManager(EngineContext context, string rootPath = "Assets") + { + _context = context; + RootPath = Path.GetFullPath(rootPath, AppContext.BaseDirectory); + + RegisterLoader((manager, path) => LoadTexture(manager._context, path)); + RegisterLoader((_, path) => LoadSoundEffect(path)); + RegisterLoader((_, path) => LoadFontSystem(path)); + RegisterLoader((manager, path) => LoadEffect(manager._context, path)); + RegisterLoader((_, path) => new MusicTrack(path)); + } + + /// Loads (or returns the cached) asset for . + public T Load(AssetRef asset) where T : class + { + var key = (typeof(T), asset.Path); + if (_cache.TryGetValue(key, out var cached)) + { + return (T)cached; + } + + if (!_loaders.TryGetValue(typeof(T), out var loader)) + { + throw new InvalidOperationException($"No asset loader registered for type {typeof(T)}."); + } + + var fullPath = ResolvePath(asset.Path); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException($"Asset '{asset.Path}' not found at '{fullPath}'.", fullPath); + } + + var loaded = (T)loader(this, fullPath); + _cache.Add(key, loaded); + return loaded; + } + + /// Removes one asset from the cache, disposing it if disposable. + public void Unload(AssetRef asset) where T : class + { + var key = (typeof(T), asset.Path); + if (_cache.Remove(key, out var value) && value is IDisposable disposable) + { + disposable.Dispose(); + } + } + + /// Replaces or adds the loader used for assets of type . + public void RegisterLoader(Func loader) where T : class => + _loaders[typeof(T)] = loader; + + /// Resolves an asset-relative path to an absolute file path. + public string ResolvePath(string relativePath) => + Path.GetFullPath(Path.Combine(RootPath, relativePath)); + + /// Disposes every cached asset and clears the cache. + public void Dispose() + { + foreach (var value in _cache.Values) + { + (value as IDisposable)?.Dispose(); + } + + _cache.Clear(); + } + + private static Texture2D LoadTexture(EngineContext context, string path) + { + using var stream = File.OpenRead(path); + return Texture2D.FromStream(context.GraphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha); + } + + private static SoundEffect LoadSoundEffect(string path) + { + using var stream = File.OpenRead(path); + return SoundEffect.FromStream(stream); + } + + private static FontSystem LoadFontSystem(string path) + { + var fontSystem = new FontSystem(); + fontSystem.AddFont(File.ReadAllBytes(path)); + return fontSystem; + } + + private static Effect LoadEffect(EngineContext context, string path) => + new(context.GraphicsDevice, File.ReadAllBytes(path)); +} + +/// Wires the assets module into the engine. +public static class AssetsEngineExtensions +{ + /// + /// Creates the and registers it as a service. + /// Call once at startup (e.g. in the first scene's OnLoad). + /// + public static AssetManager UseAssets(this EngineContext context, string rootPath = "Assets") + { + var manager = new AssetManager(context, rootPath); + context.Services.Add(manager); + return manager; + } +} diff --git a/src/MrGameEng.Assets/AssetRef.cs b/src/MrGameEng.Assets/AssetRef.cs new file mode 100644 index 0000000..0b3eb77 --- /dev/null +++ b/src/MrGameEng.Assets/AssetRef.cs @@ -0,0 +1,14 @@ +namespace MrGameEng.Assets; + +/// +/// Typed handle to an asset: a path relative to the asset root plus the asset's runtime type. +/// Instances are produced by the MrGameEng.Assets.Generator source generator — +/// game code should never construct them from string literals. +/// +/// Runtime type the asset loads into (e.g. Texture2D). +/// Path relative to the asset root, with forward slashes. +public readonly record struct AssetRef(string Path) where T : class +{ + /// + public override string ToString() => $"{typeof(T).Name}:{Path}"; +} diff --git a/src/MrGameEng.Assets/MrGameEng.Assets.csproj b/src/MrGameEng.Assets/MrGameEng.Assets.csproj new file mode 100644 index 0000000..ed6475c --- /dev/null +++ b/src/MrGameEng.Assets/MrGameEng.Assets.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + + + + + + diff --git a/src/MrGameEng.Audio/AudioManager.cs b/src/MrGameEng.Audio/AudioManager.cs new file mode 100644 index 0000000..5fdadf8 --- /dev/null +++ b/src/MrGameEng.Audio/AudioManager.cs @@ -0,0 +1,46 @@ +using Microsoft.Xna.Framework.Audio; +using MrGameEng.Core; + +namespace MrGameEng.Audio; + +/// +/// Sound-effect playback with a module-level volume, plus the player. +/// Registered as a service via context.UseAudio(). +/// +public sealed class AudioManager : IDisposable +{ + /// The streaming music player. + public MusicPlayer Music { get; } = new(); + + /// Volume multiplier applied to every sound effect, 0..1. + public float SoundVolume + { + get => _soundVolume; + set => _soundVolume = Math.Clamp(value, 0f, 1f); + } + + private float _soundVolume = 1f; + + /// Plays a sound effect (fire and forget). + /// The loaded sound effect. + /// Per-play volume 0..1, multiplied with . + /// Pitch offset in octaves, -1..1. + /// Stereo pan, -1 (left) .. 1 (right). + public void Play(SoundEffect sound, float volume = 1f, float pitch = 0f, float pan = 0f) => + sound.Play(Math.Clamp(volume, 0f, 1f) * _soundVolume, pitch, pan); + + /// + public void Dispose() => Music.Dispose(); +} + +/// Wires the audio module into the engine. +public static class AudioEngineExtensions +{ + /// Creates the and registers it as a service. Call once at startup. + public static AudioManager UseAudio(this EngineContext context) + { + var manager = new AudioManager(); + context.Services.Add(manager); + return manager; + } +} diff --git a/src/MrGameEng.Audio/MrGameEng.Audio.csproj b/src/MrGameEng.Audio/MrGameEng.Audio.csproj new file mode 100644 index 0000000..48a161c --- /dev/null +++ b/src/MrGameEng.Audio/MrGameEng.Audio.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + + + + + + diff --git a/src/MrGameEng.Audio/MusicPlayer.cs b/src/MrGameEng.Audio/MusicPlayer.cs new file mode 100644 index 0000000..1e393f6 --- /dev/null +++ b/src/MrGameEng.Audio/MusicPlayer.cs @@ -0,0 +1,113 @@ +using Microsoft.Xna.Framework.Audio; +using MrGameEng.Core; +using NVorbis; + +namespace MrGameEng.Audio; + +/// +/// Streams ogg music from disk through a using NVorbis. +/// One track plays at a time; samples are decoded on demand in ~0.5 s buffers, so even long +/// tracks use almost no memory. +/// +public sealed class MusicPlayer : IDisposable +{ + private const int BufferedSubmissions = 3; + + private VorbisReader? _reader; + private DynamicSoundEffectInstance? _instance; + private float[] _sampleBuffer = []; + private byte[] _byteBuffer = []; + private bool _loop; + private float _volume = 1f; + + /// Volume 0..1 applied to the playing and future tracks. + public float Volume + { + get => _volume; + set + { + _volume = Math.Clamp(value, 0f, 1f); + if (_instance is not null) + { + _instance.Volume = _volume; + } + } + } + + /// True while a track is playing (not stopped or paused). + public bool IsPlaying => _instance?.State == SoundState.Playing; + + /// Starts streaming , stopping the previous one. + public void Play(MusicTrack track, bool loop = true) + { + Stop(); + _loop = loop; + _reader = new VorbisReader(track.FullPath); + + // ~0.5 seconds of samples per submitted buffer. + var samplesPerBuffer = _reader.SampleRate * _reader.Channels / 2; + _sampleBuffer = new float[samplesPerBuffer]; + _byteBuffer = new byte[samplesPerBuffer * 2]; + + _instance = new DynamicSoundEffectInstance( + _reader.SampleRate, + _reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo) + { + Volume = _volume, + }; + _instance.BufferNeeded += (_, _) => FillBuffers(); + + FillBuffers(); + _instance.Play(); + } + + /// Pauses the current track. + public void Pause() => _instance?.Pause(); + + /// Resumes a paused track. + public void Resume() => _instance?.Resume(); + + /// Stops playback and releases the decoder. + public void Stop() + { + _instance?.Dispose(); + _instance = null; + _reader?.Dispose(); + _reader = null; + } + + /// + public void Dispose() => Stop(); + + private void FillBuffers() + { + if (_instance is null || _reader is null) + { + return; + } + + while (_instance.PendingBufferCount < BufferedSubmissions) + { + var read = _reader.ReadSamples(_sampleBuffer, 0, _sampleBuffer.Length); + if (read == 0) + { + if (!_loop) + { + return; + } + + _reader.SamplePosition = 0; + continue; + } + + for (var i = 0; i < read; i++) + { + var sample = (short)(Math.Clamp(_sampleBuffer[i], -1f, 1f) * short.MaxValue); + _byteBuffer[i * 2] = (byte)sample; + _byteBuffer[i * 2 + 1] = (byte)(sample >> 8); + } + + _instance.SubmitBuffer(_byteBuffer, 0, read * 2); + } + } +} diff --git a/src/MrGameEng.Core/EngineContext.cs b/src/MrGameEng.Core/EngineContext.cs new file mode 100644 index 0000000..e5f6c54 --- /dev/null +++ b/src/MrGameEng.Core/EngineContext.cs @@ -0,0 +1,39 @@ +using Microsoft.Xna.Framework.Graphics; + +namespace MrGameEng.Core; + +/// +/// Root object handed to scenes and systems: time, scene manager, services and graphics device. +/// Created by ; can also be created standalone for headless tests. +/// +public sealed class EngineContext +{ + /// Engine time service. + public GameClock Clock { get; } = new(); + + /// Scene manager owning the active scene. + public SceneManager Scenes { get; } + + /// Registry of module services (input, audio, assets, …). + public ServiceRegistry Services { get; } = new(); + + /// + /// The graphics device. Available once the host is initialized; + /// throws when accessed in a headless context (unit tests). + /// + public GraphicsDevice GraphicsDevice => + _graphicsDevice ?? throw new InvalidOperationException("GraphicsDevice is not available (headless context)."); + + /// True when a graphics device is attached. + public bool HasGraphicsDevice => _graphicsDevice is not null; + + private GraphicsDevice? _graphicsDevice; + + /// Creates a context. Games normally never create one themselves — does. + public EngineContext() + { + Scenes = new SceneManager(this); + } + + internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device; +} diff --git a/src/MrGameEng.Core/GameClock.cs b/src/MrGameEng.Core/GameClock.cs new file mode 100644 index 0000000..9e53d32 --- /dev/null +++ b/src/MrGameEng.Core/GameClock.cs @@ -0,0 +1,42 @@ +namespace MrGameEng.Core; + +/// +/// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter. +/// Advanced once per frame by . +/// +public sealed class GameClock +{ + /// Seconds elapsed since the previous frame, multiplied by . + public float DeltaTime { get; private set; } + + /// Seconds elapsed since the previous frame, unaffected by . + public float UnscaledDeltaTime { get; private set; } + + /// Total scaled time in seconds since the game started. + public double TotalTime { get; private set; } + + /// Total unscaled time in seconds since the game started. + public double UnscaledTotalTime { get; private set; } + + /// Multiplier applied to . 0 pauses gameplay, 1 is real time. Never negative. + public float TimeScale + { + get => _timeScale; + set => _timeScale = value < 0f ? 0f : value; + } + + /// Number of completed frames since the game started. + public long FrameCount { get; private set; } + + private float _timeScale = 1f; + + /// Advances the clock by one frame. Called by the host; games should not call this. + public void Advance(float unscaledDeltaSeconds) + { + UnscaledDeltaTime = unscaledDeltaSeconds; + DeltaTime = unscaledDeltaSeconds * _timeScale; + UnscaledTotalTime += unscaledDeltaSeconds; + TotalTime += DeltaTime; + FrameCount++; + } +} diff --git a/src/MrGameEng.Core/GameHost.cs b/src/MrGameEng.Core/GameHost.cs new file mode 100644 index 0000000..d266b07 --- /dev/null +++ b/src/MrGameEng.Core/GameHost.cs @@ -0,0 +1,77 @@ +using Microsoft.Xna.Framework; + +namespace MrGameEng.Core; + +/// +/// The engine's game loop host. Wraps MonoGame's : owns the +/// , advances the and drives the +/// active scene's update and draw phases. +/// +public class GameHost : Game +{ + /// Engine context shared with scenes and systems. + public EngineContext Context { get; } = new(); + + /// The graphics device manager created by the host. + public GraphicsDeviceManager Graphics { get; } + + private readonly GameHostOptions _options; + private readonly Scene _initialScene; + + /// Creates a host that starts with . + public GameHost(GameHostOptions options, Scene initialScene) + { + _options = options; + _initialScene = initialScene; + + Graphics = new GraphicsDeviceManager(this) + { + PreferredBackBufferWidth = options.Width, + PreferredBackBufferHeight = options.Height, + IsFullScreen = options.Fullscreen, + SynchronizeWithVerticalRetrace = options.VSync, + }; + + IsMouseVisible = true; + IsFixedTimeStep = options.FixedTimeStep; + if (options.FixedTimeStep) + { + TargetElapsedTime = TimeSpan.FromSeconds(1.0 / options.TargetFps); + } + } + + /// + protected override void Initialize() + { + Window.Title = _options.Title; + Window.AllowUserResizing = _options.AllowResizing; + Context.AttachGraphicsDevice(GraphicsDevice); + Context.Services.Add(Window); + base.Initialize(); + Context.Scenes.Switch(_initialScene); + } + + /// + protected override void Update(GameTime gameTime) + { + Context.Clock.Advance((float)gameTime.ElapsedGameTime.TotalSeconds); + Context.Scenes.Update(Context.Clock); + base.Update(gameTime); + } + + /// + protected override void Draw(GameTime gameTime) + { + GraphicsDevice.Clear(_options.ClearColor); + Context.Scenes.Draw(Context.Clock); + base.Draw(gameTime); + } + + /// + protected override void OnExiting(object sender, ExitingEventArgs args) + { + Context.Scenes.Switch(null); + Context.Scenes.ApplyPending(); + base.OnExiting(sender, args); + } +} diff --git a/src/MrGameEng.Core/GameHostOptions.cs b/src/MrGameEng.Core/GameHostOptions.cs new file mode 100644 index 0000000..7b5b2dd --- /dev/null +++ b/src/MrGameEng.Core/GameHostOptions.cs @@ -0,0 +1,34 @@ +using Microsoft.Xna.Framework; + +namespace MrGameEng.Core; + +/// Window and loop settings for . +public sealed class GameHostOptions +{ + /// Window title. + public string Title { get; set; } = "MrGameEng"; + + /// Backbuffer width in pixels. + public int Width { get; set; } = 1280; + + /// Backbuffer height in pixels. + public int Height { get; set; } = 720; + + /// Borderless fullscreen instead of a window. + public bool Fullscreen { get; set; } + + /// Synchronize presentation with the display's vertical retrace. + public bool VSync { get; set; } = true; + + /// Run updates on a fixed timestep () instead of as fast as possible. + public bool FixedTimeStep { get; set; } + + /// Target update rate when is enabled. + public int TargetFps { get; set; } = 60; + + /// Color the backbuffer is cleared to each frame. + public Color ClearColor { get; set; } = Color.CornflowerBlue; + + /// Allow the user to resize the window. + public bool AllowResizing { get; set; } = true; +} diff --git a/src/MrGameEng.Core/MrGameEng.Core.csproj b/src/MrGameEng.Core/MrGameEng.Core.csproj new file mode 100644 index 0000000..86c4479 --- /dev/null +++ b/src/MrGameEng.Core/MrGameEng.Core.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + + + + + + + + diff --git a/src/MrGameEng.Core/MusicTrack.cs b/src/MrGameEng.Core/MusicTrack.cs new file mode 100644 index 0000000..281b5e0 --- /dev/null +++ b/src/MrGameEng.Core/MusicTrack.cs @@ -0,0 +1,8 @@ +namespace MrGameEng.Core; + +/// +/// An ogg music file reference. Resolved by the assets module; streamed from disk by the +/// audio module's MusicPlayer rather than loaded into memory. +/// +/// Absolute path of the ogg file. +public sealed record MusicTrack(string FullPath); diff --git a/src/MrGameEng.Core/Scene.cs b/src/MrGameEng.Core/Scene.cs new file mode 100644 index 0000000..cf594c2 --- /dev/null +++ b/src/MrGameEng.Core/Scene.cs @@ -0,0 +1,62 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; + +namespace MrGameEng.Core; + +/// +/// A scene owns its ECS world () and two system roots: +/// for game logic and for rendering. +/// Override to create entities and register systems. +/// +public abstract class Scene +{ + /// The ECS world of this scene. + public EntityStore Store { get; } = new(); + + /// Systems executed every update tick, in registration order. + public SystemRoot UpdateSystems { get; } + + /// Systems executed every draw tick, in registration order. + public SystemRoot DrawSystems { get; } + + /// Engine context. Valid from until . + public EngineContext Context => _context ?? throw new InvalidOperationException("Scene is not loaded."); + + /// True while the scene is the active, loaded scene. + public bool IsLoaded => _context is not null; + + private EngineContext? _context; + + /// Initializes the scene's ECS world and system roots. + protected Scene() + { + UpdateSystems = new SystemRoot(Store, "Update"); + DrawSystems = new SystemRoot(Store, "Draw"); + } + + /// Called once when the scene becomes active: create entities, add systems. + protected abstract void OnLoad(); + + /// Called once when the scene is replaced or the game exits. Release scene resources here. + protected virtual void OnUnload() { } + + /// Runs the update phase. Called by . + public virtual void Update(GameClock clock) => + UpdateSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime)); + + /// Runs the draw phase. Called by . + public virtual void Draw(GameClock clock) => + DrawSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime)); + + internal void Load(EngineContext context) + { + _context = context; + OnLoad(); + } + + internal void Unload() + { + OnUnload(); + _context = null; + } +} diff --git a/src/MrGameEng.Core/SceneManager.cs b/src/MrGameEng.Core/SceneManager.cs new file mode 100644 index 0000000..2a4c68f --- /dev/null +++ b/src/MrGameEng.Core/SceneManager.cs @@ -0,0 +1,51 @@ +namespace MrGameEng.Core; + +/// +/// Owns the active . Scene switches are deferred to the start of the +/// next update so a scene is never unloaded in the middle of its own frame. +/// +public sealed class SceneManager +{ + /// The active scene, or null before the first switch is applied. + public Scene? Current { get; private set; } + + private readonly EngineContext _context; + private Scene? _pending; + private bool _hasPending; + + internal SceneManager(EngineContext context) => _context = context; + + /// + /// Requests a switch to . The current scene is unloaded and the new + /// one loaded at the start of the next update tick. Passing null unloads the current scene. + /// + public void Switch(Scene? scene) + { + _pending = scene; + _hasPending = true; + } + + /// Applies a pending switch, then updates the active scene. Called by the host. + public void Update(GameClock clock) + { + ApplyPending(); + Current?.Update(clock); + } + + /// Draws the active scene. Called by the host. + public void Draw(GameClock clock) => Current?.Draw(clock); + + internal void ApplyPending() + { + if (!_hasPending) + { + return; + } + + _hasPending = false; + Current?.Unload(); + Current = _pending; + _pending = null; + Current?.Load(_context); + } +} diff --git a/src/MrGameEng.Core/ServiceRegistry.cs b/src/MrGameEng.Core/ServiceRegistry.cs new file mode 100644 index 0000000..3b345fc --- /dev/null +++ b/src/MrGameEng.Core/ServiceRegistry.cs @@ -0,0 +1,33 @@ +namespace MrGameEng.Core; + +/// +/// Minimal service locator used by engine modules to expose their services +/// (input, audio, assets, …) to scenes and systems without coupling modules to each other. +/// +public sealed class ServiceRegistry +{ + private readonly Dictionary _services = new(); + + /// Registers a service instance under type . Throws if already registered. + public void Add(T service) where T : class + { + if (!_services.TryAdd(typeof(T), service)) + { + throw new InvalidOperationException($"Service of type {typeof(T)} is already registered."); + } + } + + /// Returns the registered service of type . Throws if missing. + public T Get() where T : class + { + return _services.TryGetValue(typeof(T), out var service) + ? (T)service + : throw new InvalidOperationException($"Service of type {typeof(T)} is not registered."); + } + + /// Returns the registered service of type or null. + public T? GetOrDefault() where T : class + { + return _services.TryGetValue(typeof(T), out var service) ? (T)service : null; + } +} diff --git a/src/MrGameEng.Graphics/Camera.cs b/src/MrGameEng.Graphics/Camera.cs new file mode 100644 index 0000000..4201a15 --- /dev/null +++ b/src/MrGameEng.Graphics/Camera.cs @@ -0,0 +1,32 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; + +namespace MrGameEng.Graphics; + +/// +/// Orthographic 2D camera component. The renderer uses the first entity that has this +/// component as the active camera. Create via the constructor — the struct default has zero zoom. +/// +public struct Camera : IComponent +{ + /// World position the camera looks at (center of the view). + public Vector2 Position; + + /// Zoom factor. 1 = one world unit per virtual pixel; 2 = twice as close. + public float Zoom; + + /// Camera roll in radians, clockwise. + public float Rotation; + + /// Optional world-bounds clamp: the view never leaves this rectangle (when it fits). + public RectF? Bounds; + + /// Creates a camera centered at . + public Camera(Vector2 position, float zoom = 1f, float rotation = 0f, RectF? bounds = null) + { + Position = position; + Zoom = zoom; + Rotation = rotation; + Bounds = bounds; + } +} diff --git a/src/MrGameEng.Graphics/CameraMath.cs b/src/MrGameEng.Graphics/CameraMath.cs new file mode 100644 index 0000000..182f775 --- /dev/null +++ b/src/MrGameEng.Graphics/CameraMath.cs @@ -0,0 +1,120 @@ +using Microsoft.Xna.Framework; + +namespace MrGameEng.Graphics; + +/// Maps physical screen pixels to virtual-resolution pixels (letterbox scaling). +public readonly record struct ViewportMapping(Vector2 Offset, float Scale) +{ + /// Identity mapping (no letterbox). + public static readonly ViewportMapping Identity = new(Vector2.Zero, 1f); +} + +/// Per-frame camera matrices and derived data, computed by . +public readonly struct CameraState +{ + /// World → virtual-screen transform of the active camera. + public required Matrix View { get; init; } + + /// Virtual-screen → NDC orthographic projection. + public required Matrix Projection { get; init; } + + /// Inverse of . + public required Matrix InverseView { get; init; } + + /// World-space rectangle visible through the camera; used for culling. + public required RectF CullRect { get; init; } + + /// Virtual resolution width in pixels. + public required int VirtualWidth { get; init; } + + /// Virtual resolution height in pixels. + public required int VirtualHeight { get; init; } + + /// Physical-screen to virtual-pixel mapping. + public required ViewportMapping Mapping { get; init; } + + /// Converts a physical screen point to world coordinates. + public Vector2 ScreenToWorld(Vector2 screen) + { + var virtualPoint = (screen - Mapping.Offset) / Mapping.Scale; + return Vector2.Transform(virtualPoint, InverseView); + } + + /// Converts a world point to physical screen coordinates. + public Vector2 WorldToScreen(Vector2 world) + { + var virtualPoint = Vector2.Transform(world, View); + return virtualPoint * Mapping.Scale + Mapping.Offset; + } +} + +/// Pure math for the orthographic 2D camera. Y axis points down, rotation is clockwise. +public static class CameraMath +{ + /// Computes the full camera state for a frame. + public static CameraState Compute(in Camera camera, int virtualWidth, int virtualHeight, ViewportMapping mapping) + { + var zoom = camera.Zoom <= 0f ? 1f : camera.Zoom; + var position = ClampToBounds(camera, virtualWidth, virtualHeight, zoom); + + var view = + Matrix.CreateTranslation(-position.X, -position.Y, 0f) * + Matrix.CreateRotationZ(-camera.Rotation) * + Matrix.CreateScale(zoom, zoom, 1f) * + Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f); + + var inverseView = Matrix.Invert(view); + + return new CameraState + { + View = view, + Projection = Matrix.CreateOrthographicOffCenter(0f, virtualWidth, virtualHeight, 0f, 0f, 1f), + InverseView = inverseView, + CullRect = ComputeCullRect(inverseView, virtualWidth, virtualHeight), + VirtualWidth = virtualWidth, + VirtualHeight = virtualHeight, + Mapping = mapping, + }; + } + + /// + /// Computes the letterbox mapping that fits the virtual resolution into a physical + /// viewport, preserving aspect ratio and centering. + /// + public static ViewportMapping ComputeMapping(int screenWidth, int screenHeight, int virtualWidth, int virtualHeight) + { + var scale = MathF.Min((float)screenWidth / virtualWidth, (float)screenHeight / virtualHeight); + var offset = new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale) / 2f; + return new ViewportMapping(offset, scale); + } + + private static Vector2 ClampToBounds(in Camera camera, int virtualWidth, int virtualHeight, float zoom) + { + if (camera.Bounds is not { } bounds) + { + return camera.Position; + } + + // Clamp uses unrotated view extents; with camera roll the clamp is approximate. + var halfW = virtualWidth / (2f * zoom); + var halfH = virtualHeight / (2f * zoom); + return new Vector2( + ClampAxis(camera.Position.X, bounds.Left + halfW, bounds.Right - halfW), + ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH)); + } + + private static float ClampAxis(float value, float min, float max) => + min > max ? (min + max) / 2f : Math.Clamp(value, min, max); + + private static RectF ComputeCullRect(in Matrix inverseView, int virtualWidth, int virtualHeight) + { + var c0 = Vector2.Transform(Vector2.Zero, inverseView); + var c1 = Vector2.Transform(new Vector2(virtualWidth, 0f), inverseView); + var c2 = Vector2.Transform(new Vector2(0f, virtualHeight), inverseView); + var c3 = Vector2.Transform(new Vector2(virtualWidth, virtualHeight), inverseView); + + var min = Vector2.Min(Vector2.Min(c0, c1), Vector2.Min(c2, c3)); + var max = Vector2.Max(Vector2.Max(c0, c1), Vector2.Max(c2, c3)); + return RectF.FromCorners(min, max); + } +} diff --git a/src/MrGameEng.Graphics/CullingMath.cs b/src/MrGameEng.Graphics/CullingMath.cs new file mode 100644 index 0000000..e1dee80 --- /dev/null +++ b/src/MrGameEng.Graphics/CullingMath.cs @@ -0,0 +1,41 @@ +using Microsoft.Xna.Framework; + +namespace MrGameEng.Graphics; + +/// Conservative visibility tests used before sprites are written to the batcher. +public static class CullingMath +{ + /// + /// Computes the world-space center and a conservative bounding-circle radius of a sprite + /// (valid for any rotation), given its transform, region size in pixels and origin. + /// + public static (Vector2 Center, float Radius) SpriteBoundingCircle( + in Transform2D transform, float regionWidth, float regionHeight, Vector2 origin) + { + var scaledW = regionWidth * transform.Scale.X; + var scaledH = regionHeight * transform.Scale.Y; + + // Offset from the pivot (= transform.Position) to the sprite's geometric center. + var toCenter = new Vector2( + scaledW / 2f - origin.X * transform.Scale.X, + scaledH / 2f - origin.Y * transform.Scale.Y); + + var (sin, cos) = MathF.SinCos(transform.Rotation); + var center = transform.Position + new Vector2( + toCenter.X * cos - toCenter.Y * sin, + toCenter.X * sin + toCenter.Y * cos); + + var radius = 0.5f * MathF.Sqrt(scaledW * scaledW + scaledH * scaledH); + return (center, radius); + } + + /// True when the circle overlaps the rectangle. + public static bool CircleIntersectsRect(Vector2 center, float radius, in RectF rect) + { + var nearestX = Math.Clamp(center.X, rect.Left, rect.Right); + var nearestY = Math.Clamp(center.Y, rect.Top, rect.Bottom); + var dx = center.X - nearestX; + var dy = center.Y - nearestY; + return dx * dx + dy * dy <= radius * radius; + } +} diff --git a/src/MrGameEng.Graphics/Layers.cs b/src/MrGameEng.Graphics/Layers.cs new file mode 100644 index 0000000..2f9a813 --- /dev/null +++ b/src/MrGameEng.Graphics/Layers.cs @@ -0,0 +1,62 @@ +namespace MrGameEng.Graphics; + +/// Compact identifier of a render layer. Obtained from . +public readonly record struct LayerId(byte Value) +{ + /// The default layer (the first one registered). + public static readonly LayerId Default = new(0); +} + +/// Coordinate space a layer is drawn in. +public enum LayerSpace +{ + /// Drawn through the active camera's transform. + World, + + /// Drawn in screen coordinates, ignoring the camera (HUD, UI). Never culled. + Screen, +} + +/// How sprites are ordered within a layer. +public enum LayerSortMode +{ + /// Order by the sprite's value (smaller = drawn first). + Depth, + + /// Order by world Y position (top-down games: lower on screen = drawn in front). + YSort, +} + +/// A registered render layer. +public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, LayerSortMode SortMode); + +/// +/// Registry of render layers. Layers are registered up front (typically when the renderer is +/// created) and drawn in registration order. Maximum 256 layers. +/// +public sealed class LayerRegistry +{ + private readonly List _layers = []; + + /// Creates a registry containing the built-in "Default" world layer. + public LayerRegistry() => Register("Default"); + + /// Number of registered layers. + public int Count => _layers.Count; + + /// Registers a layer drawn after all previously registered ones. + public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth) + { + if (_layers.Count == 256) + { + throw new InvalidOperationException("Maximum number of render layers (256) reached."); + } + + var id = new LayerId((byte)_layers.Count); + _layers.Add(new RenderLayer(id, name, space, sortMode)); + return id; + } + + /// Returns the layer with the given id. + public RenderLayer this[LayerId id] => _layers[id.Value]; +} diff --git a/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj b/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj new file mode 100644 index 0000000..87669eb --- /dev/null +++ b/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/src/MrGameEng.Graphics/RectF.cs b/src/MrGameEng.Graphics/RectF.cs new file mode 100644 index 0000000..737a77d --- /dev/null +++ b/src/MrGameEng.Graphics/RectF.cs @@ -0,0 +1,34 @@ +using Microsoft.Xna.Framework; + +namespace MrGameEng.Graphics; + +/// Axis-aligned rectangle with float coordinates (MonoGame's is int-only). +public readonly record struct RectF(float X, float Y, float Width, float Height) +{ + /// Left edge. + public float Left => X; + + /// Top edge. + public float Top => Y; + + /// Right edge. + public float Right => X + Width; + + /// Bottom edge. + public float Bottom => Y + Height; + + /// Center point. + public Vector2 Center => new(X + Width / 2f, Y + Height / 2f); + + /// Creates the smallest rectangle containing both corner points. + public static RectF FromCorners(Vector2 min, Vector2 max) => + new(min.X, min.Y, max.X - min.X, max.Y - min.Y); + + /// True when this rectangle and overlap. + public bool Intersects(in RectF other) => + other.Left < Right && Left < other.Right && other.Top < Bottom && Top < other.Bottom; + + /// True when the point lies inside the rectangle. + public bool Contains(Vector2 point) => + point.X >= Left && point.X < Right && point.Y >= Top && point.Y < Bottom; +} diff --git a/src/MrGameEng.Graphics/RenderSystems.cs b/src/MrGameEng.Graphics/RenderSystems.cs new file mode 100644 index 0000000..20cf180 --- /dev/null +++ b/src/MrGameEng.Graphics/RenderSystems.cs @@ -0,0 +1,66 @@ +using Friflo.Engine.ECS.Systems; + +namespace MrGameEng.Graphics; + +/// +/// First draw system: finds the active camera entity (the first one with a +/// component) and begins the renderer frame. Without a camera entity a default camera showing +/// world origin at the top-left corner is used. +/// +public sealed class CameraSystem : QuerySystem +{ + private readonly Renderer2D _renderer; + + /// Creates the system for . + public CameraSystem(Renderer2D renderer) => _renderer = renderer; + + /// + protected override void OnUpdate() + { + foreach (var (cameras, _) in Query.Chunks) + { + if (cameras.Length > 0) + { + _renderer.BeginFrame(in cameras.Span[0]); + return; + } + } + + _renderer.BeginFrameWithDefaultCamera(); + } +} + +/// Submits every entity that has both and . +public sealed class SpriteRenderSystem : QuerySystem +{ + private readonly Renderer2D _renderer; + + /// Creates the system for . + public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer; + + /// + protected override void OnUpdate() + { + foreach (var (sprites, transforms, _) in Query.Chunks) + { + var s = sprites.Span; + var t = transforms.Span; + for (var i = 0; i < s.Length; i++) + { + _renderer.Submit(in t[i], in s[i]); + } + } + } +} + +/// Last draw system: sorts the frame and issues the draw calls. +public sealed class RenderFlushSystem : BaseSystem +{ + private readonly Renderer2D _renderer; + + /// Creates the system for . + public RenderFlushSystem(Renderer2D renderer) => _renderer = renderer; + + /// + protected override void OnUpdateGroup() => _renderer.EndFrame(); +} diff --git a/src/MrGameEng.Graphics/Renderer2D.cs b/src/MrGameEng.Graphics/Renderer2D.cs new file mode 100644 index 0000000..ec8f5d7 --- /dev/null +++ b/src/MrGameEng.Graphics/Renderer2D.cs @@ -0,0 +1,324 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MrGameEng.Graphics; + +/// +/// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers. +/// Per frame: (camera) → per sprite (with culling) +/// → (sort layer → depth → texture, build vertices, issue draw calls). +/// Registered as a service; scenes attach it via scene.UseRenderer2D(). +/// +public sealed class Renderer2D : IDisposable +{ + private const int MaxQuadsPerDraw = 8192; + + /// Render layer registry. Register layers before the first frame. + public LayerRegistry Layers { get; } = new(); + + /// Camera state of the current frame. Valid between BeginFrame and the next BeginFrame. + public CameraState Camera { get; private set; } + + /// Draw calls issued by the last . + public int DrawCalls { get; private set; } + + /// Sprites accepted by this frame. + public int SubmittedSprites { get; private set; } + + /// Sprites rejected by culling this frame. + public int CulledSprites { get; private set; } + + private readonly GraphicsDevice _device; + private readonly Renderer2DOptions _options; + private readonly SpriteBatcher _batcher; + private readonly BasicEffect _effect; + private readonly IndexBuffer _indexBuffer; + private DynamicVertexBuffer _vertexBuffer; + private VertexPositionColorTexture[] _vertices; + private CameraState _screenCamera; + private bool _begun; + + /// Creates the renderer. One instance per game is enough. + public Renderer2D(GraphicsDevice device, Renderer2DOptions? options = null) + { + _device = device; + _options = options ?? new Renderer2DOptions(); + _batcher = new SpriteBatcher(_options.InitialCapacity); + _vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4]; + _vertexBuffer = new DynamicVertexBuffer( + device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length, BufferUsage.WriteOnly); + + _effect = new BasicEffect(device) + { + TextureEnabled = true, + VertexColorEnabled = true, + World = Matrix.Identity, + }; + + _indexBuffer = CreateQuadIndexBuffer(device); + } + + /// Begins a frame with the given camera. Called by . + public void BeginFrame(in Camera camera) + { + var (virtualW, virtualH, mapping) = ResolveVirtualResolution(); + Camera = CameraMath.Compute(camera, virtualW, virtualH, mapping); + _screenCamera = CameraMath.Compute( + new Camera(new Vector2(virtualW / 2f, virtualH / 2f)), virtualW, virtualH, mapping); + + _batcher.Clear(); + SubmittedSprites = 0; + CulledSprites = 0; + _begun = true; + } + + /// + /// Begins a frame with a default camera that shows the world origin at the top-left + /// corner of the screen. Used when the scene has no camera entity. + /// + public void BeginFrameWithDefaultCamera() + { + var (virtualW, virtualH, _) = ResolveVirtualResolution(); + var camera = new Camera(new Vector2(virtualW / 2f, virtualH / 2f)); + BeginFrame(in camera); + } + + /// Submits one sprite. Invisible sprites (outside the camera) are culled here. + public void Submit(in Transform2D transform, in Sprite sprite) + { + if (!_begun) + { + throw new InvalidOperationException("Submit called outside BeginFrame/EndFrame (is CameraSystem registered first?)."); + } + + if (sprite.Region is not { } region) + { + return; + } + + var layer = Layers[sprite.Layer]; + var (center, radius) = CullingMath.SpriteBoundingCircle(transform, region.Width, region.Height, sprite.Origin); + + if (layer.Space == LayerSpace.World && + !CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect)) + { + CulledSprites++; + return; + } + + var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth; + + _batcher.Submit( + new SpriteInstance + { + Region = region, + Center = center, + HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f, + Rotation = transform.Rotation, + Color = sprite.Color, + Flip = sprite.Flip, + Layer = sprite.Layer.Value, + }, + SpriteSortKey.Make(sprite.Layer.Value, depth, region.TextureSortKey)); + SubmittedSprites++; + } + + /// Sorts, builds vertices and issues draw calls. Called by . + public void EndFrame() + { + if (!_begun) + { + throw new InvalidOperationException("EndFrame called without BeginFrame."); + } + + _begun = false; + DrawCalls = 0; + + var order = _batcher.Sort(); + if (order.Length == 0) + { + return; + } + + EnsureVertexCapacity(order.Length * 4); + BuildVertices(order); + _vertexBuffer.SetData(_vertices, 0, order.Length * 4, SetDataOptions.Discard); + + _device.BlendState = BlendState.AlphaBlend; + _device.SamplerStates[0] = _options.Sampler; + _device.DepthStencilState = DepthStencilState.None; + _device.RasterizerState = RasterizerState.CullNone; + _device.SetVertexBuffer(_vertexBuffer); + _device.Indices = _indexBuffer; + + DrawBatches(order); + } + + /// Converts a physical screen point to world coordinates using the current camera. + public Vector2 ScreenToWorld(Vector2 screen) => Camera.ScreenToWorld(screen); + + /// Converts a world point to physical screen coordinates using the current camera. + public Vector2 WorldToScreen(Vector2 world) => Camera.WorldToScreen(world); + + /// + public void Dispose() + { + _effect.Dispose(); + _vertexBuffer.Dispose(); + _indexBuffer.Dispose(); + } + + private (int Width, int Height, ViewportMapping Mapping) ResolveVirtualResolution() + { + var viewport = _device.Viewport; + if (_options.VirtualResolution is not { } virtualSize) + { + return (viewport.Width, viewport.Height, ViewportMapping.Identity); + } + + return (virtualSize.X, virtualSize.Y, + CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y)); + } + + private void BuildVertices(ReadOnlySpan order) + { + for (var i = 0; i < order.Length; i++) + { + ref readonly var instance = ref _batcher[order[i]]; + var bounds = instance.Region.Bounds; + var texture = instance.Region.Texture; + + var u0 = bounds.X / (float)texture.Width; + var v0 = bounds.Y / (float)texture.Height; + var u1 = (bounds.X + bounds.Width) / (float)texture.Width; + var v1 = (bounds.Y + bounds.Height) / (float)texture.Height; + + if ((instance.Flip & SpriteFlip.X) != 0) + { + (u0, u1) = (u1, u0); + } + + if ((instance.Flip & SpriteFlip.Y) != 0) + { + (v0, v1) = (v1, v0); + } + + var (sin, cos) = MathF.SinCos(instance.Rotation); + var rx = new Vector2(instance.HalfSize.X * cos, instance.HalfSize.X * sin); + var ry = new Vector2(-instance.HalfSize.Y * sin, instance.HalfSize.Y * cos); + var center = instance.Center; + + var vertex = i * 4; + _vertices[vertex + 0] = Vertex(center - rx - ry, instance.Color, u0, v0); + _vertices[vertex + 1] = Vertex(center + rx - ry, instance.Color, u1, v0); + _vertices[vertex + 2] = Vertex(center - rx + ry, instance.Color, u0, v1); + _vertices[vertex + 3] = Vertex(center + rx + ry, instance.Color, u1, v1); + } + } + + private void DrawBatches(ReadOnlySpan order) + { + var batchStart = 0; + ref readonly var first = ref _batcher[order[0]]; + var currentTexture = first.Region.Texture; + var currentLayer = first.Layer; + ApplyLayerMatrices(currentLayer); + + for (var i = 1; i <= order.Length; i++) + { + Texture2D? texture = null; + byte layer = 0; + if (i < order.Length) + { + ref readonly var instance = ref _batcher[order[i]]; + texture = instance.Region.Texture; + layer = instance.Layer; + if (ReferenceEquals(texture, currentTexture) && layer == currentLayer) + { + continue; + } + } + + DrawRange(currentTexture, batchStart, i - batchStart); + batchStart = i; + + if (i < order.Length) + { + currentTexture = texture!; + if (layer != currentLayer) + { + currentLayer = layer; + ApplyLayerMatrices(currentLayer); + } + } + } + } + + private void ApplyLayerMatrices(byte layer) + { + var state = Layers[new LayerId(layer)].Space == LayerSpace.Screen ? _screenCamera : Camera; + _effect.View = state.View; + _effect.Projection = state.Projection; + } + + private void DrawRange(Texture2D texture, int firstQuad, int quadCount) + { + _effect.Texture = texture; + + while (quadCount > 0) + { + var quads = Math.Min(quadCount, MaxQuadsPerDraw); + foreach (var pass in _effect.CurrentTechnique.Passes) + { + pass.Apply(); + _device.DrawIndexedPrimitives(PrimitiveType.TriangleList, firstQuad * 4, 0, quads * 2); + DrawCalls++; + } + + firstQuad += quads; + quadCount -= quads; + } + } + + private void EnsureVertexCapacity(int vertexCount) + { + if (_vertices.Length >= vertexCount) + { + return; + } + + var capacity = _vertices.Length; + while (capacity < vertexCount) + { + capacity *= 2; + } + + _vertices = new VertexPositionColorTexture[capacity]; + _vertexBuffer.Dispose(); + _vertexBuffer = new DynamicVertexBuffer( + _device, VertexPositionColorTexture.VertexDeclaration, capacity, BufferUsage.WriteOnly); + } + + private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) => + new(new Vector3(position, 0f), color, new Vector2(u, v)); + + private static IndexBuffer CreateQuadIndexBuffer(GraphicsDevice device) + { + var indices = new ushort[MaxQuadsPerDraw * 6]; + for (var quad = 0; quad < MaxQuadsPerDraw; quad++) + { + var vertex = quad * 4; + var index = quad * 6; + indices[index + 0] = (ushort)(vertex + 0); + indices[index + 1] = (ushort)(vertex + 1); + indices[index + 2] = (ushort)(vertex + 2); + indices[index + 3] = (ushort)(vertex + 2); + indices[index + 4] = (ushort)(vertex + 1); + indices[index + 5] = (ushort)(vertex + 3); + } + + var buffer = new IndexBuffer(device, IndexElementSize.SixteenBits, indices.Length, BufferUsage.WriteOnly); + buffer.SetData(indices); + return buffer; + } +} diff --git a/src/MrGameEng.Graphics/Renderer2DOptions.cs b/src/MrGameEng.Graphics/Renderer2DOptions.cs new file mode 100644 index 0000000..c86627a --- /dev/null +++ b/src/MrGameEng.Graphics/Renderer2DOptions.cs @@ -0,0 +1,20 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MrGameEng.Graphics; + +/// Configuration of . +public sealed class Renderer2DOptions +{ + /// + /// Fixed virtual resolution. When set, the world is rendered at this resolution and + /// letterbox-scaled to the window. When null, the backbuffer size is used directly. + /// + public Point? VirtualResolution { get; set; } + + /// Texture sampling. Defaults to (crisp pixel art). + public SamplerState Sampler { get; set; } = SamplerState.PointClamp; + + /// Initial sprite capacity of the batcher; grows automatically. + public int InitialCapacity { get; set; } = 2048; +} diff --git a/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs b/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs new file mode 100644 index 0000000..076fe35 --- /dev/null +++ b/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs @@ -0,0 +1,40 @@ +using Friflo.Engine.ECS.Systems; +using MrGameEng.Core; + +namespace MrGameEng.Graphics; + +/// Wires the graphics module into a . +public static class SceneGraphicsExtensions +{ + /// + /// Attaches the 2D renderer to the scene: registers , + /// , any and finally + /// in the draw phase. The service + /// is created on first use and shared between scenes. Call from OnLoad. + /// + public static Renderer2D UseRenderer2D( + this Scene scene, Renderer2DOptions? options = null, params BaseSystem[] extraDrawSystems) + { + var services = scene.Context.Services; + var renderer = services.GetOrDefault(); + if (renderer is null) + { + renderer = new Renderer2D(scene.Context.GraphicsDevice, options); + services.Add(renderer); + } + + scene.DrawSystems.Add(new CameraSystem(renderer)); + scene.DrawSystems.Add(new SpriteRenderSystem(renderer)); + foreach (var system in extraDrawSystems) + { + scene.DrawSystems.Add(system); + } + + scene.DrawSystems.Add(new RenderFlushSystem(renderer)); + return renderer; + } + + /// Adds to the scene's update phase. Call from OnLoad. + public static void UseSpriteAnimation(this Scene scene) => + scene.UpdateSystems.Add(new SpriteAnimationSystem()); +} diff --git a/src/MrGameEng.Graphics/Sprite.cs b/src/MrGameEng.Graphics/Sprite.cs new file mode 100644 index 0000000..12c440d --- /dev/null +++ b/src/MrGameEng.Graphics/Sprite.cs @@ -0,0 +1,66 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; + +namespace MrGameEng.Graphics; + +/// Horizontal / vertical mirroring of a sprite. +[Flags] +public enum SpriteFlip : byte +{ + /// No mirroring. + None = 0, + + /// Mirror horizontally. + X = 1, + + /// Mirror vertically. + Y = 2, +} + +/// +/// Sprite component: a texture region plus tint, origin, layer and depth. +/// Create via the constructor — the struct default has no region and a transparent tint. +/// +public struct Sprite : IComponent +{ + /// The texture region to draw. + public Texture2DRegion? Region; + + /// Tint color, multiplied with the texture. White = unmodified. + public Color Color; + + /// + /// Pivot in region pixels, measured from the region's top-left corner. The sprite is + /// positioned, rotated and scaled around this point. + /// + public Vector2 Origin; + + /// The render layer this sprite belongs to. + public LayerId Layer; + + /// Draw order within the layer (smaller = drawn first / behind). Ignored on Y-sort layers. + public float Depth; + + /// Mirroring flags. + public SpriteFlip Flip; + + /// Creates a sprite on the given layer with a white tint and top-left origin. + public Sprite(Texture2DRegion region, LayerId layer = default) + { + Region = region; + Color = Color.White; + Origin = Vector2.Zero; + Layer = layer; + Depth = 0f; + Flip = SpriteFlip.None; + } + + /// Sets to the center of the region. + public void CenterOrigin() + { + if (Region is not null) + { + Origin = new Vector2(Region.Width / 2f, Region.Height / 2f); + } + } +} diff --git a/src/MrGameEng.Graphics/SpriteAnimation.cs b/src/MrGameEng.Graphics/SpriteAnimation.cs new file mode 100644 index 0000000..04161b6 --- /dev/null +++ b/src/MrGameEng.Graphics/SpriteAnimation.cs @@ -0,0 +1,125 @@ +using Friflo.Engine.ECS; +using Friflo.Engine.ECS.Systems; + +namespace MrGameEng.Graphics; + +/// A frame-by-frame sprite animation: an ordered list of texture regions played at a fixed rate. +public sealed class SpriteAnimationClip +{ + /// Animation frames in play order. Never empty. + public IReadOnlyList Frames { get; } + + /// Playback rate in frames per second. + public float FramesPerSecond { get; } + + /// Restart from the first frame after the last one. + public bool Loop { get; } + + /// Total clip duration in seconds. + public float Duration => Frames.Count / FramesPerSecond; + + /// Creates a clip. + public SpriteAnimationClip(IReadOnlyList frames, float framesPerSecond = 12f, bool loop = true) + { + if (frames.Count == 0) + { + throw new ArgumentException("An animation clip needs at least one frame.", nameof(frames)); + } + + Frames = frames; + FramesPerSecond = framesPerSecond; + Loop = loop; + } + + /// Returns the frame shown at seconds into the clip. + public Texture2DRegion FrameAt(float time) + { + var frame = (int)(time * FramesPerSecond); + if (Loop) + { + frame = ((frame % Frames.Count) + Frames.Count) % Frames.Count; + } + else + { + frame = Math.Clamp(frame, 0, Frames.Count - 1); + } + + return Frames[frame]; + } +} + +/// +/// Plays a on the entity's . +/// Create via the constructor — the struct default has no clip and zero speed. +/// +public struct SpriteAnimator : IComponent +{ + /// The clip being played; null = nothing to play. + public SpriteAnimationClip? Clip; + + /// Playback position in seconds. + public float Time; + + /// Playback speed multiplier. 1 = normal. + public float Speed; + + /// False pauses playback. + public bool Playing; + + /// Starts playing from the beginning. + public SpriteAnimator(SpriteAnimationClip clip) + { + Clip = clip; + Time = 0f; + Speed = 1f; + Playing = true; + } + + /// Switches to and restarts unless it is already playing. + public void Play(SpriteAnimationClip clip) + { + if (ReferenceEquals(Clip, clip) && Playing) + { + return; + } + + Clip = clip; + Time = 0f; + Playing = true; + } +} + +/// +/// Update-phase system advancing all s and writing the current +/// frame into the entity's . +/// +public sealed class SpriteAnimationSystem : QuerySystem +{ + /// + protected override void OnUpdate() + { + var delta = Tick.deltaTime; + foreach (var (sprites, animators, _) in Query.Chunks) + { + var s = sprites.Span; + var a = animators.Span; + for (var i = 0; i < s.Length; i++) + { + ref var animator = ref a[i]; + if (!animator.Playing || animator.Clip is not { } clip) + { + continue; + } + + animator.Time += delta * animator.Speed; + if (!clip.Loop && animator.Time >= clip.Duration) + { + animator.Time = clip.Duration; + animator.Playing = false; + } + + s[i].Region = clip.FrameAt(animator.Time); + } + } + } +} diff --git a/src/MrGameEng.Graphics/SpriteBatcher.cs b/src/MrGameEng.Graphics/SpriteBatcher.cs new file mode 100644 index 0000000..da85c53 --- /dev/null +++ b/src/MrGameEng.Graphics/SpriteBatcher.cs @@ -0,0 +1,94 @@ +using Microsoft.Xna.Framework; + +namespace MrGameEng.Graphics; + +/// One sprite queued for rendering this frame. +public struct SpriteInstance +{ + /// Texture region to draw. Never null for submitted instances. + public Texture2DRegion Region; + + /// World-space (or screen-space) center of the quad. + public Vector2 Center; + + /// Half extents after scaling, in pixels. May be negative for negative scale. + public Vector2 HalfSize; + + /// Rotation in radians, clockwise. + public float Rotation; + + /// Tint color. + public Color Color; + + /// Mirroring flags. + public SpriteFlip Flip; + + /// Render layer the instance belongs to. + public byte Layer; +} + +/// +/// CPU side of the renderer: collects s with their sort keys +/// and orders them layer → depth → texture. Allocation-free after warm-up +/// (arrays grow geometrically and are reused across frames). +/// +public sealed class SpriteBatcher +{ + private SpriteInstance[] _instances; + private ulong[] _keys; + private int[] _order; + private int _count; + + /// Creates a batcher with the given initial capacity. + public SpriteBatcher(int initialCapacity = 2048) + { + _instances = new SpriteInstance[initialCapacity]; + _keys = new ulong[initialCapacity]; + _order = new int[initialCapacity]; + } + + /// Number of sprites submitted this frame. + public int Count => _count; + + /// Queues one sprite. + public void Submit(in SpriteInstance instance, ulong sortKey) + { + if (_count == _instances.Length) + { + Grow(); + } + + _instances[_count] = instance; + _keys[_count] = sortKey; + _count++; + } + + /// + /// Sorts all submitted sprites and returns their indices in draw order. + /// Valid until the next . + /// + public ReadOnlySpan Sort() + { + for (var i = 0; i < _count; i++) + { + _order[i] = i; + } + + Array.Sort(_keys, _order, 0, _count); + return _order.AsSpan(0, _count); + } + + /// Returns the instance at (an index from ). + public ref readonly SpriteInstance this[int index] => ref _instances[index]; + + /// Resets the batcher for the next frame. Keeps allocated capacity. + public void Clear() => _count = 0; + + private void Grow() + { + var capacity = _instances.Length * 2; + Array.Resize(ref _instances, capacity); + Array.Resize(ref _keys, capacity); + Array.Resize(ref _order, capacity); + } +} diff --git a/src/MrGameEng.Graphics/SpriteSortKey.cs b/src/MrGameEng.Graphics/SpriteSortKey.cs new file mode 100644 index 0000000..906f99f --- /dev/null +++ b/src/MrGameEng.Graphics/SpriteSortKey.cs @@ -0,0 +1,23 @@ +namespace MrGameEng.Graphics; + +/// +/// Builds the 64-bit sort key the batcher orders sprites by: +/// layer (8 bits) → depth (32 bits) → texture (24 bits). +/// Texture bits only group equal textures for batching; collisions are harmless. +/// +public static class SpriteSortKey +{ + /// Composes a sort key from layer, depth and texture grouping key. + public static ulong Make(byte layer, float depth, int textureKey) => + ((ulong)layer << 56) | ((ulong)DepthToSortableBits(depth) << 24) | ((uint)textureKey & 0xFF_FFFF); + + /// + /// Maps a float to bits whose unsigned order matches the float order + /// (negative depths sort before positive ones). + /// + public static uint DepthToSortableBits(float depth) + { + var bits = BitConverter.SingleToUInt32Bits(depth); + return (bits & 0x8000_0000) != 0 ? ~bits : bits | 0x8000_0000; + } +} diff --git a/src/MrGameEng.Graphics/Texture2DRegion.cs b/src/MrGameEng.Graphics/Texture2DRegion.cs new file mode 100644 index 0000000..6483b86 --- /dev/null +++ b/src/MrGameEng.Graphics/Texture2DRegion.cs @@ -0,0 +1,40 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MrGameEng.Graphics; + +/// +/// A rectangular region of a texture — the unit sprites are drawn from. A standalone texture +/// is a region covering the whole texture; texture atlases hand out one region per sprite, +/// and sprites sharing an atlas batch into a single draw call automatically. +/// +public sealed class Texture2DRegion +{ + /// The texture this region belongs to. + public Texture2D Texture { get; } + + /// Region bounds in texture pixels. + public Rectangle Bounds { get; } + + /// Region width in pixels. + public int Width => Bounds.Width; + + /// Region height in pixels. + public int Height => Bounds.Height; + + internal readonly int TextureSortKey; + + /// Creates a region covering part of . + public Texture2DRegion(Texture2D texture, Rectangle bounds) + { + Texture = texture; + Bounds = bounds; + TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture); + } + + /// Creates a region covering the whole . + public Texture2DRegion(Texture2D texture) + : this(texture, new Rectangle(0, 0, texture.Width, texture.Height)) + { + } +} diff --git a/src/MrGameEng.Graphics/Transform2D.cs b/src/MrGameEng.Graphics/Transform2D.cs new file mode 100644 index 0000000..3490a2c --- /dev/null +++ b/src/MrGameEng.Graphics/Transform2D.cs @@ -0,0 +1,32 @@ +using Friflo.Engine.ECS; +using Microsoft.Xna.Framework; + +namespace MrGameEng.Graphics; + +/// +/// 2D transform component: position (world units = pixels), rotation (radians, clockwise +/// in the engine's y-down coordinate system) and per-axis scale. +/// Create via or the constructor — the struct default has zero scale. +/// +public struct Transform2D : IComponent +{ + /// World position in pixels. + public Vector2 Position; + + /// Rotation in radians, clockwise (y-down). + public float Rotation; + + /// Per-axis scale. 1 is unscaled. + public Vector2 Scale; + + /// Creates a transform with the given position, rotation and scale. + public Transform2D(Vector2 position, float rotation = 0f, Vector2? scale = null) + { + Position = position; + Rotation = rotation; + Scale = scale ?? Vector2.One; + } + + /// Creates an unrotated, unscaled transform at . + public static Transform2D At(Vector2 position) => new(position); +} diff --git a/src/MrGameEng.Input/ActionMap.cs b/src/MrGameEng.Input/ActionMap.cs new file mode 100644 index 0000000..29e1db6 --- /dev/null +++ b/src/MrGameEng.Input/ActionMap.cs @@ -0,0 +1,86 @@ +using Microsoft.Xna.Framework.Input; + +namespace MrGameEng.Input; + +/// +/// Maps game actions (an enum) to any number of physical bindings: keys, mouse buttons or +/// gamepad buttons. Query by action instead of device, rebind at runtime. +/// +/// Enum (or any value) identifying the game's actions. +public sealed class ActionMap where TAction : notnull +{ + private readonly InputManager _input; + private readonly Dictionary> _bindings = new(); + + private readonly record struct Binding(Keys? Key, MouseButton? Mouse, Buttons? GamePad); + + /// Creates an action map querying . + public ActionMap(InputManager input) => _input = input; + + /// Adds a keyboard binding for . + public ActionMap Bind(TAction action, Keys key) => Add(action, new Binding(key, null, null)); + + /// Adds a mouse-button binding for . + public ActionMap Bind(TAction action, MouseButton button) => Add(action, new Binding(null, button, null)); + + /// Adds a gamepad-button binding for . + public ActionMap Bind(TAction action, Buttons button) => Add(action, new Binding(null, null, button)); + + /// Removes every binding of (for rebinding). + public void Unbind(TAction action) => _bindings.Remove(action); + + /// True while any binding of the action is held down. + public bool IsDown(TAction action) => Any(action, + static (input, b) => + (b.Key is { } k && input.IsKeyDown(k)) || + (b.Mouse is { } m && input.IsMouseDown(m)) || + (b.GamePad is { } g && input.IsButtonDown(g))); + + /// True only on the frame any binding of the action went down. + public bool IsPressed(TAction action) => Any(action, + static (input, b) => + (b.Key is { } k && input.IsKeyPressed(k)) || + (b.Mouse is { } m && input.IsMousePressed(m)) || + (b.GamePad is { } g && input.IsButtonPressed(g))); + + /// True only on the frame any binding of the action went up. + public bool IsReleased(TAction action) => Any(action, + static (input, b) => + (b.Key is { } k && input.IsKeyReleased(k)) || + (b.Mouse is { } m && input.IsMouseReleased(m)) || + (b.GamePad is { } g && input.IsButtonReleased(g))); + + /// Composes -1/0/+1 from two digital actions (e.g. move left / move right). + public float GetAxis(TAction negative, TAction positive) => + (IsDown(positive) ? 1f : 0f) - (IsDown(negative) ? 1f : 0f); + + private ActionMap Add(TAction action, Binding binding) + { + if (!_bindings.TryGetValue(action, out var list)) + { + list = []; + _bindings.Add(action, list); + } + + list.Add(binding); + return this; + } + + private bool Any(TAction action, Func predicate) + { + if (!_bindings.TryGetValue(action, out var list)) + { + return false; + } + + foreach (var binding in list) + { + if (predicate(_input, binding)) + { + return true; + } + } + + return false; + } +} diff --git a/src/MrGameEng.Input/InputManager.cs b/src/MrGameEng.Input/InputManager.cs new file mode 100644 index 0000000..5f66720 --- /dev/null +++ b/src/MrGameEng.Input/InputManager.cs @@ -0,0 +1,98 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace MrGameEng.Input; + +/// Mouse buttons addressable through . +public enum MouseButton +{ + /// Left button. + Left, + + /// Right button. + Right, + + /// Middle button (wheel click). + Middle, +} + +/// +/// Polls keyboard, mouse and gamepad once per frame and keeps the previous frame's state, +/// enabling edge queries (Pressed = went down this frame, Released = went up). +/// Registered as a service by scene.UseInput(); polled by +/// at the start of the update phase. +/// +public sealed class InputManager +{ + private KeyboardState _keyboard; + private KeyboardState _previousKeyboard; + private MouseState _mouse; + private MouseState _previousMouse; + private GamePadState _gamePad; + private GamePadState _previousGamePad; + + /// Polls all devices. Called once per frame by . + public void Update() => Apply( + Keyboard.GetState(), + Mouse.GetState(), + GamePad.GetState(PlayerIndex.One)); + + internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad) + { + _previousKeyboard = _keyboard; + _previousMouse = _mouse; + _previousGamePad = _gamePad; + _keyboard = keyboard; + _mouse = mouse; + _gamePad = gamePad; + } + + /// True while the key is held down. + public bool IsKeyDown(Keys key) => _keyboard.IsKeyDown(key); + + /// True only on the frame the key went down. + public bool IsKeyPressed(Keys key) => _keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key); + + /// True only on the frame the key went up. + public bool IsKeyReleased(Keys key) => _keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key); + + /// Mouse cursor position in window pixels. + public Point MousePosition => _mouse.Position; + + /// Cursor movement since the previous frame. + public Point MouseDelta => _mouse.Position - _previousMouse.Position; + + /// Scroll wheel change since the previous frame (positive = up). + public int WheelDelta => _mouse.ScrollWheelValue - _previousMouse.ScrollWheelValue; + + /// True while the mouse button is held down. + public bool IsMouseDown(MouseButton button) => GetButton(_mouse, button) == ButtonState.Pressed; + + /// True only on the frame the mouse button went down. + public bool IsMousePressed(MouseButton button) => + GetButton(_mouse, button) == ButtonState.Pressed && GetButton(_previousMouse, button) == ButtonState.Released; + + /// True only on the frame the mouse button went up. + public bool IsMouseReleased(MouseButton button) => + GetButton(_mouse, button) == ButtonState.Released && GetButton(_previousMouse, button) == ButtonState.Pressed; + + /// True while the gamepad button is held down. + public bool IsButtonDown(Buttons button) => _gamePad.IsButtonDown(button); + + /// True only on the frame the gamepad button went down. + public bool IsButtonPressed(Buttons button) => _gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button); + + /// True only on the frame the gamepad button went up. + public bool IsButtonReleased(Buttons button) => _gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button); + + /// Left thumbstick, x/y in [-1, 1]. Y is inverted to match the engine's y-down world. + public Vector2 LeftStick => new(_gamePad.ThumbSticks.Left.X, -_gamePad.ThumbSticks.Left.Y); + + private static ButtonState GetButton(in MouseState state, MouseButton button) => button switch + { + MouseButton.Left => state.LeftButton, + MouseButton.Right => state.RightButton, + MouseButton.Middle => state.MiddleButton, + _ => ButtonState.Released, + }; +} diff --git a/src/MrGameEng.Input/InputSystem.cs b/src/MrGameEng.Input/InputSystem.cs new file mode 100644 index 0000000..b6b99bd --- /dev/null +++ b/src/MrGameEng.Input/InputSystem.cs @@ -0,0 +1,39 @@ +using Friflo.Engine.ECS.Systems; +using MrGameEng.Core; + +namespace MrGameEng.Input; + +/// Polls the once per frame. Registered first in the update phase. +public sealed class InputSystem : BaseSystem +{ + private readonly InputManager _input; + + /// Creates the system for . + public InputSystem(InputManager input) => _input = input; + + /// + protected override void OnUpdateGroup() => _input.Update(); +} + +/// Wires the input module into a . +public static class SceneInputExtensions +{ + /// + /// Returns the shared service (creating it on first use) and + /// inserts at the start of the scene's update phase. + /// Call from OnLoad before adding gameplay systems. + /// + public static InputManager UseInput(this Scene scene) + { + var services = scene.Context.Services; + var input = services.GetOrDefault(); + if (input is null) + { + input = new InputManager(); + services.Add(input); + } + + scene.UpdateSystems.Insert(0, new InputSystem(input)); + return input; + } +} diff --git a/src/MrGameEng.Input/MrGameEng.Input.csproj b/src/MrGameEng.Input/MrGameEng.Input.csproj new file mode 100644 index 0000000..3b70bf2 --- /dev/null +++ b/src/MrGameEng.Input/MrGameEng.Input.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + + + + + + + + + + + diff --git a/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs b/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs new file mode 100644 index 0000000..0da6ce2 --- /dev/null +++ b/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs @@ -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 values) : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, out string value) => + values.TryGetValue(key, out value!); + } + + private sealed class FakeOptionsProvider(Dictionary 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? options = null) + { + var driver = CSharpGeneratorDriver.Create( + [new AssetHandlesGenerator().AsSourceGenerator()], + additionalTexts: Array.ConvertAll(files, f => (AdditionalText)new FakeAdditionalText(f)), + optionsProvider: new FakeOptionsProvider(options ?? new Dictionary + { + ["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 Player = new(\"Textures/player.png\")", + source); + Assert.Contains( + "AssetRef Jump = new(\"Sounds/jump.wav\")", + source); + Assert.Contains("AssetRef Main", source); + Assert.Contains("AssetRef 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(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)); + } +} diff --git a/tests/MrGameEng.Assets.Generator.Tests/MrGameEng.Assets.Generator.Tests.csproj b/tests/MrGameEng.Assets.Generator.Tests/MrGameEng.Assets.Generator.Tests.csproj new file mode 100644 index 0000000..db5b6f7 --- /dev/null +++ b/tests/MrGameEng.Assets.Generator.Tests/MrGameEng.Assets.Generator.Tests.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + false + + + + + + + + + + + + + + diff --git a/tests/MrGameEng.Core.Tests/GameClockTests.cs b/tests/MrGameEng.Core.Tests/GameClockTests.cs new file mode 100644 index 0000000..f59ffaa --- /dev/null +++ b/tests/MrGameEng.Core.Tests/GameClockTests.cs @@ -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); + } +} diff --git a/tests/MrGameEng.Core.Tests/MrGameEng.Core.Tests.csproj b/tests/MrGameEng.Core.Tests/MrGameEng.Core.Tests.csproj new file mode 100644 index 0000000..6bafeb8 --- /dev/null +++ b/tests/MrGameEng.Core.Tests/MrGameEng.Core.Tests.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + false + + + + + + + + + + + + + diff --git a/tests/MrGameEng.Core.Tests/SceneManagerTests.cs b/tests/MrGameEng.Core.Tests/SceneManagerTests.cs new file mode 100644 index 0000000..0376891 --- /dev/null +++ b/tests/MrGameEng.Core.Tests/SceneManagerTests.cs @@ -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); + } +} diff --git a/tests/MrGameEng.Core.Tests/SceneSystemsTests.cs b/tests/MrGameEng.Core.Tests/SceneSystemsTests.cs new file mode 100644 index 0000000..508e219 --- /dev/null +++ b/tests/MrGameEng.Core.Tests/SceneSystemsTests.cs @@ -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 + { + 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().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().X); + } +} diff --git a/tests/MrGameEng.Graphics.Tests/CameraMathTests.cs b/tests/MrGameEng.Graphics.Tests/CameraMathTests.cs new file mode 100644 index 0000000..8bc150b --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/CameraMathTests.cs @@ -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); + } +} diff --git a/tests/MrGameEng.Graphics.Tests/CullingTests.cs b/tests/MrGameEng.Graphics.Tests/CullingTests.cs new file mode 100644 index 0000000..f68231b --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/CullingTests.cs @@ -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)); + } +} diff --git a/tests/MrGameEng.Graphics.Tests/LayerRegistryTests.cs b/tests/MrGameEng.Graphics.Tests/LayerRegistryTests.cs new file mode 100644 index 0000000..112dead --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/LayerRegistryTests.cs @@ -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); + } +} diff --git a/tests/MrGameEng.Graphics.Tests/MrGameEng.Graphics.Tests.csproj b/tests/MrGameEng.Graphics.Tests/MrGameEng.Graphics.Tests.csproj new file mode 100644 index 0000000..a03c40c --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/MrGameEng.Graphics.Tests.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + false + + + + + + + + + + + + + diff --git a/tests/MrGameEng.Graphics.Tests/SortKeyTests.cs b/tests/MrGameEng.Graphics.Tests/SortKeyTests.cs new file mode 100644 index 0000000..1c2d6d3 --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/SortKeyTests.cs @@ -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); + } +} diff --git a/tests/MrGameEng.Graphics.Tests/SpriteAnimationTests.cs b/tests/MrGameEng.Graphics.Tests/SpriteAnimationTests.cs new file mode 100644 index 0000000..2da2830 --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/SpriteAnimationTests.cs @@ -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().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().Region!.Bounds.X); + Assert.False(scene.Animated.GetComponent().Playing); + } +} diff --git a/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs b/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs new file mode 100644 index 0000000..ae89194 --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs @@ -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); + } +} diff --git a/tests/MrGameEng.Input.Tests/ActionMapTests.cs b/tests/MrGameEng.Input.Tests/ActionMapTests.cs new file mode 100644 index 0000000..9e4a269 --- /dev/null +++ b/tests/MrGameEng.Input.Tests/ActionMapTests.cs @@ -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(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(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(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(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)); + } +} diff --git a/tests/MrGameEng.Input.Tests/InputManagerTests.cs b/tests/MrGameEng.Input.Tests/InputManagerTests.cs new file mode 100644 index 0000000..d24640f --- /dev/null +++ b/tests/MrGameEng.Input.Tests/InputManagerTests.cs @@ -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)); + } +} diff --git a/tests/MrGameEng.Input.Tests/MrGameEng.Input.Tests.csproj b/tests/MrGameEng.Input.Tests/MrGameEng.Input.Tests.csproj new file mode 100644 index 0000000..0a612bd --- /dev/null +++ b/tests/MrGameEng.Input.Tests/MrGameEng.Input.Tests.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + false + + + + + + + + + + + + +