Update README.md to include project description, developer documentation links, and license information.
CI / build-test (push) Successful in 1m6s
CI / build-test (push) Successful in 1m6s
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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<T>`), never string paths.
|
||||||
|
- New engine functionality goes into the matching module, or a new
|
||||||
|
`MrGameEng.<Area>` 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`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<RootNamespace>$(MSBuildProjectName)</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('MrGameEng.')) AND !$(MSBuildProjectName.EndsWith('.Tests')) AND !$(MSBuildProjectName.EndsWith('.Generator')) AND !$(MSBuildProjectName.EndsWith('.Sample'))">
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Engine -->
|
||||||
|
<PackageVersion Include="MonoGame.Framework.DesktopGL" Version="3.8.4.1" />
|
||||||
|
<PackageVersion Include="Friflo.Engine.ECS" Version="3.6.0" />
|
||||||
|
<PackageVersion Include="FontStashSharp.MonoGame" Version="1.5.6" />
|
||||||
|
<PackageVersion Include="NVorbis" Version="0.10.5" />
|
||||||
|
|
||||||
|
<!-- Source generator -->
|
||||||
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
|
||||||
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
|
||||||
|
|
||||||
|
<!-- Tests -->
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+193
@@ -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
|
||||||
@@ -1,2 +1,10 @@
|
|||||||
# mrgameeng
|
# 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).
|
||||||
|
|||||||
@@ -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<T>`).
|
||||||
|
Никаких `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<T>` |
|
||||||
|
| `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<T>` (путь + тип), кэширует по пути и владеет временем жизни (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<Texture2D> 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<Position, Sprite, ...>`) и пишут
|
||||||
|
вершины напрямую в 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 | Компиляция шейдеров при сборке |
|
||||||
@@ -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 на очень больших мирах (если профилирование покажет необходимость)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 760 B |
Binary file not shown.
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Input\MrGameEng.Input.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
|
||||||
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AdditionalFiles Include="Assets\**\*.*" />
|
||||||
|
<None Include="Assets\**\*.*" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using MrGameEng.Graphics;
|
||||||
|
|
||||||
|
namespace MrGameEng.Sample;
|
||||||
|
|
||||||
|
/// <summary>Действия игрока, привязанные к клавишам через ActionMap.</summary>
|
||||||
|
public enum SampleAction
|
||||||
|
{
|
||||||
|
MoveLeft,
|
||||||
|
MoveRight,
|
||||||
|
MoveUp,
|
||||||
|
MoveDown,
|
||||||
|
Jump,
|
||||||
|
Pause,
|
||||||
|
ToggleMusic,
|
||||||
|
SwitchScene,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Скорость для движущихся сущностей сэмпла.</summary>
|
||||||
|
public struct Velocity : IComponent
|
||||||
|
{
|
||||||
|
public Vector2 Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Слои рендера сэмпла; регистрируются один раз на общий Renderer2D.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Управление игроком (WASD/стрелки) и прыжок-писк на пробел.</summary>
|
||||||
|
public sealed class PlayerControlSystem(
|
||||||
|
Entity player, ActionMap<SampleAction> actions, AudioManager audio, SoundEffect beep) : BaseSystem
|
||||||
|
{
|
||||||
|
protected override void OnUpdateGroup()
|
||||||
|
{
|
||||||
|
ref var transform = ref player.GetComponent<Transform2D>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Камера следует за игроком; колесо — зум, Q/E — поворот.</summary>
|
||||||
|
public sealed class CameraControlSystem(Entity cameraEntity, Entity player, InputManager input) : BaseSystem
|
||||||
|
{
|
||||||
|
protected override void OnUpdateGroup()
|
||||||
|
{
|
||||||
|
ref var camera = ref cameraEntity.GetComponent<Camera>();
|
||||||
|
var target = player.GetComponent<Transform2D>().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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Отскок сущностей со скоростью от границ мира.</summary>
|
||||||
|
public sealed class BounceSystem(RectF bounds) : QuerySystem<Transform2D, Velocity>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Пауза (P), музыка (M), переключение сцены (Tab).</summary>
|
||||||
|
public sealed class SceneHotkeysSystem(
|
||||||
|
EngineContext context, ActionMap<SampleAction> actions, Func<Scene> 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<AudioManager>().Music;
|
||||||
|
if (music.IsPlaying)
|
||||||
|
{
|
||||||
|
music.Pause();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
music.Resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.IsPressed(SampleAction.SwitchScene))
|
||||||
|
{
|
||||||
|
context.Scenes.Switch(nextScene());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FPS и статистика рендера в заголовке окна (обновляется 4 раза в секунду).</summary>
|
||||||
|
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<GameWindow>().Title =
|
||||||
|
$"MrGameEng Sample — {sceneName} | {fps:F0} FPS | sprites: {renderer.SubmittedSprites} | culled: {renderer.CulledSprites} | draw calls: {renderer.DrawCalls}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Общая настройка ввода для сцен сэмпла.</summary>
|
||||||
|
public static class SampleInput
|
||||||
|
{
|
||||||
|
public static ActionMap<SampleAction> CreateActions(InputManager input) =>
|
||||||
|
new ActionMap<SampleAction>(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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Интерактивная демо-сцена: ассеты через сгенерированные хендлы, анимация, слои
|
||||||
|
/// (мир + Y-sort + screen-space HUD), камера с зумом/поворотом, ввод, звук и музыка.
|
||||||
|
/// WASD — игрок, колесо — зум, Q/E — поворот, Space — звук, P — пауза, M — музыка, Tab — стресс-сцена.
|
||||||
|
/// </summary>
|
||||||
|
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<AssetManager>() ?? Context.UseAssets();
|
||||||
|
var audio = Context.Services.GetOrDefault<AudioManager>() ?? 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Стресс-сцена: 100 000 спрайтов скачут в мире 4000×4000. Колесо — зум (чем дальше,
|
||||||
|
/// тем больше спрайтов в кадре), Tab — обратно в основную сцену. Цель: 60 FPS.
|
||||||
|
/// </summary>
|
||||||
|
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<AssetManager>();
|
||||||
|
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}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Только зум колесом — чтобы регулировать число видимых спрайтов.</summary>
|
||||||
|
private sealed class StressCameraSystem(Entity cameraEntity, InputManager input)
|
||||||
|
: Friflo.Engine.ECS.Systems.BaseSystem
|
||||||
|
{
|
||||||
|
protected override void OnUpdateGroup()
|
||||||
|
{
|
||||||
|
ref var camera = ref cameraEntity.GetComponent<Camera>();
|
||||||
|
camera.Zoom = Math.Clamp(camera.Zoom * (1f + input.WheelDelta * 0.001f), 0.05f, 5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace MrGameEng.Assets.Generator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>static readonly AssetRef<T></c> field is emitted, nested in static classes
|
||||||
|
/// mirroring the directory tree.
|
||||||
|
/// </summary>
|
||||||
|
[Generator]
|
||||||
|
public sealed class AssetHandlesGenerator : IIncrementalGenerator
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, string> 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string?> 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("// <auto-generated by MrGameEng.Assets.Generator />");
|
||||||
|
source.AppendLine($"namespace {ns};");
|
||||||
|
source.AppendLine();
|
||||||
|
source.AppendLine("/// <summary>Typed handles for every file under the Assets directory.</summary>");
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
foreach (var (fileName, relativePath) in node.Files)
|
||||||
|
{
|
||||||
|
var type = TypeByExtension[Path.GetExtension(fileName)];
|
||||||
|
var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName)));
|
||||||
|
source.AppendLine($"{pad}/// <summary>{relativePath}</summary>");
|
||||||
|
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}/// <summary>{pair.Key}/</summary>");
|
||||||
|
source.AppendLine($"{pad}public static class {name}");
|
||||||
|
source.AppendLine($"{pad}{{");
|
||||||
|
EmitNode(source, pair.Value, indent + 1);
|
||||||
|
source.AppendLine($"{pad}}}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Converts an arbitrary file or directory name to a PascalCase C# identifier.</summary>
|
||||||
|
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<string> 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<string, Node> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<IsRoslynComponent>true</IsRoslynComponent>
|
||||||
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="MrGameEng.Assets.Generator.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using FontStashSharp;
|
||||||
|
using Microsoft.Xna.Framework.Audio;
|
||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
|
||||||
|
namespace MrGameEng.Assets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads raw asset files at runtime (no content pipeline) by typed <see cref="AssetRef{T}"/>
|
||||||
|
/// handles, caches them by path and owns their lifetime. Built-in loaders:
|
||||||
|
/// <c>Texture2D</c> (png/jpg, premultiplied), <c>SoundEffect</c> (wav),
|
||||||
|
/// <c>FontSystem</c> (ttf via FontStashSharp), <c>Effect</c> (precompiled .mgfx),
|
||||||
|
/// <c>MusicTrack</c> (ogg, streamed by the audio module). Register custom loaders
|
||||||
|
/// with <see cref="RegisterLoader{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AssetManager : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Absolute path of the asset root directory.</summary>
|
||||||
|
public string RootPath { get; }
|
||||||
|
|
||||||
|
private readonly EngineContext _context;
|
||||||
|
private readonly Dictionary<(Type Type, string Path), object> _cache = new();
|
||||||
|
private readonly Dictionary<Type, Func<AssetManager, string, object>> _loaders = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a manager reading from <paramref name="rootPath"/> (relative paths are resolved
|
||||||
|
/// against the executable directory; default "Assets").
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads (or returns the cached) asset for <paramref name="asset"/>.</summary>
|
||||||
|
public T Load<T>(AssetRef<T> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes one asset from the cache, disposing it if disposable.</summary>
|
||||||
|
public void Unload<T>(AssetRef<T> asset) where T : class
|
||||||
|
{
|
||||||
|
var key = (typeof(T), asset.Path);
|
||||||
|
if (_cache.Remove(key, out var value) && value is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces or adds the loader used for assets of type <typeparamref name="T"/>.</summary>
|
||||||
|
public void RegisterLoader<T>(Func<AssetManager, string, T> loader) where T : class =>
|
||||||
|
_loaders[typeof(T)] = loader;
|
||||||
|
|
||||||
|
/// <summary>Resolves an asset-relative path to an absolute file path.</summary>
|
||||||
|
public string ResolvePath(string relativePath) =>
|
||||||
|
Path.GetFullPath(Path.Combine(RootPath, relativePath));
|
||||||
|
|
||||||
|
/// <summary>Disposes every cached asset and clears the cache.</summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wires the assets module into the engine.</summary>
|
||||||
|
public static class AssetsEngineExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the <see cref="AssetManager"/> and registers it as a service.
|
||||||
|
/// Call once at startup (e.g. in the first scene's <c>OnLoad</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static AssetManager UseAssets(this EngineContext context, string rootPath = "Assets")
|
||||||
|
{
|
||||||
|
var manager = new AssetManager(context, rootPath);
|
||||||
|
context.Services.Add(manager);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MrGameEng.Assets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed handle to an asset: a path relative to the asset root plus the asset's runtime type.
|
||||||
|
/// Instances are produced by the <c>MrGameEng.Assets.Generator</c> source generator —
|
||||||
|
/// game code should never construct them from string literals.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Runtime type the asset loads into (e.g. <c>Texture2D</c>).</typeparam>
|
||||||
|
/// <param name="Path">Path relative to the asset root, with forward slashes.</param>
|
||||||
|
public readonly record struct AssetRef<T>(string Path) where T : class
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string ToString() => $"{typeof(T).Name}:{Path}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FontStashSharp.MonoGame" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.Xna.Framework.Audio;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
|
||||||
|
namespace MrGameEng.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sound-effect playback with a module-level volume, plus the <see cref="Music"/> player.
|
||||||
|
/// Registered as a service via <c>context.UseAudio()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AudioManager : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The streaming music player.</summary>
|
||||||
|
public MusicPlayer Music { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Volume multiplier applied to every sound effect, 0..1.</summary>
|
||||||
|
public float SoundVolume
|
||||||
|
{
|
||||||
|
get => _soundVolume;
|
||||||
|
set => _soundVolume = Math.Clamp(value, 0f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float _soundVolume = 1f;
|
||||||
|
|
||||||
|
/// <summary>Plays a sound effect (fire and forget).</summary>
|
||||||
|
/// <param name="sound">The loaded sound effect.</param>
|
||||||
|
/// <param name="volume">Per-play volume 0..1, multiplied with <see cref="SoundVolume"/>.</param>
|
||||||
|
/// <param name="pitch">Pitch offset in octaves, -1..1.</param>
|
||||||
|
/// <param name="pan">Stereo pan, -1 (left) .. 1 (right).</param>
|
||||||
|
public void Play(SoundEffect sound, float volume = 1f, float pitch = 0f, float pan = 0f) =>
|
||||||
|
sound.Play(Math.Clamp(volume, 0f, 1f) * _soundVolume, pitch, pan);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose() => Music.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wires the audio module into the engine.</summary>
|
||||||
|
public static class AudioEngineExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Creates the <see cref="AudioManager"/> and registers it as a service. Call once at startup.</summary>
|
||||||
|
public static AudioManager UseAudio(this EngineContext context)
|
||||||
|
{
|
||||||
|
var manager = new AudioManager();
|
||||||
|
context.Services.Add(manager);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="NVorbis" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using Microsoft.Xna.Framework.Audio;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
using NVorbis;
|
||||||
|
|
||||||
|
namespace MrGameEng.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams ogg music from disk through a <see cref="DynamicSoundEffectInstance"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Volume 0..1 applied to the playing and future tracks.</summary>
|
||||||
|
public float Volume
|
||||||
|
{
|
||||||
|
get => _volume;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_volume = Math.Clamp(value, 0f, 1f);
|
||||||
|
if (_instance is not null)
|
||||||
|
{
|
||||||
|
_instance.Volume = _volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True while a track is playing (not stopped or paused).</summary>
|
||||||
|
public bool IsPlaying => _instance?.State == SoundState.Playing;
|
||||||
|
|
||||||
|
/// <summary>Starts streaming <paramref name="track"/>, stopping the previous one.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pauses the current track.</summary>
|
||||||
|
public void Pause() => _instance?.Pause();
|
||||||
|
|
||||||
|
/// <summary>Resumes a paused track.</summary>
|
||||||
|
public void Resume() => _instance?.Resume();
|
||||||
|
|
||||||
|
/// <summary>Stops playback and releases the decoder.</summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_instance?.Dispose();
|
||||||
|
_instance = null;
|
||||||
|
_reader?.Dispose();
|
||||||
|
_reader = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root object handed to scenes and systems: time, scene manager, services and graphics device.
|
||||||
|
/// Created by <see cref="GameHost"/>; can also be created standalone for headless tests.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EngineContext
|
||||||
|
{
|
||||||
|
/// <summary>Engine time service.</summary>
|
||||||
|
public GameClock Clock { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Scene manager owning the active scene.</summary>
|
||||||
|
public SceneManager Scenes { get; }
|
||||||
|
|
||||||
|
/// <summary>Registry of module services (input, audio, assets, …).</summary>
|
||||||
|
public ServiceRegistry Services { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The graphics device. Available once the host is initialized;
|
||||||
|
/// throws when accessed in a headless context (unit tests).
|
||||||
|
/// </summary>
|
||||||
|
public GraphicsDevice GraphicsDevice =>
|
||||||
|
_graphicsDevice ?? throw new InvalidOperationException("GraphicsDevice is not available (headless context).");
|
||||||
|
|
||||||
|
/// <summary>True when a graphics device is attached.</summary>
|
||||||
|
public bool HasGraphicsDevice => _graphicsDevice is not null;
|
||||||
|
|
||||||
|
private GraphicsDevice? _graphicsDevice;
|
||||||
|
|
||||||
|
/// <summary>Creates a context. Games normally never create one themselves — <see cref="GameHost"/> does.</summary>
|
||||||
|
public EngineContext()
|
||||||
|
{
|
||||||
|
Scenes = new SceneManager(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter.
|
||||||
|
/// Advanced once per frame by <see cref="GameHost"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameClock
|
||||||
|
{
|
||||||
|
/// <summary>Seconds elapsed since the previous frame, multiplied by <see cref="TimeScale"/>.</summary>
|
||||||
|
public float DeltaTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Seconds elapsed since the previous frame, unaffected by <see cref="TimeScale"/>.</summary>
|
||||||
|
public float UnscaledDeltaTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Total scaled time in seconds since the game started.</summary>
|
||||||
|
public double TotalTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Total unscaled time in seconds since the game started.</summary>
|
||||||
|
public double UnscaledTotalTime { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Multiplier applied to <see cref="DeltaTime"/>. 0 pauses gameplay, 1 is real time. Never negative.</summary>
|
||||||
|
public float TimeScale
|
||||||
|
{
|
||||||
|
get => _timeScale;
|
||||||
|
set => _timeScale = value < 0f ? 0f : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of completed frames since the game started.</summary>
|
||||||
|
public long FrameCount { get; private set; }
|
||||||
|
|
||||||
|
private float _timeScale = 1f;
|
||||||
|
|
||||||
|
/// <summary>Advances the clock by one frame. Called by the host; games should not call this.</summary>
|
||||||
|
public void Advance(float unscaledDeltaSeconds)
|
||||||
|
{
|
||||||
|
UnscaledDeltaTime = unscaledDeltaSeconds;
|
||||||
|
DeltaTime = unscaledDeltaSeconds * _timeScale;
|
||||||
|
UnscaledTotalTime += unscaledDeltaSeconds;
|
||||||
|
TotalTime += DeltaTime;
|
||||||
|
FrameCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The engine's game loop host. Wraps MonoGame's <see cref="Game"/>: owns the
|
||||||
|
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/> and drives the
|
||||||
|
/// active scene's update and draw phases.
|
||||||
|
/// </summary>
|
||||||
|
public class GameHost : Game
|
||||||
|
{
|
||||||
|
/// <summary>Engine context shared with scenes and systems.</summary>
|
||||||
|
public EngineContext Context { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>The graphics device manager created by the host.</summary>
|
||||||
|
public GraphicsDeviceManager Graphics { get; }
|
||||||
|
|
||||||
|
private readonly GameHostOptions _options;
|
||||||
|
private readonly Scene _initialScene;
|
||||||
|
|
||||||
|
/// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Update(GameTime gameTime)
|
||||||
|
{
|
||||||
|
Context.Clock.Advance((float)gameTime.ElapsedGameTime.TotalSeconds);
|
||||||
|
Context.Scenes.Update(Context.Clock);
|
||||||
|
base.Update(gameTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Draw(GameTime gameTime)
|
||||||
|
{
|
||||||
|
GraphicsDevice.Clear(_options.ClearColor);
|
||||||
|
Context.Scenes.Draw(Context.Clock);
|
||||||
|
base.Draw(gameTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnExiting(object sender, ExitingEventArgs args)
|
||||||
|
{
|
||||||
|
Context.Scenes.Switch(null);
|
||||||
|
Context.Scenes.ApplyPending();
|
||||||
|
base.OnExiting(sender, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
|
/// <summary>Window and loop settings for <see cref="GameHost"/>.</summary>
|
||||||
|
public sealed class GameHostOptions
|
||||||
|
{
|
||||||
|
/// <summary>Window title.</summary>
|
||||||
|
public string Title { get; set; } = "MrGameEng";
|
||||||
|
|
||||||
|
/// <summary>Backbuffer width in pixels.</summary>
|
||||||
|
public int Width { get; set; } = 1280;
|
||||||
|
|
||||||
|
/// <summary>Backbuffer height in pixels.</summary>
|
||||||
|
public int Height { get; set; } = 720;
|
||||||
|
|
||||||
|
/// <summary>Borderless fullscreen instead of a window.</summary>
|
||||||
|
public bool Fullscreen { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Synchronize presentation with the display's vertical retrace.</summary>
|
||||||
|
public bool VSync { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Run updates on a fixed timestep (<see cref="TargetFps"/>) instead of as fast as possible.</summary>
|
||||||
|
public bool FixedTimeStep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Target update rate when <see cref="FixedTimeStep"/> is enabled.</summary>
|
||||||
|
public int TargetFps { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>Color the backbuffer is cleared to each frame.</summary>
|
||||||
|
public Color ClearColor { get; set; } = Color.CornflowerBlue;
|
||||||
|
|
||||||
|
/// <summary>Allow the user to resize the window.</summary>
|
||||||
|
public bool AllowResizing { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||||
|
<PackageReference Include="Friflo.Engine.ECS" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An ogg music file reference. Resolved by the assets module; streamed from disk by the
|
||||||
|
/// audio module's <c>MusicPlayer</c> rather than loaded into memory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="FullPath">Absolute path of the ogg file.</param>
|
||||||
|
public sealed record MusicTrack(string FullPath);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Friflo.Engine.ECS.Systems;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scene owns its ECS world (<see cref="EntityStore"/>) and two system roots:
|
||||||
|
/// <see cref="UpdateSystems"/> for game logic and <see cref="DrawSystems"/> for rendering.
|
||||||
|
/// Override <see cref="OnLoad"/> to create entities and register systems.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class Scene
|
||||||
|
{
|
||||||
|
/// <summary>The ECS world of this scene.</summary>
|
||||||
|
public EntityStore Store { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Systems executed every update tick, in registration order.</summary>
|
||||||
|
public SystemRoot UpdateSystems { get; }
|
||||||
|
|
||||||
|
/// <summary>Systems executed every draw tick, in registration order.</summary>
|
||||||
|
public SystemRoot DrawSystems { get; }
|
||||||
|
|
||||||
|
/// <summary>Engine context. Valid from <see cref="OnLoad"/> until <see cref="OnUnload"/>.</summary>
|
||||||
|
public EngineContext Context => _context ?? throw new InvalidOperationException("Scene is not loaded.");
|
||||||
|
|
||||||
|
/// <summary>True while the scene is the active, loaded scene.</summary>
|
||||||
|
public bool IsLoaded => _context is not null;
|
||||||
|
|
||||||
|
private EngineContext? _context;
|
||||||
|
|
||||||
|
/// <summary>Initializes the scene's ECS world and system roots.</summary>
|
||||||
|
protected Scene()
|
||||||
|
{
|
||||||
|
UpdateSystems = new SystemRoot(Store, "Update");
|
||||||
|
DrawSystems = new SystemRoot(Store, "Draw");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Called once when the scene becomes active: create entities, add systems.</summary>
|
||||||
|
protected abstract void OnLoad();
|
||||||
|
|
||||||
|
/// <summary>Called once when the scene is replaced or the game exits. Release scene resources here.</summary>
|
||||||
|
protected virtual void OnUnload() { }
|
||||||
|
|
||||||
|
/// <summary>Runs the update phase. Called by <see cref="SceneManager"/>.</summary>
|
||||||
|
public virtual void Update(GameClock clock) =>
|
||||||
|
UpdateSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime));
|
||||||
|
|
||||||
|
/// <summary>Runs the draw phase. Called by <see cref="SceneManager"/>.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the active <see cref="Scene"/>. Scene switches are deferred to the start of the
|
||||||
|
/// next update so a scene is never unloaded in the middle of its own frame.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SceneManager
|
||||||
|
{
|
||||||
|
/// <summary>The active scene, or null before the first switch is applied.</summary>
|
||||||
|
public Scene? Current { get; private set; }
|
||||||
|
|
||||||
|
private readonly EngineContext _context;
|
||||||
|
private Scene? _pending;
|
||||||
|
private bool _hasPending;
|
||||||
|
|
||||||
|
internal SceneManager(EngineContext context) => _context = context;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests a switch to <paramref name="scene"/>. The current scene is unloaded and the new
|
||||||
|
/// one loaded at the start of the next update tick. Passing null unloads the current scene.
|
||||||
|
/// </summary>
|
||||||
|
public void Switch(Scene? scene)
|
||||||
|
{
|
||||||
|
_pending = scene;
|
||||||
|
_hasPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies a pending switch, then updates the active scene. Called by the host.</summary>
|
||||||
|
public void Update(GameClock clock)
|
||||||
|
{
|
||||||
|
ApplyPending();
|
||||||
|
Current?.Update(clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draws the active scene. Called by the host.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal service locator used by engine modules to expose their services
|
||||||
|
/// (input, audio, assets, …) to scenes and systems without coupling modules to each other.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServiceRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Type, object> _services = new();
|
||||||
|
|
||||||
|
/// <summary>Registers a service instance under type <typeparamref name="T"/>. Throws if already registered.</summary>
|
||||||
|
public void Add<T>(T service) where T : class
|
||||||
|
{
|
||||||
|
if (!_services.TryAdd(typeof(T), service))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Service of type {typeof(T)} is already registered.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the registered service of type <typeparamref name="T"/>. Throws if missing.</summary>
|
||||||
|
public T Get<T>() where T : class
|
||||||
|
{
|
||||||
|
return _services.TryGetValue(typeof(T), out var service)
|
||||||
|
? (T)service
|
||||||
|
: throw new InvalidOperationException($"Service of type {typeof(T)} is not registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the registered service of type <typeparamref name="T"/> or null.</summary>
|
||||||
|
public T? GetOrDefault<T>() where T : class
|
||||||
|
{
|
||||||
|
return _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public struct Camera : IComponent
|
||||||
|
{
|
||||||
|
/// <summary>World position the camera looks at (center of the view).</summary>
|
||||||
|
public Vector2 Position;
|
||||||
|
|
||||||
|
/// <summary>Zoom factor. 1 = one world unit per virtual pixel; 2 = twice as close.</summary>
|
||||||
|
public float Zoom;
|
||||||
|
|
||||||
|
/// <summary>Camera roll in radians, clockwise.</summary>
|
||||||
|
public float Rotation;
|
||||||
|
|
||||||
|
/// <summary>Optional world-bounds clamp: the view never leaves this rectangle (when it fits).</summary>
|
||||||
|
public RectF? Bounds;
|
||||||
|
|
||||||
|
/// <summary>Creates a camera centered at <paramref name="position"/>.</summary>
|
||||||
|
public Camera(Vector2 position, float zoom = 1f, float rotation = 0f, RectF? bounds = null)
|
||||||
|
{
|
||||||
|
Position = position;
|
||||||
|
Zoom = zoom;
|
||||||
|
Rotation = rotation;
|
||||||
|
Bounds = bounds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Maps physical screen pixels to virtual-resolution pixels (letterbox scaling).</summary>
|
||||||
|
public readonly record struct ViewportMapping(Vector2 Offset, float Scale)
|
||||||
|
{
|
||||||
|
/// <summary>Identity mapping (no letterbox).</summary>
|
||||||
|
public static readonly ViewportMapping Identity = new(Vector2.Zero, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-frame camera matrices and derived data, computed by <see cref="CameraMath"/>.</summary>
|
||||||
|
public readonly struct CameraState
|
||||||
|
{
|
||||||
|
/// <summary>World → virtual-screen transform of the active camera.</summary>
|
||||||
|
public required Matrix View { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Virtual-screen → NDC orthographic projection.</summary>
|
||||||
|
public required Matrix Projection { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Inverse of <see cref="View"/>.</summary>
|
||||||
|
public required Matrix InverseView { get; init; }
|
||||||
|
|
||||||
|
/// <summary>World-space rectangle visible through the camera; used for culling.</summary>
|
||||||
|
public required RectF CullRect { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Virtual resolution width in pixels.</summary>
|
||||||
|
public required int VirtualWidth { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Virtual resolution height in pixels.</summary>
|
||||||
|
public required int VirtualHeight { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
|
||||||
|
public required ViewportMapping Mapping { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Converts a physical screen point to world coordinates.</summary>
|
||||||
|
public Vector2 ScreenToWorld(Vector2 screen)
|
||||||
|
{
|
||||||
|
var virtualPoint = (screen - Mapping.Offset) / Mapping.Scale;
|
||||||
|
return Vector2.Transform(virtualPoint, InverseView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Converts a world point to physical screen coordinates.</summary>
|
||||||
|
public Vector2 WorldToScreen(Vector2 world)
|
||||||
|
{
|
||||||
|
var virtualPoint = Vector2.Transform(world, View);
|
||||||
|
return virtualPoint * Mapping.Scale + Mapping.Offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pure math for the orthographic 2D camera. Y axis points down, rotation is clockwise.</summary>
|
||||||
|
public static class CameraMath
|
||||||
|
{
|
||||||
|
/// <summary>Computes the full camera state for a frame.</summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the letterbox mapping that fits the virtual resolution into a physical
|
||||||
|
/// viewport, preserving aspect ratio and centering.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Conservative visibility tests used before sprites are written to the batcher.</summary>
|
||||||
|
public static class CullingMath
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the circle overlaps the rectangle.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Compact identifier of a render layer. Obtained from <see cref="LayerRegistry.Register"/>.</summary>
|
||||||
|
public readonly record struct LayerId(byte Value)
|
||||||
|
{
|
||||||
|
/// <summary>The default layer (the first one registered).</summary>
|
||||||
|
public static readonly LayerId Default = new(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Coordinate space a layer is drawn in.</summary>
|
||||||
|
public enum LayerSpace
|
||||||
|
{
|
||||||
|
/// <summary>Drawn through the active camera's transform.</summary>
|
||||||
|
World,
|
||||||
|
|
||||||
|
/// <summary>Drawn in screen coordinates, ignoring the camera (HUD, UI). Never culled.</summary>
|
||||||
|
Screen,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How sprites are ordered within a layer.</summary>
|
||||||
|
public enum LayerSortMode
|
||||||
|
{
|
||||||
|
/// <summary>Order by the sprite's <see cref="Sprite.Depth"/> value (smaller = drawn first).</summary>
|
||||||
|
Depth,
|
||||||
|
|
||||||
|
/// <summary>Order by world Y position (top-down games: lower on screen = drawn in front).</summary>
|
||||||
|
YSort,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A registered render layer.</summary>
|
||||||
|
public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, LayerSortMode SortMode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry of render layers. Layers are registered up front (typically when the renderer is
|
||||||
|
/// created) and drawn in registration order. Maximum 256 layers.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LayerRegistry
|
||||||
|
{
|
||||||
|
private readonly List<RenderLayer> _layers = [];
|
||||||
|
|
||||||
|
/// <summary>Creates a registry containing the built-in "Default" world layer.</summary>
|
||||||
|
public LayerRegistry() => Register("Default");
|
||||||
|
|
||||||
|
/// <summary>Number of registered layers.</summary>
|
||||||
|
public int Count => _layers.Count;
|
||||||
|
|
||||||
|
/// <summary>Registers a layer drawn after all previously registered ones.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the layer with the given id.</summary>
|
||||||
|
public RenderLayer this[LayerId id] => _layers[id.Value];
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Axis-aligned rectangle with float coordinates (MonoGame's <see cref="Rectangle"/> is int-only).</summary>
|
||||||
|
public readonly record struct RectF(float X, float Y, float Width, float Height)
|
||||||
|
{
|
||||||
|
/// <summary>Left edge.</summary>
|
||||||
|
public float Left => X;
|
||||||
|
|
||||||
|
/// <summary>Top edge.</summary>
|
||||||
|
public float Top => Y;
|
||||||
|
|
||||||
|
/// <summary>Right edge.</summary>
|
||||||
|
public float Right => X + Width;
|
||||||
|
|
||||||
|
/// <summary>Bottom edge.</summary>
|
||||||
|
public float Bottom => Y + Height;
|
||||||
|
|
||||||
|
/// <summary>Center point.</summary>
|
||||||
|
public Vector2 Center => new(X + Width / 2f, Y + Height / 2f);
|
||||||
|
|
||||||
|
/// <summary>Creates the smallest rectangle containing both corner points.</summary>
|
||||||
|
public static RectF FromCorners(Vector2 min, Vector2 max) =>
|
||||||
|
new(min.X, min.Y, max.X - min.X, max.Y - min.Y);
|
||||||
|
|
||||||
|
/// <summary>True when this rectangle and <paramref name="other"/> overlap.</summary>
|
||||||
|
public bool Intersects(in RectF other) =>
|
||||||
|
other.Left < Right && Left < other.Right && other.Top < Bottom && Top < other.Bottom;
|
||||||
|
|
||||||
|
/// <summary>True when the point lies inside the rectangle.</summary>
|
||||||
|
public bool Contains(Vector2 point) =>
|
||||||
|
point.X >= Left && point.X < Right && point.Y >= Top && point.Y < Bottom;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Friflo.Engine.ECS.Systems;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First draw system: finds the active camera entity (the first one with a <see cref="Camera"/>
|
||||||
|
/// component) and begins the renderer frame. Without a camera entity a default camera showing
|
||||||
|
/// world origin at the top-left corner is used.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CameraSystem : QuerySystem<Camera>
|
||||||
|
{
|
||||||
|
private readonly Renderer2D _renderer;
|
||||||
|
|
||||||
|
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
|
||||||
|
public CameraSystem(Renderer2D renderer) => _renderer = renderer;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnUpdate()
|
||||||
|
{
|
||||||
|
foreach (var (cameras, _) in Query.Chunks)
|
||||||
|
{
|
||||||
|
if (cameras.Length > 0)
|
||||||
|
{
|
||||||
|
_renderer.BeginFrame(in cameras.Span[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderer.BeginFrameWithDefaultCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Submits every entity that has both <see cref="Sprite"/> and <see cref="Transform2D"/>.</summary>
|
||||||
|
public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
|
||||||
|
{
|
||||||
|
private readonly Renderer2D _renderer;
|
||||||
|
|
||||||
|
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
|
||||||
|
public SpriteRenderSystem(Renderer2D renderer) => _renderer = renderer;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Last draw system: sorts the frame and issues the draw calls.</summary>
|
||||||
|
public sealed class RenderFlushSystem : BaseSystem
|
||||||
|
{
|
||||||
|
private readonly Renderer2D _renderer;
|
||||||
|
|
||||||
|
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
|
||||||
|
public RenderFlushSystem(Renderer2D renderer) => _renderer = renderer;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnUpdateGroup() => _renderer.EndFrame();
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The engine's 2D renderer: a sprite batcher over dynamic vertex buffers.
|
||||||
|
/// Per frame: <see cref="BeginFrame"/> (camera) → <see cref="Submit"/> per sprite (with culling)
|
||||||
|
/// → <see cref="EndFrame"/> (sort layer → depth → texture, build vertices, issue draw calls).
|
||||||
|
/// Registered as a service; scenes attach it via <c>scene.UseRenderer2D()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Renderer2D : IDisposable
|
||||||
|
{
|
||||||
|
private const int MaxQuadsPerDraw = 8192;
|
||||||
|
|
||||||
|
/// <summary>Render layer registry. Register layers before the first frame.</summary>
|
||||||
|
public LayerRegistry Layers { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Camera state of the current frame. Valid between BeginFrame and the next BeginFrame.</summary>
|
||||||
|
public CameraState Camera { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Draw calls issued by the last <see cref="EndFrame"/>.</summary>
|
||||||
|
public int DrawCalls { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Sprites accepted by <see cref="Submit"/> this frame.</summary>
|
||||||
|
public int SubmittedSprites { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Sprites rejected by culling this frame.</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Creates the renderer. One instance per game is enough.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Begins a frame with the given camera. Called by <see cref="CameraSystem"/>.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void BeginFrameWithDefaultCamera()
|
||||||
|
{
|
||||||
|
var (virtualW, virtualH, _) = ResolveVirtualResolution();
|
||||||
|
var camera = new Camera(new Vector2(virtualW / 2f, virtualH / 2f));
|
||||||
|
BeginFrame(in camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Submits one sprite. Invisible sprites (outside the camera) are culled here.</summary>
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sorts, builds vertices and issues draw calls. Called by <see cref="RenderFlushSystem"/>.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Converts a physical screen point to world coordinates using the current camera.</summary>
|
||||||
|
public Vector2 ScreenToWorld(Vector2 screen) => Camera.ScreenToWorld(screen);
|
||||||
|
|
||||||
|
/// <summary>Converts a world point to physical screen coordinates using the current camera.</summary>
|
||||||
|
public Vector2 WorldToScreen(Vector2 world) => Camera.WorldToScreen(world);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<int> 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<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Configuration of <see cref="Renderer2D"/>.</summary>
|
||||||
|
public sealed class Renderer2DOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public Point? VirtualResolution { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Texture sampling. Defaults to <see cref="SamplerState.PointClamp"/> (crisp pixel art).</summary>
|
||||||
|
public SamplerState Sampler { get; set; } = SamplerState.PointClamp;
|
||||||
|
|
||||||
|
/// <summary>Initial sprite capacity of the batcher; grows automatically.</summary>
|
||||||
|
public int InitialCapacity { get; set; } = 2048;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Friflo.Engine.ECS.Systems;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Wires the graphics module into a <see cref="Scene"/>.</summary>
|
||||||
|
public static class SceneGraphicsExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches the 2D renderer to the scene: registers <see cref="CameraSystem"/>,
|
||||||
|
/// <see cref="SpriteRenderSystem"/>, any <paramref name="extraDrawSystems"/> and finally
|
||||||
|
/// <see cref="RenderFlushSystem"/> in the draw phase. The <see cref="Renderer2D"/> service
|
||||||
|
/// is created on first use and shared between scenes. Call from <c>OnLoad</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static Renderer2D UseRenderer2D(
|
||||||
|
this Scene scene, Renderer2DOptions? options = null, params BaseSystem[] extraDrawSystems)
|
||||||
|
{
|
||||||
|
var services = scene.Context.Services;
|
||||||
|
var renderer = services.GetOrDefault<Renderer2D>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds <see cref="SpriteAnimationSystem"/> to the scene's update phase. Call from <c>OnLoad</c>.</summary>
|
||||||
|
public static void UseSpriteAnimation(this Scene scene) =>
|
||||||
|
scene.UpdateSystems.Add(new SpriteAnimationSystem());
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Horizontal / vertical mirroring of a sprite.</summary>
|
||||||
|
[Flags]
|
||||||
|
public enum SpriteFlip : byte
|
||||||
|
{
|
||||||
|
/// <summary>No mirroring.</summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>Mirror horizontally.</summary>
|
||||||
|
X = 1,
|
||||||
|
|
||||||
|
/// <summary>Mirror vertically.</summary>
|
||||||
|
Y = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public struct Sprite : IComponent
|
||||||
|
{
|
||||||
|
/// <summary>The texture region to draw.</summary>
|
||||||
|
public Texture2DRegion? Region;
|
||||||
|
|
||||||
|
/// <summary>Tint color, multiplied with the texture. White = unmodified.</summary>
|
||||||
|
public Color Color;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pivot in region pixels, measured from the region's top-left corner. The sprite is
|
||||||
|
/// positioned, rotated and scaled around this point.
|
||||||
|
/// </summary>
|
||||||
|
public Vector2 Origin;
|
||||||
|
|
||||||
|
/// <summary>The render layer this sprite belongs to.</summary>
|
||||||
|
public LayerId Layer;
|
||||||
|
|
||||||
|
/// <summary>Draw order within the layer (smaller = drawn first / behind). Ignored on Y-sort layers.</summary>
|
||||||
|
public float Depth;
|
||||||
|
|
||||||
|
/// <summary>Mirroring flags.</summary>
|
||||||
|
public SpriteFlip Flip;
|
||||||
|
|
||||||
|
/// <summary>Creates a sprite on the given layer with a white tint and top-left origin.</summary>
|
||||||
|
public Sprite(Texture2DRegion region, LayerId layer = default)
|
||||||
|
{
|
||||||
|
Region = region;
|
||||||
|
Color = Color.White;
|
||||||
|
Origin = Vector2.Zero;
|
||||||
|
Layer = layer;
|
||||||
|
Depth = 0f;
|
||||||
|
Flip = SpriteFlip.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets <see cref="Origin"/> to the center of the region.</summary>
|
||||||
|
public void CenterOrigin()
|
||||||
|
{
|
||||||
|
if (Region is not null)
|
||||||
|
{
|
||||||
|
Origin = new Vector2(Region.Width / 2f, Region.Height / 2f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Friflo.Engine.ECS.Systems;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>A frame-by-frame sprite animation: an ordered list of texture regions played at a fixed rate.</summary>
|
||||||
|
public sealed class SpriteAnimationClip
|
||||||
|
{
|
||||||
|
/// <summary>Animation frames in play order. Never empty.</summary>
|
||||||
|
public IReadOnlyList<Texture2DRegion> Frames { get; }
|
||||||
|
|
||||||
|
/// <summary>Playback rate in frames per second.</summary>
|
||||||
|
public float FramesPerSecond { get; }
|
||||||
|
|
||||||
|
/// <summary>Restart from the first frame after the last one.</summary>
|
||||||
|
public bool Loop { get; }
|
||||||
|
|
||||||
|
/// <summary>Total clip duration in seconds.</summary>
|
||||||
|
public float Duration => Frames.Count / FramesPerSecond;
|
||||||
|
|
||||||
|
/// <summary>Creates a clip.</summary>
|
||||||
|
public SpriteAnimationClip(IReadOnlyList<Texture2DRegion> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the frame shown at <paramref name="time"/> seconds into the clip.</summary>
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays a <see cref="SpriteAnimationClip"/> on the entity's <see cref="Sprite"/>.
|
||||||
|
/// Create via the constructor — the struct default has no clip and zero speed.
|
||||||
|
/// </summary>
|
||||||
|
public struct SpriteAnimator : IComponent
|
||||||
|
{
|
||||||
|
/// <summary>The clip being played; null = nothing to play.</summary>
|
||||||
|
public SpriteAnimationClip? Clip;
|
||||||
|
|
||||||
|
/// <summary>Playback position in seconds.</summary>
|
||||||
|
public float Time;
|
||||||
|
|
||||||
|
/// <summary>Playback speed multiplier. 1 = normal.</summary>
|
||||||
|
public float Speed;
|
||||||
|
|
||||||
|
/// <summary>False pauses playback.</summary>
|
||||||
|
public bool Playing;
|
||||||
|
|
||||||
|
/// <summary>Starts playing <paramref name="clip"/> from the beginning.</summary>
|
||||||
|
public SpriteAnimator(SpriteAnimationClip clip)
|
||||||
|
{
|
||||||
|
Clip = clip;
|
||||||
|
Time = 0f;
|
||||||
|
Speed = 1f;
|
||||||
|
Playing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Switches to <paramref name="clip"/> and restarts unless it is already playing.</summary>
|
||||||
|
public void Play(SpriteAnimationClip clip)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(Clip, clip) && Playing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Clip = clip;
|
||||||
|
Time = 0f;
|
||||||
|
Playing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update-phase system advancing all <see cref="SpriteAnimator"/>s and writing the current
|
||||||
|
/// frame into the entity's <see cref="Sprite.Region"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpriteAnimationSystem : QuerySystem<Sprite, SpriteAnimator>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>One sprite queued for rendering this frame.</summary>
|
||||||
|
public struct SpriteInstance
|
||||||
|
{
|
||||||
|
/// <summary>Texture region to draw. Never null for submitted instances.</summary>
|
||||||
|
public Texture2DRegion Region;
|
||||||
|
|
||||||
|
/// <summary>World-space (or screen-space) center of the quad.</summary>
|
||||||
|
public Vector2 Center;
|
||||||
|
|
||||||
|
/// <summary>Half extents after scaling, in pixels. May be negative for negative scale.</summary>
|
||||||
|
public Vector2 HalfSize;
|
||||||
|
|
||||||
|
/// <summary>Rotation in radians, clockwise.</summary>
|
||||||
|
public float Rotation;
|
||||||
|
|
||||||
|
/// <summary>Tint color.</summary>
|
||||||
|
public Color Color;
|
||||||
|
|
||||||
|
/// <summary>Mirroring flags.</summary>
|
||||||
|
public SpriteFlip Flip;
|
||||||
|
|
||||||
|
/// <summary>Render layer the instance belongs to.</summary>
|
||||||
|
public byte Layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CPU side of the renderer: collects <see cref="SpriteInstance"/>s with their sort keys
|
||||||
|
/// and orders them layer → depth → texture. Allocation-free after warm-up
|
||||||
|
/// (arrays grow geometrically and are reused across frames).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpriteBatcher
|
||||||
|
{
|
||||||
|
private SpriteInstance[] _instances;
|
||||||
|
private ulong[] _keys;
|
||||||
|
private int[] _order;
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
/// <summary>Creates a batcher with the given initial capacity.</summary>
|
||||||
|
public SpriteBatcher(int initialCapacity = 2048)
|
||||||
|
{
|
||||||
|
_instances = new SpriteInstance[initialCapacity];
|
||||||
|
_keys = new ulong[initialCapacity];
|
||||||
|
_order = new int[initialCapacity];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of sprites submitted this frame.</summary>
|
||||||
|
public int Count => _count;
|
||||||
|
|
||||||
|
/// <summary>Queues one sprite.</summary>
|
||||||
|
public void Submit(in SpriteInstance instance, ulong sortKey)
|
||||||
|
{
|
||||||
|
if (_count == _instances.Length)
|
||||||
|
{
|
||||||
|
Grow();
|
||||||
|
}
|
||||||
|
|
||||||
|
_instances[_count] = instance;
|
||||||
|
_keys[_count] = sortKey;
|
||||||
|
_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sorts all submitted sprites and returns their indices in draw order.
|
||||||
|
/// Valid until the next <see cref="Clear"/>.
|
||||||
|
/// </summary>
|
||||||
|
public ReadOnlySpan<int> Sort()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _count; i++)
|
||||||
|
{
|
||||||
|
_order[i] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Sort(_keys, _order, 0, _count);
|
||||||
|
return _order.AsSpan(0, _count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the instance at <paramref name="index"/> (an index from <see cref="Sort"/>).</summary>
|
||||||
|
public ref readonly SpriteInstance this[int index] => ref _instances[index];
|
||||||
|
|
||||||
|
/// <summary>Resets the batcher for the next frame. Keeps allocated capacity.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpriteSortKey
|
||||||
|
{
|
||||||
|
/// <summary>Composes a sort key from layer, depth and texture grouping key.</summary>
|
||||||
|
public static ulong Make(byte layer, float depth, int textureKey) =>
|
||||||
|
((ulong)layer << 56) | ((ulong)DepthToSortableBits(depth) << 24) | ((uint)textureKey & 0xFF_FFFF);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a float to bits whose unsigned order matches the float order
|
||||||
|
/// (negative depths sort before positive ones).
|
||||||
|
/// </summary>
|
||||||
|
public static uint DepthToSortableBits(float depth)
|
||||||
|
{
|
||||||
|
var bits = BitConverter.SingleToUInt32Bits(depth);
|
||||||
|
return (bits & 0x8000_0000) != 0 ? ~bits : bits | 0x8000_0000;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Texture2DRegion
|
||||||
|
{
|
||||||
|
/// <summary>The texture this region belongs to.</summary>
|
||||||
|
public Texture2D Texture { get; }
|
||||||
|
|
||||||
|
/// <summary>Region bounds in texture pixels.</summary>
|
||||||
|
public Rectangle Bounds { get; }
|
||||||
|
|
||||||
|
/// <summary>Region width in pixels.</summary>
|
||||||
|
public int Width => Bounds.Width;
|
||||||
|
|
||||||
|
/// <summary>Region height in pixels.</summary>
|
||||||
|
public int Height => Bounds.Height;
|
||||||
|
|
||||||
|
internal readonly int TextureSortKey;
|
||||||
|
|
||||||
|
/// <summary>Creates a region covering part of <paramref name="texture"/>.</summary>
|
||||||
|
public Texture2DRegion(Texture2D texture, Rectangle bounds)
|
||||||
|
{
|
||||||
|
Texture = texture;
|
||||||
|
Bounds = bounds;
|
||||||
|
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
|
||||||
|
public Texture2DRegion(Texture2D texture)
|
||||||
|
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2D transform component: position (world units = pixels), rotation (radians, clockwise
|
||||||
|
/// in the engine's y-down coordinate system) and per-axis scale.
|
||||||
|
/// Create via <see cref="At"/> or the constructor — the struct default has zero scale.
|
||||||
|
/// </summary>
|
||||||
|
public struct Transform2D : IComponent
|
||||||
|
{
|
||||||
|
/// <summary>World position in pixels.</summary>
|
||||||
|
public Vector2 Position;
|
||||||
|
|
||||||
|
/// <summary>Rotation in radians, clockwise (y-down).</summary>
|
||||||
|
public float Rotation;
|
||||||
|
|
||||||
|
/// <summary>Per-axis scale. 1 is unscaled.</summary>
|
||||||
|
public Vector2 Scale;
|
||||||
|
|
||||||
|
/// <summary>Creates a transform with the given position, rotation and scale.</summary>
|
||||||
|
public Transform2D(Vector2 position, float rotation = 0f, Vector2? scale = null)
|
||||||
|
{
|
||||||
|
Position = position;
|
||||||
|
Rotation = rotation;
|
||||||
|
Scale = scale ?? Vector2.One;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates an unrotated, unscaled transform at <paramref name="position"/>.</summary>
|
||||||
|
public static Transform2D At(Vector2 position) => new(position);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Microsoft.Xna.Framework.Input;
|
||||||
|
|
||||||
|
namespace MrGameEng.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TAction">Enum (or any value) identifying the game's actions.</typeparam>
|
||||||
|
public sealed class ActionMap<TAction> where TAction : notnull
|
||||||
|
{
|
||||||
|
private readonly InputManager _input;
|
||||||
|
private readonly Dictionary<TAction, List<Binding>> _bindings = new();
|
||||||
|
|
||||||
|
private readonly record struct Binding(Keys? Key, MouseButton? Mouse, Buttons? GamePad);
|
||||||
|
|
||||||
|
/// <summary>Creates an action map querying <paramref name="input"/>.</summary>
|
||||||
|
public ActionMap(InputManager input) => _input = input;
|
||||||
|
|
||||||
|
/// <summary>Adds a keyboard binding for <paramref name="action"/>.</summary>
|
||||||
|
public ActionMap<TAction> Bind(TAction action, Keys key) => Add(action, new Binding(key, null, null));
|
||||||
|
|
||||||
|
/// <summary>Adds a mouse-button binding for <paramref name="action"/>.</summary>
|
||||||
|
public ActionMap<TAction> Bind(TAction action, MouseButton button) => Add(action, new Binding(null, button, null));
|
||||||
|
|
||||||
|
/// <summary>Adds a gamepad-button binding for <paramref name="action"/>.</summary>
|
||||||
|
public ActionMap<TAction> Bind(TAction action, Buttons button) => Add(action, new Binding(null, null, button));
|
||||||
|
|
||||||
|
/// <summary>Removes every binding of <paramref name="action"/> (for rebinding).</summary>
|
||||||
|
public void Unbind(TAction action) => _bindings.Remove(action);
|
||||||
|
|
||||||
|
/// <summary>True while any binding of the action is held down.</summary>
|
||||||
|
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)));
|
||||||
|
|
||||||
|
/// <summary>True only on the frame any binding of the action went down.</summary>
|
||||||
|
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)));
|
||||||
|
|
||||||
|
/// <summary>True only on the frame any binding of the action went up.</summary>
|
||||||
|
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)));
|
||||||
|
|
||||||
|
/// <summary>Composes -1/0/+1 from two digital actions (e.g. move left / move right).</summary>
|
||||||
|
public float GetAxis(TAction negative, TAction positive) =>
|
||||||
|
(IsDown(positive) ? 1f : 0f) - (IsDown(negative) ? 1f : 0f);
|
||||||
|
|
||||||
|
private ActionMap<TAction> 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<InputManager, Binding, bool> predicate)
|
||||||
|
{
|
||||||
|
if (!_bindings.TryGetValue(action, out var list))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var binding in list)
|
||||||
|
{
|
||||||
|
if (predicate(_input, binding))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Input;
|
||||||
|
|
||||||
|
namespace MrGameEng.Input;
|
||||||
|
|
||||||
|
/// <summary>Mouse buttons addressable through <see cref="InputManager"/>.</summary>
|
||||||
|
public enum MouseButton
|
||||||
|
{
|
||||||
|
/// <summary>Left button.</summary>
|
||||||
|
Left,
|
||||||
|
|
||||||
|
/// <summary>Right button.</summary>
|
||||||
|
Right,
|
||||||
|
|
||||||
|
/// <summary>Middle button (wheel click).</summary>
|
||||||
|
Middle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls keyboard, mouse and gamepad once per frame and keeps the previous frame's state,
|
||||||
|
/// enabling edge queries (<c>Pressed</c> = went down this frame, <c>Released</c> = went up).
|
||||||
|
/// Registered as a service by <c>scene.UseInput()</c>; polled by <see cref="InputSystem"/>
|
||||||
|
/// at the start of the update phase.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InputManager
|
||||||
|
{
|
||||||
|
private KeyboardState _keyboard;
|
||||||
|
private KeyboardState _previousKeyboard;
|
||||||
|
private MouseState _mouse;
|
||||||
|
private MouseState _previousMouse;
|
||||||
|
private GamePadState _gamePad;
|
||||||
|
private GamePadState _previousGamePad;
|
||||||
|
|
||||||
|
/// <summary>Polls all devices. Called once per frame by <see cref="InputSystem"/>.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True while the key is held down.</summary>
|
||||||
|
public bool IsKeyDown(Keys key) => _keyboard.IsKeyDown(key);
|
||||||
|
|
||||||
|
/// <summary>True only on the frame the key went down.</summary>
|
||||||
|
public bool IsKeyPressed(Keys key) => _keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
|
||||||
|
|
||||||
|
/// <summary>True only on the frame the key went up.</summary>
|
||||||
|
public bool IsKeyReleased(Keys key) => _keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
|
||||||
|
|
||||||
|
/// <summary>Mouse cursor position in window pixels.</summary>
|
||||||
|
public Point MousePosition => _mouse.Position;
|
||||||
|
|
||||||
|
/// <summary>Cursor movement since the previous frame.</summary>
|
||||||
|
public Point MouseDelta => _mouse.Position - _previousMouse.Position;
|
||||||
|
|
||||||
|
/// <summary>Scroll wheel change since the previous frame (positive = up).</summary>
|
||||||
|
public int WheelDelta => _mouse.ScrollWheelValue - _previousMouse.ScrollWheelValue;
|
||||||
|
|
||||||
|
/// <summary>True while the mouse button is held down.</summary>
|
||||||
|
public bool IsMouseDown(MouseButton button) => GetButton(_mouse, button) == ButtonState.Pressed;
|
||||||
|
|
||||||
|
/// <summary>True only on the frame the mouse button went down.</summary>
|
||||||
|
public bool IsMousePressed(MouseButton button) =>
|
||||||
|
GetButton(_mouse, button) == ButtonState.Pressed && GetButton(_previousMouse, button) == ButtonState.Released;
|
||||||
|
|
||||||
|
/// <summary>True only on the frame the mouse button went up.</summary>
|
||||||
|
public bool IsMouseReleased(MouseButton button) =>
|
||||||
|
GetButton(_mouse, button) == ButtonState.Released && GetButton(_previousMouse, button) == ButtonState.Pressed;
|
||||||
|
|
||||||
|
/// <summary>True while the gamepad button is held down.</summary>
|
||||||
|
public bool IsButtonDown(Buttons button) => _gamePad.IsButtonDown(button);
|
||||||
|
|
||||||
|
/// <summary>True only on the frame the gamepad button went down.</summary>
|
||||||
|
public bool IsButtonPressed(Buttons button) => _gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
|
||||||
|
|
||||||
|
/// <summary>True only on the frame the gamepad button went up.</summary>
|
||||||
|
public bool IsButtonReleased(Buttons button) => _gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button);
|
||||||
|
|
||||||
|
/// <summary>Left thumbstick, x/y in [-1, 1]. Y is inverted to match the engine's y-down world.</summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Friflo.Engine.ECS.Systems;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
|
||||||
|
namespace MrGameEng.Input;
|
||||||
|
|
||||||
|
/// <summary>Polls the <see cref="InputManager"/> once per frame. Registered first in the update phase.</summary>
|
||||||
|
public sealed class InputSystem : BaseSystem
|
||||||
|
{
|
||||||
|
private readonly InputManager _input;
|
||||||
|
|
||||||
|
/// <summary>Creates the system for <paramref name="input"/>.</summary>
|
||||||
|
public InputSystem(InputManager input) => _input = input;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnUpdateGroup() => _input.Update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wires the input module into a <see cref="Scene"/>.</summary>
|
||||||
|
public static class SceneInputExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the shared <see cref="InputManager"/> service (creating it on first use) and
|
||||||
|
/// inserts <see cref="InputSystem"/> at the start of the scene's update phase.
|
||||||
|
/// Call from <c>OnLoad</c> before adding gameplay systems.
|
||||||
|
/// </summary>
|
||||||
|
public static InputManager UseInput(this Scene scene)
|
||||||
|
{
|
||||||
|
var services = scene.Context.Services;
|
||||||
|
var input = services.GetOrDefault<InputManager>();
|
||||||
|
if (input is null)
|
||||||
|
{
|
||||||
|
input = new InputManager();
|
||||||
|
services.Add(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.UpdateSystems.Insert(0, new InputSystem(input));
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="MrGameEng.Input.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.Diagnostics;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
using MrGameEng.Assets.Generator;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Assets.Generator.Tests;
|
||||||
|
|
||||||
|
public class AssetHandlesGeneratorTests
|
||||||
|
{
|
||||||
|
private sealed class FakeAdditionalText(string path) : AdditionalText
|
||||||
|
{
|
||||||
|
public override string Path { get; } = path;
|
||||||
|
|
||||||
|
public override SourceText GetText(CancellationToken cancellationToken = default) =>
|
||||||
|
SourceText.From(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeOptions(Dictionary<string, string> values) : AnalyzerConfigOptions
|
||||||
|
{
|
||||||
|
public override bool TryGetValue(string key, out string value) =>
|
||||||
|
values.TryGetValue(key, out value!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeOptionsProvider(Dictionary<string, string> values) : AnalyzerConfigOptionsProvider
|
||||||
|
{
|
||||||
|
public override AnalyzerConfigOptions GlobalOptions { get; } = new FakeOptions(values);
|
||||||
|
|
||||||
|
public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions;
|
||||||
|
|
||||||
|
public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RunGenerator(string[] files, Dictionary<string, string>? options = null)
|
||||||
|
{
|
||||||
|
var driver = CSharpGeneratorDriver.Create(
|
||||||
|
[new AssetHandlesGenerator().AsSourceGenerator()],
|
||||||
|
additionalTexts: Array.ConvertAll(files, f => (AdditionalText)new FakeAdditionalText(f)),
|
||||||
|
optionsProvider: new FakeOptionsProvider(options ?? new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["build_property.RootNamespace"] = "MyGame",
|
||||||
|
}));
|
||||||
|
|
||||||
|
var compilation = CSharpCompilation.Create("test");
|
||||||
|
var result = driver.RunGenerators(compilation).GetRunResult();
|
||||||
|
return Assert.Single(Assert.Single(result.Results).GeneratedSources).SourceText.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GeneratesTypedHandles_ForKnownExtensions()
|
||||||
|
{
|
||||||
|
var source = RunGenerator(
|
||||||
|
[
|
||||||
|
@"D:\game\Assets\Textures\player.png",
|
||||||
|
@"D:\game\Assets\Sounds\jump.wav",
|
||||||
|
@"D:\game\Assets\Fonts\main.ttf",
|
||||||
|
@"D:\game\Assets\Music\theme.ogg",
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Contains("namespace MyGame;", source);
|
||||||
|
Assert.Contains("public static partial class GameAssets", source);
|
||||||
|
Assert.Contains("public static class Textures", source);
|
||||||
|
Assert.Contains(
|
||||||
|
"AssetRef<global::Microsoft.Xna.Framework.Graphics.Texture2D> Player = new(\"Textures/player.png\")",
|
||||||
|
source);
|
||||||
|
Assert.Contains(
|
||||||
|
"AssetRef<global::Microsoft.Xna.Framework.Audio.SoundEffect> Jump = new(\"Sounds/jump.wav\")",
|
||||||
|
source);
|
||||||
|
Assert.Contains("AssetRef<global::FontStashSharp.FontSystem> Main", source);
|
||||||
|
Assert.Contains("AssetRef<global::MrGameEng.Core.MusicTrack> Theme", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IgnoresUnknownExtensions_AndFilesOutsideAssets()
|
||||||
|
{
|
||||||
|
var source = RunGenerator(
|
||||||
|
[
|
||||||
|
@"D:\game\Assets\readme.md",
|
||||||
|
@"D:\game\Other\image.png",
|
||||||
|
@"D:\game\Assets\valid.png",
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Contains("Valid", source);
|
||||||
|
Assert.DoesNotContain("Readme", source);
|
||||||
|
Assert.DoesNotContain("Image", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NestedDirectories_BecomeNestedClasses()
|
||||||
|
{
|
||||||
|
var source = RunGenerator([@"C:\proj\Assets\UI\Icons\save-icon.png"]);
|
||||||
|
|
||||||
|
Assert.Contains("public static class UI", source);
|
||||||
|
Assert.Contains("public static class Icons", source);
|
||||||
|
Assert.Contains("SaveIcon = new(\"UI/Icons/save-icon.png\")", source);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GeneratedCode_Compiles()
|
||||||
|
{
|
||||||
|
var source = RunGenerator([@"D:\game\Assets\player.png"]);
|
||||||
|
|
||||||
|
// Подменяем внешние типы заглушками, чтобы скомпилировать сгенерированный код изолированно.
|
||||||
|
const string stubs = """
|
||||||
|
namespace MrGameEng.Assets { public readonly record struct AssetRef<T>(string Path) where T : class; }
|
||||||
|
namespace Microsoft.Xna.Framework.Graphics { public sealed class Texture2D; }
|
||||||
|
""";
|
||||||
|
|
||||||
|
var compilation = CSharpCompilation.Create(
|
||||||
|
"generated",
|
||||||
|
[CSharpSyntaxTree.ParseText(source), CSharpSyntaxTree.ParseText(stubs)],
|
||||||
|
[MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
|
||||||
|
MetadataReference.CreateFromFile(System.Runtime.Loader.AssemblyLoadContext.Default
|
||||||
|
.LoadFromAssemblyName(new System.Reflection.AssemblyName("System.Runtime")).Location)],
|
||||||
|
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||||
|
|
||||||
|
var errors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToList();
|
||||||
|
Assert.Empty(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("player", "Player")]
|
||||||
|
[InlineData("save-icon", "SaveIcon")]
|
||||||
|
[InlineData("8bit_font", "_8bitFont")]
|
||||||
|
[InlineData("...", "_")]
|
||||||
|
public void Identifier_SanitizesNames(string input, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, AssetHandlesGenerator.Identifier(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(@"D:\game\Assets\a.png", "a.png")]
|
||||||
|
[InlineData("/home/user/game/Assets/sub/b.wav", "sub/b.wav")]
|
||||||
|
[InlineData(@"D:\game\NotAssets\c.png", null)]
|
||||||
|
[InlineData(@"D:\game\Assets\unknown.xyz", null)]
|
||||||
|
public void ToAssetPath_ExtractsRelativePath(string fullPath, string? expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, AssetHandlesGenerator.ToAssetPath(fullPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using MrGameEng.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core.Tests;
|
||||||
|
|
||||||
|
public class GameClockTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Advance_SingleFrame_UpdatesDeltaTotalAndFrameCount()
|
||||||
|
{
|
||||||
|
var clock = new GameClock();
|
||||||
|
|
||||||
|
clock.Advance(0.016f);
|
||||||
|
|
||||||
|
Assert.Equal(0.016f, clock.DeltaTime);
|
||||||
|
Assert.Equal(0.016f, clock.UnscaledDeltaTime);
|
||||||
|
Assert.Equal(0.016, clock.TotalTime, 3);
|
||||||
|
Assert.Equal(1, clock.FrameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Advance_WithTimeScale_ScalesDeltaButNotUnscaled()
|
||||||
|
{
|
||||||
|
var clock = new GameClock { TimeScale = 0.5f };
|
||||||
|
|
||||||
|
clock.Advance(0.02f);
|
||||||
|
|
||||||
|
Assert.Equal(0.01f, clock.DeltaTime, 3);
|
||||||
|
Assert.Equal(0.02f, clock.UnscaledDeltaTime, 3);
|
||||||
|
Assert.Equal(0.01, clock.TotalTime, 3);
|
||||||
|
Assert.Equal(0.02, clock.UnscaledTotalTime, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TimeScale_Zero_PausesScaledTime()
|
||||||
|
{
|
||||||
|
var clock = new GameClock { TimeScale = 0f };
|
||||||
|
|
||||||
|
clock.Advance(0.016f);
|
||||||
|
|
||||||
|
Assert.Equal(0f, clock.DeltaTime);
|
||||||
|
Assert.Equal(0.0, clock.TotalTime);
|
||||||
|
Assert.Equal(0.016, clock.UnscaledTotalTime, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TimeScale_Negative_ClampsToZero()
|
||||||
|
{
|
||||||
|
var clock = new GameClock { TimeScale = -1f };
|
||||||
|
|
||||||
|
Assert.Equal(0f, clock.TimeScale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using MrGameEng.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core.Tests;
|
||||||
|
|
||||||
|
public class SceneManagerTests
|
||||||
|
{
|
||||||
|
private sealed class TrackingScene : Scene
|
||||||
|
{
|
||||||
|
public int LoadCount;
|
||||||
|
public int UnloadCount;
|
||||||
|
public int UpdateCount;
|
||||||
|
|
||||||
|
protected override void OnLoad() => LoadCount++;
|
||||||
|
|
||||||
|
protected override void OnUnload() => UnloadCount++;
|
||||||
|
|
||||||
|
public override void Update(GameClock clock)
|
||||||
|
{
|
||||||
|
UpdateCount++;
|
||||||
|
base.Update(clock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Switch_IsDeferred_UntilNextUpdate()
|
||||||
|
{
|
||||||
|
var context = new EngineContext();
|
||||||
|
var scene = new TrackingScene();
|
||||||
|
|
||||||
|
context.Scenes.Switch(scene);
|
||||||
|
|
||||||
|
Assert.Null(context.Scenes.Current);
|
||||||
|
Assert.Equal(0, scene.LoadCount);
|
||||||
|
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
|
||||||
|
Assert.Same(scene, context.Scenes.Current);
|
||||||
|
Assert.Equal(1, scene.LoadCount);
|
||||||
|
Assert.Equal(1, scene.UpdateCount);
|
||||||
|
Assert.True(scene.IsLoaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Switch_UnloadsPreviousScene_AndLoadsNext()
|
||||||
|
{
|
||||||
|
var context = new EngineContext();
|
||||||
|
var first = new TrackingScene();
|
||||||
|
var second = new TrackingScene();
|
||||||
|
|
||||||
|
context.Scenes.Switch(first);
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
context.Scenes.Switch(second);
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
|
||||||
|
Assert.Equal(1, first.UnloadCount);
|
||||||
|
Assert.False(first.IsLoaded);
|
||||||
|
Assert.Same(second, context.Scenes.Current);
|
||||||
|
Assert.Equal(1, second.LoadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Switch_ToNull_UnloadsCurrentScene()
|
||||||
|
{
|
||||||
|
var context = new EngineContext();
|
||||||
|
var scene = new TrackingScene();
|
||||||
|
|
||||||
|
context.Scenes.Switch(scene);
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
context.Scenes.Switch(null);
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
|
||||||
|
Assert.Null(context.Scenes.Current);
|
||||||
|
Assert.Equal(1, scene.UnloadCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_WithoutScene_DoesNothing()
|
||||||
|
{
|
||||||
|
var context = new EngineContext();
|
||||||
|
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
context.Scenes.Draw(context.Clock);
|
||||||
|
|
||||||
|
Assert.Null(context.Scenes.Current);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Friflo.Engine.ECS.Systems;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core.Tests;
|
||||||
|
|
||||||
|
public class SceneSystemsTests
|
||||||
|
{
|
||||||
|
private struct Velocity : IComponent
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Translation : IComponent
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MoveSystem : QuerySystem<Translation, Velocity>
|
||||||
|
{
|
||||||
|
protected override void OnUpdate()
|
||||||
|
{
|
||||||
|
foreach (var (translations, velocities, _) in Query.Chunks)
|
||||||
|
{
|
||||||
|
var t = translations.Span;
|
||||||
|
var v = velocities.Span;
|
||||||
|
for (var i = 0; i < t.Length; i++)
|
||||||
|
{
|
||||||
|
t[i].X += v[i].X * Tick.deltaTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MovingScene : Scene
|
||||||
|
{
|
||||||
|
public Entity Mover;
|
||||||
|
|
||||||
|
protected override void OnLoad()
|
||||||
|
{
|
||||||
|
Mover = Store.CreateEntity(new Translation { X = 0f }, new Velocity { X = 10f });
|
||||||
|
UpdateSystems.Add(new MoveSystem());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_RunsRegisteredQuerySystem_WithClockDelta()
|
||||||
|
{
|
||||||
|
var context = new EngineContext();
|
||||||
|
var scene = new MovingScene();
|
||||||
|
context.Scenes.Switch(scene);
|
||||||
|
|
||||||
|
context.Clock.Advance(0.5f);
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
|
||||||
|
Assert.Equal(5f, scene.Mover.GetComponent<Translation>().X, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TimeScale_AffectsSystemDelta()
|
||||||
|
{
|
||||||
|
var context = new EngineContext();
|
||||||
|
var scene = new MovingScene();
|
||||||
|
context.Scenes.Switch(scene);
|
||||||
|
context.Clock.TimeScale = 0f;
|
||||||
|
|
||||||
|
context.Clock.Advance(0.5f);
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
|
||||||
|
Assert.Equal(0f, scene.Mover.GetComponent<Translation>().X);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using MrGameEng.Graphics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics.Tests;
|
||||||
|
|
||||||
|
public class CameraMathTests
|
||||||
|
{
|
||||||
|
private static void AssertVector(Vector2 expected, Vector2 actual, float tolerance = 0.001f)
|
||||||
|
{
|
||||||
|
Assert.InRange(actual.X, expected.X - tolerance, expected.X + tolerance);
|
||||||
|
Assert.InRange(actual.Y, expected.Y - tolerance, expected.Y + tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CameraPosition_MapsToScreenCenter()
|
||||||
|
{
|
||||||
|
var camera = new Camera(new Vector2(500f, 300f), zoom: 2f, rotation: 0.7f);
|
||||||
|
|
||||||
|
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
|
||||||
|
|
||||||
|
AssertVector(new Vector2(400f, 300f), state.WorldToScreen(camera.Position));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScreenToWorld_RoundTripsWithWorldToScreen()
|
||||||
|
{
|
||||||
|
var camera = new Camera(new Vector2(123f, -45f), zoom: 1.5f, rotation: 0.3f);
|
||||||
|
var state = CameraMath.Compute(camera, 1280, 720, new ViewportMapping(new Vector2(0f, 60f), 1.5f));
|
||||||
|
|
||||||
|
var screen = new Vector2(200f, 500f);
|
||||||
|
var world = state.ScreenToWorld(screen);
|
||||||
|
|
||||||
|
AssertVector(screen, state.WorldToScreen(world));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zoom_ShrinksCullRect()
|
||||||
|
{
|
||||||
|
var camera = new Camera(Vector2.Zero, zoom: 2f);
|
||||||
|
|
||||||
|
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
|
||||||
|
|
||||||
|
Assert.Equal(400f, state.CullRect.Width, 1);
|
||||||
|
Assert.Equal(300f, state.CullRect.Height, 1);
|
||||||
|
AssertVector(Vector2.Zero, state.CullRect.Center);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rotation_ExpandsCullRectToCoverRotatedView()
|
||||||
|
{
|
||||||
|
var straight = CameraMath.Compute(new Camera(Vector2.Zero), 800, 600, ViewportMapping.Identity);
|
||||||
|
var rotated = CameraMath.Compute(new Camera(Vector2.Zero, rotation: MathF.PI / 4f), 800, 600, ViewportMapping.Identity);
|
||||||
|
|
||||||
|
Assert.True(rotated.CullRect.Width > straight.CullRect.Width);
|
||||||
|
Assert.True(rotated.CullRect.Height > straight.CullRect.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bounds_ClampCameraToWorldEdges()
|
||||||
|
{
|
||||||
|
var bounds = new RectF(0f, 0f, 2000f, 1000f);
|
||||||
|
var camera = new Camera(new Vector2(-500f, 500f), bounds: bounds);
|
||||||
|
|
||||||
|
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
|
||||||
|
|
||||||
|
// Camera should be clamped so the view's left edge sits at the world's left edge.
|
||||||
|
AssertVector(new Vector2(0f, 200f), state.ScreenToWorld(Vector2.Zero));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mapping_CentersVirtualResolutionInWiderWindow()
|
||||||
|
{
|
||||||
|
var mapping = CameraMath.ComputeMapping(1920, 1080, 640, 360);
|
||||||
|
|
||||||
|
Assert.Equal(3f, mapping.Scale);
|
||||||
|
Assert.Equal(Vector2.Zero, mapping.Offset);
|
||||||
|
|
||||||
|
var letterboxed = CameraMath.ComputeMapping(1920, 1200, 640, 360);
|
||||||
|
Assert.Equal(3f, letterboxed.Scale);
|
||||||
|
Assert.Equal(new Vector2(0f, 60f), letterboxed.Offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using MrGameEng.Graphics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics.Tests;
|
||||||
|
|
||||||
|
public class CullingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BoundingCircle_UnrotatedTopLeftOrigin_CenterIsRegionCenter()
|
||||||
|
{
|
||||||
|
var transform = Transform2D.At(new Vector2(100f, 200f));
|
||||||
|
|
||||||
|
var (center, radius) = CullingMath.SpriteBoundingCircle(transform, 32f, 32f, Vector2.Zero);
|
||||||
|
|
||||||
|
Assert.Equal(new Vector2(116f, 216f), center);
|
||||||
|
Assert.Equal(0.5f * MathF.Sqrt(32f * 32f * 2f), radius, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BoundingCircle_CenterOrigin_CenterIsPosition()
|
||||||
|
{
|
||||||
|
var transform = Transform2D.At(new Vector2(50f, 50f));
|
||||||
|
|
||||||
|
var (center, _) = CullingMath.SpriteBoundingCircle(transform, 64f, 32f, new Vector2(32f, 16f));
|
||||||
|
|
||||||
|
Assert.Equal(new Vector2(50f, 50f), center);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BoundingCircle_Scale_GrowsRadius()
|
||||||
|
{
|
||||||
|
var transform = new Transform2D(Vector2.Zero, scale: new Vector2(2f, 2f));
|
||||||
|
|
||||||
|
var (_, radius) = CullingMath.SpriteBoundingCircle(transform, 10f, 10f, Vector2.Zero);
|
||||||
|
|
||||||
|
Assert.Equal(0.5f * MathF.Sqrt(800f), radius, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(50f, 50f, true)] // inside
|
||||||
|
[InlineData(-4f, 50f, true)] // touching from the left (radius 5)
|
||||||
|
[InlineData(-20f, 50f, false)] // far left
|
||||||
|
[InlineData(50f, 130f, false)] // far below
|
||||||
|
public void CircleIntersectsRect_DetectsOverlap(float x, float y, bool expected)
|
||||||
|
{
|
||||||
|
var rect = new RectF(0f, 0f, 100f, 100f);
|
||||||
|
|
||||||
|
Assert.Equal(expected, CullingMath.CircleIntersectsRect(new Vector2(x, y), 5f, rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using MrGameEng.Graphics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics.Tests;
|
||||||
|
|
||||||
|
public class LayerRegistryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Registry_StartsWithDefaultWorldLayer()
|
||||||
|
{
|
||||||
|
var registry = new LayerRegistry();
|
||||||
|
|
||||||
|
Assert.Equal(1, registry.Count);
|
||||||
|
var layer = registry[LayerId.Default];
|
||||||
|
Assert.Equal("Default", layer.Name);
|
||||||
|
Assert.Equal(LayerSpace.World, layer.Space);
|
||||||
|
Assert.Equal(LayerSortMode.Depth, layer.SortMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_AssignsSequentialIds_InDrawOrder()
|
||||||
|
{
|
||||||
|
var registry = new LayerRegistry();
|
||||||
|
|
||||||
|
var world = registry.Register("World", LayerSpace.World, LayerSortMode.YSort);
|
||||||
|
var ui = registry.Register("UI", LayerSpace.Screen);
|
||||||
|
|
||||||
|
Assert.Equal(1, world.Value);
|
||||||
|
Assert.Equal(2, ui.Value);
|
||||||
|
Assert.Equal(LayerSortMode.YSort, registry[world].SortMode);
|
||||||
|
Assert.Equal(LayerSpace.Screen, registry[ui].Space);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using MrGameEng.Graphics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics.Tests;
|
||||||
|
|
||||||
|
public class SortKeyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LayerDominatesDepthAndTexture()
|
||||||
|
{
|
||||||
|
var lowLayer = SpriteSortKey.Make(0, 1000f, textureKey: 5);
|
||||||
|
var highLayer = SpriteSortKey.Make(1, -1000f, textureKey: 1);
|
||||||
|
|
||||||
|
Assert.True(lowLayer < highLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DepthDominatesTexture_WithinLayer()
|
||||||
|
{
|
||||||
|
var behind = SpriteSortKey.Make(3, -5f, textureKey: 999);
|
||||||
|
var inFront = SpriteSortKey.Make(3, 5f, textureKey: 1);
|
||||||
|
|
||||||
|
Assert.True(behind < inFront);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-100f, -1f)]
|
||||||
|
[InlineData(-1f, 0f)]
|
||||||
|
[InlineData(0f, 1f)]
|
||||||
|
[InlineData(1f, 100f)]
|
||||||
|
[InlineData(-0.5f, 0.5f)]
|
||||||
|
public void DepthBits_PreserveFloatOrder(float smaller, float larger)
|
||||||
|
{
|
||||||
|
Assert.True(SpriteSortKey.DepthToSortableBits(smaller) < SpriteSortKey.DepthToSortableBits(larger));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EqualLayerAndDepth_GroupByTexture()
|
||||||
|
{
|
||||||
|
var a1 = SpriteSortKey.Make(2, 1f, textureKey: 7);
|
||||||
|
var b = SpriteSortKey.Make(2, 1f, textureKey: 9);
|
||||||
|
var a2 = SpriteSortKey.Make(2, 1f, textureKey: 7);
|
||||||
|
|
||||||
|
Assert.Equal(a1, a2);
|
||||||
|
Assert.NotEqual(a1, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
using MrGameEng.Graphics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics.Tests;
|
||||||
|
|
||||||
|
public class SpriteAnimationTests
|
||||||
|
{
|
||||||
|
private static Texture2DRegion Region(int x) => new(null!, new Rectangle(x, 0, 16, 16));
|
||||||
|
|
||||||
|
private static SpriteAnimationClip Clip(bool loop, params int[] xs) =>
|
||||||
|
new(Array.ConvertAll(xs, Region), framesPerSecond: 10f, loop);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FrameAt_LoopingClip_WrapsAround()
|
||||||
|
{
|
||||||
|
var clip = Clip(loop: true, 0, 1, 2);
|
||||||
|
|
||||||
|
Assert.Equal(0, clip.FrameAt(0.00f).Bounds.X);
|
||||||
|
Assert.Equal(1, clip.FrameAt(0.10f).Bounds.X);
|
||||||
|
Assert.Equal(2, clip.FrameAt(0.25f).Bounds.X);
|
||||||
|
Assert.Equal(0, clip.FrameAt(0.30f).Bounds.X); // wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FrameAt_NonLoopingClip_ClampsToLastFrame()
|
||||||
|
{
|
||||||
|
var clip = Clip(loop: false, 0, 1);
|
||||||
|
|
||||||
|
Assert.Equal(1, clip.FrameAt(10f).Bounds.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AnimScene : Scene
|
||||||
|
{
|
||||||
|
public Entity Animated;
|
||||||
|
public SpriteAnimationClip Clip = SpriteAnimationTests.Clip(loop: false, 0, 1, 2);
|
||||||
|
|
||||||
|
protected override void OnLoad()
|
||||||
|
{
|
||||||
|
this.UseSpriteAnimation();
|
||||||
|
Animated = Store.CreateEntity(
|
||||||
|
new Sprite { Color = Color.White },
|
||||||
|
new SpriteAnimator(Clip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnimationSystem_AdvancesFrames_AndStopsAtEnd()
|
||||||
|
{
|
||||||
|
var context = new EngineContext();
|
||||||
|
var scene = new AnimScene();
|
||||||
|
context.Scenes.Switch(scene);
|
||||||
|
|
||||||
|
context.Clock.Advance(0.15f); // 10 fps → frame 1
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
Assert.Equal(1, scene.Animated.GetComponent<Sprite>().Region!.Bounds.X);
|
||||||
|
|
||||||
|
context.Clock.Advance(1.0f); // far past the end of a non-looping clip
|
||||||
|
context.Scenes.Update(context.Clock);
|
||||||
|
Assert.Equal(2, scene.Animated.GetComponent<Sprite>().Region!.Bounds.X);
|
||||||
|
Assert.False(scene.Animated.GetComponent<SpriteAnimator>().Playing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using MrGameEng.Graphics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Graphics.Tests;
|
||||||
|
|
||||||
|
public class SpriteBatcherTests
|
||||||
|
{
|
||||||
|
private static SpriteInstance Instance(byte layer) => new() { Layer = layer };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sort_OrdersByKey_ReturningOriginalIndices()
|
||||||
|
{
|
||||||
|
var batcher = new SpriteBatcher(initialCapacity: 2);
|
||||||
|
|
||||||
|
batcher.Submit(Instance(2), SpriteSortKey.Make(2, 0f, 0));
|
||||||
|
batcher.Submit(Instance(0), SpriteSortKey.Make(0, 0f, 0));
|
||||||
|
batcher.Submit(Instance(1), SpriteSortKey.Make(1, 0f, 0));
|
||||||
|
|
||||||
|
var order = batcher.Sort();
|
||||||
|
|
||||||
|
Assert.Equal(3, order.Length);
|
||||||
|
Assert.Equal(0, batcher[order[0]].Layer);
|
||||||
|
Assert.Equal(1, batcher[order[1]].Layer);
|
||||||
|
Assert.Equal(2, batcher[order[2]].Layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Submit_GrowsBeyondInitialCapacity()
|
||||||
|
{
|
||||||
|
var batcher = new SpriteBatcher(initialCapacity: 1);
|
||||||
|
|
||||||
|
for (var i = 0; i < 100; i++)
|
||||||
|
{
|
||||||
|
batcher.Submit(Instance(0), (ulong)(100 - i));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(100, batcher.Count);
|
||||||
|
var order = batcher.Sort();
|
||||||
|
Assert.Equal(99, order[0]); // последний сабмит имеет наименьший ключ
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_ResetsCount_KeepsWorking()
|
||||||
|
{
|
||||||
|
var batcher = new SpriteBatcher();
|
||||||
|
batcher.Submit(Instance(0), 5);
|
||||||
|
batcher.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(0, batcher.Count);
|
||||||
|
batcher.Submit(Instance(1), 1);
|
||||||
|
Assert.Equal(1, batcher.Count);
|
||||||
|
Assert.Equal(1, batcher[batcher.Sort()[0]].Layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.Xna.Framework.Input;
|
||||||
|
using MrGameEng.Input;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Input.Tests;
|
||||||
|
|
||||||
|
public class ActionMapTests
|
||||||
|
{
|
||||||
|
private enum GameAction
|
||||||
|
{
|
||||||
|
Jump,
|
||||||
|
MoveLeft,
|
||||||
|
MoveRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Frame(InputManager input, params Keys[] keys) =>
|
||||||
|
input.Apply(new KeyboardState(keys), default, GamePadState.Default);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsDown_TrueWhenAnyBindingIsHeld()
|
||||||
|
{
|
||||||
|
var input = new InputManager();
|
||||||
|
var map = new ActionMap<GameAction>(input)
|
||||||
|
.Bind(GameAction.Jump, Keys.Space)
|
||||||
|
.Bind(GameAction.Jump, Keys.W);
|
||||||
|
|
||||||
|
Frame(input, Keys.W);
|
||||||
|
|
||||||
|
Assert.True(map.IsDown(GameAction.Jump));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsPressed_EdgeTriggered()
|
||||||
|
{
|
||||||
|
var input = new InputManager();
|
||||||
|
var map = new ActionMap<GameAction>(input).Bind(GameAction.Jump, Keys.Space);
|
||||||
|
|
||||||
|
Frame(input, Keys.Space);
|
||||||
|
Assert.True(map.IsPressed(GameAction.Jump));
|
||||||
|
|
||||||
|
Frame(input, Keys.Space);
|
||||||
|
Assert.False(map.IsPressed(GameAction.Jump));
|
||||||
|
Assert.True(map.IsDown(GameAction.Jump));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unbind_RemovesAllBindings()
|
||||||
|
{
|
||||||
|
var input = new InputManager();
|
||||||
|
var map = new ActionMap<GameAction>(input).Bind(GameAction.Jump, Keys.Space);
|
||||||
|
|
||||||
|
map.Unbind(GameAction.Jump);
|
||||||
|
Frame(input, Keys.Space);
|
||||||
|
|
||||||
|
Assert.False(map.IsDown(GameAction.Jump));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAxis_CombinesTwoActions()
|
||||||
|
{
|
||||||
|
var input = new InputManager();
|
||||||
|
var map = new ActionMap<GameAction>(input)
|
||||||
|
.Bind(GameAction.MoveLeft, Keys.A)
|
||||||
|
.Bind(GameAction.MoveRight, Keys.D);
|
||||||
|
|
||||||
|
Frame(input, Keys.A);
|
||||||
|
Assert.Equal(-1f, map.GetAxis(GameAction.MoveLeft, GameAction.MoveRight));
|
||||||
|
|
||||||
|
Frame(input, Keys.A, Keys.D);
|
||||||
|
Assert.Equal(0f, map.GetAxis(GameAction.MoveLeft, GameAction.MoveRight));
|
||||||
|
|
||||||
|
Frame(input, Keys.D);
|
||||||
|
Assert.Equal(1f, map.GetAxis(GameAction.MoveLeft, GameAction.MoveRight));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Input;
|
||||||
|
using MrGameEng.Input;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Input.Tests;
|
||||||
|
|
||||||
|
public class InputManagerTests
|
||||||
|
{
|
||||||
|
private static MouseState Mouse(int x = 0, int y = 0, int wheel = 0, ButtonState left = ButtonState.Released) =>
|
||||||
|
new(x, y, wheel, left, ButtonState.Released, ButtonState.Released, ButtonState.Released, ButtonState.Released);
|
||||||
|
|
||||||
|
private static void Frame(InputManager input, KeyboardState keyboard = default, MouseState mouse = default) =>
|
||||||
|
input.Apply(keyboard, mouse, GamePadState.Default);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeyPressed_OnlyOnTheFrameItGoesDown()
|
||||||
|
{
|
||||||
|
var input = new InputManager();
|
||||||
|
|
||||||
|
Frame(input, new KeyboardState(Keys.Space));
|
||||||
|
Assert.True(input.IsKeyPressed(Keys.Space));
|
||||||
|
Assert.True(input.IsKeyDown(Keys.Space));
|
||||||
|
|
||||||
|
Frame(input, new KeyboardState(Keys.Space));
|
||||||
|
Assert.False(input.IsKeyPressed(Keys.Space));
|
||||||
|
Assert.True(input.IsKeyDown(Keys.Space));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeyReleased_OnlyOnTheFrameItGoesUp()
|
||||||
|
{
|
||||||
|
var input = new InputManager();
|
||||||
|
|
||||||
|
Frame(input, new KeyboardState(Keys.A));
|
||||||
|
Frame(input);
|
||||||
|
|
||||||
|
Assert.True(input.IsKeyReleased(Keys.A));
|
||||||
|
Frame(input);
|
||||||
|
Assert.False(input.IsKeyReleased(Keys.A));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MouseDeltaAndWheelDelta_ComputedBetweenFrames()
|
||||||
|
{
|
||||||
|
var input = new InputManager();
|
||||||
|
|
||||||
|
Frame(input, mouse: Mouse(x: 10, y: 10, wheel: 0));
|
||||||
|
Frame(input, mouse: Mouse(x: 25, y: 5, wheel: 120));
|
||||||
|
|
||||||
|
Assert.Equal(new Point(15, -5), input.MouseDelta);
|
||||||
|
Assert.Equal(120, input.WheelDelta);
|
||||||
|
Assert.Equal(new Point(25, 5), input.MousePosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MousePressed_DetectsLeftButtonEdge()
|
||||||
|
{
|
||||||
|
var input = new InputManager();
|
||||||
|
|
||||||
|
Frame(input, mouse: Mouse());
|
||||||
|
Frame(input, mouse: Mouse(left: ButtonState.Pressed));
|
||||||
|
|
||||||
|
Assert.True(input.IsMousePressed(MouseButton.Left));
|
||||||
|
Assert.False(input.IsMousePressed(MouseButton.Right));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Input\MrGameEng.Input.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user