Compare commits
30
Commits
19ab3cbbc0
...
main
@@ -10,10 +10,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Сборка требует SDK 10+: Roslyn-пакеты 5.3.0 (генератор ассет-хендлов)
|
||||
# не загружаются компилятором из SDK 8. TargetFramework остаётся net8.0.
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 10.0.x
|
||||
- name: Build
|
||||
run: dotnet build MrGameEng.sln --configuration Release
|
||||
- name: Test
|
||||
# Тестхост собран под net8.0, а на раннере стоит только рантайм .NET 10
|
||||
# (SDK 10). Roll-forward пускает net8.0-тестхост на рантайме 10.0.
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: Major
|
||||
run: dotnet test MrGameEng.sln --configuration Release --no-build --verbosity normal
|
||||
|
||||
@@ -10,32 +10,85 @@ Keep them up to date when architecture or conventions change.
|
||||
## Solution layout
|
||||
|
||||
```
|
||||
src/ MrGameEng.* engine libraries (one per functional area)
|
||||
src/ MrGameEng.* engine libraries (grouped by role, not one-per-feature)
|
||||
tests/ xUnit test projects, one per engine library
|
||||
tools/ CLI tools (atlas packer)
|
||||
docs/ architecture, conventions, roadmap (Russian)
|
||||
```
|
||||
|
||||
Libraries are grouped by architectural role rather than split per feature, to avoid a
|
||||
sprawl of two-file projects. A feature lives in a subfolder of its host library and
|
||||
keeps its own `MrGameEng.<Feature>` namespace (namespaces are independent of the
|
||||
assembly), so `using MrGameEng.Tilemaps;` etc. still resolve after a merge.
|
||||
|
||||
The engine has no sample project: the **LittleSim** game
|
||||
(https://gitea.hsrv.site/mrleo1nid/LittleSim, this repo as the `engine/` submodule)
|
||||
is the living showcase — new engine features are demonstrated there.
|
||||
|
||||
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),
|
||||
`Atlases` (texture-atlas builder + runtime loader; CLI wrapper in `tools/MrGameEng.AtlasTool`),
|
||||
`Tilemaps` (code-built tile grids rendered through the batcher; `scene.UseTilemaps()`
|
||||
after `UseRenderer2D()`), `Pathfinding` (grid A*/Dijkstra/BFS and flow fields over a
|
||||
game-implemented `IPathGrid`; Core-only, owns no world data), `Collisions` (`Collider`
|
||||
component, spatial hash rebuilt per tick, pairs/queries/raycast; `scene.UseCollisions()`
|
||||
after movement systems), `UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`),
|
||||
`DevConsole` (in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad).
|
||||
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.
|
||||
Documented exceptions: Myra renders with its own SpriteBatch internally; `Atlases`
|
||||
depends on `Graphics` (Texture2DRegion) and `Assets` (loader registration);
|
||||
`Tilemaps` depends on `Graphics` (regions, layers, renderer);
|
||||
`Collisions` depends on `Graphics` (Transform2D, RectF).
|
||||
Engine libraries (each feature is a namespaced subfolder of its host):
|
||||
|
||||
- **`Core`** — the platform-free kernel: ECS world, scenes and transition timing, time
|
||||
(`GameClock` with `TimeScale`; `GameSpeed` for discrete pause/1×/3×/6× speed control over
|
||||
the clock, `context.UseGameSpeed(...)`; `Calendar` turning scaled time into in-game days,
|
||||
`context.UseCalendar(secondsPerDay)`; `Climate` — continuous seasonal/daily temperature
|
||||
and season over the calendar, `context.UseClimate(settings)`), services, logging, and
|
||||
**`HeadlessHost`** — a fixed-timestep loop without a window or GPU (dedicated servers,
|
||||
batch simulation, tests). Depends only on Friflo.Engine.ECS — no MonoGame, no platform.
|
||||
- **`Host`** — the windowed MonoGame host: `GameHost` (wraps `Game`, owns the window and
|
||||
`GraphicsDeviceManager`, publishes `GraphicsDevice` as a service), visual scene
|
||||
transitions (`OverlayTransition`, `Transitions.Fade/Wipe`, `TransitionRenderer`), plus
|
||||
**Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
|
||||
`Host/Input/`). Nothing depends on `Host` except the game itself. → `Core`.
|
||||
- **`Graphics`** — custom batched renderer, camera, sprites, plus **Tilemaps**
|
||||
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
|
||||
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
|
||||
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
|
||||
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; plus a per-cell
|
||||
**lightmap** — `LightmapBuilder` (ambient × occlusion + point lights with grid-traced shadows),
|
||||
`PointLight` component, multiplied over the world via `scene.UseLighting(...)`, sampleable with
|
||||
`Lighting.SampleAt` for the simulation). → `Core`.
|
||||
- **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume`
|
||||
(one knob for effects + music). → `Core`.
|
||||
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
|
||||
loading, no MGCB pipeline; FontStash), **Atlases** (`MrGameEng.Atlases`: texture-atlas
|
||||
builder + runtime loader, CLI wrapper in `tools/MrGameEng.AtlasTool`; StbImage), and
|
||||
**Mods** (`MrGameEng.Mods`: mod discovery + load order from `About/About.json`, JSON
|
||||
`Defs/` with parent inheritance and later-mod override, `Languages/<code>/`
|
||||
localization, merged texture content trees; the game ships its content as the `Core`
|
||||
mod). → `Core`, `Graphics` (Atlases needs `Texture2DRegion`).
|
||||
- **`Simulation`** — deterministic gameplay primitives that own no world data:
|
||||
**Pathfinding** (`MrGameEng.Pathfinding`: grid A*/Dijkstra/BFS and flow fields over a
|
||||
game-implemented `IPathGrid`), **AI** (`MrGameEng.AI`: utility-AI primitives —
|
||||
`ResponseCurve`, `Consideration<TContext>`, `UtilityAction<TContext>`,
|
||||
`UtilityAi<TContext>` selector, `Blackboard`; generic over a game context), and
|
||||
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
|
||||
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
|
||||
→ `Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`).
|
||||
- **`Net`** — multiplayer building blocks, browser-compatible by design: a dependency-free
|
||||
RFC 6455 WebSocket server over `TcpListener` (browsers can't speak UDP, so WebSocket is
|
||||
the engine's one transport), a `ClientWebSocket`-based client (works in Blazor WASM),
|
||||
both behind the poll-based `INetConnection`; server-authoritative component replication
|
||||
(`ReplicationSchema` of unmanaged components, `ReplicationServer` sending per-connection
|
||||
deltas — no acks needed over a reliable ordered transport, `ReplicationClient` applying
|
||||
snapshots to a local store, `NetId`). → `Core`.
|
||||
- **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`),
|
||||
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
|
||||
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
|
||||
Chrome-DevTools-style ECS debugger overlay — entity tree by archetype, component/field
|
||||
view with simple-field editing, world-pick with selection highlight, renderer perf tab;
|
||||
`scene.UseInspector(renderer)`, toggle F1). → `Core`, `Graphics` (the inspector picks
|
||||
entities and reads renderer timings; Myra renders with its own SpriteBatch internally).
|
||||
- **`Assets.Generator`** — Roslyn source generator for typed asset handles; standalone
|
||||
netstandard2.0 analyzer.
|
||||
|
||||
Dependency rule: a library may depend only on `Core` and `Graphics`; `Core` depends only
|
||||
on Friflo.Engine.ECS (no MonoGame — the simulation must run headless). MonoGame is pulled
|
||||
in by the platform/graphics libraries (`Host`, `Graphics`, `Audio`). Platform resources
|
||||
(e.g. `GraphicsDevice`) are published by hosts as services in `EngineContext.Services`;
|
||||
graphics code reaches the device via `context.GetGraphicsDevice()` (extension in
|
||||
`Graphics`). Features grouped into one library share its package set (e.g. `Content`
|
||||
carries both FontStash and StbImage) — keep optional/heavy deps (Myra, NVorbis) in their
|
||||
own library so the rest of the engine stays free of them.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -44,6 +97,9 @@ dotnet build MrGameEng.sln
|
||||
dotnet test MrGameEng.sln
|
||||
```
|
||||
|
||||
Building requires the .NET 10+ SDK (Roslyn 5.3 packages used by the asset-handles
|
||||
generator do not load under the SDK 8 compiler); the target framework stays net8.0.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- ECS-first: components are plain data (`struct` implementing `IComponent`),
|
||||
@@ -62,8 +118,11 @@ dotnet test MrGameEng.sln
|
||||
(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.
|
||||
- New engine functionality goes into the host library for its role (a namespaced
|
||||
subfolder, e.g. a new render feature under `Graphics/`), not a fresh project. Add a
|
||||
new `MrGameEng.<Area>` library only when the area is a genuinely new role or needs an
|
||||
isolated heavy/optional dependency — never into `Core` by default, never a
|
||||
two-file project per feature.
|
||||
- Every public engine feature must be covered by tests where logic is testable
|
||||
without a GPU, and demonstrated in the LittleSim game (the engine's showcase).
|
||||
|
||||
@@ -72,6 +131,9 @@ dotnet test MrGameEng.sln
|
||||
- Nullable reference types enabled, warnings as errors, file-scoped namespaces.
|
||||
- Public engine API requires XML doc comments (English).
|
||||
- Tests: xUnit, named `Method_Scenario_Expectation`.
|
||||
- Formatting: all C# code is formatted with **CSharpier**. Match its output —
|
||||
run `csharpier format .` (or let the editor's format-on-save handle it) before
|
||||
committing; never hand-format against it.
|
||||
|
||||
## Memory (echovault MCP)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
@@ -13,5 +12,4 @@
|
||||
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('MrGameEng.')) AND !$(MSBuildProjectName.EndsWith('.Tests')) AND !$(MSBuildProjectName.EndsWith('.Generator')) AND !$(MSBuildProjectName.EndsWith('.Sample'))">
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+19
-23
@@ -1,23 +1,19 @@
|
||||
<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" />
|
||||
<PackageVersion Include="Myra" Version="1.6.1" />
|
||||
<PackageVersion Include="StbImageSharp" Version="2.30.15" />
|
||||
<PackageVersion Include="StbImageWriteSharp" Version="1.16.7" />
|
||||
|
||||
<!-- 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.v3" Version="3.2.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<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" />
|
||||
<PackageVersion Include="Myra" Version="1.6.1" />
|
||||
<PackageVersion Include="StbImageSharp" Version="2.30.15" />
|
||||
<PackageVersion Include="StbImageWriteSharp" Version="1.16.7" />
|
||||
<!-- Source generator -->
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
|
||||
<!-- Tests -->
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+143
-188
@@ -15,43 +15,37 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Graphics", "src\M
|
||||
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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI", "src\MrGameEng.UI\MrGameEng.UI.csproj", "{17EB97D5-DCF8-47DF-B810-DA45AE314170}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole", "src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj", "{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole.Tests", "tests\MrGameEng.DevConsole.Tests\MrGameEng.DevConsole.Tests.csproj", "{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases", "src\MrGameEng.Atlases\MrGameEng.Atlases.csproj", "{B5980FD4-43DF-41B3-97BB-B93D89761FB9}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AtlasTool", "tools\MrGameEng.AtlasTool\MrGameEng.AtlasTool.csproj", "{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases.Tests", "tests\MrGameEng.Atlases.Tests\MrGameEng.Atlases.Tests.csproj", "{1951D50B-122A-45B5-9356-F186A3CBC974}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Content", "src\MrGameEng.Content\MrGameEng.Content.csproj", "{6726902A-59ED-4BDA-B376-947514A19019}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Tilemaps", "src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj", "{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Simulation", "src\MrGameEng.Simulation\MrGameEng.Simulation.csproj", "{F1C3CB22-B7EC-4258-9504-2549DE290137}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Tilemaps.Tests", "tests\MrGameEng.Tilemaps.Tests\MrGameEng.Tilemaps.Tests.csproj", "{10B318BB-BB00-4A9D-8AF3-D36C570B6286}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Content.Tests", "tests\MrGameEng.Content.Tests\MrGameEng.Content.Tests.csproj", "{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Pathfinding", "src\MrGameEng.Pathfinding\MrGameEng.Pathfinding.csproj", "{84064C54-688F-4A58-9EC6-BFD478816306}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Simulation.Tests", "tests\MrGameEng.Simulation.Tests\MrGameEng.Simulation.Tests.csproj", "{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Collisions", "src\MrGameEng.Collisions\MrGameEng.Collisions.csproj", "{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "tests\MrGameEng.UI.Tests\MrGameEng.UI.Tests.csproj", "{7F7D9641-2409-40CB-88A9-56BCE8C90A45}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Pathfinding.Tests", "tests\MrGameEng.Pathfinding.Tests\MrGameEng.Pathfinding.Tests.csproj", "{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{4407F6E6-0B65-41A3-ADFA-B78684A9B918}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Collisions.Tests", "tests\MrGameEng.Collisions.Tests\MrGameEng.Collisions.Tests.csproj", "{B8C132F5-C4C8-4931-B0CE-885811F44DB0}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "src\MrGameEng.Host\MrGameEng.Host.csproj", "{59818072-0D2B-4007-A50F-1343FA189EC6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "src\MrGameEng.Net\MrGameEng.Net.csproj", "{A4E754C7-C5FD-43A2-B345-34152D3A22D1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -111,18 +105,6 @@ Global
|
||||
{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
|
||||
@@ -147,18 +129,6 @@ Global
|
||||
{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
|
||||
@@ -171,18 +141,6 @@ Global
|
||||
{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
|
||||
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -195,42 +153,6 @@ Global
|
||||
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x64.Build.0 = Release|Any CPU
|
||||
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -243,90 +165,126 @@ Global
|
||||
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.Build.0 = Release|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x64.Build.0 = Release|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x86.Build.0 = Release|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|x64.Build.0 = Release|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6726902A-59ED-4BDA-B376-947514A19019}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.Build.0 = Release|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.Build.0 = Release|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -336,23 +294,20 @@ Global
|
||||
{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}
|
||||
{17EB97D5-DCF8-47DF-B810-DA45AE314170} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{B5980FD4-43DF-41B3-97BB-B93D89761FB9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||
{1951D50B-122A-45B5-9356-F186A3CBC974} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{10B318BB-BB00-4A9D-8AF3-D36C570B6286} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{84064C54-688F-4A58-9EC6-BFD478816306} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{B8C132F5-C4C8-4931-B0CE-885811F44DB0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{6726902A-59ED-4BDA-B376-947514A19019} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{F1C3CB22-B7EC-4258-9504-2549DE290137} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{7F7D9641-2409-40CB-88A9-56BCE8C90A45} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{59818072-0D2B-4007-A50F-1343FA189EC6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{A4E754C7-C5FD-43A2-B345-34152D3A22D1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
+97
-29
@@ -27,39 +27,76 @@
|
||||
|
||||
## Модули
|
||||
|
||||
| Библиотека | Ответственность |
|
||||
|------------------------------|------------------------------------------------------------|
|
||||
| `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: классы с типизированными хендлами ресурсов |
|
||||
| `MrGameEng.Atlases` | Текстурные атласы: офлайн-сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`); CLI — `tools/MrGameEng.AtlasTool` |
|
||||
| `MrGameEng.Tilemaps` | Тайловые карты, создаваемые кодом: `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер |
|
||||
| `MrGameEng.Pathfinding` | Поиск пути по гриду: A*, Dijkstra, BFS и flow fields для толп; чистая логика без зависимостей |
|
||||
| `MrGameEng.Collisions` | Определение столкновений: компонент `Collider`, spatial hash, пары/запросы/raycast |
|
||||
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
|
||||
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
|
||||
Библиотеки сгруппированы по архитектурной роли, а не по одной на фичу. Каждая фича —
|
||||
подпапка библиотеки-хоста со своим неймспейсом `MrGameEng.<Фича>` (неймспейс не
|
||||
привязан к сборке), поэтому `using MrGameEng.Tilemaps;` и т.п. работают и после слияния.
|
||||
|
||||
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
|
||||
| Библиотека (сборка) | Фичи (неймспейсы) и ответственность |
|
||||
|------------------------------|------------------------------------------------------------|
|
||||
| `MrGameEng.Core` | Платформо-независимое ядро (зависит только от Friflo): `EngineContext`, `EntityStore`, `SystemRoot`, сцены и переходы (тайминг — `Transition`, `SceneManager`; визуал переходов — в `Host`), время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл, `ServiceRegistry`, `Log`. **`HeadlessHost`** — цикл без окна и GPU на фиксированном тике (`Tick`/`RunTicks`/`Run` с realtime-пейсингом): дедикейтед-серверы, батч-симуляция, тесты |
|
||||
| `MrGameEng.Host` | Оконный MonoGame-хост: `GameHost` (обёртка над `Game` — цикл, окно, `GraphicsDeviceManager`; публикует `GraphicsDevice` сервисом в контексте), визуальные переходы сцен (`OverlayTransition`, фабрики `Transitions.Fade/Wipe`, `TransitionRenderer`). **Input** (`MrGameEng.Input`, `Host/Input/`): клавиатура, мышь, геймпад, action maps |
|
||||
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar` → `Renderer2D.AmbientLight`, `scene.UseDayNight(renderer)`; по-клеточный лайтмап — `LightmapBuilder` (амбиент × окклюзия + точечные `PointLight` с трассировкой теней), накладывается multiply поверх мира через `scene.UseLighting(...)`, сэмплируется `Lighting.SampleAt` |
|
||||
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
|
||||
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
|
||||
| `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast |
|
||||
| `MrGameEng.Net` | Мультиплеер, совместимый с браузером по построению: WebSocket-сервер (RFC 6455 поверх `TcpListener`, без зависимостей — браузер не умеет UDP, поэтому транспорт движка один — WebSocket), клиент на `ClientWebSocket` (работает в Blazor WASM), оба за poll-интерфейсом `INetConnection`; server-authoritative репликация компонентов: `ReplicationSchema` (unmanaged-компоненты, до 32 типов), `ReplicationServer` (пер-соединенческие дельты против последнего отправленного — ack не нужны поверх надёжного упорядоченного транспорта), `ReplicationClient` (применение снапшотов в локальный `EntityStore`), компонент `NetId` |
|
||||
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг. **DevConsole** (`MrGameEng.DevConsole`): ингейм-консоль — логи `Log`, команды, история, автодополнение. **Inspector** (`MrGameEng.Inspector`): ECS-дебагер в духе Chrome DevTools — дерево сущностей по архетипам, компоненты/поля с правкой простых полей, выбор кликом по миру с подсветкой, вкладка перфа рендера (`scene.UseInspector(renderer)`, F1) |
|
||||
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) |
|
||||
|
||||
Планируемые области (по мере развития): `Physics2D`, `Particles` — добавляются как
|
||||
подпапки подходящей библиотеки или новой библиотекой, если это новая роль/тяжёлая
|
||||
зависимость.
|
||||
|
||||
### MrGameEng.Mods
|
||||
|
||||
Контент игры описывается **модами** — папками вида `Mods/<Id>` с метаданными в
|
||||
`About/About.json` (id, имя, версия, зависимости). `ModLoader.Load` упорядочивает моды
|
||||
детерминированно: зависимости раньше зависимых, при равенстве — по алфавиту id. Поздний
|
||||
мод переопределяет ранние во всех системах контента. Сама игра поставляет свой контент
|
||||
как мод `Core` — любой другой мод может переопределить её данные.
|
||||
|
||||
- **Дефы** (`Defs/**/*.json`): файл-конверт `{ "type": "<ключ>", "defs": [ … ] }`;
|
||||
CLR-тип на ключ регистрирует игра (`DefDatabase.RegisterType<T>`). Поля: `defName`
|
||||
(уникален в типе; одноимённый деф позднего мода полностью заменяет ранний), `parent`
|
||||
(поля родителя как основа, свои — поверх; вложенные объекты заменяются целиком),
|
||||
`abstract` (только родитель, в базу не попадает; не наследуется), `label`.
|
||||
- **Локализация** (`Languages/<код>/**/*.json`): плоские словари ключ→строка;
|
||||
`LanguageManager` переключает язык на лету, недостающие ключи берёт из языка по
|
||||
умолчанию, в крайнем случае возвращает сам ключ.
|
||||
- **Деревья контента**: `ModContentTree.Build(mods, "Textures")` сливает одноимённые
|
||||
папки всех модов (поздний мод побеждает по относительному пути) — результат кормится,
|
||||
например, в `AtlasBuilder.Build(options, sources)` для инкрементальной сборки атласов
|
||||
при старте игры.
|
||||
|
||||
### Правило зависимостей
|
||||
|
||||
```
|
||||
MrGameEng.Graphics ─┐
|
||||
MrGameEng.Input ─┤
|
||||
MrGameEng.Audio ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
|
||||
MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
|
||||
MrGameEng.Host ─┐
|
||||
MrGameEng.Audio ─┤
|
||||
MrGameEng.Net ─┤
|
||||
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
|
||||
│
|
||||
MrGameEng.Content ─┤
|
||||
MrGameEng.Simulation ─┤ (Content — за Texture2DRegion,
|
||||
MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер)
|
||||
```
|
||||
|
||||
Модули зависят **только от `Core`** и никогда друг от друга. `Core` зависит только
|
||||
от MonoGame и Friflo. Если двум модулям нужен общий тип — он переезжает в `Core`.
|
||||
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от Friflo —
|
||||
ни MonoGame, ни другой платформы: благодаря этому симуляция запускается и без окна
|
||||
(`HeadlessHost`). MonoGame (`MonoGame.Framework.DesktopGL`) тянут платформенные и
|
||||
графические библиотеки: `Host`, `Graphics`, `Audio` (и транзитивно их потребители).
|
||||
Платформенные ресурсы (например `GraphicsDevice`) хосты публикуют сервисами в
|
||||
`EngineContext.Services`; графический код достаёт девайс через
|
||||
`context.GetGraphicsDevice()` (расширение в `Graphics`). На `Host` не зависит никто,
|
||||
кроме самой игры — это точка входа оконной платформы. Если двум библиотекам нужен общий
|
||||
тип — он переезжает в `Core` (или, если это графический тип, в `Graphics`). `Content`
|
||||
тянет `Graphics` (атласы выдают `Texture2DRegion`); `Simulation` тянет `Graphics`
|
||||
(`Collisions` использует `Transform2D`, `RectF`); `UI` — только `Core` (Myra рисует
|
||||
своим SpriteBatch).
|
||||
|
||||
Документированные исключения: `MrGameEng.Atlases` зависит от `Graphics`
|
||||
(выдаёт `Texture2DRegion`) и от `Assets` (регистрирует загрузчик в `AssetManager`) —
|
||||
атлас по своей природе склейка этих двух областей; `MrGameEng.Tilemaps` зависит от
|
||||
`Graphics` (рисует регионы через рендерер и слои).
|
||||
Фичи, собранные в одну библиотеку, делят её набор пакетов (например, `Content` несёт и
|
||||
FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в
|
||||
отдельных библиотеках, чтобы остальной движок их не тянул.
|
||||
|
||||
`MrGameEng.Assets.Generator` — особый случай: это анализатор (netstandard2.0),
|
||||
он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит
|
||||
@@ -204,6 +241,34 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
|
||||
инвалидируются generation-штампом — повторные запросы не аллоцируют и не чистят
|
||||
массивы. Один экземпляр на систему; результаты детерминированы.
|
||||
|
||||
## ИИ агентов (utility)
|
||||
|
||||
`MrGameEng.AI` — примитивы для принятия решений агентами (жители LittleSim).
|
||||
Зависит только от Core, **не владеет данными мира** и не привязан к ECS: всё
|
||||
параметризовано контекстом `TContext`, который игра передаёт сама (снимок восприятия,
|
||||
хендл сущности, blackboard — что угодно). Основан на Infinite-Axis Utility System:
|
||||
|
||||
- `ResponseCurve` (value type) — кривая отклика, нормализованный вход `[0,1]` →
|
||||
полезность `[0,1]`: `Linear`, `Polynomial` (степень), `Logistic` (S-кривая),
|
||||
`SmoothStep`. Вход и выход клампятся. **Внимание:** `default(ResponseCurve)` имеет
|
||||
нулевой наклон (всегда 0) — для тождества используйте `ResponseCurve.Identity`.
|
||||
- `Consideration<TContext>` — одно соображение: читает сырое значение из контекста,
|
||||
нормирует по диапазону `[min,max]` и прогоняет через кривую.
|
||||
- `UtilityAction<TContext>` — действие из набора соображений. Очки = произведение
|
||||
соображений × `Weight`; любой ноль ветирует действие. Компенсирующий множитель
|
||||
(make-up value) убирает смещение произведения многих факторов вниз.
|
||||
- `UtilityAi<TContext>` — reasoner: `Select` (детерминированно лучшее действие, при
|
||||
равенстве — первое) и `SelectWeighted(random)` (рулетка по очкам для разнообразия,
|
||||
воспроизводимо при seed). Очки пишутся в переиспользуемый буфер — повторные
|
||||
вычисления не аллоцируют; один экземпляр на вид агента, не потокобезопасен.
|
||||
- `Blackboard` — типизированная рабочая память агента (`Set`/`TryGet`/`GetOrDefault`)
|
||||
для холодных путей (восприятие, планирование).
|
||||
|
||||
Витрина в LittleSim: `PawnDecisionSystem` выбирает «бродить/отдыхать» по энергии
|
||||
жителя, `PawnNeedsSystem` тратит/восстанавливает энергию, уставшие темнеют
|
||||
(`PawnAppearanceSystem`). Команда консоли `ai [energy]` печатает очки и расклад
|
||||
отдыхающих/блуждающих.
|
||||
|
||||
## Коллизии
|
||||
|
||||
`MrGameEng.Collisions` — определение столкновений (без разрешения физики — она в бэклоге):
|
||||
@@ -291,12 +356,15 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
|
||||
|
||||
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
|
||||
следующего кадра (сцена никогда не выгружается посреди собственного кадра).
|
||||
- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом:
|
||||
- `Scenes.Switch(scene, Transitions.Fade(0.5f))` — переключение с визуальным переходом:
|
||||
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
|
||||
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
|
||||
- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)`
|
||||
(шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill`
|
||||
в нормализованных координатах экрана).
|
||||
- Тайминг-машина (`Transition` — длительности фаз, покрытие) живёт в `Core` и работает
|
||||
и в headless-контексте; визуал — в `Host`: встроенные `Transitions.Fade(duration, color)`
|
||||
и `Transitions.Wipe(duration, color)` (шторка). Свои — наследованием от
|
||||
`OverlayTransition` (рисование через `TransitionRenderer.Fill` в нормализованных
|
||||
координатах экрана); `GameHost` рисует оверлей поверх сцены, читая
|
||||
`Scenes.ActiveTransition`/`TransitionCoverage`/`TransitionPhase`.
|
||||
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
|
||||
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace MrGameEng.Assets.Generator;
|
||||
@@ -14,7 +15,9 @@ namespace MrGameEng.Assets.Generator;
|
||||
[Generator]
|
||||
public sealed class AssetHandlesGenerator : IIncrementalGenerator
|
||||
{
|
||||
private static readonly Dictionary<string, string> TypeByExtension = new(StringComparer.OrdinalIgnoreCase)
|
||||
private static readonly Dictionary<string, string> TypeByExtension = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
[".png"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
|
||||
[".jpg"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
|
||||
@@ -31,38 +34,80 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
|
||||
/// <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 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))
|
||||
var projectDir = context.AnalyzerConfigOptionsProvider.Select(
|
||||
static (provider, _) =>
|
||||
{
|
||||
provider.GlobalOptions.TryGetValue("build_property.projectdir", out var dir);
|
||||
return dir ?? string.Empty;
|
||||
}
|
||||
);
|
||||
|
||||
var assets = context
|
||||
.AdditionalTextsProvider.Combine(projectDir)
|
||||
.Select(static (pair, _) => ToAssetPath(pair.Left.Path, pair.Right))
|
||||
.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)));
|
||||
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.
|
||||
/// the file is outside an Assets directory or has an unknown extension. With a known
|
||||
/// <paramref name="projectDir"/> the marker is the project's root <c>Assets/</c> folder,
|
||||
/// so nested directories that happen to be called "Assets" do not shift the root;
|
||||
/// without one, the last <c>/Assets/</c> segment of the path is used.
|
||||
/// </summary>
|
||||
internal static string? ToAssetPath(string fullPath)
|
||||
internal static string? ToAssetPath(string fullPath, string? projectDir = null)
|
||||
{
|
||||
var normalized = fullPath.Replace('\\', '/');
|
||||
var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
|
||||
if (marker < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relative = normalized.Substring(marker + "/Assets/".Length);
|
||||
string relative;
|
||||
var root = string.IsNullOrEmpty(projectDir)
|
||||
? null
|
||||
: projectDir!.Replace('\\', '/').TrimEnd('/');
|
||||
if (
|
||||
root is not null
|
||||
&& normalized.StartsWith(root + "/Assets/", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
{
|
||||
relative = normalized.Substring(root.Length + "/Assets/".Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
|
||||
if (marker < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
relative = normalized.Substring(marker + "/Assets/".Length);
|
||||
}
|
||||
|
||||
// Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas,
|
||||
// им собственные Texture2D-хендлы не нужны.
|
||||
@@ -94,39 +139,47 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
|
||||
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(
|
||||
"/// <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);
|
||||
EmitNode(source, root, indent: 1, enclosingName: className);
|
||||
source.AppendLine("}");
|
||||
return source.ToString();
|
||||
}
|
||||
|
||||
private static void EmitNode(StringBuilder source, Node node, int indent)
|
||||
private static void EmitNode(StringBuilder source, Node node, int indent, string enclosingName)
|
||||
{
|
||||
var pad = new string(' ', indent * 4);
|
||||
var usedNames = new HashSet<string>();
|
||||
// Имя вмещающего класса занято: член с тем же именем — ошибка CS0542.
|
||||
var usedNames = new HashSet<string> { enclosingName };
|
||||
|
||||
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}/// <summary>{XmlEscape(relativePath)}</summary>");
|
||||
source.AppendLine(
|
||||
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = new(\"{relativePath}\");");
|
||||
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = "
|
||||
+ $"new({SymbolDisplay.FormatLiteral(relativePath, quote: true)});"
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var pair in node.Children)
|
||||
{
|
||||
var name = Unique(usedNames, Identifier(pair.Key));
|
||||
source.AppendLine($"{pad}/// <summary>{pair.Key}/</summary>");
|
||||
source.AppendLine($"{pad}/// <summary>{XmlEscape(pair.Key)}/</summary>");
|
||||
source.AppendLine($"{pad}public static class {name}");
|
||||
source.AppendLine($"{pad}{{");
|
||||
EmitNode(source, pair.Value, indent + 1);
|
||||
EmitNode(source, pair.Value, indent + 1, enclosingName: name);
|
||||
source.AppendLine($"{pad}}}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string XmlEscape(string text) =>
|
||||
text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
/// <summary>Converts an arbitrary file or directory name to a PascalCase C# identifier.</summary>
|
||||
internal static string Identifier(string name)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<IsRoslynComponent>true</IsRoslynComponent>
|
||||
@@ -14,5 +13,4 @@
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Assets.Generator.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -19,7 +19,23 @@ public sealed class AudioManager : IDisposable
|
||||
set => _soundVolume = Math.Clamp(value, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Master volume 0..1 applied on top of <see cref="SoundVolume"/> for sound effects and
|
||||
/// mirrored onto <see cref="MusicPlayer.Volume"/>, so a single knob (e.g. a settings
|
||||
/// slider) controls both effects and music.
|
||||
/// </summary>
|
||||
public float MasterVolume
|
||||
{
|
||||
get => _masterVolume;
|
||||
set
|
||||
{
|
||||
_masterVolume = Math.Clamp(value, 0f, 1f);
|
||||
Music.Volume = _masterVolume;
|
||||
}
|
||||
}
|
||||
|
||||
private float _soundVolume = 1f;
|
||||
private float _masterVolume = 1f;
|
||||
|
||||
/// <summary>Plays a sound effect (fire and forget).</summary>
|
||||
/// <param name="sound">The loaded sound effect.</param>
|
||||
@@ -27,7 +43,7 @@ public sealed class AudioManager : IDisposable
|
||||
/// <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);
|
||||
sound.Play(Math.Clamp(volume, 0f, 1f) * _soundVolume * _masterVolume, pitch, pan);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => Music.Dispose();
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<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>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||
<PackageReference Include="NVorbis" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -40,9 +40,27 @@ public sealed class MusicPlayer : IDisposable
|
||||
/// <summary>Starts streaming <paramref name="track"/>, stopping the previous one.</summary>
|
||||
public void Play(MusicTrack track, bool loop = true)
|
||||
{
|
||||
// Валидация до Stop(): негодный файл не должен обрывать играющий трек.
|
||||
var reader = new VorbisReader(track.FullPath);
|
||||
if (reader.Channels is < 1 or > 2)
|
||||
{
|
||||
reader.Dispose();
|
||||
throw new NotSupportedException(
|
||||
$"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported."
|
||||
);
|
||||
}
|
||||
|
||||
if (reader.SampleRate is < 8000 or > 48000)
|
||||
{
|
||||
reader.Dispose();
|
||||
throw new NotSupportedException(
|
||||
$"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 8000–48000 Hz."
|
||||
);
|
||||
}
|
||||
|
||||
Stop();
|
||||
_loop = loop;
|
||||
_reader = new VorbisReader(track.FullPath);
|
||||
_reader = reader;
|
||||
|
||||
// ~0.5 seconds of samples per submitted buffer.
|
||||
var samplesPerBuffer = _reader.SampleRate * _reader.Channels / 2;
|
||||
@@ -51,7 +69,8 @@ public sealed class MusicPlayer : IDisposable
|
||||
|
||||
_instance = new DynamicSoundEffectInstance(
|
||||
_reader.SampleRate,
|
||||
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo)
|
||||
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo
|
||||
)
|
||||
{
|
||||
Volume = _volume,
|
||||
};
|
||||
@@ -91,13 +110,22 @@ public sealed class MusicPlayer : IDisposable
|
||||
var read = _reader.ReadSamples(_sampleBuffer, 0, _sampleBuffer.Length);
|
||||
if (read == 0)
|
||||
{
|
||||
if (!_loop)
|
||||
// SamplePosition > 0 отличает конец трека от пустого файла: после перемотки
|
||||
// на 0 повторный read == 0 не зацикливается, а завершает воспроизведение.
|
||||
if (_loop && _reader.SamplePosition > 0)
|
||||
{
|
||||
return;
|
||||
_reader.SamplePosition = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
_reader.SamplePosition = 0;
|
||||
continue;
|
||||
// Конец незацикленного трека (или пустой файл): когда буферы доиграли,
|
||||
// останавливаем инстанс — иначе IsPlaying остаётся true навсегда.
|
||||
if (_instance.PendingBufferCount == 0)
|
||||
{
|
||||
_instance.Stop();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < read; i++)
|
||||
|
||||
+20
-8
@@ -2,6 +2,7 @@ using FontStashSharp;
|
||||
using Microsoft.Xna.Framework.Audio;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace MrGameEng.Assets;
|
||||
|
||||
@@ -39,7 +40,8 @@ public sealed class AssetManager : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Loads (or returns the cached) asset for <paramref name="asset"/>.</summary>
|
||||
public T Load<T>(AssetRef<T> asset) where T : class
|
||||
public T Load<T>(AssetRef<T> asset)
|
||||
where T : class
|
||||
{
|
||||
var key = (typeof(T), asset.Path);
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
@@ -49,13 +51,18 @@ public sealed class AssetManager : IDisposable
|
||||
|
||||
if (!_loaders.TryGetValue(typeof(T), out var loader))
|
||||
{
|
||||
throw new InvalidOperationException($"No asset loader registered for type {typeof(T)}.");
|
||||
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);
|
||||
throw new FileNotFoundException(
|
||||
$"Asset '{asset.Path}' not found at '{fullPath}'.",
|
||||
fullPath
|
||||
);
|
||||
}
|
||||
|
||||
var loaded = (T)loader(this, fullPath);
|
||||
@@ -64,7 +71,8 @@ public sealed class AssetManager : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Removes one asset from the cache, disposing it if disposable.</summary>
|
||||
public void Unload<T>(AssetRef<T> asset) where T : class
|
||||
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)
|
||||
@@ -74,8 +82,8 @@ public sealed class AssetManager : IDisposable
|
||||
}
|
||||
|
||||
/// <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;
|
||||
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) =>
|
||||
@@ -95,7 +103,11 @@ public sealed class AssetManager : IDisposable
|
||||
private static Texture2D LoadTexture(EngineContext context, string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Texture2D.FromStream(context.GraphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
|
||||
return Texture2D.FromStream(
|
||||
context.GetGraphicsDevice(),
|
||||
stream,
|
||||
DefaultColorProcessors.PremultiplyAlpha
|
||||
);
|
||||
}
|
||||
|
||||
private static SoundEffect LoadSoundEffect(string path)
|
||||
@@ -112,7 +124,7 @@ public sealed class AssetManager : IDisposable
|
||||
}
|
||||
|
||||
private static Effect LoadEffect(EngineContext context, string path) =>
|
||||
new(context.GraphicsDevice, File.ReadAllBytes(path));
|
||||
new(context.GetGraphicsDevice(), File.ReadAllBytes(path));
|
||||
}
|
||||
|
||||
/// <summary>Wires the assets module into the engine.</summary>
|
||||
@@ -7,7 +7,8 @@ namespace MrGameEng.Assets;
|
||||
/// </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
|
||||
public readonly record struct AssetRef<T>(string Path)
|
||||
where T : class
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"{typeof(T).Name}:{Path}";
|
||||
+185
-63
@@ -3,11 +3,14 @@ using StbImageWriteSharp;
|
||||
|
||||
namespace MrGameEng.Atlases;
|
||||
|
||||
/// <summary>Options for one <see cref="AtlasBuilder.Build"/> run.</summary>
|
||||
/// <summary>Options for one <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
|
||||
public sealed class AtlasBuildOptions
|
||||
{
|
||||
/// <summary>Directory scanned recursively for source images (png/jpg/jpeg/bmp).</summary>
|
||||
public required string SourceDirectory { get; init; }
|
||||
/// <summary>
|
||||
/// Directory scanned recursively for source images (png/jpg/jpeg/bmp). Used only by the
|
||||
/// directory-scanning overload; the explicit-sources overload ignores it.
|
||||
/// </summary>
|
||||
public string SourceDirectory { get; init; } = "";
|
||||
|
||||
/// <summary>Directory the <c>.atlas</c> metadata and page images are written to.</summary>
|
||||
public required string OutputDirectory { get; init; }
|
||||
@@ -38,10 +41,13 @@ public sealed class AtlasBuildOptions
|
||||
/// <param name="Skipped">True when the atlas was up to date and not rebuilt.</param>
|
||||
public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCount, bool Skipped);
|
||||
|
||||
/// <summary>Result of an <see cref="AtlasBuilder.Build"/> run.</summary>
|
||||
/// <summary>Result of an <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
|
||||
/// <param name="Groups">Per-atlas outcomes, sorted by name.</param>
|
||||
/// <param name="DeletedOrphans">Output files of atlases whose source group no longer exists.</param>
|
||||
public sealed record AtlasBuildResult(IReadOnlyList<AtlasGroupResult> Groups, IReadOnlyList<string> DeletedOrphans);
|
||||
public sealed record AtlasBuildResult(
|
||||
IReadOnlyList<AtlasGroupResult> Groups,
|
||||
IReadOnlyList<string> DeletedOrphans
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Build-time utility converting a directory tree of loose images into texture atlases:
|
||||
@@ -54,18 +60,51 @@ public static class AtlasBuilder
|
||||
{
|
||||
private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
|
||||
|
||||
/// <summary>Builds (or incrementally refreshes) all atlases for <paramref name="options"/>.</summary>
|
||||
/// <summary>Snapshot of one source image taken at scan time (size/mtime feed the staleness check).</summary>
|
||||
private readonly record struct SourceFile(
|
||||
string FullPath,
|
||||
string Key,
|
||||
long Size,
|
||||
long ModifiedTicks
|
||||
);
|
||||
|
||||
/// <summary>Builds (or incrementally refreshes) all atlases from <see cref="AtlasBuildOptions.SourceDirectory"/>.</summary>
|
||||
public static AtlasBuildResult Build(AtlasBuildOptions options)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options.SourceDirectory))
|
||||
{
|
||||
throw new ArgumentException("SourceDirectory is not set.", nameof(options));
|
||||
}
|
||||
|
||||
var sourceRoot = Path.GetFullPath(options.SourceDirectory);
|
||||
if (!Directory.Exists(sourceRoot))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Atlas source directory not found: '{sourceRoot}'.");
|
||||
throw new DirectoryNotFoundException(
|
||||
$"Atlas source directory not found: '{sourceRoot}'."
|
||||
);
|
||||
}
|
||||
|
||||
return Build(
|
||||
options,
|
||||
Directory
|
||||
.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories)
|
||||
.Select(fullPath => (fullPath, Path.GetRelativePath(sourceRoot, fullPath)))
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds (or incrementally refreshes) all atlases from an explicit source list —
|
||||
/// e.g. a texture tree merged across mods, where files with one relative path may live
|
||||
/// in different roots. Region keys come from <c>RelativePath</c> without extension.
|
||||
/// </summary>
|
||||
public static AtlasBuildResult Build(
|
||||
AtlasBuildOptions options,
|
||||
IEnumerable<(string FullPath, string RelativePath)> sources
|
||||
)
|
||||
{
|
||||
Directory.CreateDirectory(options.OutputDirectory);
|
||||
|
||||
var groups = ScanGroups(sourceRoot, options);
|
||||
var groups = ScanGroups(sources, options);
|
||||
var results = new List<AtlasGroupResult>();
|
||||
foreach (var (name, files) in groups)
|
||||
{
|
||||
@@ -77,7 +116,11 @@ public static class AtlasBuilder
|
||||
}
|
||||
|
||||
/// <summary>Maps a source-relative image path to its atlas name and region key.</summary>
|
||||
internal static (string AtlasName, string Key) ClassifyPath(string relativePath, int groupDepth, string rootAtlasName)
|
||||
internal static (string AtlasName, string Key) ClassifyPath(
|
||||
string relativePath,
|
||||
int groupDepth,
|
||||
string rootAtlasName
|
||||
)
|
||||
{
|
||||
var normalized = relativePath.Replace('\\', '/');
|
||||
var key = normalized[..normalized.LastIndexOf('.')];
|
||||
@@ -87,23 +130,35 @@ public static class AtlasBuilder
|
||||
return (name, key);
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, List<(string FullPath, string Key)>> ScanGroups(
|
||||
string sourceRoot, AtlasBuildOptions options)
|
||||
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
|
||||
IEnumerable<(string FullPath, string RelativePath)> sources,
|
||||
AtlasBuildOptions options
|
||||
)
|
||||
{
|
||||
var groups = new SortedDictionary<string, List<(string, string)>>(StringComparer.Ordinal);
|
||||
var groups = new SortedDictionary<string, List<SourceFile>>(StringComparer.Ordinal);
|
||||
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
|
||||
foreach (var (fullPath, relative) in sources)
|
||||
{
|
||||
if (!SourceExtensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
|
||||
if (
|
||||
!SourceExtensions.Contains(
|
||||
Path.GetExtension(fullPath),
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(sourceRoot, fullPath);
|
||||
var (atlasName, key) = ClassifyPath(relative, options.GroupDepth, options.RootAtlasName);
|
||||
var (atlasName, key) = ClassifyPath(
|
||||
relative,
|
||||
options.GroupDepth,
|
||||
options.RootAtlasName
|
||||
);
|
||||
if (keys.TryGetValue(key, out var existing))
|
||||
{
|
||||
throw new InvalidDataException($"Duplicate region key '{key}': '{existing}' and '{relative}'.");
|
||||
throw new InvalidDataException(
|
||||
$"Duplicate region key '{key}': '{existing}' and '{relative}'."
|
||||
);
|
||||
}
|
||||
|
||||
keys.Add(key, relative);
|
||||
@@ -113,14 +168,18 @@ public static class AtlasBuilder
|
||||
groups.Add(atlasName, list);
|
||||
}
|
||||
|
||||
list.Add((fullPath, key));
|
||||
var info = new FileInfo(fullPath);
|
||||
list.Add(new SourceFile(fullPath, key, info.Length, info.LastWriteTimeUtc.Ticks));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static AtlasGroupResult BuildGroup(
|
||||
string name, List<(string FullPath, string Key)> files, AtlasBuildOptions options)
|
||||
string name,
|
||||
List<SourceFile> files,
|
||||
AtlasBuildOptions options
|
||||
)
|
||||
{
|
||||
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
|
||||
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
|
||||
@@ -130,11 +189,18 @@ public static class AtlasBuilder
|
||||
|
||||
// Декодирование — самая дорогая фаза, параллелим (билд-тайм, аллокации допустимы).
|
||||
var images = new ImageResult[files.Count];
|
||||
Parallel.For(0, files.Count, i =>
|
||||
{
|
||||
using var stream = File.OpenRead(files[i].FullPath);
|
||||
images[i] = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
|
||||
});
|
||||
Parallel.For(
|
||||
0,
|
||||
files.Count,
|
||||
i =>
|
||||
{
|
||||
using var stream = File.OpenRead(files[i].FullPath);
|
||||
images[i] = ImageResult.FromStream(
|
||||
stream,
|
||||
StbImageSharp.ColorComponents.RedGreenBlueAlpha
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
var items = new PackItem[files.Count];
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
@@ -151,14 +217,18 @@ public static class AtlasBuilder
|
||||
}
|
||||
|
||||
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
|
||||
WriteMetadata(name, packed, options, metadataPath);
|
||||
WriteMetadata(name, packed, files, options, metadataPath);
|
||||
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
|
||||
|
||||
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
|
||||
}
|
||||
|
||||
private static bool IsUpToDate(
|
||||
string metadataPath, List<(string FullPath, string Key)> files, AtlasBuildOptions options, out int pages)
|
||||
string metadataPath,
|
||||
List<SourceFile> files,
|
||||
AtlasBuildOptions options,
|
||||
out int pages
|
||||
)
|
||||
{
|
||||
pages = 0;
|
||||
if (!File.Exists(metadataPath))
|
||||
@@ -176,7 +246,11 @@ public static class AtlasBuilder
|
||||
return false;
|
||||
}
|
||||
|
||||
if (metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
|
||||
if (
|
||||
metadata.Version != AtlasMetadata.CurrentVersion
|
||||
|| metadata.PageSize != options.MaxPageSize
|
||||
|| metadata.Padding != options.Padding
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -187,16 +261,24 @@ public static class AtlasBuilder
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!metadata.Regions.Select(r => r.Key).Order(StringComparer.Ordinal)
|
||||
.SequenceEqual(files.Select(f => f.Key).Order(StringComparer.Ordinal)))
|
||||
// Источники сравниваются по точному снапшоту (ключ + размер + mtime), а не по
|
||||
// «новее метаданных»: переименования и копии с сохранением времени тоже ловятся.
|
||||
if (metadata.Sources.Count != files.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var builtAt = File.GetLastWriteTimeUtc(metadataPath);
|
||||
if (files.Any(f => File.GetLastWriteTimeUtc(f.FullPath) > builtAt))
|
||||
var sourcesByKey = metadata.Sources.ToDictionary(s => s.Key, StringComparer.Ordinal);
|
||||
foreach (var file in files)
|
||||
{
|
||||
return false;
|
||||
if (
|
||||
!sourcesByKey.TryGetValue(file.Key, out var source)
|
||||
|| source.Size != file.Size
|
||||
|| source.Modified != file.ModifiedTicks
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
pages = metadata.Pages.Count;
|
||||
@@ -204,52 +286,88 @@ public static class AtlasBuilder
|
||||
}
|
||||
|
||||
private static void WritePages(
|
||||
string name, PackResult packed, Dictionary<string, ImageResult> pixelsByKey, string outputDirectory)
|
||||
string name,
|
||||
PackResult packed,
|
||||
Dictionary<string, ImageResult> pixelsByKey,
|
||||
string outputDirectory
|
||||
)
|
||||
{
|
||||
Parallel.For(0, packed.PageSizes.Count, page =>
|
||||
{
|
||||
var (width, height) = packed.PageSizes[page];
|
||||
var buffer = new byte[width * height * 4];
|
||||
foreach (var placement in packed.Placements)
|
||||
Parallel.For(
|
||||
0,
|
||||
packed.PageSizes.Count,
|
||||
page =>
|
||||
{
|
||||
if (placement.Page != page)
|
||||
var (width, height) = packed.PageSizes[page];
|
||||
var buffer = new byte[width * height * 4];
|
||||
foreach (var placement in packed.Placements)
|
||||
{
|
||||
continue;
|
||||
if (placement.Page != page)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = pixelsByKey[placement.Key];
|
||||
for (var row = 0; row < source.Height; row++)
|
||||
{
|
||||
Array.Copy(
|
||||
source.Data,
|
||||
row * source.Width * 4,
|
||||
buffer,
|
||||
((placement.Y + row) * width + placement.X) * 4,
|
||||
source.Width * 4
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var source = pixelsByKey[placement.Key];
|
||||
for (var row = 0; row < source.Height; row++)
|
||||
{
|
||||
Array.Copy(
|
||||
source.Data, row * source.Width * 4,
|
||||
buffer, ((placement.Y + row) * width + placement.X) * 4,
|
||||
source.Width * 4);
|
||||
}
|
||||
using var stream = File.Create(
|
||||
Path.Combine(outputDirectory, PageFileName(name, page))
|
||||
);
|
||||
new ImageWriter().WritePng(
|
||||
buffer,
|
||||
width,
|
||||
height,
|
||||
StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha,
|
||||
stream
|
||||
);
|
||||
}
|
||||
|
||||
using var stream = File.Create(Path.Combine(outputDirectory, PageFileName(name, page)));
|
||||
new ImageWriter().WritePng(
|
||||
buffer, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream);
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
private static void WriteMetadata(string name, PackResult packed, AtlasBuildOptions options, string metadataPath)
|
||||
private static void WriteMetadata(
|
||||
string name,
|
||||
PackResult packed,
|
||||
List<SourceFile> files,
|
||||
AtlasBuildOptions options,
|
||||
string metadataPath
|
||||
)
|
||||
{
|
||||
var metadata = new AtlasMetadata
|
||||
{
|
||||
Name = name,
|
||||
PageSize = options.MaxPageSize,
|
||||
Padding = options.Padding,
|
||||
Pages = packed.PageSizes
|
||||
.Select((size, index) => new AtlasPage
|
||||
Sources = files
|
||||
.OrderBy(f => f.Key, StringComparer.Ordinal)
|
||||
.Select(f => new AtlasSource
|
||||
{
|
||||
File = PageFileName(name, index),
|
||||
Width = size.Width,
|
||||
Height = size.Height,
|
||||
Key = f.Key,
|
||||
Size = f.Size,
|
||||
Modified = f.ModifiedTicks,
|
||||
})
|
||||
.ToList(),
|
||||
Regions = packed.Placements
|
||||
.OrderBy(p => p.Key, StringComparer.Ordinal)
|
||||
Pages = packed
|
||||
.PageSizes.Select(
|
||||
(size, index) =>
|
||||
new AtlasPage
|
||||
{
|
||||
File = PageFileName(name, index),
|
||||
Width = size.Width,
|
||||
Height = size.Height,
|
||||
}
|
||||
)
|
||||
.ToList(),
|
||||
Regions = packed
|
||||
.Placements.OrderBy(p => p.Key, StringComparer.Ordinal)
|
||||
.Select(p => new AtlasRegion
|
||||
{
|
||||
Key = p.Key,
|
||||
@@ -265,7 +383,8 @@ public static class AtlasBuilder
|
||||
File.WriteAllText(metadataPath, metadata.ToJson());
|
||||
}
|
||||
|
||||
private static string PageFileName(string atlasName, int page) => $"{atlasName}.atlas.{page}.png";
|
||||
private static string PageFileName(string atlasName, int page) =>
|
||||
$"{atlasName}.atlas.{page}.png";
|
||||
|
||||
private static void DeleteExtraPages(string name, int pageCount, string outputDirectory)
|
||||
{
|
||||
@@ -281,7 +400,10 @@ public static class AtlasBuilder
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> DeleteOrphans(string outputDirectory, IEnumerable<string> liveAtlasNames)
|
||||
private static List<string> DeleteOrphans(
|
||||
string outputDirectory,
|
||||
IEnumerable<string> liveAtlasNames
|
||||
)
|
||||
{
|
||||
var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal);
|
||||
var deleted = new List<string>();
|
||||
+25
-2
@@ -15,8 +15,11 @@ public sealed class AtlasMetadata
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
/// <summary>Format version, bumped on breaking metadata changes.</summary>
|
||||
public int Version { get; init; } = 1;
|
||||
/// <summary>Current format version. Bumped on breaking metadata changes.</summary>
|
||||
public const int CurrentVersion = 2;
|
||||
|
||||
/// <summary>Format version of this file; readers reject other versions.</summary>
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Atlas name (group key with '/' replaced by '.').</summary>
|
||||
public string Name { get; init; } = "";
|
||||
@@ -33,6 +36,9 @@ public sealed class AtlasMetadata
|
||||
/// <summary>Packed regions, sorted by key.</summary>
|
||||
public List<AtlasRegion> Regions { get; init; } = [];
|
||||
|
||||
/// <summary>Source files the atlas was built from, sorted by key (staleness check input).</summary>
|
||||
public List<AtlasSource> Sources { get; init; } = [];
|
||||
|
||||
/// <summary>Serializes this metadata to indented JSON.</summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||
|
||||
@@ -55,6 +61,23 @@ public sealed class AtlasPage
|
||||
public int Height { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of one source file at build time. The incremental rebuild compares key, size and
|
||||
/// modification time instead of relying on "source newer than metadata", so timestamp-preserving
|
||||
/// renames and copies still invalidate the atlas.
|
||||
/// </summary>
|
||||
public sealed class AtlasSource
|
||||
{
|
||||
/// <summary>Region key of the source file.</summary>
|
||||
public string Key { get; init; } = "";
|
||||
|
||||
/// <summary>Source file size in bytes.</summary>
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>Source file <c>LastWriteTimeUtc</c> in ticks.</summary>
|
||||
public long Modified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One packed source texture inside an atlas.</summary>
|
||||
public sealed class AtlasRegion
|
||||
{
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
using MrGameEng.Assets;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace MrGameEng.Atlases;
|
||||
|
||||
@@ -14,6 +15,6 @@ public static class AtlasesEngineExtensions
|
||||
public static void UseTextureAtlases(this EngineContext context)
|
||||
{
|
||||
var assets = context.Services.Get<AssetManager>();
|
||||
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
|
||||
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GetGraphicsDevice(), path));
|
||||
}
|
||||
}
|
||||
+62
-19
@@ -13,12 +13,25 @@ public readonly record struct PackItem(string Key, int Width, int Height);
|
||||
/// <param name="Y">Y position in page pixels.</param>
|
||||
/// <param name="Width">Item width in pixels.</param>
|
||||
/// <param name="Height">Item height in pixels.</param>
|
||||
public readonly record struct PackPlacement(string Key, int Page, int X, int Y, int Width, int Height);
|
||||
public readonly record struct PackPlacement(
|
||||
string Key,
|
||||
int Page,
|
||||
int X,
|
||||
int Y,
|
||||
int Width,
|
||||
int Height
|
||||
);
|
||||
|
||||
/// <summary>Result of a packing run: placements plus the trimmed size of every page.</summary>
|
||||
/// <param name="Placements">One placement per input item.</param>
|
||||
/// <param name="PageSizes">Width/height of each page, trimmed to the next power of two covering its content.</param>
|
||||
public sealed record PackResult(IReadOnlyList<PackPlacement> Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
|
||||
/// <param name="PageSizes">
|
||||
/// Width/height of each page: the next power of two covering its content, clamped to the
|
||||
/// page-size limit. Dedicated pages of oversized items keep their exact (padded) size.
|
||||
/// </param>
|
||||
public sealed record PackResult(
|
||||
IReadOnlyList<PackPlacement> Placements,
|
||||
IReadOnlyList<(int Width, int Height)> PageSizes
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic shelf packer: items are sorted by height (then width, then key) and laid out
|
||||
@@ -38,17 +51,19 @@ public static class ShelfPacker
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(padding);
|
||||
|
||||
var sorted = items.ToList();
|
||||
sorted.Sort(static (a, b) =>
|
||||
{
|
||||
var byHeight = b.Height.CompareTo(a.Height);
|
||||
if (byHeight != 0)
|
||||
sorted.Sort(
|
||||
static (a, b) =>
|
||||
{
|
||||
return byHeight;
|
||||
}
|
||||
var byHeight = b.Height.CompareTo(a.Height);
|
||||
if (byHeight != 0)
|
||||
{
|
||||
return byHeight;
|
||||
}
|
||||
|
||||
var byWidth = b.Width.CompareTo(a.Width);
|
||||
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
|
||||
});
|
||||
var byWidth = b.Width.CompareTo(a.Width);
|
||||
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
|
||||
}
|
||||
);
|
||||
|
||||
var placements = new List<PackPlacement>(items.Count);
|
||||
var pageSizes = new List<(int Width, int Height)>();
|
||||
@@ -65,7 +80,12 @@ public static class ShelfPacker
|
||||
{
|
||||
if (open)
|
||||
{
|
||||
pageSizes.Add((NextPowerOfTwo(usedWidth + padding), NextPowerOfTwo(usedHeight + padding)));
|
||||
pageSizes.Add(
|
||||
(
|
||||
PageDimension(usedWidth + padding, maxPageSize),
|
||||
PageDimension(usedHeight + padding, maxPageSize)
|
||||
)
|
||||
);
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
@@ -81,15 +101,31 @@ public static class ShelfPacker
|
||||
usedHeight = 0;
|
||||
}
|
||||
|
||||
// Негабаритные — первыми, на отдельные страницы точно под себя: посреди потока
|
||||
// они закрывали бы наполовину заполненную общую страницу (потеря occupancy).
|
||||
foreach (var item in sorted)
|
||||
{
|
||||
// Слишком большой для общей страницы — отдельная страница точно под него.
|
||||
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
|
||||
{
|
||||
CloseOpenPage();
|
||||
placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height));
|
||||
pageSizes.Add((NextPowerOfTwo(item.Width + 2 * padding), NextPowerOfTwo(item.Height + 2 * padding)));
|
||||
continue;
|
||||
placements.Add(
|
||||
new PackPlacement(
|
||||
item.Key,
|
||||
pageSizes.Count,
|
||||
padding,
|
||||
padding,
|
||||
item.Width,
|
||||
item.Height
|
||||
)
|
||||
);
|
||||
pageSizes.Add((item.Width + 2 * padding, item.Height + 2 * padding));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var item in sorted)
|
||||
{
|
||||
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
|
||||
{
|
||||
continue; // уже размещён на отдельной странице
|
||||
}
|
||||
|
||||
if (!open)
|
||||
@@ -108,7 +144,9 @@ public static class ShelfPacker
|
||||
}
|
||||
}
|
||||
|
||||
placements.Add(new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height));
|
||||
placements.Add(
|
||||
new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height)
|
||||
);
|
||||
x += item.Width + padding;
|
||||
shelfHeight = Math.Max(shelfHeight, item.Height);
|
||||
usedWidth = Math.Max(usedWidth, x - padding);
|
||||
@@ -119,6 +157,11 @@ public static class ShelfPacker
|
||||
return new PackResult(placements, pageSizes);
|
||||
}
|
||||
|
||||
// POT удобен GPU, но страница не должна превышать заявленный лимит,
|
||||
// когда maxPageSize сам не степень двойки.
|
||||
private static int PageDimension(int used, int maxPageSize) =>
|
||||
Math.Min(NextPowerOfTwo(used), maxPageSize);
|
||||
|
||||
internal static int NextPowerOfTwo(int value)
|
||||
{
|
||||
var result = 1;
|
||||
+34
-5
@@ -28,14 +28,19 @@ public sealed class TextureAtlas : IDisposable
|
||||
{
|
||||
Name = metadata.Name;
|
||||
Pages = pages;
|
||||
_regions = new Dictionary<string, Texture2DRegion>(metadata.Regions.Count, StringComparer.Ordinal);
|
||||
_regions = new Dictionary<string, Texture2DRegion>(
|
||||
metadata.Regions.Count,
|
||||
StringComparer.Ordinal
|
||||
);
|
||||
foreach (var region in metadata.Regions)
|
||||
{
|
||||
_regions.Add(
|
||||
region.Key,
|
||||
new Texture2DRegion(
|
||||
pages[region.Page],
|
||||
new Rectangle(region.X, region.Y, region.Width, region.Height)));
|
||||
new Rectangle(region.X, region.Y, region.Width, region.Height)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +61,36 @@ public sealed class TextureAtlas : IDisposable
|
||||
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
|
||||
{
|
||||
var metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
|
||||
if (metadata.Version != AtlasMetadata.CurrentVersion)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Atlas '{metadataPath}' has format version {metadata.Version}, expected "
|
||||
+ $"{AtlasMetadata.CurrentVersion}. Rebuild the atlases with the atlas tool."
|
||||
);
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
|
||||
var pages = new Texture2D[metadata.Pages.Count];
|
||||
for (var i = 0; i < pages.Length; i++)
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
|
||||
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
|
||||
for (var i = 0; i < pages.Length; i++)
|
||||
{
|
||||
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
|
||||
pages[i] = Texture2D.FromStream(
|
||||
graphicsDevice,
|
||||
stream,
|
||||
DefaultColorProcessors.PremultiplyAlpha
|
||||
);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var page in pages)
|
||||
{
|
||||
page?.Dispose(); // частично загруженные страницы не должны утекать
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return new TextureAtlas(metadata, pages);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace MrGameEng.Genetics;
|
||||
|
||||
/// <summary>
|
||||
/// A diploid gene slot: the two allele values an individual carries for one gene. Stored as floats
|
||||
/// for both gene kinds — a <see cref="GeneKind.Discrete"/> gene simply holds integral variant
|
||||
/// indices. How the pair becomes a single phenotype value is decided by the gene's
|
||||
/// <see cref="GeneKind"/> (see <see cref="Genome.Express"/>).
|
||||
/// </summary>
|
||||
public readonly record struct Allele(float A, float B)
|
||||
{
|
||||
/// <summary>The average of the two alleles — the phenotype of a numeric gene.</summary>
|
||||
public float Mean => (A + B) * 0.5f;
|
||||
|
||||
/// <summary>The lower (dominant) of the two alleles — the phenotype of a discrete gene.</summary>
|
||||
public float Dominant => MathF.Min(A, B);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MrGameEng.Formulas;
|
||||
using MrGameEng.Mods;
|
||||
|
||||
namespace MrGameEng.Genetics;
|
||||
|
||||
/// <summary>How a gene's two alleles are stored and expressed into a phenotype value.</summary>
|
||||
public enum GeneKind
|
||||
{
|
||||
/// <summary>A continuous value; the phenotype is the average of the two alleles (hybrid blending).</summary>
|
||||
Numeric,
|
||||
|
||||
/// <summary>
|
||||
/// A discrete allele index in <c>[0, Variants)</c>; the lower index is dominant, so the
|
||||
/// phenotype is <c>min(a, b)</c> — a higher (recessive) variant shows only when homozygous.
|
||||
/// A two-variant discrete gene is effectively a flag.
|
||||
/// </summary>
|
||||
Discrete,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An organism-agnostic gene definition — the unit the whole gene system is built from. A
|
||||
/// <see cref="GeneDef"/> describes how to generate an individual's two alleles, how they mutate
|
||||
/// when bred, and how the gene <see cref="Effects"/> contribute to named phenotype traits via
|
||||
/// <see cref="Formula"/> expressions. Nothing here is plant-, animal- or human-specific, so the
|
||||
/// same machinery drives any organism and arbitrary hybrids (a genome can carry any mix of genes).
|
||||
/// </summary>
|
||||
public sealed class GeneDef : Def
|
||||
{
|
||||
/// <summary>Whether the gene is continuous or a discrete dominant/recessive allele.</summary>
|
||||
public GeneKind Kind { get; init; } = GeneKind.Numeric;
|
||||
|
||||
/// <summary>Numeric: the central value an allele is generated around.</summary>
|
||||
public float Default { get; init; }
|
||||
|
||||
/// <summary>Numeric: lower clamp for generated and mutated allele values.</summary>
|
||||
public float Min { get; init; } = float.NegativeInfinity;
|
||||
|
||||
/// <summary>Numeric: upper clamp for generated and mutated allele values.</summary>
|
||||
public float Max { get; init; } = float.PositiveInfinity;
|
||||
|
||||
/// <summary>Numeric: relative spread of generated alleles around <see cref="Default"/> (allele = Default ± Spread·|Default|).</summary>
|
||||
public float Spread { get; init; }
|
||||
|
||||
/// <summary>Numeric: relative magnitude of a mutation step (value ± Magnitude·|value|).</summary>
|
||||
public float MutationMagnitude { get; init; } = 0.1f;
|
||||
|
||||
/// <summary>Discrete: number of allele variants, valued <c>0..Variants-1</c>.</summary>
|
||||
public int Variants { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Discrete: relative weights for generating each variant (length <see cref="Variants"/>).
|
||||
/// Empty means a uniform distribution.
|
||||
/// </summary>
|
||||
public float[] VariantWeights { get; init; } = [];
|
||||
|
||||
/// <summary>Probability, per allele, that a mutation occurs when this gene is passed to a child.</summary>
|
||||
public float MutationChance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The gene's contributions to phenotype traits: trait name → formula. Each formula may use the
|
||||
/// variable <c>value</c> (this gene's expressed phenotype), any other gene's id (its expressed
|
||||
/// value) and any environment variable the caller supplies. Contributions to the same trait
|
||||
/// across genes are summed.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Effects { get; init; } = new();
|
||||
|
||||
/// <summary>Free-form category tags for grouping genes (used by formula grouping and content tooling).</summary>
|
||||
public string[] Tags { get; init; } = [];
|
||||
|
||||
private IReadOnlyDictionary<string, Formula>? _compiled;
|
||||
|
||||
/// <summary>The <see cref="Effects"/> compiled once into evaluable formulas (lazy, cached).</summary>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyDictionary<string, Formula> CompiledEffects => _compiled ??= CompileEffects();
|
||||
|
||||
private Dictionary<string, Formula> CompileEffects()
|
||||
{
|
||||
var compiled = new Dictionary<string, Formula>(StringComparer.Ordinal);
|
||||
foreach (var (trait, expression) in Effects)
|
||||
{
|
||||
try
|
||||
{
|
||||
compiled[trait] = Formula.Compile(expression);
|
||||
}
|
||||
catch (FormulaException error)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Gene '{DefName}' effect on trait '{trait}' has an invalid formula "
|
||||
+ $"\"{expression}\": {error.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace MrGameEng.Genetics;
|
||||
|
||||
/// <summary>
|
||||
/// Shared, deterministic allele sampling used by both <see cref="Genome.Generate"/> (gene defaults)
|
||||
/// and <see cref="GenomeTemplate"/> (per-individual base overrides). Centralizes the numeric
|
||||
/// spread+clamp and the weighted discrete pick so the two paths stay consistent.
|
||||
/// </summary>
|
||||
internal static class GeneSampling
|
||||
{
|
||||
/// <summary>A numeric allele drawn as <c>baseValue ± spread·|baseValue|</c>, clamped to the gene's range.</summary>
|
||||
public static float Numeric(GeneDef gene, float baseValue, float spread, Random random)
|
||||
{
|
||||
var value = baseValue + (random.NextSingle() * 2f - 1f) * spread * MathF.Abs(baseValue);
|
||||
return Math.Clamp(value, gene.Min, gene.Max);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A discrete variant index in <c>[0, Variants)</c>, picked from <paramref name="weights"/> when
|
||||
/// they match the variant count, otherwise the gene's own weights, otherwise uniformly.
|
||||
/// </summary>
|
||||
public static float Variant(GeneDef gene, float[]? weights, Random random)
|
||||
{
|
||||
var variants = Math.Max(1, gene.Variants);
|
||||
var w =
|
||||
weights is { Length: > 0 } && weights.Length == variants
|
||||
? weights
|
||||
: gene.VariantWeights;
|
||||
if (w.Length != variants)
|
||||
{
|
||||
return random.Next(variants);
|
||||
}
|
||||
|
||||
var total = 0f;
|
||||
foreach (var value in w)
|
||||
{
|
||||
total += MathF.Max(0f, value);
|
||||
}
|
||||
|
||||
if (total <= 0f)
|
||||
{
|
||||
return random.Next(variants);
|
||||
}
|
||||
|
||||
var roll = random.NextSingle() * total;
|
||||
for (var i = 0; i < variants; i++)
|
||||
{
|
||||
roll -= MathF.Max(0f, w[i]);
|
||||
if (roll < 0f)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return variants - 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
namespace MrGameEng.Genetics;
|
||||
|
||||
/// <summary>
|
||||
/// An individual's managed genome: a variable-composition map from gene id to the
|
||||
/// <see cref="Allele"/> pair it carries. Because composition is open, two organisms need not share
|
||||
/// the same gene set and a genome can gain "foreign" genes — the basis for arbitrary hybrids. The
|
||||
/// genome is generated from a set of <see cref="GeneDef"/>s, bred meiotically with mutation, and
|
||||
/// expressed into phenotype values; all randomness flows through a caller-owned seeded
|
||||
/// <see cref="Random"/> so the simulation stays deterministic.
|
||||
/// </summary>
|
||||
public sealed class Genome
|
||||
{
|
||||
private readonly Dictionary<string, Allele> _alleles;
|
||||
|
||||
/// <summary>Creates an empty genome.</summary>
|
||||
public Genome() => _alleles = new Dictionary<string, Allele>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Creates a genome from an existing allele map (copied).</summary>
|
||||
public Genome(IReadOnlyDictionary<string, Allele> alleles) =>
|
||||
_alleles = new Dictionary<string, Allele>(alleles, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The carried genes and their allele pairs.</summary>
|
||||
public IReadOnlyDictionary<string, Allele> Alleles => _alleles;
|
||||
|
||||
/// <summary>Whether the genome carries the gene <paramref name="geneId"/>.</summary>
|
||||
public bool Has(string geneId) => _alleles.ContainsKey(geneId);
|
||||
|
||||
/// <summary>Gets or sets the allele pair for <paramref name="geneId"/>.</summary>
|
||||
public Allele this[string geneId]
|
||||
{
|
||||
get => _alleles[geneId];
|
||||
set => _alleles[geneId] = value;
|
||||
}
|
||||
|
||||
/// <summary>Removes a gene from the genome; returns whether it was present.</summary>
|
||||
public bool Remove(string geneId) => _alleles.Remove(geneId);
|
||||
|
||||
/// <summary>
|
||||
/// Expresses the gene's phenotype value: the mean of the alleles for a numeric gene, the
|
||||
/// dominant (lower) allele for a discrete one. Throws if the genome does not carry the gene.
|
||||
/// </summary>
|
||||
public float Express(GeneDef gene)
|
||||
{
|
||||
if (!_alleles.TryGetValue(gene.DefName, out var allele))
|
||||
{
|
||||
throw new KeyNotFoundException($"Genome does not carry gene '{gene.DefName}'.");
|
||||
}
|
||||
|
||||
return gene.Kind == GeneKind.Numeric ? allele.Mean : allele.Dominant;
|
||||
}
|
||||
|
||||
/// <summary>Builds the allele map for serialization (a copy).</summary>
|
||||
public Dictionary<string, Allele> ToDictionary() => new(_alleles, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a fresh genome carrying every gene in <paramref name="genes"/>, each allele drawn
|
||||
/// independently around the gene's default with its spread (numeric) or from its variant
|
||||
/// distribution (discrete).
|
||||
/// </summary>
|
||||
public static Genome Generate(IEnumerable<GeneDef> genes, Random random)
|
||||
{
|
||||
var genome = new Genome();
|
||||
foreach (var gene in genes)
|
||||
{
|
||||
genome._alleles[gene.DefName] = new Allele(
|
||||
GenerateAllele(gene, random),
|
||||
GenerateAllele(gene, random)
|
||||
);
|
||||
}
|
||||
|
||||
return genome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breeds a child genome from two parents (meiosis): the child carries every gene either parent
|
||||
/// has. For a gene both carry, one allele is drawn from each parent; for a gene only one parent
|
||||
/// carries, it is inherited (from that parent, on both sides) with 50% probability. Every
|
||||
/// inherited allele may then mutate per its <see cref="GeneDef"/>. <paramref name="registry"/>
|
||||
/// supplies the def for each gene id; genes absent from it are skipped.
|
||||
/// <paramref name="mutationChance"/>, when given, overrides every gene's
|
||||
/// <see cref="GeneDef.MutationChance"/> — letting the caller drive mutation from an evolvable
|
||||
/// trait rather than a fixed per-gene constant.
|
||||
/// </summary>
|
||||
public static Genome Breed(
|
||||
Genome a,
|
||||
Genome b,
|
||||
IReadOnlyDictionary<string, GeneDef> registry,
|
||||
Random random,
|
||||
float? mutationChance = null
|
||||
)
|
||||
{
|
||||
var child = new Genome();
|
||||
foreach (var geneId in UnionKeys(a, b))
|
||||
{
|
||||
if (!registry.TryGetValue(geneId, out var gene))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var chance = mutationChance ?? gene.MutationChance;
|
||||
var inA = a.Has(geneId);
|
||||
var inB = b.Has(geneId);
|
||||
if (inA && inB)
|
||||
{
|
||||
child._alleles[geneId] = new Allele(
|
||||
Meiosis(gene, a[geneId], chance, random),
|
||||
Meiosis(gene, b[geneId], chance, random)
|
||||
);
|
||||
}
|
||||
else if (random.NextSingle() < 0.5f)
|
||||
{
|
||||
var parent = inA ? a : b;
|
||||
child._alleles[geneId] = new Allele(
|
||||
Meiosis(gene, parent[geneId], chance, random),
|
||||
Meiosis(gene, parent[geneId], chance, random)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
// One inherited allele: pick one of the parent slot's two alleles, then maybe mutate it.
|
||||
private static float Meiosis(GeneDef gene, Allele parent, float mutationChance, Random random)
|
||||
{
|
||||
var inherited = random.NextSingle() < 0.5f ? parent.A : parent.B;
|
||||
if (random.NextSingle() >= mutationChance)
|
||||
{
|
||||
return inherited;
|
||||
}
|
||||
|
||||
if (gene.Kind == GeneKind.Discrete)
|
||||
{
|
||||
return GeneSampling.Variant(gene, null, random);
|
||||
}
|
||||
|
||||
var shifted =
|
||||
inherited
|
||||
+ (random.NextSingle() * 2f - 1f) * gene.MutationMagnitude * MathF.Abs(inherited);
|
||||
return Math.Clamp(shifted, gene.Min, gene.Max);
|
||||
}
|
||||
|
||||
private static float GenerateAllele(GeneDef gene, Random random) =>
|
||||
gene.Kind == GeneKind.Discrete
|
||||
? GeneSampling.Variant(gene, null, random)
|
||||
: GeneSampling.Numeric(gene, gene.Default, gene.Spread, random);
|
||||
|
||||
// Deterministic union of both parents' gene ids (ordered) so breeding is reproducible.
|
||||
private static IEnumerable<string> UnionKeys(Genome a, Genome b)
|
||||
{
|
||||
var keys = new SortedSet<string>(StringComparer.Ordinal);
|
||||
keys.UnionWith(a._alleles.Keys);
|
||||
keys.UnionWith(b._alleles.Keys);
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace MrGameEng.Genetics;
|
||||
|
||||
/// <summary>
|
||||
/// A species' (or any organism kind's) gene allotment: which <see cref="GeneDef"/>s an individual
|
||||
/// carries and the per-organism base values its alleles are generated around. The same
|
||||
/// <see cref="GeneDef"/> (e.g. "optimal light") is shared by every species, while the template
|
||||
/// supplies the species-specific centre and spread — so an oak and grass differ in values, not in
|
||||
/// machinery. <see cref="Generate"/> draws a fresh individual; <see cref="Registry"/> feeds breeding
|
||||
/// and trait computation.
|
||||
/// </summary>
|
||||
public sealed class GenomeTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// One gene in the allotment. <paramref name="Base"/>/<paramref name="Spread"/> centre a numeric
|
||||
/// gene's alleles; <paramref name="VariantWeights"/> (optional) override a discrete gene's
|
||||
/// variant distribution for this organism.
|
||||
/// </summary>
|
||||
public readonly record struct Entry(
|
||||
GeneDef Gene,
|
||||
float Base,
|
||||
float Spread,
|
||||
float[]? VariantWeights = null
|
||||
);
|
||||
|
||||
private readonly List<Entry> _entries;
|
||||
private readonly Dictionary<string, GeneDef> _registry;
|
||||
|
||||
/// <summary>Builds a template from its gene entries.</summary>
|
||||
public GenomeTemplate(IEnumerable<Entry> entries)
|
||||
{
|
||||
_entries = entries.ToList();
|
||||
_registry = new Dictionary<string, GeneDef>(StringComparer.Ordinal);
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
_registry[entry.Gene.DefName] = entry.Gene;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The gene entries that make up the allotment.</summary>
|
||||
public IReadOnlyList<Entry> Entries => _entries;
|
||||
|
||||
/// <summary>Gene id → def for every carried gene; pass to <see cref="Genome.Breed"/> and <see cref="Phenotype.Compute"/>.</summary>
|
||||
public IReadOnlyDictionary<string, GeneDef> Registry => _registry;
|
||||
|
||||
/// <summary>Generates a fresh individual: two alleles per gene drawn around each entry's base/variant.</summary>
|
||||
public Genome Generate(Random random)
|
||||
{
|
||||
var genome = new Genome();
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
genome[entry.Gene.DefName] = new Allele(Draw(entry, random), Draw(entry, random));
|
||||
}
|
||||
|
||||
return genome;
|
||||
}
|
||||
|
||||
private static float Draw(Entry entry, Random random) =>
|
||||
entry.Gene.Kind == GeneKind.Discrete
|
||||
? GeneSampling.Variant(entry.Gene, entry.VariantWeights, random)
|
||||
: GeneSampling.Numeric(entry.Gene, entry.Base, entry.Spread, random);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using MrGameEng.Formulas;
|
||||
|
||||
namespace MrGameEng.Genetics;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the trait layer — the phenotype the simulation actually reads — from a
|
||||
/// <see cref="Genome"/>. Each gene's <see cref="GeneDef.Effects"/> formulas are evaluated and their
|
||||
/// results summed per trait name, so systems never touch genes directly: one fruiting system reads
|
||||
/// a <c>fruitYield</c> trait whether it comes from a tree or a human carrying a "fruit" gene.
|
||||
/// Formulas see the variable <c>value</c> (the contributing gene's expressed phenotype), any other
|
||||
/// carried gene's id, and whatever environment variables the caller supplies.
|
||||
/// </summary>
|
||||
public static class Phenotype
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates every carried gene's effects against the genome and an optional
|
||||
/// <paramref name="environment"/>, summing contributions into a trait map. Genes missing from
|
||||
/// <paramref name="registry"/> are skipped.
|
||||
/// </summary>
|
||||
public static Dictionary<string, float> Compute(
|
||||
Genome genome,
|
||||
IReadOnlyDictionary<string, GeneDef> registry,
|
||||
IFormulaContext? environment = null
|
||||
)
|
||||
{
|
||||
var traits = new Dictionary<string, float>(StringComparer.Ordinal);
|
||||
var context = new GenomeContext(genome, registry, environment);
|
||||
foreach (var geneId in genome.Alleles.Keys)
|
||||
{
|
||||
if (!registry.TryGetValue(geneId, out var gene) || gene.CompiledEffects.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
context.Self = genome.Express(gene);
|
||||
foreach (var (trait, formula) in gene.CompiledEffects)
|
||||
{
|
||||
traits[trait] = traits.GetValueOrDefault(trait) + formula.Evaluate(context);
|
||||
}
|
||||
}
|
||||
|
||||
return traits;
|
||||
}
|
||||
|
||||
// Resolves formula variables for a gene effect: 'value' is the current gene's phenotype, any
|
||||
// carried gene's id resolves to its phenotype, anything else falls through to the environment.
|
||||
private sealed class GenomeContext(
|
||||
Genome genome,
|
||||
IReadOnlyDictionary<string, GeneDef> registry,
|
||||
IFormulaContext? environment
|
||||
) : IFormulaContext
|
||||
{
|
||||
public float Self;
|
||||
|
||||
public float Resolve(string name)
|
||||
{
|
||||
if (name == "value")
|
||||
{
|
||||
return Self;
|
||||
}
|
||||
|
||||
if (genome.Has(name) && registry.TryGetValue(name, out var gene))
|
||||
{
|
||||
return genome.Express(gene);
|
||||
}
|
||||
|
||||
if (environment is not null)
|
||||
{
|
||||
return environment.Resolve(name);
|
||||
}
|
||||
|
||||
throw new FormulaException($"Unknown variable '{name}' while computing traits.");
|
||||
}
|
||||
|
||||
// Группировка генов в формулах: значения всех генов, чьи id подходят под шаблон (gsum/gavg/…).
|
||||
public IEnumerable<float> ResolveMatching(Func<string, bool> matches)
|
||||
{
|
||||
foreach (var geneId in genome.Alleles.Keys)
|
||||
{
|
||||
if (matches(geneId) && registry.TryGetValue(geneId, out var gene))
|
||||
{
|
||||
yield return genome.Express(gene);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
/// <summary>
|
||||
/// Base class of all data definitions loaded by <see cref="DefDatabase"/> from mod JSON.
|
||||
/// Games subclass this per content kind (terrain, things, pawns, …) with plain
|
||||
/// serializable properties.
|
||||
/// </summary>
|
||||
public abstract class Def
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique name within the def type. A later mod redefining the same name fully
|
||||
/// replaces the earlier def.
|
||||
/// </summary>
|
||||
public string DefName { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Name of the def (same type) whose fields this def starts from; own fields override
|
||||
/// the inherited ones. Abstractness is not inherited.
|
||||
/// </summary>
|
||||
public string? Parent { get; init; }
|
||||
|
||||
/// <summary>Abstract defs only serve as parents and are not emitted into the database.</summary>
|
||||
public bool Abstract { get; init; }
|
||||
|
||||
/// <summary>Display label — plain text or a localization key, as the game decides.</summary>
|
||||
public string Label { get; init; } = "";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"{GetType().Name} {DefName}";
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
/// <summary>
|
||||
/// Database of data definitions loaded from mod JSON. Each file under a mod's
|
||||
/// <c>Defs/</c> folder is an envelope <c>{ "type": "<key>", "defs": [ … ] }</c>; the
|
||||
/// game registers the CLR type for every key before <see cref="Load"/>. Defs from later
|
||||
/// mods replace same-named defs of earlier mods; <c>parent</c> chains are merged
|
||||
/// field-by-field (own fields win, nested objects are replaced whole); defs marked
|
||||
/// <c>abstract</c> serve only as parents.
|
||||
/// </summary>
|
||||
public sealed class DefDatabase
|
||||
{
|
||||
private sealed class TypeEntry
|
||||
{
|
||||
public required string Key;
|
||||
public required Type ClrType;
|
||||
public readonly Dictionary<string, JsonObject> Raw = new(StringComparer.Ordinal);
|
||||
public readonly SortedDictionary<string, Def> Resolved = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Type, TypeEntry> _byType = [];
|
||||
|
||||
/// <summary>Reserved def-file <c>"type"</c> that carries content patches rather than defs.</summary>
|
||||
public const string PatchTypeKey = "Patch";
|
||||
|
||||
private sealed record PatchRule(string DefType, Regex Match, JsonObject Set);
|
||||
|
||||
private sealed record ValidationRule(string Field, Regex Pattern, string Description);
|
||||
|
||||
private readonly List<PatchRule> _patches = [];
|
||||
private readonly Dictionary<string, List<ValidationRule>> _validators = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
private static readonly JsonDocumentOptions DocumentOptions = new()
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Registers the CLR type behind a def-type key (the <c>"type"</c> field of def files).</summary>
|
||||
public void RegisterType<T>(string typeKey)
|
||||
where T : Def
|
||||
{
|
||||
var entry = new TypeEntry { Key = typeKey, ClrType = typeof(T) };
|
||||
if (!_byKey.TryAdd(typeKey, entry))
|
||||
{
|
||||
throw new InvalidOperationException($"Def type '{typeKey}' is already registered.");
|
||||
}
|
||||
|
||||
_byType.Add(typeof(T), entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a load-time validation: the string <paramref name="field"/> of every resolved def of
|
||||
/// type <paramref name="typeKey"/> must match <paramref name="pattern"/> (a regex), or
|
||||
/// <see cref="Load"/> throws. Non-string or absent fields are skipped. Use it to enforce naming
|
||||
/// conventions (e.g. gene ids start with <c>Gene</c>) or key/format rules across a mod's content.
|
||||
/// </summary>
|
||||
public void RegisterValidator(
|
||||
string typeKey,
|
||||
string field,
|
||||
string pattern,
|
||||
string? description = null
|
||||
)
|
||||
{
|
||||
Regex regex;
|
||||
try
|
||||
{
|
||||
regex = new Regex(pattern, RegexOptions.CultureInvariant);
|
||||
}
|
||||
catch (ArgumentException error)
|
||||
{
|
||||
throw new ArgumentException($"Invalid validator regex '{pattern}': {error.Message}");
|
||||
}
|
||||
|
||||
if (!_validators.TryGetValue(typeKey, out var list))
|
||||
{
|
||||
list = [];
|
||||
_validators[typeKey] = list;
|
||||
}
|
||||
|
||||
list.Add(new ValidationRule(field, regex, description ?? $"pattern /{pattern}/"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
|
||||
/// resolves inheritance. Call once after registering all def types.
|
||||
/// </summary>
|
||||
public void Load(IReadOnlyList<Mod> mods)
|
||||
{
|
||||
foreach (var mod in mods)
|
||||
{
|
||||
var defsDir = mod.ContentPath("Defs");
|
||||
if (!Directory.Exists(defsDir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var files = Directory
|
||||
.EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories)
|
||||
.OrderBy(f => f, StringComparer.Ordinal);
|
||||
foreach (var file in files)
|
||||
{
|
||||
LoadFile(mod, file);
|
||||
}
|
||||
}
|
||||
|
||||
ApplyPatches();
|
||||
|
||||
foreach (var entry in _byKey.Values)
|
||||
{
|
||||
Resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>; throws when missing.</summary>
|
||||
public T Get<T>(string defName)
|
||||
where T : Def =>
|
||||
TryGet<T>(defName, out var def)
|
||||
? def
|
||||
: throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'.");
|
||||
|
||||
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>, or false.</summary>
|
||||
public bool TryGet<T>(string defName, out T def)
|
||||
where T : Def
|
||||
{
|
||||
if (Entry<T>().Resolved.TryGetValue(defName, out var found))
|
||||
{
|
||||
def = (T)found;
|
||||
return true;
|
||||
}
|
||||
|
||||
def = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>All resolved defs of type <typeparamref name="T"/>, sorted by def name (deterministic).</summary>
|
||||
public IReadOnlyList<T> All<T>()
|
||||
where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
|
||||
|
||||
/// <summary>Registered def-type keys, sorted.</summary>
|
||||
public IReadOnlyList<string> TypeKeys =>
|
||||
_byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
|
||||
|
||||
/// <summary>Resolved def names of the given type key, sorted; empty for unknown keys.</summary>
|
||||
public IReadOnlyList<string> NamesOf(string typeKey) =>
|
||||
_byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : [];
|
||||
|
||||
private TypeEntry Entry<T>()
|
||||
where T : Def =>
|
||||
_byType.TryGetValue(typeof(T), out var entry)
|
||||
? entry
|
||||
: throw new InvalidOperationException($"Def type {typeof(T).Name} is not registered.");
|
||||
|
||||
private void LoadFile(Mod mod, string file)
|
||||
{
|
||||
JsonNode root;
|
||||
try
|
||||
{
|
||||
root =
|
||||
JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions)
|
||||
?? throw new InvalidDataException("file is empty");
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}",
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
var typeKey =
|
||||
root["type"]?.GetValue<string>()
|
||||
?? throw new InvalidDataException(
|
||||
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."
|
||||
);
|
||||
if (string.Equals(typeKey, PatchTypeKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LoadPatches(mod, file, root);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_byKey.TryGetValue(typeKey, out var entry))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; "
|
||||
+ $"registered: {string.Join(", ", TypeKeys)}."
|
||||
);
|
||||
}
|
||||
|
||||
if (root["defs"] is not JsonArray defs)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array."
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var node in defs)
|
||||
{
|
||||
if (node is not JsonObject def)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry."
|
||||
);
|
||||
}
|
||||
|
||||
var defName = def["defName"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(defName))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"A def in '{file}' (mod '{mod.Id}') has no \"defName\"."
|
||||
);
|
||||
}
|
||||
|
||||
entry.Raw[defName] = def; // поздний мод/файл полностью заменяет одноимённый деф
|
||||
}
|
||||
}
|
||||
|
||||
// Парсит файл-патч: операции { defType, match (регэксп по defName), set: {поля} }.
|
||||
private void LoadPatches(Mod mod, string file, JsonNode root)
|
||||
{
|
||||
if (root["patches"] is not JsonArray patches)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Patch file '{file}' (mod '{mod.Id}') has no \"patches\" array."
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var node in patches)
|
||||
{
|
||||
if (node is not JsonObject patch)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Patch file '{file}' (mod '{mod.Id}') contains a non-object patch."
|
||||
);
|
||||
}
|
||||
|
||||
var defType =
|
||||
patch["defType"]?.GetValue<string>()
|
||||
?? throw new InvalidDataException($"A patch in '{file}' has no \"defType\".");
|
||||
var match =
|
||||
patch["match"]?.GetValue<string>()
|
||||
?? throw new InvalidDataException($"A patch in '{file}' has no \"match\".");
|
||||
if (patch["set"] is not JsonObject set)
|
||||
{
|
||||
throw new InvalidDataException($"A patch in '{file}' has no \"set\" object.");
|
||||
}
|
||||
|
||||
Regex regex;
|
||||
try
|
||||
{
|
||||
regex = new Regex(match, RegexOptions.CultureInvariant);
|
||||
}
|
||||
catch (ArgumentException error)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Patch in '{file}' has invalid regex '{match}': {error.Message}"
|
||||
);
|
||||
}
|
||||
|
||||
_patches.Add(new PatchRule(defType, regex, (JsonObject)set.DeepClone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Применяет патчи к сырым дефам (до резолва), в порядке загрузки: каждому дефу нужного типа,
|
||||
// чьё имя подходит под регэксп, проставляются поля set. Поля наследуются детьми как обычно.
|
||||
private void ApplyPatches()
|
||||
{
|
||||
foreach (var patch in _patches)
|
||||
{
|
||||
if (!_byKey.TryGetValue(patch.DefType, out var entry))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"A patch targets unknown def type '{patch.DefType}'."
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var raw in entry.Raw)
|
||||
{
|
||||
if (!patch.Match.IsMatch(raw.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in patch.Set)
|
||||
{
|
||||
raw.Value[key] = value?.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Resolve(TypeEntry entry)
|
||||
{
|
||||
_validators.TryGetValue(entry.Key, out var rules);
|
||||
foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
|
||||
{
|
||||
var merged = MergeChain(entry, defName, []);
|
||||
if (merged["abstract"]?.GetValue<bool>() == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rules is not null)
|
||||
{
|
||||
Validate(entry.Key, defName, merged, rules);
|
||||
}
|
||||
|
||||
var def =
|
||||
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
|
||||
?? throw new InvalidDataException(
|
||||
$"Def '{defName}' ({entry.Key}) deserialized to null."
|
||||
);
|
||||
entry.Resolved[defName] = def;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
string typeKey,
|
||||
string defName,
|
||||
JsonObject merged,
|
||||
List<ValidationRule> rules
|
||||
)
|
||||
{
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (
|
||||
merged[rule.Field] is JsonValue value
|
||||
&& value.TryGetValue<string>(out var text)
|
||||
&& !rule.Pattern.IsMatch(text)
|
||||
)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Def '{defName}' ({typeKey}) field '{rule.Field}'=\"{text}\" violates {rule.Description}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
|
||||
{
|
||||
if (!seen.Add(defName))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Cyclic def inheritance involving '{defName}' ({entry.Key})."
|
||||
);
|
||||
}
|
||||
|
||||
if (!entry.Raw.TryGetValue(defName, out var node))
|
||||
{
|
||||
throw new InvalidDataException($"Unknown parent def '{defName}' ({entry.Key}).");
|
||||
}
|
||||
|
||||
var parentName = node["parent"]?.GetValue<string>();
|
||||
if (parentName is null)
|
||||
{
|
||||
return (JsonObject)node.DeepClone();
|
||||
}
|
||||
|
||||
var merged = MergeChain(entry, parentName, seen);
|
||||
merged.Remove("abstract"); // абстрактность не наследуется
|
||||
merged.Remove("defName");
|
||||
foreach (var (key, value) in node)
|
||||
{
|
||||
merged[key] = value?.DeepClone();
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
/// <summary>
|
||||
/// Keyed localization strings loaded from mods: <c>Languages/<code>/**/*.json</c>,
|
||||
/// each file a flat string-to-string map. Later mods override earlier ones key by key.
|
||||
/// Lookup falls back from the current language to the default one; a missing key returns
|
||||
/// the key itself, so untranslated strings are visible instead of crashing.
|
||||
/// </summary>
|
||||
public sealed class LanguageManager
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _languages = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
/// <summary>Creates a manager whose fallback language is <paramref name="defaultLanguage"/>.</summary>
|
||||
public LanguageManager(string defaultLanguage = "en")
|
||||
{
|
||||
DefaultLanguage = defaultLanguage;
|
||||
CurrentLanguage = defaultLanguage;
|
||||
}
|
||||
|
||||
/// <summary>Fallback language code.</summary>
|
||||
public string DefaultLanguage { get; }
|
||||
|
||||
/// <summary>Active language code. Change with <see cref="SetLanguage"/>.</summary>
|
||||
public string CurrentLanguage { get; private set; }
|
||||
|
||||
/// <summary>Increments whenever loaded strings or the active language change — UI rebuilds on it.</summary>
|
||||
public int Revision { get; private set; }
|
||||
|
||||
/// <summary>Language codes that have at least one loaded string, sorted.</summary>
|
||||
public IReadOnlyList<string> AvailableLanguages =>
|
||||
_languages.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
/// <summary>Loads (merges in) language files of <paramref name="mods"/> in load order.</summary>
|
||||
public void Load(IReadOnlyList<Mod> mods)
|
||||
{
|
||||
foreach (var mod in mods)
|
||||
{
|
||||
var languagesDir = mod.ContentPath("Languages");
|
||||
if (!Directory.Exists(languagesDir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (
|
||||
var languageDir in Directory
|
||||
.EnumerateDirectories(languagesDir)
|
||||
.OrderBy(d => d, StringComparer.Ordinal)
|
||||
)
|
||||
{
|
||||
var code = Path.GetFileName(languageDir);
|
||||
if (!_languages.TryGetValue(code, out var strings))
|
||||
{
|
||||
strings = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
_languages.Add(code, strings);
|
||||
}
|
||||
|
||||
var files = Directory
|
||||
.EnumerateFiles(languageDir, "*.json", SearchOption.AllDirectories)
|
||||
.OrderBy(f => f, StringComparer.Ordinal);
|
||||
foreach (var file in files)
|
||||
{
|
||||
LoadFile(mod, file, strings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Revision++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches the active language. Returns false (and keeps the current one) when no
|
||||
/// strings are loaded for <paramref name="code"/>.
|
||||
/// </summary>
|
||||
public bool SetLanguage(string code)
|
||||
{
|
||||
if (!_languages.ContainsKey(code))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CurrentLanguage = code;
|
||||
Revision++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Returns the string for <paramref name="key"/>: current language → default language → the key itself.</summary>
|
||||
public string Get(string key)
|
||||
{
|
||||
if (
|
||||
_languages.TryGetValue(CurrentLanguage, out var current)
|
||||
&& current.TryGetValue(key, out var value)
|
||||
)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
_languages.TryGetValue(DefaultLanguage, out var fallback)
|
||||
&& fallback.TryGetValue(key, out value)
|
||||
)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>Formats the string for <paramref name="key"/> with <paramref name="args"/> (invariant culture).</summary>
|
||||
public string Format(string key, params object[] args) =>
|
||||
string.Format(CultureInfo.InvariantCulture, Get(key), args);
|
||||
|
||||
private static void LoadFile(Mod mod, string file, Dictionary<string, string> strings)
|
||||
{
|
||||
JsonNode root;
|
||||
try
|
||||
{
|
||||
root =
|
||||
JsonNode.Parse(
|
||||
File.ReadAllText(file),
|
||||
documentOptions: new JsonDocumentOptions
|
||||
{
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
}
|
||||
) ?? throw new InvalidDataException("file is empty");
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Invalid language file '{file}' (mod '{mod.Id}'): {exception.Message}",
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
if (root is not JsonObject map)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Language file '{file}' (mod '{mod.Id}') must be a flat JSON object."
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var (key, value) in map)
|
||||
{
|
||||
if (
|
||||
value is not JsonValue jsonValue
|
||||
|| jsonValue.GetValueKind() != JsonValueKind.String
|
||||
)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Language file '{file}' (mod '{mod.Id}'): key '{key}' must map to a string."
|
||||
);
|
||||
}
|
||||
|
||||
strings[key] = jsonValue.GetValue<string>(); // поздний мод переопределяет ключ
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
/// <summary>One discovered mod: its metadata and content directories on disk.</summary>
|
||||
public sealed class Mod
|
||||
{
|
||||
internal Mod(ModInfo info, string rootPath)
|
||||
{
|
||||
Info = info;
|
||||
RootPath = rootPath;
|
||||
}
|
||||
|
||||
/// <summary>Metadata from <c>About/About.json</c>.</summary>
|
||||
public ModInfo Info { get; }
|
||||
|
||||
/// <summary>Absolute path of the mod's root directory.</summary>
|
||||
public string RootPath { get; }
|
||||
|
||||
/// <summary>Unique mod id (shortcut for <c>Info.Id</c>).</summary>
|
||||
public string Id => Info.Id;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path of a content folder inside the mod (e.g. <c>Defs</c>, <c>Textures</c>,
|
||||
/// <c>Languages</c>). The folder is not required to exist.
|
||||
/// </summary>
|
||||
public string ContentPath(string folder) => Path.Combine(RootPath, folder);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"{Id} {Info.Version}".TrimEnd();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
/// <summary>A file contributed by a mod, addressed by its content-relative path.</summary>
|
||||
/// <param name="RelativePath">Path relative to the content folder, forward slashes.</param>
|
||||
/// <param name="FullPath">Absolute path of the winning file on disk.</param>
|
||||
/// <param name="Mod">The mod that contributed the file.</param>
|
||||
public readonly record struct ModFile(string RelativePath, string FullPath, Mod Mod);
|
||||
|
||||
/// <summary>
|
||||
/// Merged view of one content folder (e.g. <c>Textures</c>) across the active mods in load
|
||||
/// order: a later mod shipping a file under the same relative path overrides the earlier
|
||||
/// one. Paths use forward slashes and match case-insensitively (Windows-friendly content).
|
||||
/// </summary>
|
||||
public sealed class ModContentTree
|
||||
{
|
||||
private readonly Dictionary<string, ModFile> _files;
|
||||
|
||||
private ModContentTree(Dictionary<string, ModFile> files, IReadOnlyList<ModFile> ordered)
|
||||
{
|
||||
_files = files;
|
||||
Files = ordered;
|
||||
}
|
||||
|
||||
/// <summary>Winning files, sorted by relative path — deterministic for identical mod sets.</summary>
|
||||
public IReadOnlyList<ModFile> Files { get; }
|
||||
|
||||
/// <summary>Returns the winning file for <paramref name="relativePath"/>.</summary>
|
||||
public bool TryGet(string relativePath, out ModFile file) =>
|
||||
_files.TryGetValue(Normalize(relativePath), out file);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the merged tree of <paramref name="contentFolder"/> over <paramref name="mods"/>
|
||||
/// (in load order). With <paramref name="extensions"/> only matching files are included
|
||||
/// (e.g. <c>".png"</c>); without them, every file.
|
||||
/// </summary>
|
||||
public static ModContentTree Build(
|
||||
IReadOnlyList<Mod> mods,
|
||||
string contentFolder,
|
||||
params string[] extensions
|
||||
)
|
||||
{
|
||||
var files = new Dictionary<string, ModFile>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var mod in mods)
|
||||
{
|
||||
var root = mod.ContentPath(contentFolder);
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (
|
||||
var fullPath in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
|
||||
)
|
||||
{
|
||||
if (
|
||||
extensions.Length > 0
|
||||
&& !extensions.Contains(
|
||||
Path.GetExtension(fullPath),
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relative = Normalize(Path.GetRelativePath(root, fullPath));
|
||||
files[relative] = new ModFile(relative, fullPath, mod); // поздний мод побеждает
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = files.Values.OrderBy(f => f.RelativePath, StringComparer.Ordinal).ToList();
|
||||
return new ModContentTree(files, ordered);
|
||||
}
|
||||
|
||||
private static string Normalize(string path) => path.Replace('\\', '/');
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
/// <summary>
|
||||
/// Mod metadata loaded from <c>About/About.json</c> in the mod's root directory.
|
||||
/// </summary>
|
||||
public sealed class ModInfo
|
||||
{
|
||||
/// <summary>JSON options shared by all mod content readers (camelCase, comments and trailing commas allowed).</summary>
|
||||
internal static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>Human-readable mod name.</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Mod author.</summary>
|
||||
public string Author { get; init; } = "";
|
||||
|
||||
/// <summary>Mod version string (informational).</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Short description shown in mod lists.</summary>
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>Ids of mods that must be active and load before this one.</summary>
|
||||
public List<string> Dependencies { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers mods — subdirectories of a mods root containing <c>About/About.json</c> —
|
||||
/// and resolves a deterministic load order: dependencies first, ties broken
|
||||
/// alphabetically by id. Later mods override earlier ones in every content system
|
||||
/// (defs, textures, languages).
|
||||
/// </summary>
|
||||
public static class ModLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Walks up from <paramref name="startDirectory"/> looking for a <c>Mods</c> folder.
|
||||
/// Lets dev builds run from <c>bin/…</c> while shipped builds keep <c>Mods</c> next to
|
||||
/// the executable. Returns null when no such folder exists on the path to the root.
|
||||
/// </summary>
|
||||
public static string? FindModsRoot(string startDirectory)
|
||||
{
|
||||
for (
|
||||
var dir = new DirectoryInfo(Path.GetFullPath(startDirectory));
|
||||
dir is not null;
|
||||
dir = dir.Parent
|
||||
)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "Mods");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads mods under <paramref name="modsRoot"/> in dependency order. With
|
||||
/// <paramref name="activeIds"/> only that subset is loaded (its dependencies must be
|
||||
/// included); without it every discovered mod is active.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<Mod> Load(string modsRoot, IEnumerable<string>? activeIds = null)
|
||||
{
|
||||
if (!Directory.Exists(modsRoot))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Mods root not found: '{modsRoot}'.");
|
||||
}
|
||||
|
||||
var discovered = Discover(modsRoot);
|
||||
|
||||
List<Mod> active;
|
||||
if (activeIds is null)
|
||||
{
|
||||
active = discovered.Values.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
active = [];
|
||||
foreach (var id in activeIds)
|
||||
{
|
||||
if (!discovered.TryGetValue(id, out var mod))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Active mod '{id}' is not installed under '{modsRoot}'."
|
||||
);
|
||||
}
|
||||
|
||||
active.Add(mod);
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = SortByDependencies(active);
|
||||
Log.Info($"Mods loaded: {string.Join(", ", ordered)}");
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, Mod> Discover(string modsRoot)
|
||||
{
|
||||
var discovered = new SortedDictionary<string, Mod>(StringComparer.Ordinal);
|
||||
foreach (var dir in Directory.EnumerateDirectories(modsRoot))
|
||||
{
|
||||
var aboutPath = Path.Combine(dir, "About", "About.json");
|
||||
if (!File.Exists(aboutPath))
|
||||
{
|
||||
continue; // не мод — служебная папка
|
||||
}
|
||||
|
||||
ModInfo info;
|
||||
try
|
||||
{
|
||||
info =
|
||||
JsonSerializer.Deserialize<ModInfo>(
|
||||
File.ReadAllText(aboutPath),
|
||||
ModInfo.JsonOptions
|
||||
) ?? throw new InvalidDataException("About.json deserialized to null.");
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Invalid mod metadata '{aboutPath}': {exception.Message}",
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(info.Id))
|
||||
{
|
||||
throw new InvalidDataException($"Mod at '{dir}' has an empty id in About.json.");
|
||||
}
|
||||
|
||||
if (discovered.TryGetValue(info.Id, out var existing))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Duplicate mod id '{info.Id}': '{existing.RootPath}' and '{dir}'."
|
||||
);
|
||||
}
|
||||
|
||||
discovered.Add(info.Id, new Mod(info, dir));
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
private static List<Mod> SortByDependencies(List<Mod> active)
|
||||
{
|
||||
var byId = active.ToDictionary(m => m.Id, StringComparer.Ordinal);
|
||||
var ordered = new List<Mod>(active.Count);
|
||||
var state = new Dictionary<string, bool>(StringComparer.Ordinal); // false = в обработке, true = готов
|
||||
|
||||
void Visit(Mod mod)
|
||||
{
|
||||
if (state.TryGetValue(mod.Id, out var done))
|
||||
{
|
||||
if (!done)
|
||||
{
|
||||
throw new InvalidDataException($"Cyclic mod dependency involving '{mod.Id}'.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
state[mod.Id] = false;
|
||||
foreach (var dependency in mod.Info.Dependencies)
|
||||
{
|
||||
if (!byId.TryGetValue(dependency, out var parent))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Mod '{mod.Id}' requires '{dependency}', which is not installed or not active."
|
||||
);
|
||||
}
|
||||
|
||||
Visit(parent);
|
||||
}
|
||||
|
||||
state[mod.Id] = true;
|
||||
ordered.Add(mod);
|
||||
}
|
||||
|
||||
// Обход в алфавитном порядке id — итоговый порядок детерминирован.
|
||||
foreach (var mod in active.OrderBy(m => m.Id, StringComparer.Ordinal))
|
||||
{
|
||||
Visit(mod);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -1,14 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Atlases.Tests" />
|
||||
<InternalsVisibleTo Include="MrGameEng.Content.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FontStashSharp.MonoGame" />
|
||||
<PackageReference Include="StbImageSharp" />
|
||||
<PackageReference Include="StbImageWriteSharp" />
|
||||
</ItemGroup>
|
||||
@@ -16,7 +16,5 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
|
||||
<ProjectReference Include="..\MrGameEng.Assets\MrGameEng.Assets.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace MrGameEng.Core;
|
||||
|
||||
/// <summary>
|
||||
/// In-game calendar layered over <see cref="GameClock"/>: turns scaled elapsed time into whole
|
||||
/// days and a fraction-of-day (time of day). One in-game day spans <see cref="SecondsPerDay"/>
|
||||
/// seconds of scaled clock time, so pausing or changing <see cref="GameClock.TimeScale"/> slows
|
||||
/// or stops the calendar automatically. Pure read-side, GPU-free and deterministic; registered as
|
||||
/// a service via <see cref="CalendarEngineExtensions.UseCalendar"/>.
|
||||
/// </summary>
|
||||
public sealed class Calendar
|
||||
{
|
||||
private readonly GameClock _clock;
|
||||
private readonly double _startDay;
|
||||
private float _secondsPerDay;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calendar reading <paramref name="clock"/>; one day spans
|
||||
/// <paramref name="secondsPerDay"/> seconds of scaled time (must be positive).
|
||||
/// <paramref name="startDay"/> offsets the calendar by (fractional) days at clock 0 — e.g.
|
||||
/// <c>7.0 / 24</c> starts the world at 07:00 instead of midnight. It shifts the time of day and
|
||||
/// the day/night phase that reads <see cref="DayProgress"/>, without touching the clock itself.
|
||||
/// </summary>
|
||||
public Calendar(GameClock clock, float secondsPerDay, double startDay = 0.0)
|
||||
{
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
SecondsPerDay = secondsPerDay;
|
||||
_startDay = startDay;
|
||||
}
|
||||
|
||||
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary>
|
||||
public float SecondsPerDay
|
||||
{
|
||||
get => _secondsPerDay;
|
||||
set =>
|
||||
_secondsPerDay =
|
||||
value > 0f
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(
|
||||
nameof(value),
|
||||
"Seconds per day must be positive."
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4), incl. the start offset.</summary>
|
||||
public double TotalDays => _clock.TotalTime / _secondsPerDay + _startDay;
|
||||
|
||||
/// <summary>The current day number, counting from 1.</summary>
|
||||
public int Day => (int)TotalDays + 1;
|
||||
|
||||
/// <summary>Progress through the current day in <c>[0, 1)</c> — 0 at dawn, ~0.5 at midday.</summary>
|
||||
public float DayProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
var days = TotalDays;
|
||||
return (float)(days - Math.Floor(days));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minutes elapsed in the current day, 0–1439 (a day is 24×60 in-game minutes).</summary>
|
||||
public int MinuteOfDay => (int)(DayProgress * 1440f) % 1440;
|
||||
|
||||
/// <summary>Hour of the current day, 0–23.</summary>
|
||||
public int Hour => MinuteOfDay / 60;
|
||||
|
||||
/// <summary>Minute of the current hour, 0–59.</summary>
|
||||
public int Minute => MinuteOfDay % 60;
|
||||
}
|
||||
|
||||
/// <summary>Wires the in-game calendar into the engine.</summary>
|
||||
public static class CalendarEngineExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Calendar"/> bound to the context's clock and registers it as a
|
||||
/// service. Call once per world. <paramref name="secondsPerDay"/> is the scaled-time length
|
||||
/// of one in-game day (must be positive); <paramref name="startDay"/> offsets the starting
|
||||
/// time of day in fractional days (e.g. <c>7.0 / 24</c> begins the world at 07:00).
|
||||
/// </summary>
|
||||
public static Calendar UseCalendar(
|
||||
this EngineContext context,
|
||||
float secondsPerDay,
|
||||
double startDay = 0.0
|
||||
)
|
||||
{
|
||||
var calendar = new Calendar(context.Clock, secondsPerDay, startDay);
|
||||
context.Services.Add(calendar);
|
||||
return calendar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
namespace MrGameEng.Core;
|
||||
|
||||
/// <summary>The four seasons, in calendar order from the start of the year.</summary>
|
||||
public enum Season
|
||||
{
|
||||
/// <summary>First quarter of the year — warming.</summary>
|
||||
Spring,
|
||||
|
||||
/// <summary>Second quarter — warmest.</summary>
|
||||
Summer,
|
||||
|
||||
/// <summary>Third quarter — cooling.</summary>
|
||||
Autumn,
|
||||
|
||||
/// <summary>Last quarter — coldest.</summary>
|
||||
Winter,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tuning for <see cref="Climate"/>. A year spans <see cref="DaysPerYear"/> in-game days; temperature
|
||||
/// follows a seasonal cosine peaking on <see cref="WarmestDay"/>, plus a daily swing (cooler at night).
|
||||
/// </summary>
|
||||
public readonly record struct ClimateSettings
|
||||
{
|
||||
/// <summary>In-game days in one year (e.g. 60 = four 15-day seasons).</summary>
|
||||
public int DaysPerYear { get; init; }
|
||||
|
||||
/// <summary>Yearly mean temperature.</summary>
|
||||
public float MeanTemperature { get; init; }
|
||||
|
||||
/// <summary>Peak deviation from the mean across the seasons (summer high / winter low).</summary>
|
||||
public float SeasonalAmplitude { get; init; }
|
||||
|
||||
/// <summary>Peak deviation from the seasonal mean across one day (warmer at noon, cooler at night).</summary>
|
||||
public float DailyAmplitude { get; init; }
|
||||
|
||||
/// <summary>Day of the year (0-based) with the highest temperature; defaults to mid-summer.</summary>
|
||||
public int WarmestDay { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Day of the year (0-based) that the calendar's day 0 maps to. Shifts the whole seasonal phase
|
||||
/// (season and temperature together) so a world can begin in a chosen part of the year — e.g. a
|
||||
/// warm late spring instead of the cold turn of the year. Defaults to 0 (year begins at day 0).
|
||||
/// </summary>
|
||||
public int StartDayOfYear { get; init; }
|
||||
|
||||
/// <summary>Temperate defaults: a 60-day year, mean 12°, ±14° seasonal, ±5° daily, warmest mid-summer.</summary>
|
||||
public static ClimateSettings Default =>
|
||||
new()
|
||||
{
|
||||
DaysPerYear = 60,
|
||||
MeanTemperature = 12f,
|
||||
SeasonalAmplitude = 14f,
|
||||
DailyAmplitude = 5f,
|
||||
WarmestDay = 22, // ~middle of the summer quarter of a 60-day year
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Continuous climate layered over <see cref="Calendar"/>: a temperature that varies smoothly with the
|
||||
/// season and the time of day, plus the current season and year. The whole curve is derived from the
|
||||
/// calendar's elapsed time, so it slows or stops with the game clock. Pure read-side, GPU-free and
|
||||
/// deterministic; registered as a service via <see cref="ClimateEngineExtensions.UseClimate"/>.
|
||||
/// </summary>
|
||||
public sealed class Climate
|
||||
{
|
||||
private readonly Calendar _calendar;
|
||||
private readonly ClimateSettings _settings;
|
||||
|
||||
/// <summary>Creates a climate reading <paramref name="calendar"/> with the given <paramref name="settings"/>.</summary>
|
||||
public Climate(Calendar calendar, ClimateSettings settings)
|
||||
{
|
||||
_calendar = calendar ?? throw new ArgumentNullException(nameof(calendar));
|
||||
if (settings.DaysPerYear <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(settings),
|
||||
"Days per year must be positive."
|
||||
);
|
||||
}
|
||||
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>In-game days per year.</summary>
|
||||
public int DaysPerYear => _settings.DaysPerYear;
|
||||
|
||||
/// <summary>Elapsed days shifted by <see cref="ClimateSettings.StartDayOfYear"/> — the seasonal clock.</summary>
|
||||
private double YearDays => _calendar.TotalDays + _settings.StartDayOfYear;
|
||||
|
||||
/// <summary>Continuous position within the current year in <c>[0, 1)</c>.</summary>
|
||||
public double YearProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
var years = YearDays / _settings.DaysPerYear;
|
||||
return years - Math.Floor(years);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The current year, counting from 1.</summary>
|
||||
public int Year => (int)(YearDays / _settings.DaysPerYear) + 1;
|
||||
|
||||
/// <summary>Day within the current year, 0-based.</summary>
|
||||
public int DayOfYear => (int)(YearProgress * _settings.DaysPerYear);
|
||||
|
||||
/// <summary>The current season, derived from the quarter of the year.</summary>
|
||||
public Season Season => (Season)Math.Clamp((int)(YearProgress * 4.0), 0, 3);
|
||||
|
||||
/// <summary>Current temperature: seasonal cosine peaking on the warmest day, plus a daily swing.</summary>
|
||||
public float Temperature
|
||||
{
|
||||
get
|
||||
{
|
||||
var warmFraction = _settings.WarmestDay / (float)_settings.DaysPerYear;
|
||||
var seasonal =
|
||||
_settings.MeanTemperature
|
||||
+ _settings.SeasonalAmplitude
|
||||
* MathF.Cos(MathF.Tau * ((float)YearProgress - warmFraction));
|
||||
var daily = _settings.DailyAmplitude * (2f * Daylight() - 1f);
|
||||
return seasonal + daily;
|
||||
}
|
||||
}
|
||||
|
||||
// Daytime factor 0..1 (0 at midnight, 1 at noon). Mirrors the day/night curve without a
|
||||
// Graphics dependency — Core owns the daily temperature swing.
|
||||
private float Daylight()
|
||||
{
|
||||
var value = -MathF.Cos(MathF.Tau * _calendar.DayProgress);
|
||||
return value > 0f ? value : 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wires the climate model into the engine.</summary>
|
||||
public static class ClimateEngineExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Climate"/> bound to the context's <see cref="Calendar"/> service and
|
||||
/// registers it. Call once per world, after <see cref="CalendarEngineExtensions.UseCalendar"/>.
|
||||
/// </summary>
|
||||
public static Climate UseClimate(this EngineContext context, ClimateSettings settings)
|
||||
{
|
||||
var climate = new Climate(context.Services.Get<Calendar>(), settings);
|
||||
context.Services.Add(climate);
|
||||
return climate;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
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.
|
||||
/// Root object handed to scenes and systems: time, scene manager and services.
|
||||
/// Created by a host (the MonoGame game host or <see cref="HeadlessHost"/>); can also be
|
||||
/// created standalone for unit tests. Platform resources such as the graphics device are
|
||||
/// published through <see cref="Services"/> by hosts that have them — the core itself has
|
||||
/// no platform dependencies.
|
||||
/// </summary>
|
||||
public sealed class EngineContext
|
||||
{
|
||||
@@ -17,23 +18,16 @@ public sealed class EngineContext
|
||||
/// <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>
|
||||
/// <summary>Creates a context. Games normally never create one themselves — the host does.</summary>
|
||||
public EngineContext()
|
||||
{
|
||||
Scenes = new SceneManager(this);
|
||||
}
|
||||
|
||||
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
|
||||
/// <summary>
|
||||
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services.
|
||||
/// Instances in <paramref name="except"/> (the host itself, platform resources the host
|
||||
/// disposes on its own) are skipped. Called by hosts on shutdown.
|
||||
/// </summary>
|
||||
internal void DisposeOwnedResources(params object[] except) => Services.DisposeServices(except);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// A compiled arithmetic expression — the keystone of the data-driven gene system. A formula is
|
||||
/// parsed once from a string in a def (e.g. a gene effect on a trait) into a delegate tree, then
|
||||
/// evaluated many times against an <see cref="IFormulaContext"/> that supplies variable values
|
||||
/// (gene values, environment readings, other traits). Evaluation is deterministic and
|
||||
/// allocation-free; all the work happens in <see cref="Compile"/>.
|
||||
///
|
||||
/// <para>Grammar: numbers, variables, the constants <c>pi</c>/<c>tau</c>/<c>e</c>, operators
|
||||
/// <c>+ - * / %</c>, comparisons <c>< > <= >= == !=</c>, logical <c>&& || !</c>,
|
||||
/// the ternary <c>cond ? a : b</c>, and functions <c>abs sign floor ceil round sqrt exp log sin cos
|
||||
/// tan min max pow clamp lerp step</c>. Comparisons and logical operators yield <c>1</c>/<c>0</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class Formula
|
||||
{
|
||||
private static readonly IFormulaContext Empty = new EmptyContext();
|
||||
|
||||
private readonly Func<IFormulaContext, float> _root;
|
||||
|
||||
private Formula(string source, Func<IFormulaContext, float> root)
|
||||
{
|
||||
Source = source;
|
||||
_root = root;
|
||||
}
|
||||
|
||||
/// <summary>The original expression text this formula was compiled from.</summary>
|
||||
public string Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parses and compiles <paramref name="expression"/>. Throws <see cref="FormulaException"/> on any
|
||||
/// lexical or syntactic error, with the offending position.
|
||||
/// </summary>
|
||||
public static Formula Compile(string expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
var tokens = FormulaLexer.Tokenize(expression);
|
||||
var root = new FormulaParser(tokens).ParseProgram();
|
||||
return new Formula(expression, root);
|
||||
}
|
||||
|
||||
/// <summary>Evaluates the formula, resolving variables through <paramref name="context"/>.</summary>
|
||||
public float Evaluate(IFormulaContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return _root(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a formula that references no variables. Throws <see cref="FormulaException"/> if it
|
||||
/// turns out to reference one.
|
||||
/// </summary>
|
||||
public float Evaluate() => _root(Empty);
|
||||
|
||||
private sealed class EmptyContext : IFormulaContext
|
||||
{
|
||||
public float Resolve(string name) =>
|
||||
throw new FormulaException($"No context to resolve variable '{name}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies variable values to a compiled <see cref="Formula"/>. A context maps a variable name
|
||||
/// (a gene value, an environment reading, another trait…) to a number; the formula engine itself
|
||||
/// is data-agnostic, so any consumer can back this with whatever lookup it owns.
|
||||
/// </summary>
|
||||
public interface IFormulaContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the value bound to <paramref name="name"/>. Throw (e.g. <see cref="FormulaException"/>)
|
||||
/// if the name is unknown — the engine does not invent a default.
|
||||
/// </summary>
|
||||
float Resolve(string name);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the values of every variable whose name satisfies <paramref name="matches"/> — the
|
||||
/// backing for the group functions (<c>gsum</c>, <c>gavg</c>, …) that aggregate over a name
|
||||
/// pattern, e.g. all <c>leaf_*</c> genes. Contexts with no enumerable variables return nothing.
|
||||
/// </summary>
|
||||
IEnumerable<float> ResolveMatching(Func<string, bool> matches) => [];
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IFormulaContext"/> backed by a lookup delegate — handy for tests and ad-hoc use.</summary>
|
||||
public sealed class DelegateFormulaContext : IFormulaContext
|
||||
{
|
||||
private readonly Func<string, float> _resolve;
|
||||
|
||||
/// <summary>Wraps <paramref name="resolve"/>; it is called once per variable reference per evaluation.</summary>
|
||||
public DelegateFormulaContext(Func<string, float> resolve) =>
|
||||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
|
||||
/// <inheritdoc />
|
||||
public float Resolve(string name) => _resolve(name);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a <see cref="Formula"/> cannot be lexed or parsed, or when a compiled formula
|
||||
/// references a variable the context cannot resolve at evaluation time.
|
||||
/// </summary>
|
||||
public sealed class FormulaException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception with a human-readable <paramref name="message"/>.</summary>
|
||||
public FormulaException(string message)
|
||||
: base(message) { }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
|
||||
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// The built-in function set available inside formulas. Each entry validates its argument count at
|
||||
/// compile time and returns a <see cref="Node"/> that evaluates its operands then the math. Kept
|
||||
/// deterministic and side-effect-free so formulas stay pure.
|
||||
/// </summary>
|
||||
internal static class FormulaFunctions
|
||||
{
|
||||
public static Node Build(string name, List<Node> args)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "abs":
|
||||
return Unary(name, args, MathF.Abs);
|
||||
case "sign":
|
||||
return Unary(name, args, x => MathF.Sign(x));
|
||||
case "floor":
|
||||
return Unary(name, args, MathF.Floor);
|
||||
case "ceil":
|
||||
return Unary(name, args, MathF.Ceiling);
|
||||
case "round":
|
||||
return Unary(name, args, MathF.Round);
|
||||
case "sqrt":
|
||||
return Unary(name, args, MathF.Sqrt);
|
||||
case "exp":
|
||||
return Unary(name, args, MathF.Exp);
|
||||
case "log":
|
||||
return args.Count == 2
|
||||
? Binary(name, args, MathF.Log)
|
||||
: Unary(name, args, MathF.Log);
|
||||
case "sin":
|
||||
return Unary(name, args, MathF.Sin);
|
||||
case "cos":
|
||||
return Unary(name, args, MathF.Cos);
|
||||
case "tan":
|
||||
return Unary(name, args, MathF.Tan);
|
||||
case "min":
|
||||
return Binary(name, args, MathF.Min);
|
||||
case "max":
|
||||
return Binary(name, args, MathF.Max);
|
||||
case "pow":
|
||||
return Binary(name, args, MathF.Pow);
|
||||
case "clamp":
|
||||
return Ternary(name, args, (x, lo, hi) => Math.Clamp(x, lo, hi));
|
||||
case "lerp":
|
||||
return Ternary(name, args, (a, b, t) => a + (b - a) * t);
|
||||
case "step":
|
||||
return Binary(name, args, (edge, x) => x < edge ? 0f : 1f);
|
||||
default:
|
||||
throw new FormulaException($"Unknown function '{name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Node Unary(string name, List<Node> args, Func<float, float> op)
|
||||
{
|
||||
Require(name, args, 1);
|
||||
var a = args[0];
|
||||
return ctx => op(a(ctx));
|
||||
}
|
||||
|
||||
private static Node Binary(string name, List<Node> args, Func<float, float, float> op)
|
||||
{
|
||||
Require(name, args, 2);
|
||||
var a = args[0];
|
||||
var b = args[1];
|
||||
return ctx => op(a(ctx), b(ctx));
|
||||
}
|
||||
|
||||
private static Node Ternary(string name, List<Node> args, Func<float, float, float, float> op)
|
||||
{
|
||||
Require(name, args, 3);
|
||||
var a = args[0];
|
||||
var b = args[1];
|
||||
var c = args[2];
|
||||
return ctx => op(a(ctx), b(ctx), c(ctx));
|
||||
}
|
||||
|
||||
private static void Require(string name, List<Node> args, int count)
|
||||
{
|
||||
if (args.Count != count)
|
||||
{
|
||||
throw new FormulaException(
|
||||
$"Function '{name}' expects {count} argument(s) but got {args.Count}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
|
||||
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// The group functions — <c>gsum</c>, <c>gcount</c>, <c>gavg</c>, <c>gmin</c>, <c>gmax</c> — which
|
||||
/// aggregate over every context variable whose name matches a regex literal, e.g.
|
||||
/// <c>gsum('leaf_.*')</c> sums all <c>leaf_*</c> genes. The pattern is a string literal compiled to a
|
||||
/// <see cref="Regex"/> once at parse time; aggregation reads <see cref="IFormulaContext.ResolveMatching"/>.
|
||||
/// </summary>
|
||||
internal static class FormulaGroups
|
||||
{
|
||||
private static readonly HashSet<string> Names = new(StringComparer.Ordinal)
|
||||
{
|
||||
"gsum",
|
||||
"gcount",
|
||||
"gavg",
|
||||
"gmin",
|
||||
"gmax",
|
||||
};
|
||||
|
||||
public static bool IsGroupFunction(string name) => Names.Contains(name);
|
||||
|
||||
public static Node Build(string name, string pattern)
|
||||
{
|
||||
Regex regex;
|
||||
try
|
||||
{
|
||||
regex = new Regex(pattern, RegexOptions.CultureInvariant);
|
||||
}
|
||||
catch (ArgumentException error)
|
||||
{
|
||||
throw new FormulaException($"Invalid regex '{pattern}': {error.Message}");
|
||||
}
|
||||
|
||||
bool Match(string variable) => regex.IsMatch(variable);
|
||||
return name switch
|
||||
{
|
||||
"gsum" => ctx => Aggregate(ctx.ResolveMatching(Match), sum: true),
|
||||
"gavg" => ctx => Aggregate(ctx.ResolveMatching(Match), average: true),
|
||||
"gcount" => ctx => Count(ctx.ResolveMatching(Match)),
|
||||
"gmin" => ctx => Extreme(ctx.ResolveMatching(Match), max: false),
|
||||
"gmax" => ctx => Extreme(ctx.ResolveMatching(Match), max: true),
|
||||
_ => throw new FormulaException($"Unknown group function '{name}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private static float Aggregate(
|
||||
IEnumerable<float> values,
|
||||
bool sum = false,
|
||||
bool average = false
|
||||
)
|
||||
{
|
||||
var total = 0f;
|
||||
var count = 0;
|
||||
foreach (var value in values)
|
||||
{
|
||||
total += value;
|
||||
count++;
|
||||
}
|
||||
|
||||
if (average)
|
||||
{
|
||||
return count == 0 ? 0f : total / count;
|
||||
}
|
||||
|
||||
return total; // sum (count==0 → 0)
|
||||
}
|
||||
|
||||
private static float Count(IEnumerable<float> values)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var _ in values)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static float Extreme(IEnumerable<float> values, bool max)
|
||||
{
|
||||
var has = false;
|
||||
var best = 0f;
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!has || (max ? value > best : value < best))
|
||||
{
|
||||
best = value;
|
||||
}
|
||||
|
||||
has = true;
|
||||
}
|
||||
|
||||
return best; // empty → 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
internal enum TokenType
|
||||
{
|
||||
Number,
|
||||
Identifier,
|
||||
String,
|
||||
Plus,
|
||||
Minus,
|
||||
Star,
|
||||
Slash,
|
||||
Percent,
|
||||
LParen,
|
||||
RParen,
|
||||
Comma,
|
||||
Less,
|
||||
Greater,
|
||||
LessEqual,
|
||||
GreaterEqual,
|
||||
EqualEqual,
|
||||
NotEqual,
|
||||
And,
|
||||
Or,
|
||||
Not,
|
||||
Question,
|
||||
Colon,
|
||||
End,
|
||||
}
|
||||
|
||||
internal readonly struct Token(TokenType type, int position, float number = 0f, string text = "")
|
||||
{
|
||||
public TokenType Type { get; } = type;
|
||||
public int Position { get; } = position;
|
||||
public float Number { get; } = number;
|
||||
public string Text { get; } = text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a formula string into a flat token list. Pure and allocation-light; recognizes numbers,
|
||||
/// identifiers, the arithmetic/comparison/logical operators and the punctuation the parser needs.
|
||||
/// </summary>
|
||||
internal static class FormulaLexer
|
||||
{
|
||||
public static List<Token> Tokenize(string source)
|
||||
{
|
||||
var tokens = new List<Token>();
|
||||
var i = 0;
|
||||
while (i < source.Length)
|
||||
{
|
||||
var c = source[i];
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
char.IsDigit(c)
|
||||
|| (c == '.' && i + 1 < source.Length && char.IsDigit(source[i + 1]))
|
||||
)
|
||||
{
|
||||
var start = i;
|
||||
while (i < source.Length && (char.IsDigit(source[i]) || source[i] == '.'))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var span = source.AsSpan(start, i - start);
|
||||
if (
|
||||
!float.TryParse(
|
||||
span,
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var value
|
||||
)
|
||||
)
|
||||
{
|
||||
throw new FormulaException(
|
||||
$"Invalid number '{span.ToString()}' at position {start}."
|
||||
);
|
||||
}
|
||||
|
||||
tokens.Add(new Token(TokenType.Number, start, value));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsLetter(c) || c == '_')
|
||||
{
|
||||
var start = i;
|
||||
while (i < source.Length && (char.IsLetterOrDigit(source[i]) || source[i] == '_'))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
tokens.Add(
|
||||
new Token(TokenType.Identifier, start, text: source.Substring(start, i - start))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\'')
|
||||
{
|
||||
var open = i;
|
||||
var start = ++i;
|
||||
while (i < source.Length && source[i] != '\'')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i >= source.Length)
|
||||
{
|
||||
throw new FormulaException($"Unterminated string at position {open}.");
|
||||
}
|
||||
|
||||
tokens.Add(
|
||||
new Token(TokenType.String, open, text: source.Substring(start, i - start))
|
||||
);
|
||||
i++; // пропускаем закрывающую кавычку
|
||||
continue;
|
||||
}
|
||||
|
||||
var pos = i;
|
||||
switch (c)
|
||||
{
|
||||
case '+':
|
||||
tokens.Add(new Token(TokenType.Plus, pos));
|
||||
i++;
|
||||
break;
|
||||
case '-':
|
||||
tokens.Add(new Token(TokenType.Minus, pos));
|
||||
i++;
|
||||
break;
|
||||
case '*':
|
||||
tokens.Add(new Token(TokenType.Star, pos));
|
||||
i++;
|
||||
break;
|
||||
case '/':
|
||||
tokens.Add(new Token(TokenType.Slash, pos));
|
||||
i++;
|
||||
break;
|
||||
case '%':
|
||||
tokens.Add(new Token(TokenType.Percent, pos));
|
||||
i++;
|
||||
break;
|
||||
case '(':
|
||||
tokens.Add(new Token(TokenType.LParen, pos));
|
||||
i++;
|
||||
break;
|
||||
case ')':
|
||||
tokens.Add(new Token(TokenType.RParen, pos));
|
||||
i++;
|
||||
break;
|
||||
case ',':
|
||||
tokens.Add(new Token(TokenType.Comma, pos));
|
||||
i++;
|
||||
break;
|
||||
case '?':
|
||||
tokens.Add(new Token(TokenType.Question, pos));
|
||||
i++;
|
||||
break;
|
||||
case ':':
|
||||
tokens.Add(new Token(TokenType.Colon, pos));
|
||||
i++;
|
||||
break;
|
||||
case '<':
|
||||
i = AddMaybeEqual(tokens, source, i, TokenType.LessEqual, TokenType.Less);
|
||||
break;
|
||||
case '>':
|
||||
i = AddMaybeEqual(tokens, source, i, TokenType.GreaterEqual, TokenType.Greater);
|
||||
break;
|
||||
case '=':
|
||||
if (Next(source, i) == '=')
|
||||
{
|
||||
tokens.Add(new Token(TokenType.EqualEqual, pos));
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
|
||||
throw new FormulaException($"Expected '==' at position {pos}.");
|
||||
case '!':
|
||||
if (Next(source, i) == '=')
|
||||
{
|
||||
tokens.Add(new Token(TokenType.NotEqual, pos));
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
|
||||
tokens.Add(new Token(TokenType.Not, pos));
|
||||
i++;
|
||||
break;
|
||||
case '&':
|
||||
if (Next(source, i) == '&')
|
||||
{
|
||||
tokens.Add(new Token(TokenType.And, pos));
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
|
||||
throw new FormulaException($"Expected '&&' at position {pos}.");
|
||||
case '|':
|
||||
if (Next(source, i) == '|')
|
||||
{
|
||||
tokens.Add(new Token(TokenType.Or, pos));
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
|
||||
throw new FormulaException($"Expected '||' at position {pos}.");
|
||||
default:
|
||||
throw new FormulaException($"Unexpected character '{c}' at position {pos}.");
|
||||
}
|
||||
}
|
||||
|
||||
tokens.Add(new Token(TokenType.End, source.Length));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static int AddMaybeEqual(
|
||||
List<Token> tokens,
|
||||
string source,
|
||||
int i,
|
||||
TokenType withEqual,
|
||||
TokenType plain
|
||||
)
|
||||
{
|
||||
if (Next(source, i) == '=')
|
||||
{
|
||||
tokens.Add(new Token(withEqual, i));
|
||||
return i + 2;
|
||||
}
|
||||
|
||||
tokens.Add(new Token(plain, i));
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
private static char Next(string source, int i) => i + 1 < source.Length ? source[i + 1] : '\0';
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
|
||||
|
||||
namespace MrGameEng.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// Recursive-descent parser that compiles a token list straight into a tree of <see cref="Node"/>
|
||||
/// delegates. Parsing (and therefore all closure allocation) happens once; evaluating the returned
|
||||
/// node is allocation-free. Precedence, low to high: ternary, <c>||</c>, <c>&&</c>, equality,
|
||||
/// comparison, additive, multiplicative, unary, primary.
|
||||
/// </summary>
|
||||
internal sealed class FormulaParser(List<Token> tokens)
|
||||
{
|
||||
private const float True = 1f;
|
||||
private const float False = 0f;
|
||||
|
||||
private int _pos;
|
||||
|
||||
public Node ParseProgram()
|
||||
{
|
||||
var node = ParseTernary();
|
||||
Expect(TokenType.End);
|
||||
return node;
|
||||
}
|
||||
|
||||
private Node ParseTernary()
|
||||
{
|
||||
var condition = ParseOr();
|
||||
if (!Match(TokenType.Question))
|
||||
{
|
||||
return condition;
|
||||
}
|
||||
|
||||
var whenTrue = ParseTernary();
|
||||
Expect(TokenType.Colon);
|
||||
var whenFalse = ParseTernary();
|
||||
return ctx => condition(ctx) != False ? whenTrue(ctx) : whenFalse(ctx);
|
||||
}
|
||||
|
||||
private Node ParseOr()
|
||||
{
|
||||
var left = ParseAnd();
|
||||
while (Match(TokenType.Or))
|
||||
{
|
||||
var right = ParseAnd();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) != False || right(ctx) != False ? True : False;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Node ParseAnd()
|
||||
{
|
||||
var left = ParseEquality();
|
||||
while (Match(TokenType.And))
|
||||
{
|
||||
var right = ParseEquality();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) != False && right(ctx) != False ? True : False;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
private Node ParseEquality()
|
||||
{
|
||||
var left = ParseComparison();
|
||||
while (true)
|
||||
{
|
||||
if (Match(TokenType.EqualEqual))
|
||||
{
|
||||
var right = ParseComparison();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) == right(ctx) ? True : False;
|
||||
}
|
||||
else if (Match(TokenType.NotEqual))
|
||||
{
|
||||
var right = ParseComparison();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) != right(ctx) ? True : False;
|
||||
}
|
||||
else
|
||||
{
|
||||
return left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseComparison()
|
||||
{
|
||||
var left = ParseAdditive();
|
||||
while (true)
|
||||
{
|
||||
if (Match(TokenType.Less))
|
||||
{
|
||||
left = Compare(left, ParseAdditive(), (a, b) => a < b);
|
||||
}
|
||||
else if (Match(TokenType.LessEqual))
|
||||
{
|
||||
left = Compare(left, ParseAdditive(), (a, b) => a <= b);
|
||||
}
|
||||
else if (Match(TokenType.Greater))
|
||||
{
|
||||
left = Compare(left, ParseAdditive(), (a, b) => a > b);
|
||||
}
|
||||
else if (Match(TokenType.GreaterEqual))
|
||||
{
|
||||
left = Compare(left, ParseAdditive(), (a, b) => a >= b);
|
||||
}
|
||||
else
|
||||
{
|
||||
return left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseAdditive()
|
||||
{
|
||||
var left = ParseMultiplicative();
|
||||
while (true)
|
||||
{
|
||||
if (Match(TokenType.Plus))
|
||||
{
|
||||
var right = ParseMultiplicative();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) + right(ctx);
|
||||
}
|
||||
else if (Match(TokenType.Minus))
|
||||
{
|
||||
var right = ParseMultiplicative();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) - right(ctx);
|
||||
}
|
||||
else
|
||||
{
|
||||
return left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseMultiplicative()
|
||||
{
|
||||
var left = ParseUnary();
|
||||
while (true)
|
||||
{
|
||||
if (Match(TokenType.Star))
|
||||
{
|
||||
var right = ParseUnary();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) * right(ctx);
|
||||
}
|
||||
else if (Match(TokenType.Slash))
|
||||
{
|
||||
var right = ParseUnary();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) / right(ctx);
|
||||
}
|
||||
else if (Match(TokenType.Percent))
|
||||
{
|
||||
var right = ParseUnary();
|
||||
var l = left;
|
||||
left = ctx => l(ctx) % right(ctx);
|
||||
}
|
||||
else
|
||||
{
|
||||
return left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseUnary()
|
||||
{
|
||||
if (Match(TokenType.Minus))
|
||||
{
|
||||
var operand = ParseUnary();
|
||||
return ctx => -operand(ctx);
|
||||
}
|
||||
|
||||
if (Match(TokenType.Plus))
|
||||
{
|
||||
return ParseUnary();
|
||||
}
|
||||
|
||||
if (Match(TokenType.Not))
|
||||
{
|
||||
var operand = ParseUnary();
|
||||
return ctx => operand(ctx) != False ? False : True;
|
||||
}
|
||||
|
||||
return ParsePrimary();
|
||||
}
|
||||
|
||||
private Node ParsePrimary()
|
||||
{
|
||||
var token = Current;
|
||||
if (Match(TokenType.Number))
|
||||
{
|
||||
var value = token.Number;
|
||||
return _ => value;
|
||||
}
|
||||
|
||||
if (Match(TokenType.LParen))
|
||||
{
|
||||
var inner = ParseTernary();
|
||||
Expect(TokenType.RParen);
|
||||
return inner;
|
||||
}
|
||||
|
||||
if (Match(TokenType.Identifier))
|
||||
{
|
||||
return Peek(TokenType.LParen) ? ParseCall(token.Text) : ParseName(token.Text);
|
||||
}
|
||||
|
||||
throw new FormulaException($"Unexpected token at position {token.Position}.");
|
||||
}
|
||||
|
||||
private Node ParseName(string name)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "pi":
|
||||
return _ => MathF.PI;
|
||||
case "tau":
|
||||
return _ => MathF.Tau;
|
||||
case "e":
|
||||
return _ => MathF.E;
|
||||
default:
|
||||
return ctx => ctx.Resolve(name);
|
||||
}
|
||||
}
|
||||
|
||||
private Node ParseCall(string name)
|
||||
{
|
||||
Expect(TokenType.LParen);
|
||||
if (FormulaGroups.IsGroupFunction(name))
|
||||
{
|
||||
var pattern = Current;
|
||||
if (!Match(TokenType.String))
|
||||
{
|
||||
throw new FormulaException(
|
||||
$"Group function '{name}' expects a quoted regex pattern at position {pattern.Position}."
|
||||
);
|
||||
}
|
||||
|
||||
Expect(TokenType.RParen);
|
||||
return FormulaGroups.Build(name, pattern.Text);
|
||||
}
|
||||
|
||||
var args = new List<Node>();
|
||||
if (!Peek(TokenType.RParen))
|
||||
{
|
||||
do
|
||||
{
|
||||
args.Add(ParseTernary());
|
||||
} while (Match(TokenType.Comma));
|
||||
}
|
||||
|
||||
Expect(TokenType.RParen);
|
||||
return FormulaFunctions.Build(name, args);
|
||||
}
|
||||
|
||||
private static Node Compare(Node left, Node right, Func<float, float, bool> op) =>
|
||||
ctx => op(left(ctx), right(ctx)) ? True : False;
|
||||
|
||||
private Token Current => tokens[_pos];
|
||||
|
||||
private bool Peek(TokenType type) => Current.Type == type;
|
||||
|
||||
private bool Match(TokenType type)
|
||||
{
|
||||
if (Current.Type != type)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_pos++;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Expect(TokenType type)
|
||||
{
|
||||
if (!Match(type))
|
||||
{
|
||||
throw new FormulaException($"Expected {type} at position {Current.Position}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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"/>.
|
||||
/// Advanced once per frame (or per fixed tick) by the host.
|
||||
/// </summary>
|
||||
public sealed class GameClock
|
||||
{
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
namespace MrGameEng.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Discrete game-speed control layered over <see cref="GameClock.TimeScale"/>: a pause plus an
|
||||
/// ordered list of speed multipliers (1×, 3×, 6× by default). Pausing remembers the current
|
||||
/// running step so <see cref="Resume"/> restores it. <see cref="Changed"/> fires on every
|
||||
/// transition so UI (speed buttons, indicators) can refresh. Deterministic and GPU-free;
|
||||
/// registered as a service via <see cref="GameSpeedEngineExtensions.UseGameSpeed"/>.
|
||||
/// </summary>
|
||||
public sealed class GameSpeed
|
||||
{
|
||||
private readonly GameClock _clock;
|
||||
private readonly float[] _steps;
|
||||
private int _stepIndex;
|
||||
private bool _paused;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a controller that writes <see cref="CurrentSpeed"/> to
|
||||
/// <paramref name="clock"/>. <paramref name="steps"/> are the running multipliers in
|
||||
/// ascending order; each must be positive. Empty defaults to 1×, 3×, 6×.
|
||||
/// </summary>
|
||||
public GameSpeed(GameClock clock, params float[] steps)
|
||||
{
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_steps = steps is { Length: > 0 } ? (float[])steps.Clone() : [1f, 3f, 6f];
|
||||
foreach (var step in _steps)
|
||||
{
|
||||
if (step <= 0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(steps),
|
||||
"Speed steps must be positive."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>The ordered running speeds (excludes the pause state).</summary>
|
||||
public IReadOnlyList<float> Steps => _steps;
|
||||
|
||||
/// <summary>Index of the active running step within <see cref="Steps"/>.</summary>
|
||||
public int StepIndex => _stepIndex;
|
||||
|
||||
/// <summary>True while gameplay is paused (clock time scale is 0).</summary>
|
||||
public bool IsPaused => _paused;
|
||||
|
||||
/// <summary>Active multiplier: 0 while paused, otherwise <c>Steps[StepIndex]</c>.</summary>
|
||||
public float CurrentSpeed => _paused ? 0f : _steps[_stepIndex];
|
||||
|
||||
/// <summary>Raised after any change to the pause state or the active step.</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>Pauses gameplay, remembering the current step for <see cref="Resume"/>.</summary>
|
||||
public void Pause()
|
||||
{
|
||||
if (_paused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_paused = true;
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>Resumes gameplay at the remembered step.</summary>
|
||||
public void Resume()
|
||||
{
|
||||
if (!_paused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_paused = false;
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>Toggles between paused and running.</summary>
|
||||
public void TogglePause()
|
||||
{
|
||||
_paused = !_paused;
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>Selects a running step by index (clamped to the valid range) and unpauses.</summary>
|
||||
public void SetStep(int index)
|
||||
{
|
||||
_stepIndex = Math.Clamp(index, 0, _steps.Length - 1);
|
||||
_paused = false;
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>Steps to the next faster speed (clamped to the fastest) and unpauses.</summary>
|
||||
public void Faster() => SetStep(_stepIndex + 1);
|
||||
|
||||
/// <summary>Steps to the next slower speed (clamped to the slowest) and unpauses.</summary>
|
||||
public void Slower() => SetStep(_stepIndex - 1);
|
||||
|
||||
/// <summary>
|
||||
/// Cycles through states: pause → slowest step → … → fastest step → pause. Handy for a
|
||||
/// single "next speed" key or button.
|
||||
/// </summary>
|
||||
public void Cycle()
|
||||
{
|
||||
if (_paused)
|
||||
{
|
||||
_paused = false;
|
||||
_stepIndex = 0;
|
||||
}
|
||||
else if (_stepIndex + 1 < _steps.Length)
|
||||
{
|
||||
_stepIndex++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_paused = true;
|
||||
}
|
||||
|
||||
Apply();
|
||||
}
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
_clock.TimeScale = CurrentSpeed;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wires the game-speed controller into the engine.</summary>
|
||||
public static class GameSpeedEngineExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="GameSpeed"/> bound to the context's clock and registers it as a
|
||||
/// service. Call once at startup. <paramref name="steps"/> are the running multipliers
|
||||
/// (defaults to 1×, 3×, 6× when empty).
|
||||
/// </summary>
|
||||
public static GameSpeed UseGameSpeed(this EngineContext context, params float[] steps)
|
||||
{
|
||||
var speed = new GameSpeed(context.Clock, steps);
|
||||
context.Services.Add(speed);
|
||||
return speed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace MrGameEng.Core;
|
||||
|
||||
/// <summary>
|
||||
/// A game-loop host without a window, GPU or any platform dependency: drives the active
|
||||
/// scene's update phase on a fixed timestep. Suits dedicated servers, batch simulation and
|
||||
/// tests. Scenes still own draw systems — they are simply never run, and platform services
|
||||
/// (graphics device, window) are absent from <see cref="EngineContext.Services"/>, so only
|
||||
/// platform-free modules can be used. The fixed step makes simulation time independent of
|
||||
/// wall-clock jitter: N ticks always advance the world by exactly N ×
|
||||
/// <see cref="FixedDeltaTime"/> seconds.
|
||||
/// </summary>
|
||||
public sealed class HeadlessHost : IDisposable
|
||||
{
|
||||
/// <summary>Engine context shared with scenes and systems.</summary>
|
||||
public EngineContext Context { get; } = new();
|
||||
|
||||
/// <summary>Fixed simulation step in seconds: 1 / <see cref="HeadlessHostOptions.TicksPerSecond"/>.</summary>
|
||||
public float FixedDeltaTime { get; }
|
||||
|
||||
/// <summary>Ticks completed since the host was created.</summary>
|
||||
public long TickCount => Context.Clock.FrameCount;
|
||||
|
||||
// Отстав сильнее этого, Run ресинкается с настоящим временем вместо лавины тиков.
|
||||
private const double MaxLagSeconds = 1.0;
|
||||
|
||||
private readonly HeadlessHostOptions _options;
|
||||
|
||||
/// <summary>Creates a host that starts with <paramref name="initialScene"/>. The scene
|
||||
/// loads on the first tick, mirroring the windowed host's deferred switch.</summary>
|
||||
public HeadlessHost(HeadlessHostOptions options, Scene initialScene)
|
||||
{
|
||||
if (options.TicksPerSecond <= 0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(options),
|
||||
"TicksPerSecond must be positive."
|
||||
);
|
||||
}
|
||||
|
||||
_options = options;
|
||||
FixedDeltaTime = 1f / options.TicksPerSecond;
|
||||
Context.Scenes.Switch(initialScene);
|
||||
}
|
||||
|
||||
/// <summary>Advances the world by exactly one fixed tick.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
Context.Clock.Advance(FixedDeltaTime);
|
||||
Context.Scenes.Update(Context.Clock);
|
||||
}
|
||||
|
||||
/// <summary>Advances the world by <paramref name="count"/> ticks as fast as possible.</summary>
|
||||
public void RunTicks(long count)
|
||||
{
|
||||
for (long i = 0; i < count; i++)
|
||||
{
|
||||
Tick();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs until <paramref name="cancellationToken"/> is cancelled. With
|
||||
/// <see cref="HeadlessHostOptions.Realtime"/> ticks are paced to the wall clock — the
|
||||
/// loop sleeps when ahead and, having fallen more than a second behind, resyncs instead
|
||||
/// of bursting a catch-up avalanche. Pacing relies on <see cref="Thread.Sleep(TimeSpan)"/>
|
||||
/// and is accurate to a few milliseconds, not exact.
|
||||
/// </summary>
|
||||
public void Run(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.Realtime)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Tick();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var wallClock = Stopwatch.StartNew();
|
||||
var nextTickAt = 0.0;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Tick();
|
||||
nextTickAt += FixedDeltaTime;
|
||||
var ahead = nextTickAt - wallClock.Elapsed.TotalSeconds;
|
||||
if (ahead > 0)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromSeconds(ahead));
|
||||
}
|
||||
else if (-ahead > MaxLagSeconds)
|
||||
{
|
||||
nextTickAt = wallClock.Elapsed.TotalSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Unloads the active scene and disposes context-owned services.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Context.Scenes.Switch(null);
|
||||
Context.Scenes.ApplyPending();
|
||||
Context.DisposeOwnedResources();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace MrGameEng.Core;
|
||||
|
||||
/// <summary>Loop settings for <see cref="HeadlessHost"/>.</summary>
|
||||
public sealed class HeadlessHostOptions
|
||||
{
|
||||
/// <summary>Fixed simulation rate in ticks per second. Must be positive.</summary>
|
||||
public float TicksPerSecond { get; set; } = 60f;
|
||||
|
||||
/// <summary>
|
||||
/// When true, <see cref="HeadlessHost.Run"/> paces ticks to the wall clock (a dedicated
|
||||
/// server); when false it runs flat out (batch simulation). <see cref="HeadlessHost.RunTicks"/>
|
||||
/// always runs flat out regardless of this setting.
|
||||
/// </summary>
|
||||
public bool Realtime { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace MrGameEng.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Shared flag service: true while a UI overlay (the developer console, a modal dialog)
|
||||
/// captures input. Producers (e.g. the DevConsole module) set <see cref="Captured"/> while
|
||||
/// open; consumers (the Input module, scene UI rendering) suppress game-facing input while
|
||||
/// it is set. Lives in Core so modules can cooperate without referencing each other.
|
||||
/// </summary>
|
||||
public sealed class InputCapture
|
||||
{
|
||||
/// <summary>True while game-facing input should be suppressed.</summary>
|
||||
public bool Captured { get; set; }
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||
<PackageReference Include="Friflo.Engine.ECS" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
|
||||
<InternalsVisibleTo Include="MrGameEng.Host" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace MrGameEng.Core;
|
||||
/// 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.
|
||||
/// Scene instances are single-use: <see cref="OnLoad"/> populates the store and the system
|
||||
/// roots, and nothing resets them on unload — switch to a <b>new</b> instance instead of
|
||||
/// reloading an old one (re-loading throws).
|
||||
/// </summary>
|
||||
public abstract class Scene
|
||||
{
|
||||
@@ -20,12 +23,15 @@ public abstract class Scene
|
||||
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.");
|
||||
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;
|
||||
private bool _loadedOnce;
|
||||
private readonly List<Action> _unloadActions = [];
|
||||
|
||||
/// <summary>Initializes the scene's ECS world and system roots.</summary>
|
||||
protected Scene()
|
||||
@@ -40,6 +46,12 @@ public abstract class Scene
|
||||
/// <summary>Called once when the scene is replaced or the game exits. Release scene resources here.</summary>
|
||||
protected virtual void OnUnload() { }
|
||||
|
||||
/// <summary>
|
||||
/// Registers a callback run once when the scene unloads (after <see cref="OnUnload"/>),
|
||||
/// in reverse registration order. Engine modules use this to release per-scene resources.
|
||||
/// </summary>
|
||||
public void RegisterUnload(Action action) => _unloadActions.Add(action);
|
||||
|
||||
/// <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));
|
||||
@@ -50,6 +62,17 @@ public abstract class Scene
|
||||
|
||||
internal void Load(EngineContext context)
|
||||
{
|
||||
// Без guard'а повторная загрузка молча дублирует системы и сущности
|
||||
// (OnLoad добавляет в те же SystemRoot/EntityStore поверх старого содержимого).
|
||||
if (_loadedOnce)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Scene '{GetType().Name}' was already loaded once. Scene instances are "
|
||||
+ "single-use: create a new instance instead of switching back to an old one."
|
||||
);
|
||||
}
|
||||
|
||||
_loadedOnce = true;
|
||||
_context = context;
|
||||
OnLoad();
|
||||
}
|
||||
@@ -57,6 +80,12 @@ public abstract class Scene
|
||||
internal void Unload()
|
||||
{
|
||||
OnUnload();
|
||||
for (var i = _unloadActions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
_unloadActions[i]();
|
||||
}
|
||||
|
||||
_unloadActions.Clear();
|
||||
_context = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,24 @@ public sealed class SceneManager
|
||||
/// <summary>True while a transition is covering or revealing.</summary>
|
||||
public bool IsTransitioning => _state != State.Idle;
|
||||
|
||||
/// <summary>The transition currently covering or revealing, or null while idle.
|
||||
/// Hosts that render overlays read this together with <see cref="TransitionCoverage"/>
|
||||
/// and <see cref="TransitionPhase"/> during their draw phase.</summary>
|
||||
public Transition? ActiveTransition => _state == State.Idle ? null : _transition;
|
||||
|
||||
/// <summary>Coverage of the active transition: 0 = scene fully visible, 1 = fully covered.</summary>
|
||||
public float TransitionCoverage => Math.Clamp(_coverage, 0f, 1f);
|
||||
|
||||
/// <summary>Phase of the active transition. Meaningful only while <see cref="IsTransitioning"/>.</summary>
|
||||
public TransitionPhase TransitionPhase =>
|
||||
_state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
|
||||
|
||||
private readonly EngineContext _context;
|
||||
private Scene? _pending;
|
||||
private bool _hasPending;
|
||||
private Transition? _transition;
|
||||
private State _state;
|
||||
private float _coverage;
|
||||
private TransitionRenderer? _renderer;
|
||||
|
||||
internal SceneManager(EngineContext context) => _context = context;
|
||||
|
||||
@@ -36,18 +47,24 @@ public sealed class SceneManager
|
||||
/// Requests a switch to <paramref name="scene"/>. Without a transition the swap happens at
|
||||
/// the start of the next update tick; with one, the old scene is first covered.
|
||||
/// Passing null unloads the current scene. Calling during an active transition replaces
|
||||
/// the pending target scene.
|
||||
/// the pending target scene; a switch requested while a transition is revealing starts
|
||||
/// covering again from the current coverage.
|
||||
/// </summary>
|
||||
public void Switch(Scene? scene, Transition? transition = null)
|
||||
{
|
||||
_pending = scene;
|
||||
_hasPending = true;
|
||||
|
||||
if (_state == State.Idle && transition is not null)
|
||||
if (transition is not null && _state != State.CoveringOut)
|
||||
{
|
||||
_transition = transition;
|
||||
if (_state == State.Idle)
|
||||
{
|
||||
_coverage = 0f;
|
||||
}
|
||||
|
||||
// Из RevealingIn закрытие продолжается с текущего coverage — без скачка.
|
||||
_state = State.CoveringOut;
|
||||
_coverage = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +78,12 @@ public sealed class SceneManager
|
||||
break;
|
||||
|
||||
case State.CoveringOut:
|
||||
_coverage = Advance(_coverage, +1f, _transition!.OutDuration, clock.UnscaledDeltaTime);
|
||||
_coverage = Advance(
|
||||
_coverage,
|
||||
+1f,
|
||||
_transition!.OutDuration,
|
||||
clock.UnscaledDeltaTime
|
||||
);
|
||||
if (_coverage >= 1f)
|
||||
{
|
||||
ApplyPending();
|
||||
@@ -71,7 +93,12 @@ public sealed class SceneManager
|
||||
break;
|
||||
|
||||
case State.RevealingIn:
|
||||
_coverage = Advance(_coverage, -1f, _transition!.InDuration, clock.UnscaledDeltaTime);
|
||||
_coverage = Advance(
|
||||
_coverage,
|
||||
-1f,
|
||||
_transition!.InDuration,
|
||||
clock.UnscaledDeltaTime
|
||||
);
|
||||
if (_coverage <= 0f)
|
||||
{
|
||||
_state = State.Idle;
|
||||
@@ -84,20 +111,9 @@ public sealed class SceneManager
|
||||
Current?.Update(clock);
|
||||
}
|
||||
|
||||
/// <summary>Draws the active scene and the transition overlay on top. Called by the host.</summary>
|
||||
public void Draw(GameClock clock)
|
||||
{
|
||||
Current?.Draw(clock);
|
||||
|
||||
if (_state == State.Idle || !_context.HasGraphicsDevice)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_renderer ??= new TransitionRenderer(_context.GraphicsDevice);
|
||||
var phase = _state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
|
||||
_transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase);
|
||||
}
|
||||
/// <summary>Draws the active scene. Transition overlays are rendered by the host on top,
|
||||
/// from <see cref="ActiveTransition"/> and <see cref="TransitionCoverage"/>.</summary>
|
||||
public void Draw(GameClock clock) => Current?.Draw(clock);
|
||||
|
||||
internal void ApplyPending()
|
||||
{
|
||||
@@ -114,7 +130,12 @@ public sealed class SceneManager
|
||||
Log.Info($"Scene switched to {Current?.GetType().Name ?? "<none>"}");
|
||||
}
|
||||
|
||||
private static float Advance(float coverage, float direction, float duration, float deltaTime) =>
|
||||
private static float Advance(
|
||||
float coverage,
|
||||
float direction,
|
||||
float duration,
|
||||
float deltaTime
|
||||
) =>
|
||||
duration <= 0f
|
||||
? coverage + direction
|
||||
: Math.Clamp(coverage + direction * deltaTime / duration, 0f, 1f);
|
||||
|
||||
@@ -9,25 +9,56 @@ 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
|
||||
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.");
|
||||
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
|
||||
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.");
|
||||
: 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
|
||||
public T? GetOrDefault<T>()
|
||||
where T : class
|
||||
{
|
||||
return _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes every registered <see cref="IDisposable"/> service (each instance once, even
|
||||
/// when registered under several types) and clears the registry. Instances in
|
||||
/// <paramref name="except"/> are skipped. Called on host shutdown.
|
||||
/// </summary>
|
||||
internal void DisposeServices(params object[] except)
|
||||
{
|
||||
var skipped = new HashSet<object>(except, ReferenceEqualityComparer.Instance);
|
||||
var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var service in _services.Values)
|
||||
{
|
||||
if (
|
||||
!skipped.Contains(service)
|
||||
&& service is IDisposable disposable
|
||||
&& disposed.Add(service)
|
||||
)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
_services.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Core;
|
||||
|
||||
/// <summary>Phase of a scene transition.</summary>
|
||||
@@ -13,10 +11,12 @@ public enum TransitionPhase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visual transition between scenes. The scene switch itself happens at full coverage,
|
||||
/// so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
|
||||
/// Transitions are stateless and reusable; progress is tracked by <see cref="SceneManager"/>.
|
||||
/// Runs on unscaled time, so it works while gameplay is paused.
|
||||
/// Timing of a visual transition between scenes. The scene switch itself happens at full
|
||||
/// coverage, so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
|
||||
/// Progress is tracked by <see cref="SceneManager"/> on unscaled time, so transitions work
|
||||
/// while gameplay is paused. The core only times the phases — how the overlay looks is
|
||||
/// defined by the host (the MonoGame host's <c>OverlayTransition</c> and the
|
||||
/// <c>Transitions</c> factories); headless hosts simply let transitions pass invisibly.
|
||||
/// </summary>
|
||||
public abstract class Transition
|
||||
{
|
||||
@@ -32,42 +32,4 @@ public abstract class Transition
|
||||
OutDuration = Math.Max(0f, outDuration);
|
||||
InDuration = Math.Max(0f, inDuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the overlay. <paramref name="coverage"/> is 0 (scene fully visible) to
|
||||
/// 1 (scene fully covered); <paramref name="phase"/> tells which side of the switch this is.
|
||||
/// </summary>
|
||||
public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase);
|
||||
|
||||
/// <summary>Fade through a solid color (black by default). Total duration is split between out and in.</summary>
|
||||
public static Transition Fade(float duration = 0.6f, Color? color = null) =>
|
||||
new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
|
||||
|
||||
/// <summary>A curtain wiping across the screen (black by default). Total duration is split between out and in.</summary>
|
||||
public static Transition Wipe(float duration = 0.6f, Color? color = null) =>
|
||||
new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
|
||||
|
||||
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
|
||||
: Transition(outDuration, inDuration)
|
||||
{
|
||||
public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase) =>
|
||||
renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
|
||||
}
|
||||
|
||||
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
|
||||
: Transition(outDuration, inDuration)
|
||||
{
|
||||
public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase)
|
||||
{
|
||||
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
|
||||
if (phase == TransitionPhase.Out)
|
||||
{
|
||||
renderer.Fill(0f, 0f, coverage, 1f, color);
|
||||
}
|
||||
else
|
||||
{
|
||||
renderer.Fill(1f - coverage, 0f, coverage, 1f, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Myra" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -33,6 +33,15 @@ public readonly struct CameraState
|
||||
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
|
||||
public required ViewportMapping Mapping { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// World point at the centre of the virtual screen — the camera's <em>effective</em> position
|
||||
/// after bounds-clamping, i.e. what the view is actually built around. Prefer this over the raw
|
||||
/// <see cref="Camera.Position"/> when anchoring zoom-to-cursor, so the reposition matches what is
|
||||
/// rendered even while the camera is clamped against <see cref="Camera.Bounds"/>.
|
||||
/// </summary>
|
||||
public Vector2 WorldCenter =>
|
||||
Vector2.Transform(new Vector2(VirtualWidth / 2f, VirtualHeight / 2f), InverseView);
|
||||
|
||||
/// <summary>Converts a physical screen point to world coordinates.</summary>
|
||||
public Vector2 ScreenToWorld(Vector2 screen)
|
||||
{
|
||||
@@ -52,23 +61,35 @@ public readonly struct CameraState
|
||||
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)
|
||||
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);
|
||||
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),
|
||||
Projection = Matrix.CreateOrthographicOffCenter(
|
||||
0f,
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
0f,
|
||||
0f,
|
||||
1f
|
||||
),
|
||||
InverseView = inverseView,
|
||||
CullRect = ComputeCullRect(inverseView, virtualWidth, virtualHeight),
|
||||
VirtualWidth = virtualWidth,
|
||||
@@ -81,14 +102,29 @@ public static class CameraMath
|
||||
/// 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)
|
||||
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;
|
||||
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)
|
||||
private static Vector2 ClampToBounds(
|
||||
in Camera camera,
|
||||
int virtualWidth,
|
||||
int virtualHeight,
|
||||
float zoom
|
||||
)
|
||||
{
|
||||
if (camera.Bounds is not { } bounds)
|
||||
{
|
||||
@@ -100,7 +136,8 @@ public static class CameraMath
|
||||
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));
|
||||
ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH)
|
||||
);
|
||||
}
|
||||
|
||||
private static float ClampAxis(float value, float min, float max) =>
|
||||
|
||||
@@ -10,7 +10,11 @@ public static class CullingMath
|
||||
/// (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)
|
||||
in Transform2D transform,
|
||||
float regionWidth,
|
||||
float regionHeight,
|
||||
Vector2 origin
|
||||
)
|
||||
{
|
||||
var scaledW = regionWidth * transform.Scale.X;
|
||||
var scaledH = regionHeight * transform.Scale.Y;
|
||||
@@ -24,20 +28,33 @@ public static class CullingMath
|
||||
/// diagonal (no square root per sprite; conservative for non-uniform scale, exact for uniform).
|
||||
/// </summary>
|
||||
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
|
||||
in Transform2D transform, Texture2DRegion region, Vector2 origin)
|
||||
in Transform2D transform,
|
||||
Texture2DRegion region,
|
||||
Vector2 origin
|
||||
)
|
||||
{
|
||||
var center = SpriteCenter(
|
||||
in transform, region.Width * transform.Scale.X, region.Height * transform.Scale.Y, origin);
|
||||
in transform,
|
||||
region.Width * transform.Scale.X,
|
||||
region.Height * transform.Scale.Y,
|
||||
origin
|
||||
);
|
||||
var maxScale = MathF.Max(MathF.Abs(transform.Scale.X), MathF.Abs(transform.Scale.Y));
|
||||
return (center, 0.5f * region.Diagonal * maxScale);
|
||||
}
|
||||
|
||||
private static Vector2 SpriteCenter(in Transform2D transform, float scaledW, float scaledH, Vector2 origin)
|
||||
private static Vector2 SpriteCenter(
|
||||
in Transform2D transform,
|
||||
float scaledW,
|
||||
float scaledH,
|
||||
Vector2 origin
|
||||
)
|
||||
{
|
||||
// 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);
|
||||
scaledH / 2f - origin.Y * transform.Scale.Y
|
||||
);
|
||||
|
||||
if (transform.Rotation == 0f)
|
||||
{
|
||||
@@ -45,9 +62,8 @@ public static class CullingMath
|
||||
}
|
||||
|
||||
var (sin, cos) = MathF.SinCos(transform.Rotation);
|
||||
return transform.Position + new Vector2(
|
||||
toCenter.X * cos - toCenter.Y * sin,
|
||||
toCenter.X * sin + toCenter.Y * cos);
|
||||
return transform.Position
|
||||
+ new Vector2(toCenter.X * cos - toCenter.Y * sin, toCenter.X * sin + toCenter.Y * cos);
|
||||
}
|
||||
|
||||
/// <summary>True when the circle overlaps the rectangle.</summary>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Graphics;
|
||||
|
||||
/// <summary>Graphics-side accessors for the platform-free <see cref="EngineContext"/>.</summary>
|
||||
public static class EngineContextGraphicsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the <see cref="GraphicsDevice"/> service published by a windowed host.
|
||||
/// Throws in a headless context, where no graphics device exists.
|
||||
/// </summary>
|
||||
public static GraphicsDevice GetGraphicsDevice(this EngineContext context) =>
|
||||
context.Services.GetOrDefault<GraphicsDevice>()
|
||||
?? throw new InvalidOperationException(
|
||||
"GraphicsDevice is not available: no windowed host has published it "
|
||||
+ "(headless context, or graphics are not initialized yet)."
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,11 @@ 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>
|
||||
/// <summary>
|
||||
/// Order by the entity's world Y position (<see cref="Transform2D.Position"/>):
|
||||
/// top-down games, lower on screen = drawn in front. Place sprite origins at the
|
||||
/// feet/base so the sort point matches the visual anchor.
|
||||
/// </summary>
|
||||
YSort,
|
||||
}
|
||||
|
||||
@@ -32,31 +36,59 @@ public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, Laye
|
||||
|
||||
/// <summary>
|
||||
/// Registry of render layers. Layers are registered up front (typically when the renderer is
|
||||
/// created) and drawn in registration order. Maximum 256 layers.
|
||||
/// created) and drawn in registration order. Maximum 256 layers. Reads are lock-free and
|
||||
/// thread-safe (the renderer reads layers from parallel submit workers); registration swaps
|
||||
/// an immutable snapshot, so registering mid-frame never tears a concurrent read.
|
||||
/// </summary>
|
||||
public sealed class LayerRegistry
|
||||
{
|
||||
private readonly List<RenderLayer> _layers = [];
|
||||
private readonly object _sync = new();
|
||||
private volatile 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;
|
||||
public int Count => _layers.Length;
|
||||
|
||||
/// <summary>Registers a layer drawn after all previously registered ones.</summary>
|
||||
public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth)
|
||||
public LayerId Register(
|
||||
string name,
|
||||
LayerSpace space = LayerSpace.World,
|
||||
LayerSortMode sortMode = LayerSortMode.Depth
|
||||
)
|
||||
{
|
||||
if (_layers.Count == 256)
|
||||
lock (_sync)
|
||||
{
|
||||
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
|
||||
}
|
||||
var layers = _layers;
|
||||
if (layers.Length == 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;
|
||||
var id = new LayerId((byte)layers.Length);
|
||||
var grown = new RenderLayer[layers.Length + 1];
|
||||
Array.Copy(layers, grown, layers.Length);
|
||||
grown[layers.Length] = new RenderLayer(id, name, space, sortMode);
|
||||
_layers = grown;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the layer with the given id.</summary>
|
||||
public RenderLayer this[LayerId id] => _layers[id.Value];
|
||||
/// <summary>Returns the layer with the given id; throws when the id was never registered.</summary>
|
||||
public RenderLayer this[LayerId id]
|
||||
{
|
||||
get
|
||||
{
|
||||
var layers = _layers;
|
||||
return id.Value < layers.Length
|
||||
? layers[id.Value]
|
||||
: throw new ArgumentOutOfRangeException(
|
||||
nameof(id),
|
||||
$"Render layer {id.Value} is not registered (registered: {layers.Length})."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>Colors for the day/night ambient: the tint at deep night and at midday.</summary>
|
||||
public readonly record struct DayNightSettings
|
||||
{
|
||||
/// <summary>Ambient tint at midnight (a dim, cool night).</summary>
|
||||
public Color NightColor { get; init; }
|
||||
|
||||
/// <summary>Ambient tint at noon (white = no tint).</summary>
|
||||
public Color DayColor { get; init; }
|
||||
|
||||
/// <summary>Sensible defaults: a dim blue night, untinted day.</summary>
|
||||
public static DayNightSettings Default =>
|
||||
new() { NightColor = new Color(45, 55, 95), DayColor = Color.White };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Day/night ambient driven by <see cref="Calendar.DayProgress"/>: a daylight factor (0 at midnight,
|
||||
/// 1 at noon) and an ambient color lerped from night to day. <see cref="SampleAt"/> is the sampleable
|
||||
/// light interface read by both the renderer (scene darkening) and the simulation (plant light);
|
||||
/// it returns the global daylight today and will return locally-shadowed light once point lights and
|
||||
/// occlusion land. Pure read-side, GPU-free and deterministic.
|
||||
/// </summary>
|
||||
public sealed class DayNight
|
||||
{
|
||||
private readonly Calendar _calendar;
|
||||
private readonly DayNightSettings _settings;
|
||||
|
||||
/// <summary>Creates the cycle reading <paramref name="calendar"/> with the given <paramref name="settings"/>.</summary>
|
||||
public DayNight(Calendar calendar, DayNightSettings settings)
|
||||
{
|
||||
_calendar = calendar ?? throw new ArgumentNullException(nameof(calendar));
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
/// <summary>Daylight factor in <c>[0, 1]</c> — 0 at midnight, 1 at noon, 0 again at midnight.</summary>
|
||||
public float Daylight
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = -MathF.Cos(MathF.Tau * _calendar.DayProgress);
|
||||
return value > 0f ? value : 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Global light intensity in <c>[0, 1]</c>; same as <see cref="Daylight"/> today.</summary>
|
||||
public float Intensity => Daylight;
|
||||
|
||||
/// <summary>Light intensity at a world point in <c>[0, 1]</c>. Global today; local (with shadows) later.</summary>
|
||||
public float SampleAt(Vector2 world) => Daylight;
|
||||
|
||||
/// <summary>
|
||||
/// Offset, in grid cells, of the shadow an occluder casts under the current sun: opposite the
|
||||
/// sun's east–west position and longest near sunrise/sunset (low sun), shrinking to zero at noon
|
||||
/// and at night. Feeds the lightmap's directional shadow pass; <paramref name="maxLength"/> caps
|
||||
/// the dawn/dusk shadow length. Tilted slightly "south" (down) so shadows fall in front of objects.
|
||||
/// </summary>
|
||||
public Vector2 SunShadow(float maxLength)
|
||||
{
|
||||
var day = (_calendar.DayProgress - 0.25f) / 0.5f; // daytime fraction over [06:00, 18:00]
|
||||
if (day <= 0f || day >= 1f)
|
||||
{
|
||||
return Vector2.Zero; // night — no sun; the ambient floor handles darkness
|
||||
}
|
||||
|
||||
var altitude = MathF.Sin(day * MathF.PI); // 0 at dawn/dusk, 1 at noon
|
||||
var direction = new Vector2(2f * day - 1f, 0.4f); // sun east→west ⇒ shadow west→east, tilted south
|
||||
direction.Normalize();
|
||||
return direction * (maxLength * (1f - altitude));
|
||||
}
|
||||
|
||||
/// <summary>Ambient tint for the scene: night color at night, day color at noon, eased between.</summary>
|
||||
public Color Ambient
|
||||
{
|
||||
get
|
||||
{
|
||||
var t = Smoothstep(Daylight);
|
||||
return Color.Lerp(_settings.NightColor, _settings.DayColor, t);
|
||||
}
|
||||
}
|
||||
|
||||
private static float Smoothstep(float x) => x * x * (3f - 2f * x);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Update-phase system that pushes the current <see cref="DayNight.Ambient"/> into the renderer's
|
||||
/// <see cref="Renderer2D.AmbientLight"/> each frame, so the world darkens toward night and brightens
|
||||
/// toward noon. Runs in the update phase, before the draw phase consumes the value.
|
||||
/// </summary>
|
||||
public sealed class DayNightSystem : BaseSystem
|
||||
{
|
||||
private readonly DayNight _dayNight;
|
||||
private readonly Renderer2D _renderer;
|
||||
|
||||
internal DayNightSystem(DayNight dayNight, Renderer2D renderer)
|
||||
{
|
||||
_dayNight = dayNight;
|
||||
_renderer = renderer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdateGroup() => _renderer.AmbientLight = _dayNight.Ambient;
|
||||
}
|
||||
|
||||
/// <summary>Wires the day/night ambient cycle into a <see cref="Scene"/>.</summary>
|
||||
public static class SceneLightingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="DayNight"/> bound to the scene's <see cref="Calendar"/> service, registers
|
||||
/// it, and drives <paramref name="renderer"/>'s ambient light from it. Call from <c>OnLoad</c>
|
||||
/// after <see cref="CalendarEngineExtensions.UseCalendar"/> and <c>UseRenderer2D</c>.
|
||||
/// </summary>
|
||||
public static DayNight UseDayNight(
|
||||
this Scene scene,
|
||||
Renderer2D renderer,
|
||||
DayNightSettings? settings = null
|
||||
)
|
||||
{
|
||||
var dayNight = new DayNight(
|
||||
scene.Context.Services.Get<Calendar>(),
|
||||
settings ?? DayNightSettings.Default
|
||||
);
|
||||
scene.Context.Services.Add(dayNight);
|
||||
scene.UpdateSystems.Add(new DayNightSystem(dayNight, renderer));
|
||||
return dayNight;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Scene lighting service: owns the <see cref="Lightmap"/> and exposes a sampleable local light for
|
||||
/// the simulation. Built by <see cref="SceneLightmapExtensions.UseLighting"/>, which also registers
|
||||
/// the systems that rebuild the lightmap and multiply it over the scene.
|
||||
/// </summary>
|
||||
public sealed class Lighting
|
||||
{
|
||||
/// <summary>The light grid rendered over the world and sampled by the simulation.</summary>
|
||||
public Lightmap Lightmap { get; }
|
||||
|
||||
/// <summary>Creates the service over <paramref name="lightmap"/>.</summary>
|
||||
public Lighting(Lightmap lightmap) => Lightmap = lightmap;
|
||||
|
||||
/// <summary>Local light in <c>[0, 1]</c> at a world point (e.g. for plant growth under canopy).</summary>
|
||||
public float SampleAt(Vector2 world) => Lightmap.SampleAt(world);
|
||||
}
|
||||
|
||||
/// <summary>Wires the 2D lightmap (day/night × occlusion + point lights) into a <see cref="Scene"/>.</summary>
|
||||
public static class SceneLightmapExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a <see cref="Lighting"/> service for a <paramref name="width"/>×<paramref name="height"/>
|
||||
/// cell world and registers the rebuild and composite systems. Ambient comes from
|
||||
/// <paramref name="dayNight"/>; <paramref name="occluders"/> supplies the current occluder grid
|
||||
/// (row-major, length width×height) each rebuild. Call after <c>UseRenderer2D</c>/<c>UseTilemaps</c>
|
||||
/// and before <c>UseUI</c> so the lightmap composites over the world but under the HUD.
|
||||
/// </summary>
|
||||
public static Lighting UseLighting(
|
||||
this Scene scene,
|
||||
Renderer2D renderer,
|
||||
DayNight dayNight,
|
||||
int width,
|
||||
int height,
|
||||
float cellSize,
|
||||
Vector2 origin,
|
||||
Func<bool[]> occluders
|
||||
)
|
||||
{
|
||||
var device = scene.Context.GetGraphicsDevice();
|
||||
var lightmap = new Lightmap(device, width, height, cellSize, origin);
|
||||
var lighting = new Lighting(lightmap);
|
||||
scene.Context.Services.Add(lighting);
|
||||
RegisterUnloadDispose(scene, lightmap);
|
||||
|
||||
scene.UpdateSystems.Add(
|
||||
new LightmapSystem(scene.Store, lightmap, dayNight, occluders, cellSize, origin)
|
||||
);
|
||||
scene.DrawSystems.Add(new LightmapRenderSystem(device, renderer, lightmap));
|
||||
return lighting;
|
||||
}
|
||||
|
||||
private static void RegisterUnloadDispose(Scene scene, Lightmap lightmap) =>
|
||||
scene.RegisterUnload(lightmap.Dispose);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// A per-cell light grid backed by a greyscale <see cref="Texture2D"/>. <see cref="LightmapBuilder"/>
|
||||
/// fills <see cref="Light"/>; <see cref="Upload"/> pushes it to the texture (the renderer multiplies
|
||||
/// it over the world for soft, cell-resolution shadows); <see cref="SampleAt"/> reads bilinearly for
|
||||
/// the simulation (local light at a plant). The grid maps cell (x,y) to world
|
||||
/// <c>Origin + (x+0.5, y+0.5)·CellSize</c>.
|
||||
/// </summary>
|
||||
public sealed class Lightmap : IDisposable
|
||||
{
|
||||
/// <summary>Grid width in cells.</summary>
|
||||
public int Width { get; }
|
||||
|
||||
/// <summary>Grid height in cells.</summary>
|
||||
public int Height { get; }
|
||||
|
||||
/// <summary>World size of one cell.</summary>
|
||||
public float CellSize { get; }
|
||||
|
||||
/// <summary>World position of cell (0,0)'s top-left.</summary>
|
||||
public Vector2 Origin { get; }
|
||||
|
||||
/// <summary>Per-cell light in <c>[0, 1]</c>, row-major. Filled by <see cref="LightmapBuilder"/>.</summary>
|
||||
public float[] Light { get; }
|
||||
|
||||
/// <summary>The greyscale light texture (one texel per cell), updated by <see cref="Upload"/>.</summary>
|
||||
public Texture2D Texture { get; }
|
||||
|
||||
private readonly Color[] _pixels;
|
||||
|
||||
/// <summary>Creates a lightmap grid and its backing texture on <paramref name="device"/>.</summary>
|
||||
public Lightmap(GraphicsDevice device, int width, int height, float cellSize, Vector2 origin)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
CellSize = cellSize;
|
||||
Origin = origin;
|
||||
Light = new float[width * height];
|
||||
_pixels = new Color[width * height];
|
||||
Texture = new Texture2D(device, width, height);
|
||||
Array.Fill(Light, 1f);
|
||||
Upload();
|
||||
}
|
||||
|
||||
/// <summary>Writes the current <see cref="Light"/> grid into the texture as greyscale.</summary>
|
||||
public void Upload()
|
||||
{
|
||||
for (var i = 0; i < Light.Length; i++)
|
||||
{
|
||||
var b = (byte)(Math.Clamp(Light[i], 0f, 1f) * 255f);
|
||||
_pixels[i] = new Color(b, b, b, (byte)255);
|
||||
}
|
||||
|
||||
Texture.SetData(_pixels);
|
||||
}
|
||||
|
||||
/// <summary>Bilinearly samples the light at a world point; clamps at the edges.</summary>
|
||||
public float SampleAt(Vector2 world)
|
||||
{
|
||||
var fx = (world.X - Origin.X) / CellSize - 0.5f;
|
||||
var fy = (world.Y - Origin.Y) / CellSize - 0.5f;
|
||||
fx = Math.Clamp(fx, 0f, Width - 1f);
|
||||
fy = Math.Clamp(fy, 0f, Height - 1f);
|
||||
|
||||
var x0 = (int)fx;
|
||||
var y0 = (int)fy;
|
||||
var x1 = Math.Min(x0 + 1, Width - 1);
|
||||
var y1 = Math.Min(y0 + 1, Height - 1);
|
||||
var tx = fx - x0;
|
||||
var ty = fy - y0;
|
||||
|
||||
var top = Lerp(Light[y0 * Width + x0], Light[y0 * Width + x1], tx);
|
||||
var bottom = Lerp(Light[y1 * Width + x0], Light[y1 * Width + x1], tx);
|
||||
return Lerp(top, bottom, ty);
|
||||
}
|
||||
|
||||
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => Texture.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>One point light projected onto the light grid: a cell position, a radius in cells and an intensity.</summary>
|
||||
public readonly record struct LightSample(int X, int Y, float Radius, float Intensity);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a per-cell light grid (CPU, GPU-free, testable): an ambient base dimmed under occluders,
|
||||
/// plus point lights that attenuate with distance and are blocked by occluders between the source
|
||||
/// and the cell (grid-traced shadows). Pure data — a <see cref="Lightmap"/> turns the grid into a
|
||||
/// texture and the simulation samples it for local light.
|
||||
/// </summary>
|
||||
public static class LightmapBuilder
|
||||
{
|
||||
/// <summary>How much of the ambient light reaches a cell that is itself an occluder (canopy/shade).</summary>
|
||||
public const float OccluderShade = 0.35f;
|
||||
|
||||
/// <summary>
|
||||
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with
|
||||
/// ambient light, shading occluder cells, casts each occluder's directional sun shadow along
|
||||
/// <paramref name="sunShadow"/> (cells), then adds each point light with grid-traced occlusion.
|
||||
/// Values end clamped to <c>[0, 1]</c>. A zero <paramref name="sunShadow"/> or
|
||||
/// <paramref name="sunShadowStrength"/> skips the directional pass (e.g. at night/noon).
|
||||
/// </summary>
|
||||
public static void Build(
|
||||
float[] light,
|
||||
int width,
|
||||
int height,
|
||||
float ambient,
|
||||
ReadOnlySpan<bool> occluders,
|
||||
IReadOnlyList<LightSample> lights,
|
||||
Vector2 sunShadow = default,
|
||||
float sunShadowStrength = 0f
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < light.Length; i++)
|
||||
{
|
||||
light[i] = occluders[i] ? ambient * OccluderShade : ambient;
|
||||
}
|
||||
|
||||
CastSunShadows(light, width, height, occluders, sunShadow, sunShadowStrength);
|
||||
|
||||
foreach (var l in lights)
|
||||
{
|
||||
if (l.Radius <= 0f || l.Intensity <= 0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var r = (int)MathF.Ceiling(l.Radius);
|
||||
var minX = Math.Max(0, l.X - r);
|
||||
var maxX = Math.Min(width - 1, l.X + r);
|
||||
var minY = Math.Max(0, l.Y - r);
|
||||
var maxY = Math.Min(height - 1, l.Y + r);
|
||||
for (var y = minY; y <= maxY; y++)
|
||||
{
|
||||
for (var x = minX; x <= maxX; x++)
|
||||
{
|
||||
var dx = x - l.X;
|
||||
var dy = y - l.Y;
|
||||
var d = MathF.Sqrt(dx * dx + dy * dy);
|
||||
if (d > l.Radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Visible(l.X, l.Y, x, y, occluders, width))
|
||||
{
|
||||
continue; // в тени за препятствием
|
||||
}
|
||||
|
||||
light[y * width + x] += l.Intensity * (1f - d / l.Radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < light.Length; i++)
|
||||
{
|
||||
light[i] = Math.Clamp(light[i], 0f, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
// Направленная тень от солнца: каждый окклюдер (гора/зрелая крона) отбрасывает тень вдоль
|
||||
// вектора sunShadow (в клетках). Затемнение гуще у основания и тает к концу тени; сами клетки-
|
||||
// окклюдеры не трогаем (они уже затенены). Окклюдеры разрежены, так что проход дёшев.
|
||||
private static void CastSunShadows(
|
||||
float[] light,
|
||||
int width,
|
||||
int height,
|
||||
ReadOnlySpan<bool> occluders,
|
||||
Vector2 sunShadow,
|
||||
float strength
|
||||
)
|
||||
{
|
||||
if (strength <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var steps = (int)MathF.Ceiling(sunShadow.Length());
|
||||
if (steps <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var stepX = sunShadow.X / steps;
|
||||
var stepY = sunShadow.Y / steps;
|
||||
for (var oy = 0; oy < height; oy++)
|
||||
{
|
||||
for (var ox = 0; ox < width; ox++)
|
||||
{
|
||||
if (!occluders[oy * width + ox])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var s = 1; s <= steps; s++)
|
||||
{
|
||||
var cx = ox + (int)MathF.Round(stepX * s);
|
||||
var cy = oy + (int)MathF.Round(stepY * s);
|
||||
if (cx < 0 || cx >= width || cy < 0 || cy >= height)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var index = cy * width + cx;
|
||||
if (occluders[index])
|
||||
{
|
||||
continue; // тень проходит над другими окклюдерами — они и так тёмные
|
||||
}
|
||||
|
||||
var falloff = 1f - (float)(s - 1) / steps; // гуще у основания тени
|
||||
light[index] *= 1f - strength * falloff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем
|
||||
// промежуточные клетки на окклюдер (концы исключены).
|
||||
private static bool Visible(
|
||||
int x0,
|
||||
int y0,
|
||||
int x1,
|
||||
int y1,
|
||||
ReadOnlySpan<bool> occluders,
|
||||
int width
|
||||
)
|
||||
{
|
||||
var dx = Math.Abs(x1 - x0);
|
||||
var dy = Math.Abs(y1 - y0);
|
||||
var sx = x0 < x1 ? 1 : -1;
|
||||
var sy = y0 < y1 ? 1 : -1;
|
||||
var err = dx - dy;
|
||||
var x = x0;
|
||||
var y = y0;
|
||||
while (true)
|
||||
{
|
||||
if (x == x1 && y == y1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((x != x0 || y != y0) && occluders[y * width + x])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var e2 = 2 * err;
|
||||
if (e2 > -dy)
|
||||
{
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
|
||||
if (e2 < dx)
|
||||
{
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Friflo.Engine.ECS.Systems;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the lightmap a few times a second: ambient from the day/night cycle, occluder cells from
|
||||
/// the game-supplied grid and point lights from the ECS, then uploads it to the texture. Throttled by
|
||||
/// frame skipping — the light changes slowly, so 10 Hz looks smooth and keeps cost low.
|
||||
/// </summary>
|
||||
public sealed class LightmapSystem : BaseSystem
|
||||
{
|
||||
private const int RebuildEvery = 6; // ~10 Гц при 60 fps
|
||||
private const float NightFloor = 0.24f; // ночь тусклая, но не чёрная (лунный свет) — чуть светлее, чем было
|
||||
private const float MaxShadowCells = 7f; // макс. длина тени от солнца (на рассвете/закате)
|
||||
private const float SunShadowStrength = 0.5f; // насколько темнеет клетка у основания тени
|
||||
|
||||
private readonly Lightmap _lightmap;
|
||||
private readonly DayNight _dayNight;
|
||||
private readonly Func<bool[]> _occluders;
|
||||
private readonly float _cellSize;
|
||||
private readonly Vector2 _origin;
|
||||
private readonly ArchetypeQuery<Transform2D, PointLight> _query;
|
||||
private readonly List<LightSample> _lights = [];
|
||||
private int _frame;
|
||||
|
||||
internal LightmapSystem(
|
||||
EntityStore store,
|
||||
Lightmap lightmap,
|
||||
DayNight dayNight,
|
||||
Func<bool[]> occluders,
|
||||
float cellSize,
|
||||
Vector2 origin
|
||||
)
|
||||
{
|
||||
_lightmap = lightmap;
|
||||
_dayNight = dayNight;
|
||||
_occluders = occluders;
|
||||
_cellSize = cellSize;
|
||||
_origin = origin;
|
||||
_query = store.Query<Transform2D, PointLight>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
if (++_frame < RebuildEvery)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_frame = 0;
|
||||
|
||||
_lights.Clear();
|
||||
foreach (var (transforms, lights, _) in _query.Chunks)
|
||||
{
|
||||
var t = transforms.Span;
|
||||
var l = lights.Span;
|
||||
for (var i = 0; i < t.Length; i++)
|
||||
{
|
||||
if (l[i].Radius <= 0f || l[i].Intensity <= 0f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cx = (int)((t[i].Position.X - _origin.X) / _cellSize);
|
||||
var cy = (int)((t[i].Position.Y - _origin.Y) / _cellSize);
|
||||
_lights.Add(new LightSample(cx, cy, l[i].Radius / _cellSize, l[i].Intensity));
|
||||
}
|
||||
}
|
||||
|
||||
var ambient = NightFloor + (1f - NightFloor) * _dayNight.Intensity;
|
||||
LightmapBuilder.Build(
|
||||
_lightmap.Light,
|
||||
_lightmap.Width,
|
||||
_lightmap.Height,
|
||||
ambient,
|
||||
_occluders(),
|
||||
_lights,
|
||||
_dayNight.SunShadow(MaxShadowCells),
|
||||
SunShadowStrength
|
||||
);
|
||||
_lightmap.Upload();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the lightmap over the world as a single texture quad with multiply blending and linear
|
||||
/// filtering — the world darkens by the grid and shadows read soft. Registered after the sprite flush
|
||||
/// (so it lands on the drawn scene) and before the screen-space UI (so the HUD stays at full brightness).
|
||||
/// </summary>
|
||||
public sealed class LightmapRenderSystem : BaseSystem
|
||||
{
|
||||
private static readonly BlendState Multiply = new()
|
||||
{
|
||||
ColorSourceBlend = Blend.DestinationColor,
|
||||
ColorDestinationBlend = Blend.Zero,
|
||||
AlphaSourceBlend = Blend.DestinationAlpha,
|
||||
AlphaDestinationBlend = Blend.Zero,
|
||||
};
|
||||
|
||||
private readonly GraphicsDevice _device;
|
||||
private readonly Renderer2D _renderer;
|
||||
private readonly Lightmap _lightmap;
|
||||
private readonly BasicEffect _effect;
|
||||
private readonly VertexPositionTexture[] _quad;
|
||||
|
||||
internal LightmapRenderSystem(GraphicsDevice device, Renderer2D renderer, Lightmap lightmap)
|
||||
{
|
||||
_device = device;
|
||||
_renderer = renderer;
|
||||
_lightmap = lightmap;
|
||||
_effect = new BasicEffect(device)
|
||||
{
|
||||
TextureEnabled = true,
|
||||
VertexColorEnabled = false,
|
||||
World = Matrix.Identity,
|
||||
};
|
||||
|
||||
var x0 = lightmap.Origin.X;
|
||||
var y0 = lightmap.Origin.Y;
|
||||
var x1 = x0 + lightmap.Width * lightmap.CellSize;
|
||||
var y1 = y0 + lightmap.Height * lightmap.CellSize;
|
||||
_quad =
|
||||
[
|
||||
new VertexPositionTexture(new Vector3(x0, y0, 0f), new Vector2(0f, 0f)),
|
||||
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
|
||||
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
|
||||
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
|
||||
new VertexPositionTexture(new Vector3(x1, y1, 0f), new Vector2(1f, 1f)),
|
||||
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnUpdateGroup()
|
||||
{
|
||||
var camera = _renderer.Camera;
|
||||
_effect.View = camera.View;
|
||||
_effect.Projection = camera.Projection;
|
||||
_effect.Texture = _lightmap.Texture;
|
||||
|
||||
var previousViewport = _device.Viewport;
|
||||
var mapping = camera.Mapping;
|
||||
_device.Viewport = new Viewport(
|
||||
(int)MathF.Round(mapping.Offset.X),
|
||||
(int)MathF.Round(mapping.Offset.Y),
|
||||
(int)MathF.Round(camera.VirtualWidth * mapping.Scale),
|
||||
(int)MathF.Round(camera.VirtualHeight * mapping.Scale)
|
||||
);
|
||||
_device.BlendState = Multiply;
|
||||
_device.SamplerStates[0] = SamplerState.LinearClamp;
|
||||
_device.DepthStencilState = DepthStencilState.None;
|
||||
_device.RasterizerState = RasterizerState.CullNone;
|
||||
|
||||
foreach (var pass in _effect.CurrentTechnique.Passes)
|
||||
{
|
||||
pass.Apply();
|
||||
_device.DrawUserPrimitives(PrimitiveType.TriangleList, _quad, 0, 2);
|
||||
}
|
||||
|
||||
_device.Viewport = previousViewport;
|
||||
_device.BlendState = BlendState.AlphaBlend;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// A point light: an entity with this component plus a <see cref="MrGameEng.Graphics.Transform2D"/>
|
||||
/// adds light around its world position, attenuating to zero at <see cref="Radius"/> and casting
|
||||
/// grid-traced shadows behind occluders. Picked up by the lighting system into the lightmap.
|
||||
/// </summary>
|
||||
public struct PointLight : IComponent
|
||||
{
|
||||
/// <summary>Reach of the light in world units (light fades to zero at this distance).</summary>
|
||||
public float Radius;
|
||||
|
||||
/// <summary>Light tint (reserved for colored lights; intensity currently drives brightness).</summary>
|
||||
public Color Color;
|
||||
|
||||
/// <summary>Peak brightness added at the light's centre (0..1+).</summary>
|
||||
public float Intensity;
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -96,20 +96,24 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
|
||||
}
|
||||
|
||||
_renderer.BeginChunkedSubmit(_segmentLengths.AsSpan(0, _segments.Count));
|
||||
Parallel.For(0, _segments.Count, segmentIndex =>
|
||||
{
|
||||
var (chunk, start, length) = _segments[segmentIndex];
|
||||
var (sprites, transforms) = _chunks[chunk];
|
||||
var writer = _renderer.GetChunkWriter(segmentIndex);
|
||||
var s = sprites.Span.Slice(start, length);
|
||||
var t = transforms.Span.Slice(start, length);
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
Parallel.For(
|
||||
0,
|
||||
_segments.Count,
|
||||
segmentIndex =>
|
||||
{
|
||||
_renderer.SubmitInto(ref writer, in t[i], in s[i]);
|
||||
}
|
||||
var (chunk, start, length) = _segments[segmentIndex];
|
||||
var (sprites, transforms) = _chunks[chunk];
|
||||
var writer = _renderer.GetChunkWriter(segmentIndex);
|
||||
var s = sprites.Span.Slice(start, length);
|
||||
var t = transforms.Span.Slice(start, length);
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
_renderer.SubmitInto(ref writer, in t[i], in s[i]);
|
||||
}
|
||||
|
||||
_renderer.EndChunk(segmentIndex, in writer);
|
||||
});
|
||||
_renderer.EndChunk(segmentIndex, in writer);
|
||||
}
|
||||
);
|
||||
_renderer.CommitChunkedSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ public sealed class Renderer2D : IDisposable
|
||||
private const int MaxQuadsPerDraw = 8192;
|
||||
private const int ParallelBlock = 4096;
|
||||
|
||||
private static readonly int VertexStride = VertexPositionColorTexture.VertexDeclaration.VertexStride;
|
||||
private static readonly int VertexStride = VertexPositionColorTexture
|
||||
.VertexDeclaration
|
||||
.VertexStride;
|
||||
|
||||
/// <summary>Render layer registry. Register layers before the first frame.</summary>
|
||||
public LayerRegistry Layers { get; } = new();
|
||||
@@ -25,6 +27,13 @@ public sealed class Renderer2D : IDisposable
|
||||
/// <summary>Camera state of the current frame. Valid between BeginFrame and the next BeginFrame.</summary>
|
||||
public CameraState Camera { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Global ambient light multiplied into every <see cref="LayerSpace.World"/> sprite — the engine
|
||||
/// hook a day/night cycle drives. <see cref="Color.White"/> (default) leaves the scene unchanged;
|
||||
/// screen-space layers (HUD overlays) are never tinted.
|
||||
/// </summary>
|
||||
public Color AmbientLight { get; set; } = Color.White;
|
||||
|
||||
/// <summary>Draw calls issued by the last <see cref="EndFrame"/>.</summary>
|
||||
public int DrawCalls { get; private set; }
|
||||
|
||||
@@ -67,10 +76,15 @@ public sealed class Renderer2D : IDisposable
|
||||
{
|
||||
_device = device;
|
||||
_options = options ?? new Renderer2DOptions();
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(_options.InitialCapacity, 1, nameof(options));
|
||||
_batcher = new SpriteBatcher(_options.InitialCapacity);
|
||||
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
|
||||
_vertexBuffer = new DynamicVertexBuffer(
|
||||
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly);
|
||||
device,
|
||||
VertexPositionColorTexture.VertexDeclaration,
|
||||
_vertices.Length * 2,
|
||||
BufferUsage.WriteOnly
|
||||
);
|
||||
|
||||
_effect = new BasicEffect(device)
|
||||
{
|
||||
@@ -90,7 +104,11 @@ public sealed class Renderer2D : IDisposable
|
||||
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);
|
||||
new Camera(new Vector2(virtualW / 2f, virtualH / 2f)),
|
||||
virtualW,
|
||||
virtualH,
|
||||
mapping
|
||||
);
|
||||
|
||||
_batcher.Clear();
|
||||
SubmittedSprites = 0;
|
||||
@@ -154,14 +172,18 @@ public sealed class Renderer2D : IDisposable
|
||||
if (count >= _options.ParallelThreshold)
|
||||
{
|
||||
var blocks = (count + ParallelBlock - 1) / ParallelBlock;
|
||||
Parallel.For(0, blocks, block =>
|
||||
{
|
||||
var end = Math.Min((block + 1) * ParallelBlock, count);
|
||||
for (var i = block * ParallelBlock; i < end; i++)
|
||||
Parallel.For(
|
||||
0,
|
||||
blocks,
|
||||
block =>
|
||||
{
|
||||
BuildVertex(order, i);
|
||||
var end = Math.Min((block + 1) * ParallelBlock, count);
|
||||
for (var i = block * ParallelBlock; i < end; i++)
|
||||
{
|
||||
BuildVertex(order, i);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -189,7 +211,14 @@ public sealed class Renderer2D : IDisposable
|
||||
hint = SetDataOptions.Discard;
|
||||
}
|
||||
|
||||
_vertexBuffer.SetData(_ringBaseVertex * VertexStride, _vertices, 0, vertexCount, VertexStride, hint);
|
||||
_vertexBuffer.SetData(
|
||||
_ringBaseVertex * VertexStride,
|
||||
_vertices,
|
||||
0,
|
||||
vertexCount,
|
||||
VertexStride,
|
||||
hint
|
||||
);
|
||||
_ringCursor = _ringBaseVertex + vertexCount;
|
||||
var uploadEnd = Stopwatch.GetTimestamp();
|
||||
UploadMs = ToMs(uploadEnd - buildEnd);
|
||||
@@ -201,7 +230,27 @@ public sealed class Renderer2D : IDisposable
|
||||
_device.SetVertexBuffer(_vertexBuffer);
|
||||
_device.Indices = _indexBuffer;
|
||||
|
||||
// Виртуальное разрешение рисуется в letterbox-прямоугольник — тот же, по которому
|
||||
// считают ScreenToWorld/WorldToScreen; иначе картинка растягивается мимо маппинга.
|
||||
var previousViewport = _device.Viewport;
|
||||
var letterboxed = _options.VirtualResolution is not null;
|
||||
if (letterboxed)
|
||||
{
|
||||
var mapping = Camera.Mapping;
|
||||
_device.Viewport = new Viewport(
|
||||
(int)MathF.Round(mapping.Offset.X),
|
||||
(int)MathF.Round(mapping.Offset.Y),
|
||||
(int)MathF.Round(Camera.VirtualWidth * mapping.Scale),
|
||||
(int)MathF.Round(Camera.VirtualHeight * mapping.Scale)
|
||||
);
|
||||
}
|
||||
|
||||
DrawBatches(order, count);
|
||||
if (letterboxed)
|
||||
{
|
||||
_device.Viewport = previousViewport;
|
||||
}
|
||||
|
||||
DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
|
||||
}
|
||||
|
||||
@@ -227,9 +276,14 @@ public sealed class Renderer2D : IDisposable
|
||||
_batcher.BeginChunks(chunkLengths);
|
||||
}
|
||||
|
||||
internal SpriteChunkWriter GetChunkWriter(int chunkIndex) => _batcher.GetChunkWriter(chunkIndex);
|
||||
internal SpriteChunkWriter GetChunkWriter(int chunkIndex) =>
|
||||
_batcher.GetChunkWriter(chunkIndex);
|
||||
|
||||
internal void SubmitInto(ref SpriteChunkWriter writer, in Transform2D transform, in Sprite sprite)
|
||||
internal void SubmitInto(
|
||||
ref SpriteChunkWriter writer,
|
||||
in Transform2D transform,
|
||||
in Sprite sprite
|
||||
)
|
||||
{
|
||||
switch (TryBuildInstance(in transform, in sprite, out var instance, out var key))
|
||||
{
|
||||
@@ -242,7 +296,8 @@ public sealed class Renderer2D : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) => _batcher.EndChunk(chunkIndex, in writer);
|
||||
internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) =>
|
||||
_batcher.EndChunk(chunkIndex, in writer);
|
||||
|
||||
internal void CommitChunkedSubmit()
|
||||
{
|
||||
@@ -258,7 +313,11 @@ public sealed class Renderer2D : IDisposable
|
||||
}
|
||||
|
||||
private SubmitResult TryBuildInstance(
|
||||
in Transform2D transform, in Sprite sprite, out SpriteInstance instance, out ulong key)
|
||||
in Transform2D transform,
|
||||
in Sprite sprite,
|
||||
out SpriteInstance instance,
|
||||
out ulong key
|
||||
)
|
||||
{
|
||||
instance = default;
|
||||
key = 0;
|
||||
@@ -268,23 +327,39 @@ public sealed class Renderer2D : IDisposable
|
||||
}
|
||||
|
||||
var layer = Layers[sprite.Layer];
|
||||
var (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin);
|
||||
var (center, radius) = CullingMath.SpriteBoundingCircle(
|
||||
in transform,
|
||||
region,
|
||||
sprite.Origin
|
||||
);
|
||||
|
||||
if (layer.Space == LayerSpace.World &&
|
||||
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
|
||||
if (
|
||||
layer.Space == LayerSpace.World
|
||||
&& !CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect)
|
||||
)
|
||||
{
|
||||
return SubmitResult.Culled;
|
||||
}
|
||||
|
||||
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
|
||||
// Y-sort по пивоту (Transform2D.Position), а не по центру квада: спрайты разной
|
||||
// высоты с origin «в ногах» сортируются по ногам, как принято в top-down.
|
||||
var depth = layer.SortMode == LayerSortMode.YSort ? transform.Position.Y : sprite.Depth;
|
||||
|
||||
// Ambient light tints world sprites (day/night); screen-space overlays stay at full brightness.
|
||||
var color =
|
||||
layer.Space == LayerSpace.World && AmbientLight != Color.White
|
||||
? new Color(sprite.Color.ToVector4() * AmbientLight.ToVector4())
|
||||
: sprite.Color;
|
||||
|
||||
instance = new SpriteInstance
|
||||
{
|
||||
Region = region,
|
||||
Center = center,
|
||||
HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f,
|
||||
HalfSize =
|
||||
new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y)
|
||||
/ 2f,
|
||||
Rotation = transform.Rotation,
|
||||
Color = sprite.Color,
|
||||
Color = color,
|
||||
Flip = sprite.Flip,
|
||||
Layer = sprite.Layer.Value,
|
||||
};
|
||||
@@ -297,7 +372,8 @@ public sealed class Renderer2D : IDisposable
|
||||
if (!_begun)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?).");
|
||||
"Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,8 +385,11 @@ public sealed class Renderer2D : IDisposable
|
||||
return (viewport.Width, viewport.Height, ViewportMapping.Identity);
|
||||
}
|
||||
|
||||
return (virtualSize.X, virtualSize.Y,
|
||||
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y));
|
||||
return (
|
||||
virtualSize.X,
|
||||
virtualSize.Y,
|
||||
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y)
|
||||
);
|
||||
}
|
||||
|
||||
private void BuildVertex(int[] order, int i)
|
||||
@@ -333,7 +412,8 @@ public sealed class Renderer2D : IDisposable
|
||||
(v0, v1) = (v1, v0);
|
||||
}
|
||||
|
||||
Vector2 rx, ry;
|
||||
Vector2 rx,
|
||||
ry;
|
||||
if (instance.Rotation == 0f)
|
||||
{
|
||||
rx = new Vector2(instance.HalfSize.X, 0f);
|
||||
@@ -410,7 +490,11 @@ public sealed class Renderer2D : IDisposable
|
||||
{
|
||||
pass.Apply();
|
||||
_device.DrawIndexedPrimitives(
|
||||
PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2);
|
||||
PrimitiveType.TriangleList,
|
||||
_ringBaseVertex + firstQuad * 4,
|
||||
0,
|
||||
quads * 2
|
||||
);
|
||||
DrawCalls++;
|
||||
}
|
||||
|
||||
@@ -439,15 +523,24 @@ public sealed class Renderer2D : IDisposable
|
||||
{
|
||||
_vertexBuffer.Dispose();
|
||||
_vertexBuffer = new DynamicVertexBuffer(
|
||||
_device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly);
|
||||
_device,
|
||||
VertexPositionColorTexture.VertexDeclaration,
|
||||
wantedBuffer,
|
||||
BufferUsage.WriteOnly
|
||||
);
|
||||
_ringCursor = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static float ToMs(long timestampDelta) => (float)timestampDelta * 1000f / Stopwatch.Frequency;
|
||||
private static float ToMs(long timestampDelta) =>
|
||||
(float)timestampDelta * 1000f / Stopwatch.Frequency;
|
||||
|
||||
private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) =>
|
||||
new(new Vector3(position, 0f), color, new Vector2(u, v));
|
||||
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)
|
||||
{
|
||||
@@ -464,7 +557,12 @@ public sealed class Renderer2D : IDisposable
|
||||
indices[index + 5] = (ushort)(vertex + 3);
|
||||
}
|
||||
|
||||
var buffer = new IndexBuffer(device, IndexElementSize.SixteenBits, indices.Length, BufferUsage.WriteOnly);
|
||||
var buffer = new IndexBuffer(
|
||||
device,
|
||||
IndexElementSize.SixteenBits,
|
||||
indices.Length,
|
||||
BufferUsage.WriteOnly
|
||||
);
|
||||
buffer.SetData(indices);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
@@ -13,15 +13,25 @@ public static class SceneGraphicsExtensions
|
||||
/// 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)
|
||||
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);
|
||||
renderer = new Renderer2D(scene.Context.GetGraphicsDevice(), options);
|
||||
services.Add(renderer);
|
||||
}
|
||||
else if (options is not null)
|
||||
{
|
||||
Log.Warning(
|
||||
"UseRenderer2D: the renderer already exists, the passed options are ignored "
|
||||
+ "(Renderer2D is a shared service configured by its first user)."
|
||||
);
|
||||
}
|
||||
|
||||
scene.DrawSystems.Add(new CameraSystem(renderer));
|
||||
scene.DrawSystems.Add(new SpriteRenderSystem(renderer));
|
||||
|
||||
@@ -19,11 +19,18 @@ public sealed class SpriteAnimationClip
|
||||
public float Duration => Frames.Count / FramesPerSecond;
|
||||
|
||||
/// <summary>Creates a clip.</summary>
|
||||
public SpriteAnimationClip(IReadOnlyList<Texture2DRegion> frames, float framesPerSecond = 12f, bool loop = true)
|
||||
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));
|
||||
throw new ArgumentException(
|
||||
"An animation clip needs at least one frame.",
|
||||
nameof(frames)
|
||||
);
|
||||
}
|
||||
|
||||
Frames = frames;
|
||||
|
||||
@@ -130,7 +130,8 @@ public sealed class SpriteBatcher
|
||||
|
||||
// Биты, различающиеся хотя бы у одной пары ключей: проходы по одинаковым
|
||||
// разрядам (один слой, одна глубина) пропускаются целиком.
|
||||
ulong orBits = 0, andBits = ~0UL;
|
||||
ulong orBits = 0,
|
||||
andBits = ~0UL;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
orBits |= _keys[i];
|
||||
|
||||
@@ -9,7 +9,9 @@ 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);
|
||||
((ulong)layer << 56)
|
||||
| ((ulong)DepthToSortableBits(depth) << 24)
|
||||
| ((uint)textureKey & 0xFF_FFFF);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a float to bits whose unsigned order matches the float order
|
||||
|
||||
@@ -35,22 +35,25 @@ public sealed class Texture2DRegion
|
||||
Texture = texture;
|
||||
Bounds = bounds;
|
||||
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
|
||||
Diagonal = MathF.Sqrt((float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height);
|
||||
Diagonal = MathF.Sqrt(
|
||||
(float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height
|
||||
);
|
||||
|
||||
// UV предрассчитаны один раз — в кадре на каждый спрайт экономятся 4 деления.
|
||||
// texture может быть null только в headless-тестах.
|
||||
if (texture is not null)
|
||||
{
|
||||
U0 = bounds.X / (float)texture.Width;
|
||||
V0 = bounds.Y / (float)texture.Height;
|
||||
U1 = (bounds.X + bounds.Width) / (float)texture.Width;
|
||||
V1 = (bounds.Y + bounds.Height) / (float)texture.Height;
|
||||
// Полутексельный inset: UV идут от центра крайнего текселя к центру крайнего, а не по
|
||||
// самым кромкам региона. С point-фильтрацией это гарантирует, что края тайла никогда не
|
||||
// сэмплят прозрачный «жёлоб» атласа (Padding) — иначе при дробном зуме видны чёрные швы.
|
||||
U0 = (bounds.X + 0.5f) / texture.Width;
|
||||
V0 = (bounds.Y + 0.5f) / texture.Height;
|
||||
U1 = (bounds.X + bounds.Width - 0.5f) / texture.Width;
|
||||
V1 = (bounds.Y + bounds.Height - 0.5f) / texture.Height;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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))
|
||||
{
|
||||
}
|
||||
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height)) { }
|
||||
}
|
||||
|
||||
+8
-3
@@ -12,8 +12,11 @@ public static class SceneTilemapExtensions
|
||||
/// </summary>
|
||||
public static void UseTilemaps(this Scene scene)
|
||||
{
|
||||
var renderer = scene.Context.Services.GetOrDefault<Renderer2D>()
|
||||
?? throw new InvalidOperationException("UseTilemaps requires UseRenderer2D to be called first.");
|
||||
var renderer =
|
||||
scene.Context.Services.GetOrDefault<Renderer2D>()
|
||||
?? throw new InvalidOperationException(
|
||||
"UseTilemaps requires UseRenderer2D to be called first."
|
||||
);
|
||||
|
||||
var systems = scene.DrawSystems.ChildSystems;
|
||||
for (var i = 0; i < systems.Count; i++)
|
||||
@@ -25,6 +28,8 @@ public static class SceneTilemapExtensions
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("RenderFlushSystem not found (is UseRenderer2D wired on this scene?).");
|
||||
throw new InvalidOperationException(
|
||||
"RenderFlushSystem not found (is UseRenderer2D wired on this scene?)."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,9 @@ public sealed class TileGrid
|
||||
if (!Contains(x, y))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(x), $"Cell ({x},{y}) is outside the {Width}x{Height} grid.");
|
||||
nameof(x),
|
||||
$"Cell ({x},{y}) is outside the {Width}x{Height} grid."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,7 @@ public readonly record struct TileDef(Texture2DRegion Region, Color Color)
|
||||
{
|
||||
/// <summary>Creates an untinted tile.</summary>
|
||||
public TileDef(Texture2DRegion region)
|
||||
: this(region, Color.White)
|
||||
{
|
||||
}
|
||||
: this(region, Color.White) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
+10
-2
@@ -11,8 +11,16 @@ public static class TilemapMath
|
||||
/// Returns false when the map is entirely outside the rectangle.
|
||||
/// </summary>
|
||||
public static bool VisibleCells(
|
||||
in RectF cullRect, Vector2 origin, float tileSize, int width, int height,
|
||||
out int x0, out int y0, out int x1, out int y1)
|
||||
in RectF cullRect,
|
||||
Vector2 origin,
|
||||
float tileSize,
|
||||
int width,
|
||||
int height,
|
||||
out int x0,
|
||||
out int y0,
|
||||
out int x1,
|
||||
out int y1
|
||||
)
|
||||
{
|
||||
x0 = Math.Max(0, (int)MathF.Floor((cullRect.Left - origin.X) / tileSize));
|
||||
y0 = Math.Max(0, (int)MathF.Floor((cullRect.Top - origin.Y) / tileSize));
|
||||
+33
-9
@@ -25,7 +25,11 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
|
||||
{
|
||||
foreach (ref readonly var map in maps.Span)
|
||||
{
|
||||
if (map.Grid is not { } grid || map.TileSet is not { } tileSet || map.TileSize <= 0f)
|
||||
if (
|
||||
map.Grid is not { } grid
|
||||
|| map.TileSet is not { } tileSet
|
||||
|| map.TileSize <= 0f
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -36,9 +40,19 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
|
||||
// Screen-space слои не куллятся камерой — рисуем весь грид.
|
||||
SubmitRange(in map, grid, tileSet, 0, 0, grid.Width - 1, grid.Height - 1);
|
||||
}
|
||||
else if (TilemapMath.VisibleCells(
|
||||
in cullRect, map.Origin, map.TileSize, grid.Width, grid.Height,
|
||||
out var x0, out var y0, out var x1, out var y1))
|
||||
else if (
|
||||
TilemapMath.VisibleCells(
|
||||
in cullRect,
|
||||
map.Origin,
|
||||
map.TileSize,
|
||||
grid.Width,
|
||||
grid.Height,
|
||||
out var x0,
|
||||
out var y0,
|
||||
out var x1,
|
||||
out var y1
|
||||
)
|
||||
)
|
||||
{
|
||||
SubmitRange(in map, grid, tileSet, x0, y0, x1, y1);
|
||||
}
|
||||
@@ -46,7 +60,15 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
|
||||
}
|
||||
}
|
||||
|
||||
private void SubmitRange(in Tilemap map, TileGrid grid, TileSet tileSet, int x0, int y0, int x1, int y1)
|
||||
private void SubmitRange(
|
||||
in Tilemap map,
|
||||
TileGrid grid,
|
||||
TileSet tileSet,
|
||||
int x0,
|
||||
int y0,
|
||||
int x1,
|
||||
int y1
|
||||
)
|
||||
{
|
||||
for (var y = y0; y <= y1; y++)
|
||||
{
|
||||
@@ -62,12 +84,14 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
|
||||
var region = def.Region;
|
||||
var transform = new Transform2D(
|
||||
map.Origin + new Vector2(x, y) * map.TileSize,
|
||||
scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height));
|
||||
scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height)
|
||||
);
|
||||
var sprite = new Sprite(region, map.Layer)
|
||||
{
|
||||
Color = map.Color == Color.White
|
||||
? def.Color
|
||||
: new Color(def.Color.ToVector4() * map.Color.ToVector4()),
|
||||
Color =
|
||||
map.Color == Color.White
|
||||
? def.Color
|
||||
: new Color(def.Color.ToVector4() * map.Color.ToVector4()),
|
||||
Depth = map.Depth,
|
||||
};
|
||||
_renderer.Submit(in transform, in sprite);
|
||||
@@ -1,11 +1,16 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Core;
|
||||
namespace MrGameEng.Host;
|
||||
|
||||
/// <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.
|
||||
/// The engine's windowed game-loop host. Wraps MonoGame's <see cref="Game"/>: owns the
|
||||
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/>, drives the active
|
||||
/// scene's update and draw phases and renders scene-transition overlays. The
|
||||
/// <see cref="GraphicsDevice"/> is published as a service so graphics modules can reach it
|
||||
/// through the context. For a loop without a window or GPU see
|
||||
/// <see cref="HeadlessHost"/> in the core.
|
||||
/// </summary>
|
||||
public class GameHost : Game
|
||||
{
|
||||
@@ -17,6 +22,7 @@ public class GameHost : Game
|
||||
|
||||
private readonly GameHostOptions _options;
|
||||
private readonly Scene _initialScene;
|
||||
private TransitionRenderer? _transitionRenderer;
|
||||
|
||||
/// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary>
|
||||
public GameHost(GameHostOptions options, Scene initialScene)
|
||||
@@ -29,6 +35,9 @@ public class GameHost : Game
|
||||
PreferredBackBufferWidth = options.Width,
|
||||
PreferredBackBufferHeight = options.Height,
|
||||
IsFullScreen = options.Fullscreen,
|
||||
// Borderless, как и обещает GameHostOptions.Fullscreen; по умолчанию MonoGame
|
||||
// делает эксклюзивное переключение видеорежима монитора.
|
||||
HardwareModeSwitch = false,
|
||||
SynchronizeWithVerticalRetrace = options.VSync,
|
||||
};
|
||||
|
||||
@@ -45,7 +54,7 @@ public class GameHost : Game
|
||||
{
|
||||
Window.Title = _options.Title;
|
||||
Window.AllowUserResizing = _options.AllowResizing;
|
||||
Context.AttachGraphicsDevice(GraphicsDevice);
|
||||
Context.Services.Add(GraphicsDevice);
|
||||
Context.Services.Add(Window);
|
||||
Context.Services.Add<Game>(this);
|
||||
base.Initialize();
|
||||
@@ -65,9 +74,25 @@ public class GameHost : Game
|
||||
{
|
||||
GraphicsDevice.Clear(_options.ClearColor);
|
||||
Context.Scenes.Draw(Context.Clock);
|
||||
DrawTransitionOverlay();
|
||||
base.Draw(gameTime);
|
||||
}
|
||||
|
||||
private void DrawTransitionOverlay()
|
||||
{
|
||||
if (Context.Scenes.ActiveTransition is not OverlayTransition transition)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_transitionRenderer ??= new TransitionRenderer(GraphicsDevice);
|
||||
transition.Draw(
|
||||
_transitionRenderer,
|
||||
Context.Scenes.TransitionCoverage,
|
||||
Context.Scenes.TransitionPhase
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnExiting(object sender, ExitingEventArgs args)
|
||||
{
|
||||
@@ -75,4 +100,19 @@ public class GameHost : Game
|
||||
Context.Scenes.ApplyPending();
|
||||
base.OnExiting(sender, args);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_transitionRenderer?.Dispose();
|
||||
_transitionRenderer = null;
|
||||
// GraphicsDevice зарегистрирован как сервис, но им владеет MonoGame:
|
||||
// base.Dispose сам его освобождает, реестру трогать нельзя.
|
||||
Context.DisposeOwnedResources(this, GraphicsDevice);
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace MrGameEng.Core;
|
||||
namespace MrGameEng.Host;
|
||||
|
||||
/// <summary>Window and loop settings for <see cref="GameHost"/>.</summary>
|
||||
public sealed class GameHostOptions
|
||||
@@ -7,7 +7,8 @@ namespace MrGameEng.Input;
|
||||
/// 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
|
||||
public sealed class ActionMap<TAction>
|
||||
where TAction : notnull
|
||||
{
|
||||
private readonly InputManager _input;
|
||||
private readonly Dictionary<TAction, List<Binding>> _bindings = new();
|
||||
@@ -18,37 +19,49 @@ public sealed class ActionMap<TAction> where TAction : notnull
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
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)));
|
||||
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)));
|
||||
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)));
|
||||
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) =>
|
||||
@@ -20,10 +20,13 @@ public enum MouseButton
|
||||
/// 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.
|
||||
/// at the start of the update phase. While <see cref="MrGameEng.Core.InputCapture.Captured"/>
|
||||
/// is set (e.g. the developer console is open), game-facing input reads as released.
|
||||
/// </summary>
|
||||
public sealed class InputManager
|
||||
{
|
||||
private readonly MrGameEng.Core.InputCapture? _capture;
|
||||
|
||||
private KeyboardState _keyboard;
|
||||
private KeyboardState _previousKeyboard;
|
||||
private MouseState _mouse;
|
||||
@@ -31,11 +34,38 @@ public sealed class InputManager
|
||||
private GamePadState _gamePad;
|
||||
private GamePadState _previousGamePad;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a manager. With a <paramref name="capture"/>, input is suppressed while a UI
|
||||
/// overlay holds it (keys/buttons read as released, mouse position and wheel freeze).
|
||||
/// </summary>
|
||||
public InputManager(MrGameEng.Core.InputCapture? capture = null) => _capture = capture;
|
||||
|
||||
/// <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));
|
||||
public void Update()
|
||||
{
|
||||
if (_capture?.Captured == true)
|
||||
{
|
||||
// Оверлей (консоль) захватил ввод: клавиши и кнопки считаются отпущенными,
|
||||
// позиция мыши и счётчик колеса замораживаются — все дельты нулевые.
|
||||
Apply(
|
||||
default,
|
||||
new MouseState(
|
||||
_mouse.X,
|
||||
_mouse.Y,
|
||||
_mouse.ScrollWheelValue,
|
||||
ButtonState.Released,
|
||||
ButtonState.Released,
|
||||
ButtonState.Released,
|
||||
ButtonState.Released,
|
||||
ButtonState.Released
|
||||
),
|
||||
default
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Apply(Keyboard.GetState(), Mouse.GetState(), GamePad.GetState(PlayerIndex.One));
|
||||
}
|
||||
|
||||
internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad)
|
||||
{
|
||||
@@ -51,10 +81,12 @@ public sealed class InputManager
|
||||
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);
|
||||
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);
|
||||
public bool IsKeyReleased(Keys key) =>
|
||||
_keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
|
||||
|
||||
/// <summary>Mouse cursor position in window pixels.</summary>
|
||||
public Point MousePosition => _mouse.Position;
|
||||
@@ -70,29 +102,34 @@ public sealed class InputManager
|
||||
|
||||
/// <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;
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -29,7 +29,14 @@ public static class SceneInputExtensions
|
||||
var input = services.GetOrDefault<InputManager>();
|
||||
if (input is null)
|
||||
{
|
||||
input = new InputManager();
|
||||
var capture = services.GetOrDefault<InputCapture>();
|
||||
if (capture is null)
|
||||
{
|
||||
capture = new InputCapture();
|
||||
services.Add(capture);
|
||||
}
|
||||
|
||||
input = new InputManager(capture);
|
||||
services.Add(input);
|
||||
}
|
||||
|
||||
+5
-3
@@ -1,15 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FontStashSharp.MonoGame" />
|
||||
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Host.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,18 +1,21 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace MrGameEng.Core;
|
||||
namespace MrGameEng.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal overlay renderer handed to <see cref="Transition.Draw"/>: fills rectangles in
|
||||
/// normalized screen coordinates (0..1 on both axes) over the rendered scene.
|
||||
/// Minimal overlay renderer handed to <see cref="OverlayTransition.Draw"/>: fills rectangles
|
||||
/// in normalized screen coordinates (0..1 on both axes) over the rendered scene.
|
||||
/// </summary>
|
||||
public sealed class TransitionRenderer
|
||||
public sealed class TransitionRenderer : IDisposable
|
||||
{
|
||||
private readonly GraphicsDevice _device;
|
||||
private readonly BasicEffect _effect;
|
||||
private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6];
|
||||
|
||||
/// <summary>Disposes the GPU effect. Called by <see cref="GameHost"/> on shutdown.</summary>
|
||||
public void Dispose() => _effect.Dispose();
|
||||
|
||||
internal TransitionRenderer(GraphicsDevice device)
|
||||
{
|
||||
_device = device;
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Host;
|
||||
|
||||
/// <summary>
|
||||
/// A scene transition that draws a full-screen overlay through a
|
||||
/// <see cref="TransitionRenderer"/>. The timing state machine lives in
|
||||
/// <see cref="SceneManager"/>; <see cref="GameHost"/> renders the overlay each draw while a
|
||||
/// transition is active. Transitions are stateless and reusable.
|
||||
/// </summary>
|
||||
public abstract class OverlayTransition : Transition
|
||||
{
|
||||
/// <summary>Creates a transition with explicit phase durations.</summary>
|
||||
protected OverlayTransition(float outDuration, float inDuration)
|
||||
: base(outDuration, inDuration) { }
|
||||
|
||||
/// <summary>
|
||||
/// Draws the overlay. <paramref name="coverage"/> is 0 (scene fully visible) to
|
||||
/// 1 (scene fully covered); <paramref name="phase"/> tells which side of the switch this is.
|
||||
/// </summary>
|
||||
public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase);
|
||||
}
|
||||
|
||||
/// <summary>Factories for the built-in visual scene transitions.</summary>
|
||||
public static class Transitions
|
||||
{
|
||||
/// <summary>Fade through a solid color (black by default). Total duration is split between out and in.</summary>
|
||||
public static Transition Fade(float duration = 0.6f, Color? color = null) =>
|
||||
new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
|
||||
|
||||
/// <summary>A curtain wiping across the screen (black by default). Total duration is split between out and in.</summary>
|
||||
public static Transition Wipe(float duration = 0.6f, Color? color = null) =>
|
||||
new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
|
||||
|
||||
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
|
||||
: OverlayTransition(outDuration, inDuration)
|
||||
{
|
||||
public override void Draw(
|
||||
TransitionRenderer renderer,
|
||||
float coverage,
|
||||
TransitionPhase phase
|
||||
) => renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
|
||||
}
|
||||
|
||||
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
|
||||
: OverlayTransition(outDuration, inDuration)
|
||||
{
|
||||
public override void Draw(
|
||||
TransitionRenderer renderer,
|
||||
float coverage,
|
||||
TransitionPhase phase
|
||||
)
|
||||
{
|
||||
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
|
||||
if (phase == TransitionPhase.Out)
|
||||
{
|
||||
renderer.Fill(0f, 0f, coverage, 1f, color);
|
||||
}
|
||||
else
|
||||
{
|
||||
renderer.Fill(1f - coverage, 0f, coverage, 1f, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<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,25 @@
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// A bidirectional, reliable, ordered binary message channel (a WebSocket under the hood).
|
||||
/// Receiving is poll-based to fit the simulation loop: incoming messages queue up on a
|
||||
/// background reader and are drained with <see cref="TryReceive"/> from the tick. Sending
|
||||
/// never blocks the caller. Implementations are safe to use from one simulation thread.
|
||||
/// </summary>
|
||||
public interface INetConnection
|
||||
{
|
||||
/// <summary>Connection id, unique within its owner (server-assigned; 0 for a client's own connection).</summary>
|
||||
int Id { get; }
|
||||
|
||||
/// <summary>False once the peer disconnected or the connection failed; sends become no-ops.</summary>
|
||||
bool IsOpen { get; }
|
||||
|
||||
/// <summary>Queues one binary message for delivery. No-op when the connection is closed.</summary>
|
||||
void Send(ReadOnlySpan<byte> message);
|
||||
|
||||
/// <summary>Dequeues the next received binary message, if any.</summary>
|
||||
bool TryReceive(out byte[] message);
|
||||
|
||||
/// <summary>Closes the connection.</summary>
|
||||
void Close();
|
||||
}
|
||||
+6
-4
@@ -1,16 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Tilemaps.Tests" />
|
||||
<PackageReference Include="Friflo.Engine.ECS" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Net.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
using Friflo.Engine.ECS;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an entity as replicated and identifies it across the network. The server assigns
|
||||
/// values (see <see cref="ReplicationServer.NextNetId"/>); the client creates a local
|
||||
/// entity with the same <see cref="Value"/> when the first snapshot arrives.
|
||||
/// </summary>
|
||||
public struct NetId : IComponent
|
||||
{
|
||||
/// <summary>Network-wide entity id, unique per server world.</summary>
|
||||
public int Value;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Client side of replication: applies snapshot messages from a
|
||||
/// <see cref="ReplicationServer"/> to a local <see cref="EntityStore"/>. Unknown net ids
|
||||
/// spawn local entities (carrying <see cref="NetId"/>), known ones get their changed
|
||||
/// components overwritten, despawns delete. The game decorates replicated entities with
|
||||
/// presentation components (sprites etc.) on top — replication never touches types outside
|
||||
/// its <see cref="ReplicationSchema"/>.
|
||||
/// </summary>
|
||||
public sealed class ReplicationClient
|
||||
{
|
||||
/// <summary>Number of replicated entities currently alive locally.</summary>
|
||||
public int EntityCount => _entities.Count;
|
||||
|
||||
/// <summary>Raised after an entity is created from a snapshot. Hook presentation setup here.</summary>
|
||||
public event Action<Entity>? EntitySpawned;
|
||||
|
||||
private readonly ReplicationSchema _schema;
|
||||
private readonly EntityStore _store;
|
||||
private readonly Dictionary<int, Entity> _entities = [];
|
||||
private bool _warnedVersion;
|
||||
|
||||
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
|
||||
public ReplicationClient(ReplicationSchema schema, EntityStore store)
|
||||
{
|
||||
_schema = schema;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
/// <summary>Applies every message queued on <paramref name="connection"/>.</summary>
|
||||
public void Pump(INetConnection connection)
|
||||
{
|
||||
while (connection.TryReceive(out var message))
|
||||
{
|
||||
Apply(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes every replicated entity and forgets all net ids. Call before reconnecting: the
|
||||
/// fresh connection receives the full world again with a clean id space, so stale entities
|
||||
/// from the previous session don't linger as duplicates.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var entity in _entities.Values)
|
||||
{
|
||||
entity.DeleteEntity();
|
||||
}
|
||||
|
||||
_entities.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Applies one snapshot message to the local store.</summary>
|
||||
public void Apply(byte[] message)
|
||||
{
|
||||
using var reader = new BinaryReader(new MemoryStream(message));
|
||||
// Заголовок: тип(1) + версия(1). Короче — точно не наш снапшот.
|
||||
if (message.Length < 2 || reader.ReadByte() != ReplicationMessage.Snapshot)
|
||||
{
|
||||
return; // незнакомый тип сообщения — пропускаем, это не снапшот
|
||||
}
|
||||
|
||||
var version = reader.ReadByte();
|
||||
if (version != ReplicationMessage.ProtocolVersion)
|
||||
{
|
||||
if (!_warnedVersion)
|
||||
{
|
||||
_warnedVersion = true;
|
||||
Log.Warning(
|
||||
$"Replication protocol mismatch: server v{version}, client "
|
||||
+ $"v{ReplicationMessage.ProtocolVersion} — snapshots dropped. Schemas out of sync."
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var slots = _schema.Slots;
|
||||
try
|
||||
{
|
||||
var count = reader.ReadInt32();
|
||||
for (var record = 0; record < count; record++)
|
||||
{
|
||||
var netId = reader.ReadInt32();
|
||||
var op = reader.ReadByte();
|
||||
if (op == ReplicationMessage.OpDespawn)
|
||||
{
|
||||
if (_entities.Remove(netId, out var dead))
|
||||
{
|
||||
dead.DeleteEntity();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var mask = reader.ReadUInt32();
|
||||
var spawned = false;
|
||||
if (!_entities.TryGetValue(netId, out var entity))
|
||||
{
|
||||
entity = _store.CreateEntity(new NetId { Value = netId });
|
||||
_entities[netId] = entity;
|
||||
spawned = true;
|
||||
}
|
||||
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
if ((mask & (1u << slot.Bit)) == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = reader.ReadBytes(slot.Size);
|
||||
if (data.Length < slot.Size)
|
||||
{
|
||||
return; // снапшот оборван на полпути — дальше читать нечего
|
||||
}
|
||||
|
||||
slot.Apply(entity, data, 0);
|
||||
}
|
||||
|
||||
if (spawned)
|
||||
{
|
||||
EntitySpawned?.Invoke(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EndOfStreamException)
|
||||
{
|
||||
// Структурно битый/усечённый снапшот — игнорируем остаток, соединение не роняем.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using Friflo.Engine.ECS;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// The set of component types a game replicates, registered in the same order on the
|
||||
/// server and every client (the order defines the wire ids). Components must be
|
||||
/// unmanaged structs — they are blitted to the wire as raw bytes, so server and client
|
||||
/// must run on the same engine version. Up to 32 types.
|
||||
/// </summary>
|
||||
public sealed class ReplicationSchema
|
||||
{
|
||||
internal sealed class ComponentSlot
|
||||
{
|
||||
public required int Bit;
|
||||
public required int Size;
|
||||
public required Func<Entity, byte[], bool> TryWrite;
|
||||
public required Action<Entity, byte[], int> Apply;
|
||||
}
|
||||
|
||||
internal readonly List<ComponentSlot> Slots = [];
|
||||
|
||||
/// <summary>
|
||||
/// Registers component type <typeparamref name="T"/> for replication. Returns this
|
||||
/// schema for fluent chaining.
|
||||
/// </summary>
|
||||
public ReplicationSchema Register<T>()
|
||||
where T : unmanaged, IComponent
|
||||
{
|
||||
if (Slots.Count == 32)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ReplicationSchema supports at most 32 component types."
|
||||
);
|
||||
}
|
||||
|
||||
var size = Unsafe.SizeOf<T>();
|
||||
Slots.Add(
|
||||
new ComponentSlot
|
||||
{
|
||||
Bit = Slots.Count,
|
||||
Size = size,
|
||||
TryWrite = (entity, buffer) =>
|
||||
{
|
||||
if (!entity.HasComponent<T>())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
MemoryMarshal.Write(buffer, in entity.GetComponent<T>());
|
||||
return true;
|
||||
},
|
||||
Apply = (entity, data, offset) =>
|
||||
{
|
||||
var value = MemoryMarshal.Read<T>(data.AsSpan(offset, size));
|
||||
entity.AddComponent(value);
|
||||
},
|
||||
}
|
||||
);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Friflo.Engine.ECS;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Server-authoritative component replication. Each call to <see cref="Send"/> snapshots
|
||||
/// every entity carrying <see cref="NetId"/> and sends each connection only what changed
|
||||
/// since that connection's previous snapshot (per-component deltas; a new connection gets
|
||||
/// the full state the same way). Deltas need no acknowledgements because the transport is
|
||||
/// reliable and ordered. Call at the desired send rate (e.g. every Nth simulation tick),
|
||||
/// from the simulation thread.
|
||||
/// </summary>
|
||||
public sealed class ReplicationServer
|
||||
{
|
||||
private sealed class ConnectionState
|
||||
{
|
||||
// По netId: последний отправленный блоб каждого зарегистрированного компонента.
|
||||
public readonly Dictionary<int, byte[]?[]> LastSent = [];
|
||||
}
|
||||
|
||||
private readonly ReplicationSchema _schema;
|
||||
private readonly ArchetypeQuery<NetId> _query;
|
||||
private readonly Dictionary<INetConnection, ConnectionState> _states = [];
|
||||
|
||||
// Снапшот текущего тика, переиспользуется между соединениями.
|
||||
private readonly List<(int NetId, byte[]?[] Components)> _current = [];
|
||||
private readonly HashSet<int> _currentIds = [];
|
||||
private int _nextNetId;
|
||||
|
||||
/// <summary>Creates a replication server over <paramref name="store"/>.</summary>
|
||||
public ReplicationServer(ReplicationSchema schema, EntityStore store)
|
||||
{
|
||||
_schema = schema;
|
||||
_query = store.Query<NetId>();
|
||||
}
|
||||
|
||||
/// <summary>Allocates the next free network id for a newly spawned replicated entity.</summary>
|
||||
public int NextNetId() => ++_nextNetId;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots the world once and sends per-connection deltas. Closed connections are
|
||||
/// forgotten; brand-new ones receive the full state.
|
||||
/// </summary>
|
||||
public void Send(IReadOnlyList<INetConnection> connections)
|
||||
{
|
||||
CaptureCurrentState();
|
||||
|
||||
foreach (var connection in connections)
|
||||
{
|
||||
if (!connection.IsOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_states.TryGetValue(connection, out var state))
|
||||
{
|
||||
state = new ConnectionState();
|
||||
_states[connection] = state;
|
||||
}
|
||||
|
||||
var message = BuildDelta(state);
|
||||
if (message is not null)
|
||||
{
|
||||
connection.Send(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Забываем состояние умерших соединений, чтобы не копить мусор.
|
||||
foreach (var dead in _states.Keys.Where(c => !c.IsOpen).ToList())
|
||||
{
|
||||
_states.Remove(dead);
|
||||
}
|
||||
}
|
||||
|
||||
private void CaptureCurrentState()
|
||||
{
|
||||
_current.Clear();
|
||||
_currentIds.Clear();
|
||||
var slots = _schema.Slots;
|
||||
_query.ForEachEntity(
|
||||
(ref NetId netId, Entity entity) =>
|
||||
{
|
||||
var components = new byte[]?[slots.Count];
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
var buffer = new byte[slot.Size];
|
||||
components[slot.Bit] = slot.TryWrite(entity, buffer) ? buffer : null;
|
||||
}
|
||||
|
||||
_current.Add((netId.Value, components));
|
||||
_currentIds.Add(netId.Value);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private byte[]? BuildDelta(ConnectionState state)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream);
|
||||
writer.Write(ReplicationMessage.Snapshot);
|
||||
writer.Write(ReplicationMessage.ProtocolVersion); // версия формата — клиент отвергает чужую
|
||||
var countPosition = stream.Position;
|
||||
writer.Write(0); // количество записей, допишем в конце
|
||||
var records = 0;
|
||||
|
||||
foreach (var (netId, components) in _current)
|
||||
{
|
||||
if (!state.LastSent.TryGetValue(netId, out var lastSent))
|
||||
{
|
||||
lastSent = new byte[]?[components.Length];
|
||||
state.LastSent[netId] = lastSent;
|
||||
}
|
||||
|
||||
uint mask = 0;
|
||||
for (var bit = 0; bit < components.Length; bit++)
|
||||
{
|
||||
var current = components[bit];
|
||||
if (current is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastSent[bit] is null || !current.AsSpan().SequenceEqual(lastSent[bit]))
|
||||
{
|
||||
mask |= 1u << bit;
|
||||
lastSent[bit] = current;
|
||||
}
|
||||
}
|
||||
|
||||
if (mask == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
writer.Write(netId);
|
||||
writer.Write(ReplicationMessage.OpUpsert);
|
||||
writer.Write(mask);
|
||||
for (var bit = 0; bit < components.Length; bit++)
|
||||
{
|
||||
if ((mask & (1u << bit)) != 0)
|
||||
{
|
||||
writer.Write(components[bit]!);
|
||||
}
|
||||
}
|
||||
|
||||
records++;
|
||||
}
|
||||
|
||||
// Сущности, которые соединение знает, а в мире их больше нет.
|
||||
foreach (var known in state.LastSent.Keys.Where(id => !_currentIds.Contains(id)).ToList())
|
||||
{
|
||||
state.LastSent.Remove(known);
|
||||
writer.Write(known);
|
||||
writer.Write(ReplicationMessage.OpDespawn);
|
||||
records++;
|
||||
}
|
||||
|
||||
if (records == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.Position = countPosition;
|
||||
writer.Write(records);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wire constants shared by <see cref="ReplicationServer"/> and <see cref="ReplicationClient"/>.</summary>
|
||||
internal static class ReplicationMessage
|
||||
{
|
||||
internal const byte Snapshot = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-format version. Bump whenever the snapshot layout or the meaning of the schema's
|
||||
/// component blits changes; a client receiving a mismatched version drops the message
|
||||
/// instead of decoding garbage (guards against a desync between server and client schemas).
|
||||
/// </summary>
|
||||
internal const byte ProtocolVersion = 1;
|
||||
|
||||
internal const byte OpUpsert = 0;
|
||||
internal const byte OpDespawn = 1;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Client side of <see cref="INetConnection"/>: a thin wrapper over the BCL
|
||||
/// <see cref="ClientWebSocket"/>, which works on desktop and inside Blazor WebAssembly
|
||||
/// (where it maps to the browser's WebSocket). Receiving runs on a background task into a
|
||||
/// queue; sends are chained fire-and-forget so the caller — and the browser's single
|
||||
/// thread — never blocks.
|
||||
/// </summary>
|
||||
public sealed class WebSocketClient : INetConnection, IDisposable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public int Id => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOpen => !_closed && _socket.State == WebSocketState.Open;
|
||||
|
||||
private readonly ClientWebSocket _socket;
|
||||
private readonly ConcurrentQueue<byte[]> _inbox = new();
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private Task _sendTail = Task.CompletedTask;
|
||||
private volatile bool _closed;
|
||||
|
||||
private WebSocketClient(ClientWebSocket socket) => _socket = socket;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to <paramref name="uri"/> (ws:// or wss://) and starts the receive loop.
|
||||
/// </summary>
|
||||
public static async Task<WebSocketClient> ConnectAsync(
|
||||
Uri uri,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var socket = new ClientWebSocket();
|
||||
await socket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false);
|
||||
var client = new WebSocketClient(socket);
|
||||
_ = Task.Run(client.ReceiveLoop);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Send(ReadOnlySpan<byte> message)
|
||||
{
|
||||
if (!IsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var copy = message.ToArray();
|
||||
// Отправки сцеплены в хвост: ClientWebSocket не терпит параллельных SendAsync,
|
||||
// а блокировать поток нельзя (в wasm это смерть).
|
||||
lock (_shutdown)
|
||||
{
|
||||
_sendTail = _sendTail.ContinueWith(
|
||||
_ => SendCore(copy),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.None,
|
||||
TaskScheduler.Default
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendCore(byte[] message)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _socket
|
||||
.SendAsync(
|
||||
message,
|
||||
WebSocketMessageType.Binary,
|
||||
endOfMessage: true,
|
||||
_shutdown.Token
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Close()
|
||||
{
|
||||
if (_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_closed = true;
|
||||
_shutdown.Cancel();
|
||||
_socket.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Closes the connection.</summary>
|
||||
public void Dispose() => Close();
|
||||
|
||||
/// <summary>Largest reassembled message accepted from the server before the connection is dropped.</summary>
|
||||
public const int MaxMessageBytes = 16 * 1024 * 1024;
|
||||
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
var message = new MemoryStream();
|
||||
try
|
||||
{
|
||||
while (!_closed)
|
||||
{
|
||||
var result = await _socket
|
||||
.ReceiveAsync(buffer, _shutdown.Token)
|
||||
.ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.Length + result.Count > MaxMessageBytes)
|
||||
{
|
||||
break; // сервер шлёт ненормально большое сообщение — рвём соединение
|
||||
}
|
||||
|
||||
message.Write(buffer, 0, result.Count);
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
if (result.MessageType == WebSocketMessageType.Binary)
|
||||
{
|
||||
_inbox.Enqueue(message.ToArray());
|
||||
}
|
||||
|
||||
message.SetLength(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// обрыв или закрытие — штатное завершение цикла
|
||||
}
|
||||
finally
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>WebSocket frame opcodes used by the server.</summary>
|
||||
internal enum WebSocketOpcode : byte
|
||||
{
|
||||
Continuation = 0x0,
|
||||
Text = 0x1,
|
||||
Binary = 0x2,
|
||||
Close = 0x8,
|
||||
Ping = 0x9,
|
||||
Pong = 0xA,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal RFC 6455 building blocks for the server side: the upgrade handshake and frame
|
||||
/// encode/decode over a <see cref="Stream"/>. Kept free of sockets so the protocol logic is
|
||||
/// unit-testable; <see cref="WebSocketServer"/> wires it to TCP.
|
||||
/// </summary>
|
||||
internal static class WebSocketProtocol
|
||||
{
|
||||
private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
/// <summary>Computes the Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key.</summary>
|
||||
internal static string AcceptKey(string secWebSocketKey)
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(secWebSocketKey + HandshakeGuid);
|
||||
return Convert.ToBase64String(SHA1.HashData(bytes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the HTTP upgrade request from <paramref name="stream"/> (up to the blank line)
|
||||
/// and extracts the Sec-WebSocket-Key header. Returns false on a malformed request.
|
||||
/// </summary>
|
||||
internal static bool TryReadHandshakeKey(Stream stream, out string key)
|
||||
{
|
||||
key = "";
|
||||
var buffer = new byte[8 * 1024];
|
||||
var length = 0;
|
||||
// Читаем до конца заголовков (\r\n\r\n); запрос маленький, побайтовое чтение не больно.
|
||||
while (length < buffer.Length)
|
||||
{
|
||||
var read = stream.Read(buffer, length, 1);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
length++;
|
||||
if (
|
||||
length >= 4
|
||||
&& buffer[length - 4] == (byte)'\r'
|
||||
&& buffer[length - 3] == (byte)'\n'
|
||||
&& buffer[length - 2] == (byte)'\r'
|
||||
&& buffer[length - 1] == (byte)'\n'
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var request = Encoding.ASCII.GetString(buffer, 0, length);
|
||||
foreach (var line in request.Split("\r\n"))
|
||||
{
|
||||
var separator = line.IndexOf(':');
|
||||
if (separator < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
line[..separator]
|
||||
.Trim()
|
||||
.Equals("Sec-WebSocket-Key", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
{
|
||||
key = line[(separator + 1)..].Trim();
|
||||
return key.Length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Writes the 101 Switching Protocols response completing the handshake.</summary>
|
||||
internal static void WriteHandshakeResponse(Stream stream, string secWebSocketKey)
|
||||
{
|
||||
var response =
|
||||
"HTTP/1.1 101 Switching Protocols\r\n"
|
||||
+ "Upgrade: websocket\r\n"
|
||||
+ "Connection: Upgrade\r\n"
|
||||
+ $"Sec-WebSocket-Accept: {AcceptKey(secWebSocketKey)}\r\n"
|
||||
+ "\r\n";
|
||||
var bytes = Encoding.ASCII.GetBytes(response);
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes one complete (FIN) frame. Server frames are unmasked per RFC 6455;
|
||||
/// <paramref name="maskKey"/> is for tests that emulate a client.
|
||||
/// </summary>
|
||||
internal static byte[] EncodeFrame(
|
||||
ReadOnlySpan<byte> payload,
|
||||
WebSocketOpcode opcode,
|
||||
byte[]? maskKey = null
|
||||
)
|
||||
{
|
||||
var masked = maskKey is not null;
|
||||
var headerLength =
|
||||
2
|
||||
+ payload.Length switch
|
||||
{
|
||||
<= 125 => 0,
|
||||
<= ushort.MaxValue => 2,
|
||||
_ => 8,
|
||||
};
|
||||
var frame = new byte[headerLength + (masked ? 4 : 0) + payload.Length];
|
||||
frame[0] = (byte)(0x80 | (byte)opcode);
|
||||
switch (payload.Length)
|
||||
{
|
||||
case <= 125:
|
||||
frame[1] = (byte)payload.Length;
|
||||
break;
|
||||
case <= ushort.MaxValue:
|
||||
frame[1] = 126;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), (ushort)payload.Length);
|
||||
break;
|
||||
default:
|
||||
frame[1] = 127;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(frame.AsSpan(2), (ulong)payload.Length);
|
||||
break;
|
||||
}
|
||||
|
||||
var offset = headerLength;
|
||||
if (masked)
|
||||
{
|
||||
frame[1] |= 0x80;
|
||||
maskKey!.CopyTo(frame, offset);
|
||||
offset += 4;
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
{
|
||||
frame[offset + i] = (byte)(payload[i] ^ maskKey[i % 4]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
payload.CopyTo(frame.AsSpan(offset));
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one frame. Returns false on a clean end of stream. Masked payloads are unmasked.
|
||||
/// </summary>
|
||||
internal static bool TryReadFrame(
|
||||
Stream stream,
|
||||
out WebSocketOpcode opcode,
|
||||
out bool fin,
|
||||
out byte[] payload
|
||||
)
|
||||
{
|
||||
opcode = WebSocketOpcode.Close;
|
||||
fin = true;
|
||||
payload = [];
|
||||
|
||||
var header = new byte[2];
|
||||
if (!TryReadExactly(stream, header))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fin = (header[0] & 0x80) != 0;
|
||||
opcode = (WebSocketOpcode)(header[0] & 0x0F);
|
||||
var masked = (header[1] & 0x80) != 0;
|
||||
long length = header[1] & 0x7F;
|
||||
if (length == 126)
|
||||
{
|
||||
var extended = new byte[2];
|
||||
if (!TryReadExactly(stream, extended))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
length = BinaryPrimitives.ReadUInt16BigEndian(extended);
|
||||
}
|
||||
else if (length == 127)
|
||||
{
|
||||
var extended = new byte[8];
|
||||
if (!TryReadExactly(stream, extended))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
length = (long)BinaryPrimitives.ReadUInt64BigEndian(extended);
|
||||
}
|
||||
|
||||
if (length > MaxPayloadBytes)
|
||||
{
|
||||
return false; // защита от злонамеренной длины — соединение закроется
|
||||
}
|
||||
|
||||
var maskKey = new byte[4];
|
||||
if (masked && !TryReadExactly(stream, maskKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = new byte[length];
|
||||
if (!TryReadExactly(stream, payload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (masked)
|
||||
{
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
{
|
||||
payload[i] ^= maskKey[i % 4];
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Upper bound for a single frame payload accepted by the server.</summary>
|
||||
internal const int MaxPayloadBytes = 16 * 1024 * 1024;
|
||||
|
||||
private static bool TryReadExactly(Stream stream, byte[] buffer)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = stream.Read(buffer, offset, buffer.Length - offset);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Net;
|
||||
|
||||
/// <summary>
|
||||
/// A dependency-free WebSocket server (RFC 6455 over <see cref="TcpListener"/>) for
|
||||
/// dedicated servers. Accepting and reading happen on background tasks; the simulation
|
||||
/// drains new connections with <see cref="TryAcceptConnection"/> and reads messages by
|
||||
/// polling each connection — nothing here touches the ECS world from another thread.
|
||||
/// Binary messages only; incoming pings are answered automatically and the server itself
|
||||
/// heartbeats each connection, closing any that goes silent past <see cref="IdleTimeout"/>
|
||||
/// (detects half-open TCP — a peer that vanished without a close frame). A reassembled
|
||||
/// message is capped at <see cref="MaxMessageBytes"/> so a peer can't exhaust memory.
|
||||
/// </summary>
|
||||
public sealed class WebSocketServer : IDisposable
|
||||
{
|
||||
/// <summary>Largest reassembled (possibly fragmented) message accepted from a peer.</summary>
|
||||
public const int MaxMessageBytes = WebSocketProtocol.MaxPayloadBytes;
|
||||
|
||||
/// <summary>The port the server listens on.</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>How often the server pings each connection to keep it alive and probe liveness.</summary>
|
||||
public TimeSpan HeartbeatInterval { get; }
|
||||
|
||||
/// <summary>A connection with no traffic for longer than this is considered dead and closed.</summary>
|
||||
public TimeSpan IdleTimeout { get; }
|
||||
|
||||
/// <summary>Snapshot of currently open connections.</summary>
|
||||
public IReadOnlyList<INetConnection> Connections
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
return _connections.Where(c => c.IsOpen).Cast<INetConnection>().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly TcpListener _listener;
|
||||
private readonly List<ServerConnection> _connections = [];
|
||||
private readonly ConcurrentQueue<ServerConnection> _accepted = new();
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private int _nextConnectionId;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/>
|
||||
/// to listen. <paramref name="heartbeatInterval"/> (default 10 s) sets how often each
|
||||
/// connection is pinged; <paramref name="idleTimeout"/> (default 30 s) how long a silent
|
||||
/// connection lives before it's dropped as dead. The timeout must exceed the interval so a
|
||||
/// healthy peer's pong lands before it's judged idle.
|
||||
/// </summary>
|
||||
public WebSocketServer(
|
||||
int port,
|
||||
TimeSpan? heartbeatInterval = null,
|
||||
TimeSpan? idleTimeout = null
|
||||
)
|
||||
{
|
||||
Port = port;
|
||||
HeartbeatInterval = heartbeatInterval ?? TimeSpan.FromSeconds(10);
|
||||
IdleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30);
|
||||
_listener = new TcpListener(IPAddress.Any, port);
|
||||
}
|
||||
|
||||
/// <summary>Starts listening and accepting connections in the background.</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_started = true;
|
||||
_listener.Start();
|
||||
Task.Run(AcceptLoop);
|
||||
Task.Run(HeartbeatLoop);
|
||||
Log.Info($"WebSocketServer listening on port {Port}");
|
||||
}
|
||||
|
||||
/// <summary>Dequeues a connection that completed its handshake since the last call.</summary>
|
||||
public bool TryAcceptConnection(out INetConnection connection)
|
||||
{
|
||||
if (_accepted.TryDequeue(out var accepted))
|
||||
{
|
||||
connection = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
connection = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Stops listening and closes every connection.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_shutdown.Cancel();
|
||||
_listener.Stop();
|
||||
lock (_connections)
|
||||
{
|
||||
foreach (var connection in _connections)
|
||||
{
|
||||
connection.Close();
|
||||
}
|
||||
|
||||
_connections.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcceptLoop()
|
||||
{
|
||||
while (!_shutdown.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try
|
||||
{
|
||||
client = await _listener.AcceptTcpClientAsync(_shutdown.Token);
|
||||
}
|
||||
catch (Exception) when (_shutdown.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Warning($"WebSocketServer accept failed: {exception.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = Task.Run(() => Handshake(client));
|
||||
}
|
||||
}
|
||||
|
||||
// Пингует живые соединения и закрывает те, что молчат дольше IdleTimeout (мёртвый peer
|
||||
// не отвечает pong'ом — его активность не обновляется и он отваливается по таймауту).
|
||||
private async Task HeartbeatLoop()
|
||||
{
|
||||
while (!_shutdown.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(HeartbeatInterval, _shutdown.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ServerConnection[] snapshot;
|
||||
lock (_connections)
|
||||
{
|
||||
snapshot = _connections.ToArray();
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var connection in snapshot)
|
||||
{
|
||||
if (!connection.IsOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now - connection.LastActivityUtc > IdleTimeout)
|
||||
{
|
||||
Log.Info($"WebSocketServer: connection #{connection.Id} timed out (idle)");
|
||||
connection.Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
connection.SendPing();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Handshake(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
var stream = client.GetStream();
|
||||
if (!WebSocketProtocol.TryReadHandshakeKey(stream, out var key))
|
||||
{
|
||||
client.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
WebSocketProtocol.WriteHandshakeResponse(stream, key);
|
||||
var connection = new ServerConnection(
|
||||
Interlocked.Increment(ref _nextConnectionId),
|
||||
client
|
||||
);
|
||||
lock (_connections)
|
||||
{
|
||||
_connections.RemoveAll(c => !c.IsOpen);
|
||||
_connections.Add(connection);
|
||||
}
|
||||
|
||||
_accepted.Enqueue(connection);
|
||||
connection.StartReceiveLoop();
|
||||
Log.Info($"WebSocketServer: connection #{connection.Id} accepted");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.Warning($"WebSocketServer handshake failed: {exception.Message}");
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ServerConnection : INetConnection
|
||||
{
|
||||
public int Id { get; }
|
||||
public bool IsOpen => !_closed;
|
||||
|
||||
/// <summary>UTC of the last frame received from the peer — drives idle-timeout detection.</summary>
|
||||
public DateTime LastActivityUtc =>
|
||||
new(Volatile.Read(ref _lastActivityTicks), DateTimeKind.Utc);
|
||||
|
||||
private readonly TcpClient _client;
|
||||
private readonly NetworkStream _stream;
|
||||
private readonly ConcurrentQueue<byte[]> _inbox = new();
|
||||
private readonly object _sendLock = new();
|
||||
private volatile bool _closed;
|
||||
private long _lastActivityTicks;
|
||||
|
||||
internal ServerConnection(int id, TcpClient client)
|
||||
{
|
||||
Id = id;
|
||||
_client = client;
|
||||
_stream = client.GetStream();
|
||||
_lastActivityTicks = DateTime.UtcNow.Ticks;
|
||||
}
|
||||
|
||||
internal void StartReceiveLoop() => Task.Run(ReceiveLoop);
|
||||
|
||||
/// <summary>Sends a heartbeat ping; a live peer answers with a pong, refreshing activity.</summary>
|
||||
internal void SendPing() => SendControl(WebSocketOpcode.Ping, []);
|
||||
|
||||
public void Send(ReadOnlySpan<byte> message)
|
||||
{
|
||||
if (_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var frame = WebSocketProtocol.EncodeFrame(message, WebSocketOpcode.Binary);
|
||||
try
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
_stream.Write(frame, 0, frame.Length);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void SendControl(WebSocketOpcode opcode, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var frame = WebSocketProtocol.EncodeFrame(payload, opcode);
|
||||
try
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
_stream.Write(frame, 0, frame.Length);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (_closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_closed = true;
|
||||
try
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// соединение уже мертво — закрытие не должно бросать
|
||||
}
|
||||
}
|
||||
|
||||
private void ReceiveLoop()
|
||||
{
|
||||
var pending = new List<byte>();
|
||||
var pendingOpcode = WebSocketOpcode.Binary;
|
||||
try
|
||||
{
|
||||
while (!_closed)
|
||||
{
|
||||
if (
|
||||
!WebSocketProtocol.TryReadFrame(
|
||||
_stream,
|
||||
out var opcode,
|
||||
out var fin,
|
||||
out var payload
|
||||
)
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Любой кадр (включая pong) — признак жизни: сбрасываем счётчик простоя.
|
||||
Volatile.Write(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
|
||||
|
||||
switch (opcode)
|
||||
{
|
||||
case WebSocketOpcode.Ping:
|
||||
SendControl(WebSocketOpcode.Pong, payload);
|
||||
continue;
|
||||
case WebSocketOpcode.Pong:
|
||||
continue;
|
||||
case WebSocketOpcode.Close:
|
||||
SendControl(WebSocketOpcode.Close, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opcode != WebSocketOpcode.Continuation)
|
||||
{
|
||||
pendingOpcode = opcode;
|
||||
pending.Clear();
|
||||
}
|
||||
|
||||
if (pending.Count + payload.Length > MaxMessageBytes)
|
||||
{
|
||||
Log.Warning(
|
||||
$"WebSocketServer: connection #{Id} exceeded {MaxMessageBytes}-byte "
|
||||
+ "message cap — closing"
|
||||
);
|
||||
return; // finally закроет соединение
|
||||
}
|
||||
|
||||
pending.AddRange(payload);
|
||||
if (fin && pendingOpcode == WebSocketOpcode.Binary)
|
||||
{
|
||||
_inbox.Enqueue(pending.ToArray());
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// обрыв соединения — штатный путь завершения цикла
|
||||
}
|
||||
finally
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace MrGameEng.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A small typed key/value store for an agent's working memory: perceived facts, a current target, a
|
||||
/// cached path goal — whatever the considerations and actions need to share without being threaded
|
||||
/// through method signatures. Keys are case-sensitive strings; values are stored boxed, so the
|
||||
/// blackboard is a convenience for cold paths (perception, planning), not the per-frame hot loop.
|
||||
/// </summary>
|
||||
public sealed class Blackboard
|
||||
{
|
||||
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>The number of keys currently stored.</summary>
|
||||
public int Count => _values.Count;
|
||||
|
||||
/// <summary>Stores <paramref name="value"/> under <paramref name="key"/>, replacing any existing entry.</summary>
|
||||
public void Set<T>(string key, T value) => _values[key] = value;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the value under <paramref name="key"/> as <typeparamref name="T"/>. Returns <c>false</c> when
|
||||
/// the key is missing or holds a value of a different type.
|
||||
/// </summary>
|
||||
public bool TryGet<T>(string key, out T value)
|
||||
{
|
||||
if (_values.TryGetValue(key, out var stored) && stored is T typed)
|
||||
{
|
||||
value = typed;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the value under <paramref name="key"/>, or returns <paramref name="fallback"/> when the key is
|
||||
/// missing or holds a different type.
|
||||
/// </summary>
|
||||
public T GetOrDefault<T>(string key, T fallback = default!) =>
|
||||
TryGet<T>(key, out var value) ? value : fallback;
|
||||
|
||||
/// <summary>True when <paramref name="key"/> has a value (of any type).</summary>
|
||||
public bool Has(string key) => _values.ContainsKey(key);
|
||||
|
||||
/// <summary>Removes <paramref name="key"/>. Returns true when it was present.</summary>
|
||||
public bool Remove(string key) => _values.Remove(key);
|
||||
|
||||
/// <summary>Drops every stored value.</summary>
|
||||
public void Clear() => _values.Clear();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace MrGameEng.AI;
|
||||
|
||||
/// <summary>
|
||||
/// One input to a utility decision. It reads a raw value from the agent's context, normalizes it to
|
||||
/// <c>[0,1]</c> against an expected range, and shapes it through a <see cref="ResponseCurve"/> into a
|
||||
/// utility score. Considerations are stateless and reusable: the context carries everything that
|
||||
/// varies. <typeparamref name="TContext"/> is whatever the game passes in — a struct of perceived
|
||||
/// values, an entity handle, a blackboard — the AI module never owns it.
|
||||
/// </summary>
|
||||
public sealed class Consideration<TContext>
|
||||
{
|
||||
private readonly Func<TContext, float> _input;
|
||||
private readonly float _min;
|
||||
private readonly float _inverseSpan;
|
||||
private readonly ResponseCurve _curve;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a consideration named <paramref name="name"/> that reads <paramref name="input"/> from the
|
||||
/// context, normalizes it from <c>[<paramref name="min"/>, <paramref name="max"/>]</c> to <c>[0,1]</c>
|
||||
/// (values outside the range clamp to the ends), then applies <paramref name="curve"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException"><paramref name="max"/> is not greater than <paramref name="min"/>.</exception>
|
||||
public Consideration(
|
||||
string name,
|
||||
Func<TContext, float> input,
|
||||
float min = 0f,
|
||||
float max = 1f,
|
||||
ResponseCurve? curve = null
|
||||
)
|
||||
{
|
||||
if (max <= min)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"max ({max}) must be greater than min ({min}).",
|
||||
nameof(max)
|
||||
);
|
||||
}
|
||||
|
||||
Name = name;
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
_min = min;
|
||||
_inverseSpan = 1f / (max - min);
|
||||
// default(ResponseCurve) has slope 0 (always 0), so omitting the curve means the identity.
|
||||
_curve = curve ?? ResponseCurve.Identity;
|
||||
}
|
||||
|
||||
/// <summary>A human-readable label, surfaced in debug/console output.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>Reads the context and returns this consideration's utility in <c>[0,1]</c>.</summary>
|
||||
public float Score(TContext context)
|
||||
{
|
||||
var normalized = Math.Clamp((_input(context) - _min) * _inverseSpan, 0f, 1f);
|
||||
return _curve.Evaluate(normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace MrGameEng.AI;
|
||||
|
||||
/// <summary>Shape of a <see cref="ResponseCurve"/> mapping a normalized input to a utility.</summary>
|
||||
public enum CurveType
|
||||
{
|
||||
/// <summary>Straight line: <c>y = slope·(x − xShift) + yShift</c>.</summary>
|
||||
Linear,
|
||||
|
||||
/// <summary>Power curve: <c>y = slope·(x − xShift)^exponent + yShift</c>; the exponent eases in/out.</summary>
|
||||
Polynomial,
|
||||
|
||||
/// <summary>S-shaped logistic centred on <c>xShift</c>; <c>exponent</c> is the steepness.</summary>
|
||||
Logistic,
|
||||
|
||||
/// <summary>Hermite smoothstep over <c>[xShift, xShift + 1/slope]</c>; flat ends, smooth middle.</summary>
|
||||
SmoothStep,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a normalized input in <c>[0,1]</c> to a utility in <c>[0,1]</c> through one of a few
|
||||
/// shapes. The input is clamped before evaluation and the output is clamped after, so a curve is
|
||||
/// always safe to feed a raw normalized <see cref="Consideration{TContext}"/> value. Curves are
|
||||
/// immutable value types — build them once and reuse them across evaluations.
|
||||
/// </summary>
|
||||
public readonly struct ResponseCurve
|
||||
{
|
||||
/// <summary>The shape applied by <see cref="Evaluate"/>.</summary>
|
||||
public CurveType Type { get; }
|
||||
|
||||
/// <summary>Vertical scale / steepness (the <c>m</c> term). See <see cref="CurveType"/> per shape.</summary>
|
||||
public float Slope { get; }
|
||||
|
||||
/// <summary>Power for <see cref="CurveType.Polynomial"/> and steepness for <see cref="CurveType.Logistic"/>.</summary>
|
||||
public float Exponent { get; }
|
||||
|
||||
/// <summary>Horizontal shift of the curve (the <c>c</c> term): the input value mapped to the origin.</summary>
|
||||
public float XShift { get; }
|
||||
|
||||
/// <summary>Vertical shift of the curve (the <c>b</c> term) added after scaling.</summary>
|
||||
public float YShift { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a curve from raw parameters. Prefer the named factories
|
||||
/// (<see cref="Linear"/>, <see cref="Polynomial"/>, <see cref="Logistic"/>, <see cref="SmoothStep"/>)
|
||||
/// which document the meaning of each term for their shape.
|
||||
/// </summary>
|
||||
public ResponseCurve(
|
||||
CurveType type,
|
||||
float slope = 1f,
|
||||
float exponent = 1f,
|
||||
float xShift = 0f,
|
||||
float yShift = 0f
|
||||
)
|
||||
{
|
||||
Type = type;
|
||||
Slope = slope;
|
||||
Exponent = exponent;
|
||||
XShift = xShift;
|
||||
YShift = yShift;
|
||||
}
|
||||
|
||||
/// <summary>The identity curve: <c>y = x</c>. The default when a consideration needs no shaping.</summary>
|
||||
public static ResponseCurve Identity => new(CurveType.Linear);
|
||||
|
||||
/// <summary>Straight line <c>y = slope·(x − xShift) + yShift</c>. A negative slope inverts the input.</summary>
|
||||
public static ResponseCurve Linear(float slope = 1f, float xShift = 0f, float yShift = 0f) =>
|
||||
new(CurveType.Linear, slope, 1f, xShift, yShift);
|
||||
|
||||
/// <summary>
|
||||
/// Power curve <c>y = slope·(x − xShift)^exponent + yShift</c>. An exponent above 1 eases in
|
||||
/// (slow start), below 1 eases out (fast start). Quadratic is <c>exponent = 2</c>.
|
||||
/// </summary>
|
||||
public static ResponseCurve Polynomial(
|
||||
float exponent,
|
||||
float slope = 1f,
|
||||
float xShift = 0f,
|
||||
float yShift = 0f
|
||||
) => new(CurveType.Polynomial, slope, exponent, xShift, yShift);
|
||||
|
||||
/// <summary>
|
||||
/// Logistic S-curve centred on <paramref name="midpoint"/>; <paramref name="steepness"/> controls how
|
||||
/// sharp the transition is (≈10 gives a soft threshold, larger is more switch-like).
|
||||
/// </summary>
|
||||
public static ResponseCurve Logistic(float steepness = 10f, float midpoint = 0.5f) =>
|
||||
new(CurveType.Logistic, 1f, steepness, midpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Hermite smoothstep rising from 0 to 1 over <c>[xShift, xShift + 1/slope]</c>: flat below the
|
||||
/// start, flat above the end, smooth in between. Default rises across the whole <c>[0,1]</c> range.
|
||||
/// </summary>
|
||||
public static ResponseCurve SmoothStep(float slope = 1f, float xShift = 0f) =>
|
||||
new(CurveType.SmoothStep, slope, 1f, xShift);
|
||||
|
||||
/// <summary>Evaluates the curve. <paramref name="x"/> is clamped to <c>[0,1]</c>; the result is clamped to <c>[0,1]</c>.</summary>
|
||||
public float Evaluate(float x)
|
||||
{
|
||||
x = Math.Clamp(x, 0f, 1f);
|
||||
var y = Type switch
|
||||
{
|
||||
CurveType.Linear => Slope * (x - XShift) + YShift,
|
||||
CurveType.Polynomial => Slope * MathF.Pow(x - XShift, Exponent) + YShift,
|
||||
CurveType.Logistic => 1f / (1f + MathF.Exp(-Exponent * (x - XShift))) * Slope + YShift,
|
||||
CurveType.SmoothStep => SmoothStepValue(x),
|
||||
_ => x,
|
||||
};
|
||||
|
||||
return Math.Clamp(y, 0f, 1f);
|
||||
}
|
||||
|
||||
private float SmoothStepValue(float x)
|
||||
{
|
||||
var t = Math.Clamp((x - XShift) * Slope, 0f, 1f);
|
||||
return t * t * (3f - 2f * t) + YShift;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user