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 0000000..e1a45a2 Binary files /dev/null and b/samples/MrGameEng.Sample/Assets/Music/theme.ogg differ diff --git a/samples/MrGameEng.Sample/Assets/Sounds/beep.wav b/samples/MrGameEng.Sample/Assets/Sounds/beep.wav new file mode 100644 index 0000000..2f2e70e Binary files /dev/null and b/samples/MrGameEng.Sample/Assets/Sounds/beep.wav differ diff --git a/samples/MrGameEng.Sample/Assets/Textures/player.png b/samples/MrGameEng.Sample/Assets/Textures/player.png new file mode 100644 index 0000000..a5f8e5a Binary files /dev/null and b/samples/MrGameEng.Sample/Assets/Textures/player.png differ diff --git a/samples/MrGameEng.Sample/Assets/Textures/shapes.png b/samples/MrGameEng.Sample/Assets/Textures/shapes.png new file mode 100644 index 0000000..6c86a4c Binary files /dev/null and b/samples/MrGameEng.Sample/Assets/Textures/shapes.png differ 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 + + + + + + + + + + + + +