Compare commits
17
Commits
44019a706e
...
main
@@ -27,14 +27,26 @@ is the living showcase — new engine features are demonstrated there.
|
|||||||
|
|
||||||
Engine libraries (each feature is a namespaced subfolder of its host):
|
Engine libraries (each feature is a namespaced subfolder of its host):
|
||||||
|
|
||||||
- **`Core`** — game loop, ECS world, scenes, time (`GameClock` with `TimeScale`; `GameSpeed`
|
- **`Core`** — the platform-free kernel: ECS world, scenes and transition timing, time
|
||||||
for discrete pause/1×/3×/6× speed control over the clock, `context.UseGameSpeed(...)`;
|
(`GameClock` with `TimeScale`; `GameSpeed` for discrete pause/1×/3×/6× speed control over
|
||||||
`Calendar` turning scaled time into in-game days, `context.UseCalendar(secondsPerDay)`),
|
the clock, `context.UseGameSpeed(...)`; `Calendar` turning scaled time into in-game days,
|
||||||
plus **Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
|
`context.UseCalendar(secondsPerDay)`; `Climate` — continuous seasonal/daily temperature
|
||||||
`Core/Input/`). Depends only on MonoGame and Friflo.Engine.ECS.
|
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**
|
- **`Graphics`** — custom batched renderer, camera, sprites, plus **Tilemaps**
|
||||||
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
|
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
|
||||||
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`). → `Core`.
|
`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`
|
- **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume`
|
||||||
(one knob for effects + music). → `Core`.
|
(one knob for effects + music). → `Core`.
|
||||||
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
|
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
|
||||||
@@ -52,6 +64,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
|
|||||||
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
|
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
|
||||||
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
|
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
|
||||||
→ `Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`).
|
→ `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()`),
|
- **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`),
|
||||||
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
|
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
|
||||||
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
|
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
|
||||||
@@ -63,9 +82,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
|
|||||||
netstandard2.0 analyzer.
|
netstandard2.0 analyzer.
|
||||||
|
|
||||||
Dependency rule: a library may depend only on `Core` and `Graphics`; `Core` depends only
|
Dependency rule: a library may depend only on `Core` and `Graphics`; `Core` depends only
|
||||||
on MonoGame and Friflo.Engine.ECS. Features grouped into one library share its package
|
on Friflo.Engine.ECS (no MonoGame — the simulation must run headless). MonoGame is pulled
|
||||||
set (e.g. `Content` carries both FontStash and StbImage) — keep optional/heavy deps
|
in by the platform/graphics libraries (`Host`, `Graphics`, `Audio`). Platform resources
|
||||||
(Myra, NVorbis) in their own library so the rest of the engine stays free of them.
|
(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
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
@@ -13,5 +12,4 @@
|
|||||||
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('MrGameEng.')) AND !$(MSBuildProjectName.EndsWith('.Tests')) AND !$(MSBuildProjectName.EndsWith('.Generator')) AND !$(MSBuildProjectName.EndsWith('.Sample'))">
|
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('MrGameEng.')) AND !$(MSBuildProjectName.EndsWith('.Tests')) AND !$(MSBuildProjectName.EndsWith('.Generator')) AND !$(MSBuildProjectName.EndsWith('.Sample'))">
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "tests
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{4407F6E6-0B65-41A3-ADFA-B78684A9B918}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{4407F6E6-0B65-41A3-ADFA-B78684A9B918}"
|
||||||
EndProject
|
EndProject
|
||||||
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -229,6 +237,54 @@ Global
|
|||||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -249,5 +305,9 @@ Global
|
|||||||
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272} = {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}
|
{7F7D9641-2409-40CB-88A9-56BCE8C90A45} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
+28
-14
@@ -33,11 +33,13 @@
|
|||||||
|
|
||||||
| Библиотека (сборка) | Фичи (неймспейсы) и ответственность |
|
| Библиотека (сборка) | Фичи (неймспейсы) и ответственность |
|
||||||
|------------------------------|------------------------------------------------------------|
|
|------------------------------|------------------------------------------------------------|
|
||||||
| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`), жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps |
|
| `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.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер |
|
| `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.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.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.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.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) |
|
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) |
|
||||||
|
|
||||||
@@ -69,19 +71,28 @@
|
|||||||
### Правило зависимостей
|
### Правило зависимостей
|
||||||
|
|
||||||
```
|
```
|
||||||
MrGameEng.Audio ─┐
|
MrGameEng.Host ─┐
|
||||||
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
|
MrGameEng.Audio ─┤
|
||||||
│ └──► Friflo.Engine.ECS
|
MrGameEng.Net ─┤
|
||||||
|
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
|
||||||
|
│
|
||||||
MrGameEng.Content ─┤
|
MrGameEng.Content ─┤
|
||||||
MrGameEng.Simulation ─┤ (Content — за Texture2DRegion,
|
MrGameEng.Simulation ─┤ (Content — за Texture2DRegion,
|
||||||
MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер)
|
MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер)
|
||||||
```
|
```
|
||||||
|
|
||||||
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от MonoGame
|
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от Friflo —
|
||||||
и Friflo. Если двум библиотекам нужен общий тип — он переезжает в `Core` (или, если это
|
ни MonoGame, ни другой платформы: благодаря этому симуляция запускается и без окна
|
||||||
графический тип, в `Graphics`). `Content` тянет `Graphics` (атласы выдают
|
(`HeadlessHost`). MonoGame (`MonoGame.Framework.DesktopGL`) тянут платформенные и
|
||||||
`Texture2DRegion`); `Simulation` тянет `Graphics` (`Collisions` использует `Transform2D`,
|
графические библиотеки: `Host`, `Graphics`, `Audio` (и транзитивно их потребители).
|
||||||
`RectF`); `UI` — только `Core` (Myra рисует своим SpriteBatch).
|
Платформенные ресурсы (например `GraphicsDevice`) хосты публикуют сервисами в
|
||||||
|
`EngineContext.Services`; графический код достаёт девайс через
|
||||||
|
`context.GetGraphicsDevice()` (расширение в `Graphics`). На `Host` не зависит никто,
|
||||||
|
кроме самой игры — это точка входа оконной платформы. Если двум библиотекам нужен общий
|
||||||
|
тип — он переезжает в `Core` (или, если это графический тип, в `Graphics`). `Content`
|
||||||
|
тянет `Graphics` (атласы выдают `Texture2DRegion`); `Simulation` тянет `Graphics`
|
||||||
|
(`Collisions` использует `Transform2D`, `RectF`); `UI` — только `Core` (Myra рисует
|
||||||
|
своим SpriteBatch).
|
||||||
|
|
||||||
Фичи, собранные в одну библиотеку, делят её набор пакетов (например, `Content` несёт и
|
Фичи, собранные в одну библиотеку, делят её набор пакетов (например, `Content` несёт и
|
||||||
FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в
|
FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в
|
||||||
@@ -345,12 +356,15 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
|
|||||||
|
|
||||||
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
|
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
|
||||||
следующего кадра (сцена никогда не выгружается посреди собственного кадра).
|
следующего кадра (сцена никогда не выгружается посреди собственного кадра).
|
||||||
- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом:
|
- `Scenes.Switch(scene, Transitions.Fade(0.5f))` — переключение с визуальным переходом:
|
||||||
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
|
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
|
||||||
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
|
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
|
||||||
- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)`
|
- Тайминг-машина (`Transition` — длительности фаз, покрытие) живёт в `Core` и работает
|
||||||
(шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill`
|
и в headless-контексте; визуал — в `Host`: встроенные `Transitions.Fade(duration, color)`
|
||||||
в нормализованных координатах экрана).
|
и `Transitions.Wipe(duration, color)` (шторка). Свои — наследованием от
|
||||||
|
`OverlayTransition` (рисование через `TransitionRenderer.Fill` в нормализованных
|
||||||
|
координатах экрана); `GameHost` рисует оверлей поверх сцены, читая
|
||||||
|
`Scenes.ActiveTransition`/`TransitionCoverage`/`TransitionPhase`.
|
||||||
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
|
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
|
||||||
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
|
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||||
<PackageReference Include="NVorbis" />
|
<PackageReference Include="NVorbis" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FontStashSharp;
|
|||||||
using Microsoft.Xna.Framework.Audio;
|
using Microsoft.Xna.Framework.Audio;
|
||||||
using Microsoft.Xna.Framework.Graphics;
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
using MrGameEng.Core;
|
using MrGameEng.Core;
|
||||||
|
using MrGameEng.Graphics;
|
||||||
|
|
||||||
namespace MrGameEng.Assets;
|
namespace MrGameEng.Assets;
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ public sealed class AssetManager : IDisposable
|
|||||||
{
|
{
|
||||||
using var stream = File.OpenRead(path);
|
using var stream = File.OpenRead(path);
|
||||||
return Texture2D.FromStream(
|
return Texture2D.FromStream(
|
||||||
context.GraphicsDevice,
|
context.GetGraphicsDevice(),
|
||||||
stream,
|
stream,
|
||||||
DefaultColorProcessors.PremultiplyAlpha
|
DefaultColorProcessors.PremultiplyAlpha
|
||||||
);
|
);
|
||||||
@@ -123,7 +124,7 @@ public sealed class AssetManager : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Effect LoadEffect(EngineContext context, string path) =>
|
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>
|
/// <summary>Wires the assets module into the engine.</summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using MrGameEng.Assets;
|
using MrGameEng.Assets;
|
||||||
using MrGameEng.Core;
|
using MrGameEng.Core;
|
||||||
|
using MrGameEng.Graphics;
|
||||||
|
|
||||||
namespace MrGameEng.Atlases;
|
namespace MrGameEng.Atlases;
|
||||||
|
|
||||||
@@ -14,6 +15,6 @@ public static class AtlasesEngineExtensions
|
|||||||
public static void UseTextureAtlases(this EngineContext context)
|
public static void UseTextureAtlases(this EngineContext context)
|
||||||
{
|
{
|
||||||
var assets = context.Services.Get<AssetManager>();
|
var assets = context.Services.Get<AssetManager>();
|
||||||
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
|
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GetGraphicsDevice(), path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace MrGameEng.Mods;
|
namespace MrGameEng.Mods;
|
||||||
|
|
||||||
@@ -24,6 +25,18 @@ public sealed class DefDatabase
|
|||||||
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<Type, TypeEntry> _byType = [];
|
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()
|
private static readonly JsonDocumentOptions DocumentOptions = new()
|
||||||
{
|
{
|
||||||
CommentHandling = JsonCommentHandling.Skip,
|
CommentHandling = JsonCommentHandling.Skip,
|
||||||
@@ -43,6 +56,38 @@ public sealed class DefDatabase
|
|||||||
_byType.Add(typeof(T), entry);
|
_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>
|
/// <summary>
|
||||||
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
|
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
|
||||||
/// resolves inheritance. Call once after registering all def types.
|
/// resolves inheritance. Call once after registering all def types.
|
||||||
@@ -66,6 +111,8 @@ public sealed class DefDatabase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyPatches();
|
||||||
|
|
||||||
foreach (var entry in _byKey.Values)
|
foreach (var entry in _byKey.Values)
|
||||||
{
|
{
|
||||||
Resolve(entry);
|
Resolve(entry);
|
||||||
@@ -133,6 +180,12 @@ public sealed class DefDatabase
|
|||||||
?? throw new InvalidDataException(
|
?? throw new InvalidDataException(
|
||||||
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."
|
$"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))
|
if (!_byKey.TryGetValue(typeKey, out var entry))
|
||||||
{
|
{
|
||||||
throw new InvalidDataException(
|
throw new InvalidDataException(
|
||||||
@@ -169,8 +222,83 @@ public sealed class DefDatabase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Resolve(TypeEntry entry)
|
// Парсит файл-патч: операции { 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))
|
foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
var merged = MergeChain(entry, defName, []);
|
var merged = MergeChain(entry, defName, []);
|
||||||
@@ -179,6 +307,11 @@ public sealed class DefDatabase
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rules is not null)
|
||||||
|
{
|
||||||
|
Validate(entry.Key, defName, merged, rules);
|
||||||
|
}
|
||||||
|
|
||||||
var def =
|
var def =
|
||||||
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
|
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
|
||||||
?? throw new InvalidDataException(
|
?? throw new InvalidDataException(
|
||||||
@@ -188,6 +321,28 @@ public sealed class DefDatabase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
|
||||||
{
|
{
|
||||||
if (!seen.Add(defName))
|
if (!seen.Add(defName))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace MrGameEng.Mods;
|
namespace MrGameEng.Mods;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ public sealed class ModInfo
|
|||||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
AllowTrailingCommas = true,
|
AllowTrailingCommas = true,
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
|
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
|
||||||
|
|||||||
@@ -10,16 +10,21 @@ namespace MrGameEng.Core;
|
|||||||
public sealed class Calendar
|
public sealed class Calendar
|
||||||
{
|
{
|
||||||
private readonly GameClock _clock;
|
private readonly GameClock _clock;
|
||||||
|
private readonly double _startDay;
|
||||||
private float _secondsPerDay;
|
private float _secondsPerDay;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a calendar reading <paramref name="clock"/>; one day spans
|
/// Creates a calendar reading <paramref name="clock"/>; one day spans
|
||||||
/// <paramref name="secondsPerDay"/> seconds of scaled time (must be positive).
|
/// <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>
|
/// </summary>
|
||||||
public Calendar(GameClock clock, float secondsPerDay)
|
public Calendar(GameClock clock, float secondsPerDay, double startDay = 0.0)
|
||||||
{
|
{
|
||||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
SecondsPerDay = secondsPerDay;
|
SecondsPerDay = secondsPerDay;
|
||||||
|
_startDay = startDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary>
|
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary>
|
||||||
@@ -36,8 +41,8 @@ public sealed class Calendar
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4).</summary>
|
/// <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;
|
public double TotalDays => _clock.TotalTime / _secondsPerDay + _startDay;
|
||||||
|
|
||||||
/// <summary>The current day number, counting from 1.</summary>
|
/// <summary>The current day number, counting from 1.</summary>
|
||||||
public int Day => (int)TotalDays + 1;
|
public int Day => (int)TotalDays + 1;
|
||||||
@@ -51,6 +56,15 @@ public sealed class Calendar
|
|||||||
return (float)(days - Math.Floor(days));
|
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>
|
/// <summary>Wires the in-game calendar into the engine.</summary>
|
||||||
@@ -59,11 +73,16 @@ public static class CalendarEngineExtensions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a <see cref="Calendar"/> bound to the context's clock and registers it as a
|
/// 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
|
/// service. Call once per world. <paramref name="secondsPerDay"/> is the scaled-time length
|
||||||
/// of one in-game day (must be positive).
|
/// 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>
|
/// </summary>
|
||||||
public static Calendar UseCalendar(this EngineContext context, float secondsPerDay)
|
public static Calendar UseCalendar(
|
||||||
|
this EngineContext context,
|
||||||
|
float secondsPerDay,
|
||||||
|
double startDay = 0.0
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var calendar = new Calendar(context.Clock, secondsPerDay);
|
var calendar = new Calendar(context.Clock, secondsPerDay, startDay);
|
||||||
context.Services.Add(calendar);
|
context.Services.Add(calendar);
|
||||||
return 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;
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Root object handed to scenes and systems: time, scene manager, services and graphics device.
|
/// Root object handed to scenes and systems: time, scene manager and services.
|
||||||
/// Created by <see cref="GameHost"/>; can also be created standalone for headless tests.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class EngineContext
|
public sealed class EngineContext
|
||||||
{
|
{
|
||||||
@@ -17,38 +18,16 @@ public sealed class EngineContext
|
|||||||
/// <summary>Registry of module services (input, audio, assets, …).</summary>
|
/// <summary>Registry of module services (input, audio, assets, …).</summary>
|
||||||
public ServiceRegistry Services { get; } = new();
|
public ServiceRegistry Services { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Creates a context. Games normally never create one themselves — the host does.</summary>
|
||||||
/// The graphics device. Available once the host is initialized;
|
|
||||||
/// throws when accessed in a headless context (unit tests).
|
|
||||||
/// </summary>
|
|
||||||
public GraphicsDevice GraphicsDevice =>
|
|
||||||
_graphicsDevice
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
"GraphicsDevice is not available (headless context)."
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>True when a graphics device is attached.</summary>
|
|
||||||
public bool HasGraphicsDevice => _graphicsDevice is not null;
|
|
||||||
|
|
||||||
private GraphicsDevice? _graphicsDevice;
|
|
||||||
|
|
||||||
/// <summary>Creates a context. Games normally never create one themselves — <see cref="GameHost"/> does.</summary>
|
|
||||||
public EngineContext()
|
public EngineContext()
|
||||||
{
|
{
|
||||||
Scenes = new SceneManager(this);
|
Scenes = new SceneManager(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services
|
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services.
|
||||||
/// and the transition renderer. <paramref name="except"/> (the host itself, also a
|
/// Instances in <paramref name="except"/> (the host itself, platform resources the host
|
||||||
/// registered service) is skipped — it is being disposed by the caller already.
|
/// disposes on its own) are skipped. Called by hosts on shutdown.
|
||||||
/// Called by <see cref="GameHost.Dispose(bool)"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal void DisposeOwnedResources(object except)
|
internal void DisposeOwnedResources(params object[] except) => Services.DisposeServices(except);
|
||||||
{
|
|
||||||
Scenes.DisposeRenderer();
|
|
||||||
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>
|
/// <summary>
|
||||||
/// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class GameClock
|
public sealed class GameClock
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
|
||||||
<PackageReference Include="Friflo.Engine.ECS" />
|
<PackageReference Include="Friflo.Engine.ECS" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
|
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
|
||||||
|
<InternalsVisibleTo Include="MrGameEng.Host" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -22,13 +22,24 @@ public sealed class SceneManager
|
|||||||
/// <summary>True while a transition is covering or revealing.</summary>
|
/// <summary>True while a transition is covering or revealing.</summary>
|
||||||
public bool IsTransitioning => _state != State.Idle;
|
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 readonly EngineContext _context;
|
||||||
private Scene? _pending;
|
private Scene? _pending;
|
||||||
private bool _hasPending;
|
private bool _hasPending;
|
||||||
private Transition? _transition;
|
private Transition? _transition;
|
||||||
private State _state;
|
private State _state;
|
||||||
private float _coverage;
|
private float _coverage;
|
||||||
private TransitionRenderer? _renderer;
|
|
||||||
|
|
||||||
internal SceneManager(EngineContext context) => _context = context;
|
internal SceneManager(EngineContext context) => _context = context;
|
||||||
|
|
||||||
@@ -100,27 +111,9 @@ public sealed class SceneManager
|
|||||||
Current?.Update(clock);
|
Current?.Update(clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Draws the active scene and the transition overlay on top. Called by the host.</summary>
|
/// <summary>Draws the active scene. Transition overlays are rendered by the host on top,
|
||||||
public void Draw(GameClock clock)
|
/// from <see cref="ActiveTransition"/> and <see cref="TransitionCoverage"/>.</summary>
|
||||||
{
|
public void Draw(GameClock clock) => Current?.Draw(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>Disposes the lazily created transition renderer. Called on host shutdown.</summary>
|
|
||||||
internal void DisposeRenderer()
|
|
||||||
{
|
|
||||||
_renderer?.Dispose();
|
|
||||||
_renderer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void ApplyPending()
|
internal void ApplyPending()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,16 +40,17 @@ public sealed class ServiceRegistry
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disposes every registered <see cref="IDisposable"/> service (each instance once, even
|
/// Disposes every registered <see cref="IDisposable"/> service (each instance once, even
|
||||||
/// when registered under several types) and clears the registry. <paramref name="except"/>
|
/// when registered under several types) and clears the registry. Instances in
|
||||||
/// is skipped. Called on host shutdown.
|
/// <paramref name="except"/> are skipped. Called on host shutdown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal void DisposeServices(object? except = null)
|
internal void DisposeServices(params object[] except)
|
||||||
{
|
{
|
||||||
|
var skipped = new HashSet<object>(except, ReferenceEqualityComparer.Instance);
|
||||||
var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||||
foreach (var service in _services.Values)
|
foreach (var service in _services.Values)
|
||||||
{
|
{
|
||||||
if (
|
if (
|
||||||
!ReferenceEquals(service, except)
|
!skipped.Contains(service)
|
||||||
&& service is IDisposable disposable
|
&& service is IDisposable disposable
|
||||||
&& disposed.Add(service)
|
&& disposed.Add(service)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using Microsoft.Xna.Framework;
|
|
||||||
|
|
||||||
namespace MrGameEng.Core;
|
namespace MrGameEng.Core;
|
||||||
|
|
||||||
/// <summary>Phase of a scene transition.</summary>
|
/// <summary>Phase of a scene transition.</summary>
|
||||||
@@ -13,10 +11,12 @@ public enum TransitionPhase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Visual transition between scenes. The scene switch itself happens at full coverage,
|
/// Timing of a visual transition between scenes. The scene switch itself happens at full
|
||||||
/// so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
|
/// 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"/>.
|
/// Progress is tracked by <see cref="SceneManager"/> on unscaled time, so transitions work
|
||||||
/// Runs on unscaled time, so it works while gameplay is paused.
|
/// 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>
|
/// </summary>
|
||||||
public abstract class Transition
|
public abstract class Transition
|
||||||
{
|
{
|
||||||
@@ -32,49 +32,4 @@ public abstract class Transition
|
|||||||
OutDuration = Math.Max(0f, outDuration);
|
OutDuration = Math.Max(0f, outDuration);
|
||||||
InDuration = Math.Max(0f, inDuration);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ public readonly struct CameraState
|
|||||||
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
|
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
|
||||||
public required ViewportMapping Mapping { get; init; }
|
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>
|
/// <summary>Converts a physical screen point to world coordinates.</summary>
|
||||||
public Vector2 ScreenToWorld(Vector2 screen)
|
public Vector2 ScreenToWorld(Vector2 screen)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)."
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
|
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ public sealed class Renderer2D : IDisposable
|
|||||||
/// <summary>Camera state of the current frame. Valid between BeginFrame and the next BeginFrame.</summary>
|
/// <summary>Camera state of the current frame. Valid between BeginFrame and the next BeginFrame.</summary>
|
||||||
public CameraState Camera { get; private set; }
|
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>
|
/// <summary>Draw calls issued by the last <see cref="EndFrame"/>.</summary>
|
||||||
public int DrawCalls { get; private set; }
|
public int DrawCalls { get; private set; }
|
||||||
|
|
||||||
@@ -338,6 +345,12 @@ public sealed class Renderer2D : IDisposable
|
|||||||
// высоты с origin «в ногах» сортируются по ногам, как принято в top-down.
|
// высоты с origin «в ногах» сортируются по ногам, как принято в top-down.
|
||||||
var depth = layer.SortMode == LayerSortMode.YSort ? transform.Position.Y : sprite.Depth;
|
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
|
instance = new SpriteInstance
|
||||||
{
|
{
|
||||||
Region = region,
|
Region = region,
|
||||||
@@ -346,7 +359,7 @@ public sealed class Renderer2D : IDisposable
|
|||||||
new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y)
|
new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y)
|
||||||
/ 2f,
|
/ 2f,
|
||||||
Rotation = transform.Rotation,
|
Rotation = transform.Rotation,
|
||||||
Color = sprite.Color,
|
Color = color,
|
||||||
Flip = sprite.Flip,
|
Flip = sprite.Flip,
|
||||||
Layer = sprite.Layer.Value,
|
Layer = sprite.Layer.Value,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public static class SceneGraphicsExtensions
|
|||||||
var renderer = services.GetOrDefault<Renderer2D>();
|
var renderer = services.GetOrDefault<Renderer2D>();
|
||||||
if (renderer is null)
|
if (renderer is null)
|
||||||
{
|
{
|
||||||
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
|
renderer = new Renderer2D(scene.Context.GetGraphicsDevice(), options);
|
||||||
services.Add(renderer);
|
services.Add(renderer);
|
||||||
}
|
}
|
||||||
else if (options is not null)
|
else if (options is not null)
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ public sealed class Texture2DRegion
|
|||||||
// texture может быть null только в headless-тестах.
|
// texture может быть null только в headless-тестах.
|
||||||
if (texture is not null)
|
if (texture is not null)
|
||||||
{
|
{
|
||||||
U0 = bounds.X / (float)texture.Width;
|
// Полутексельный inset: UV идут от центра крайнего текселя к центру крайнего, а не по
|
||||||
V0 = bounds.Y / (float)texture.Height;
|
// самым кромкам региона. С point-фильтрацией это гарантирует, что края тайла никогда не
|
||||||
U1 = (bounds.X + bounds.Width) / (float)texture.Width;
|
// сэмплят прозрачный «жёлоб» атласа (Padding) — иначе при дробном зуме видны чёрные швы.
|
||||||
V1 = (bounds.Y + bounds.Height) / (float)texture.Height;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
using Microsoft.Xna.Framework;
|
using Microsoft.Xna.Framework;
|
||||||
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
|
||||||
namespace MrGameEng.Core;
|
namespace MrGameEng.Host;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The engine's game loop host. Wraps MonoGame's <see cref="Game"/>: owns the
|
/// The engine's windowed game-loop host. Wraps MonoGame's <see cref="Game"/>: owns the
|
||||||
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/> and drives the
|
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/>, drives the active
|
||||||
/// active scene's update and draw phases.
|
/// 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>
|
/// </summary>
|
||||||
public class GameHost : Game
|
public class GameHost : Game
|
||||||
{
|
{
|
||||||
@@ -17,6 +22,7 @@ public class GameHost : Game
|
|||||||
|
|
||||||
private readonly GameHostOptions _options;
|
private readonly GameHostOptions _options;
|
||||||
private readonly Scene _initialScene;
|
private readonly Scene _initialScene;
|
||||||
|
private TransitionRenderer? _transitionRenderer;
|
||||||
|
|
||||||
/// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary>
|
/// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary>
|
||||||
public GameHost(GameHostOptions options, Scene initialScene)
|
public GameHost(GameHostOptions options, Scene initialScene)
|
||||||
@@ -48,7 +54,7 @@ public class GameHost : Game
|
|||||||
{
|
{
|
||||||
Window.Title = _options.Title;
|
Window.Title = _options.Title;
|
||||||
Window.AllowUserResizing = _options.AllowResizing;
|
Window.AllowUserResizing = _options.AllowResizing;
|
||||||
Context.AttachGraphicsDevice(GraphicsDevice);
|
Context.Services.Add(GraphicsDevice);
|
||||||
Context.Services.Add(Window);
|
Context.Services.Add(Window);
|
||||||
Context.Services.Add<Game>(this);
|
Context.Services.Add<Game>(this);
|
||||||
base.Initialize();
|
base.Initialize();
|
||||||
@@ -68,9 +74,25 @@ public class GameHost : Game
|
|||||||
{
|
{
|
||||||
GraphicsDevice.Clear(_options.ClearColor);
|
GraphicsDevice.Clear(_options.ClearColor);
|
||||||
Context.Scenes.Draw(Context.Clock);
|
Context.Scenes.Draw(Context.Clock);
|
||||||
|
DrawTransitionOverlay();
|
||||||
base.Draw(gameTime);
|
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 />
|
/// <inheritdoc />
|
||||||
protected override void OnExiting(object sender, ExitingEventArgs args)
|
protected override void OnExiting(object sender, ExitingEventArgs args)
|
||||||
{
|
{
|
||||||
@@ -84,7 +106,11 @@ public class GameHost : Game
|
|||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
{
|
{
|
||||||
Context.DisposeOwnedResources(except: this);
|
_transitionRenderer?.Dispose();
|
||||||
|
_transitionRenderer = null;
|
||||||
|
// GraphicsDevice зарегистрирован как сервис, но им владеет MonoGame:
|
||||||
|
// base.Dispose сам его освобождает, реестру трогать нельзя.
|
||||||
|
Context.DisposeOwnedResources(this, GraphicsDevice);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Xna.Framework;
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
namespace MrGameEng.Core;
|
namespace MrGameEng.Host;
|
||||||
|
|
||||||
/// <summary>Window and loop settings for <see cref="GameHost"/>.</summary>
|
/// <summary>Window and loop settings for <see cref="GameHost"/>.</summary>
|
||||||
public sealed class GameHostOptions
|
public sealed class GameHostOptions
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="MrGameEng.Host.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using Microsoft.Xna.Framework;
|
using Microsoft.Xna.Framework;
|
||||||
using Microsoft.Xna.Framework.Graphics;
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
|
||||||
namespace MrGameEng.Core;
|
namespace MrGameEng.Host;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal overlay renderer handed to <see cref="Transition.Draw"/>: fills rectangles in
|
/// Minimal overlay renderer handed to <see cref="OverlayTransition.Draw"/>: fills rectangles
|
||||||
/// normalized screen coordinates (0..1 on both axes) over the rendered scene.
|
/// in normalized screen coordinates (0..1 on both axes) over the rendered scene.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TransitionRenderer : IDisposable
|
public sealed class TransitionRenderer : IDisposable
|
||||||
{
|
{
|
||||||
@@ -13,7 +13,7 @@ public sealed class TransitionRenderer : IDisposable
|
|||||||
private readonly BasicEffect _effect;
|
private readonly BasicEffect _effect;
|
||||||
private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6];
|
private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6];
|
||||||
|
|
||||||
/// <summary>Disposes the GPU effect. Called by <see cref="SceneManager"/> on shutdown.</summary>
|
/// <summary>Disposes the GPU effect. Called by <see cref="GameHost"/> on shutdown.</summary>
|
||||||
public void Dispose() => _effect.Dispose();
|
public void Dispose() => _effect.Dispose();
|
||||||
|
|
||||||
internal TransitionRenderer(GraphicsDevice device)
|
internal TransitionRenderer(GraphicsDevice 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Friflo.Engine.ECS" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="MrGameEng.Net.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
namespace MrGameEng.AI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suitability curves: how well an environmental value matches a preferred optimum. Unlike
|
||||||
|
/// <see cref="ResponseCurve"/> (monotonic shaping over <c>[0,1]</c>), these are bell shapes around an
|
||||||
|
/// optimum on an arbitrary scale — the building block for "this organism likes ~18°C, tolerates ±12°".
|
||||||
|
/// Pure and GPU-free.
|
||||||
|
/// </summary>
|
||||||
|
public static class Suitability
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gaussian bell in <c>[0, 1]</c>: 1 when <paramref name="value"/> equals <paramref name="optimum"/>,
|
||||||
|
/// falling off as it departs, reaching <c>e^-0.5 ≈ 0.607</c> one <paramref name="tolerance"/> away.
|
||||||
|
/// A non-positive <paramref name="tolerance"/> degenerates to an exact match (1 at the optimum, else 0).
|
||||||
|
/// </summary>
|
||||||
|
public static float Gaussian(float value, float optimum, float tolerance)
|
||||||
|
{
|
||||||
|
if (tolerance <= 0f)
|
||||||
|
{
|
||||||
|
return value == optimum ? 1f : 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
var z = (value - optimum) / tolerance;
|
||||||
|
return MathF.Exp(-0.5f * z * z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trapezoidal suitability in <c>[0, 1]</c>: <c>0</c> at or beyond the hard limits
|
||||||
|
/// <paramref name="min"/>/<paramref name="max"/>, ramping linearly up to <c>1</c> at
|
||||||
|
/// <paramref name="optimalLow"/>, flat <c>1</c> across the comfortable plateau to
|
||||||
|
/// <paramref name="optimalHigh"/>, then ramping back down to <c>0</c> at <paramref name="max"/>.
|
||||||
|
/// Models a hard tolerance band with a plateau (RimWorld-style plant growth vs. temperature:
|
||||||
|
/// dormant below <paramref name="min"/> or above <paramref name="max"/>). A degenerate edge
|
||||||
|
/// (<paramref name="optimalLow"/> ≤ <paramref name="min"/> or <paramref name="optimalHigh"/> ≥
|
||||||
|
/// <paramref name="max"/>) becomes a hard step on that side. The four bounds are expected
|
||||||
|
/// ordered (<c>min ≤ optimalLow ≤ optimalHigh ≤ max</c>); pass ordered values.
|
||||||
|
/// </summary>
|
||||||
|
public static float Trapezoid(
|
||||||
|
float value,
|
||||||
|
float min,
|
||||||
|
float optimalLow,
|
||||||
|
float optimalHigh,
|
||||||
|
float max
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (value <= min || value >= max)
|
||||||
|
{
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < optimalLow)
|
||||||
|
{
|
||||||
|
return optimalLow > min ? (value - min) / (optimalLow - min) : 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value > optimalHigh)
|
||||||
|
{
|
||||||
|
return max > optimalHigh ? (max - value) / (max - optimalHigh) : 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using MrGameEng.Genetics;
|
||||||
|
using MrGameEng.Mods;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Genetics.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="GeneDef"/> loads through the real <see cref="DefDatabase"/> the way the game
|
||||||
|
/// loads <c>genes.json</c>: string-named <see cref="GeneKind"/>, parent inheritance and effect maps.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GeneDefLoadTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Directory.CreateTempSubdirectory("mrge-gene-tests-").FullName;
|
||||||
|
|
||||||
|
public void Dispose() => Directory.Delete(_root, recursive: true);
|
||||||
|
|
||||||
|
private DefDatabase Load(string defsJson)
|
||||||
|
{
|
||||||
|
var modDir = Path.Combine(_root, "mod");
|
||||||
|
Directory.CreateDirectory(Path.Combine(modDir, "Defs"));
|
||||||
|
File.WriteAllText(Path.Combine(modDir, "Defs", "genes.json"), defsJson);
|
||||||
|
var database = new DefDatabase();
|
||||||
|
database.RegisterType<GeneDef>("Gene");
|
||||||
|
database.Load([new Mod(new ModInfo { Id = "mod" }, modDir)]);
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_NumericGeneWithParentAndEffects_Resolves()
|
||||||
|
{
|
||||||
|
var database = Load(
|
||||||
|
"""
|
||||||
|
{ "type": "Gene", "defs": [
|
||||||
|
{ "defName": "BaseGene", "abstract": true, "spread": 0.1, "mutationChance": 0.05 },
|
||||||
|
{ "defName": "GeneVigor", "parent": "BaseGene", "default": 1.0, "min": 0.1, "max": 3.0,
|
||||||
|
"tags": ["growth"], "effects": { "vigor": "value" } }
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
var gene = database.Get<GeneDef>("GeneVigor");
|
||||||
|
Assert.Equal(GeneKind.Numeric, gene.Kind);
|
||||||
|
Assert.Equal(0.1f, gene.Spread); // inherited from parent
|
||||||
|
Assert.Equal(0.05f, gene.MutationChance); // inherited
|
||||||
|
Assert.Equal(3.0f, gene.Max);
|
||||||
|
Assert.Equal(["growth"], gene.Tags);
|
||||||
|
Assert.Equal("value", gene.Effects["vigor"]);
|
||||||
|
Assert.Single(gene.CompiledEffects); // formula compiles
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_DiscreteKindByName_Parses()
|
||||||
|
{
|
||||||
|
var database = Load(
|
||||||
|
"""
|
||||||
|
{ "type": "Gene", "defs": [
|
||||||
|
{ "defName": "GeneMorph", "kind": "Discrete", "variants": 2,
|
||||||
|
"variantWeights": [0.8, 0.2], "effects": { "variant": "value" } }
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
var gene = database.Get<GeneDef>("GeneMorph");
|
||||||
|
Assert.Equal(GeneKind.Discrete, gene.Kind);
|
||||||
|
Assert.Equal(2, gene.Variants);
|
||||||
|
Assert.Equal([0.8f, 0.2f], gene.VariantWeights);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using MrGameEng.Formulas;
|
||||||
|
using MrGameEng.Genetics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Genetics.Tests;
|
||||||
|
|
||||||
|
public class GeneticsTests
|
||||||
|
{
|
||||||
|
private static GeneDef Numeric(
|
||||||
|
string name,
|
||||||
|
float def,
|
||||||
|
float spread = 0f,
|
||||||
|
float min = float.NegativeInfinity,
|
||||||
|
float max = float.PositiveInfinity,
|
||||||
|
float mutationChance = 0f,
|
||||||
|
float mutationMagnitude = 0.1f,
|
||||||
|
Dictionary<string, string>? effects = null
|
||||||
|
) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DefName = name,
|
||||||
|
Kind = GeneKind.Numeric,
|
||||||
|
Default = def,
|
||||||
|
Spread = spread,
|
||||||
|
Min = min,
|
||||||
|
Max = max,
|
||||||
|
MutationChance = mutationChance,
|
||||||
|
MutationMagnitude = mutationMagnitude,
|
||||||
|
Effects = effects ?? new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GeneDef Discrete(string name, int variants = 2, float mutationChance = 0f) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DefName = name,
|
||||||
|
Kind = GeneKind.Discrete,
|
||||||
|
Variants = variants,
|
||||||
|
MutationChance = mutationChance,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, GeneDef> Registry(params GeneDef[] genes) =>
|
||||||
|
genes.ToDictionary(g => g.DefName, g => g, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_NumericAlleles_StayWithinSpreadAndClamp()
|
||||||
|
{
|
||||||
|
var gene = Numeric("vigor", def: 1f, spread: 0.2f, min: 0f, max: 2f);
|
||||||
|
var random = new Random(7);
|
||||||
|
|
||||||
|
for (var i = 0; i < 200; i++)
|
||||||
|
{
|
||||||
|
var genome = Genome.Generate([gene], random);
|
||||||
|
var allele = genome["vigor"];
|
||||||
|
Assert.InRange(allele.A, 0.8f, 1.2f);
|
||||||
|
Assert.InRange(allele.B, 0.8f, 1.2f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_SameSeed_IsDeterministic()
|
||||||
|
{
|
||||||
|
var genes = new[] { Numeric("a", 1f, 0.3f), Discrete("morph", 2) };
|
||||||
|
|
||||||
|
var first = Genome.Generate(genes, new Random(42));
|
||||||
|
var second = Genome.Generate(genes, new Random(42));
|
||||||
|
|
||||||
|
Assert.Equal(first["a"], second["a"]);
|
||||||
|
Assert.Equal(first["morph"], second["morph"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Express_Numeric_IsAlleleMean()
|
||||||
|
{
|
||||||
|
var gene = Numeric("opt", 0f);
|
||||||
|
var genome = new Genome { ["opt"] = new Allele(0.4f, 0.8f) };
|
||||||
|
|
||||||
|
Assert.Equal(0.6f, genome.Express(gene), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Express_Discrete_DominantIsLowerIndex()
|
||||||
|
{
|
||||||
|
var gene = Discrete("morph", 2);
|
||||||
|
|
||||||
|
Assert.Equal(0f, new Genome { ["morph"] = new Allele(0f, 1f) }.Express(gene)); // heterozygous → dominant 0
|
||||||
|
Assert.Equal(1f, new Genome { ["morph"] = new Allele(1f, 1f) }.Express(gene)); // homozygous recessive
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Breed_WithoutMutation_InheritsOneAlleleFromEachParent()
|
||||||
|
{
|
||||||
|
var gene = Numeric("g", 0f, mutationChance: 0f);
|
||||||
|
var registry = Registry(gene);
|
||||||
|
var a = new Genome { ["g"] = new Allele(1f, 2f) };
|
||||||
|
var b = new Genome { ["g"] = new Allele(3f, 4f) };
|
||||||
|
|
||||||
|
var child = Genome.Breed(a, b, registry, new Random(1));
|
||||||
|
|
||||||
|
Assert.Contains(child["g"].A, new[] { 1f, 2f }); // first allele from parent a
|
||||||
|
Assert.Contains(child["g"].B, new[] { 3f, 4f }); // second allele from parent b
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Breed_UnionOfGenes_ProducesHybridComposition()
|
||||||
|
{
|
||||||
|
// a carries only "leaf", b carries only "root" — a child can carry both (hybrid).
|
||||||
|
var registry = Registry(Numeric("leaf", 1f), Numeric("root", 1f));
|
||||||
|
var a = new Genome { ["leaf"] = new Allele(1f, 1f) };
|
||||||
|
var b = new Genome { ["root"] = new Allele(2f, 2f) };
|
||||||
|
|
||||||
|
var carriedBoth = false;
|
||||||
|
for (var seed = 0; seed < 50 && !carriedBoth; seed++)
|
||||||
|
{
|
||||||
|
var child = Genome.Breed(a, b, registry, new Random(seed));
|
||||||
|
carriedBoth = child.Has("leaf") && child.Has("root");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(carriedBoth, "single-parent genes should sometimes both be inherited");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Breed_HighMutation_DiscreteCanFlipVariant()
|
||||||
|
{
|
||||||
|
var gene = Discrete("morph", variants: 2, mutationChance: 1f);
|
||||||
|
var registry = Registry(gene);
|
||||||
|
var parent = new Genome { ["morph"] = new Allele(0f, 0f) };
|
||||||
|
|
||||||
|
var sawOne = false;
|
||||||
|
for (var seed = 0; seed < 50 && !sawOne; seed++)
|
||||||
|
{
|
||||||
|
var child = Genome.Breed(parent, parent, registry, new Random(seed));
|
||||||
|
var allele = child["morph"];
|
||||||
|
sawOne = allele.A == 1f || allele.B == 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(sawOne, "with full mutation a 0/0 parent should sometimes yield variant 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_GeneEffect_UsesValueVariable()
|
||||||
|
{
|
||||||
|
var gene = Numeric("vigor", 0f, effects: new() { ["growth"] = "value * 2" });
|
||||||
|
var genome = new Genome { ["vigor"] = new Allele(1.5f, 2.5f) }; // mean 2
|
||||||
|
|
||||||
|
var traits = Phenotype.Compute(genome, Registry(gene));
|
||||||
|
|
||||||
|
Assert.Equal(4f, traits["growth"], 5); // 2 * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_MultipleGenes_SumContributionsPerTrait()
|
||||||
|
{
|
||||||
|
var a = Numeric("a", 0f, effects: new() { ["yield"] = "value" });
|
||||||
|
var b = Numeric("b", 0f, effects: new() { ["yield"] = "value" });
|
||||||
|
var genome = new Genome { ["a"] = new Allele(3f, 3f), ["b"] = new Allele(4f, 4f) };
|
||||||
|
|
||||||
|
var traits = Phenotype.Compute(genome, Registry(a, b));
|
||||||
|
|
||||||
|
Assert.Equal(7f, traits["yield"], 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_FormulaReadsEnvironmentAndOtherGenes()
|
||||||
|
{
|
||||||
|
var opt = Numeric("optLight", 0.5f);
|
||||||
|
var vigor = Numeric(
|
||||||
|
"vigor",
|
||||||
|
1f,
|
||||||
|
effects: new() { ["rate"] = "value * (1 - abs(light - optLight))" }
|
||||||
|
);
|
||||||
|
var genome = new Genome
|
||||||
|
{
|
||||||
|
["optLight"] = new Allele(0.5f, 0.5f),
|
||||||
|
["vigor"] = new Allele(1f, 1f),
|
||||||
|
};
|
||||||
|
var env = new DelegateFormulaContext(n =>
|
||||||
|
n == "light" ? 0.7f : throw new FormulaException(n)
|
||||||
|
);
|
||||||
|
|
||||||
|
var traits = Phenotype.Compute(genome, Registry(opt, vigor), env);
|
||||||
|
|
||||||
|
Assert.Equal(0.8f, traits["rate"], 5); // 1 * (1 - |0.7 - 0.5|)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompiledEffects_InvalidFormula_ThrowsWithGeneAndTrait()
|
||||||
|
{
|
||||||
|
var gene = Numeric("bad", 0f, effects: new() { ["t"] = "value *" });
|
||||||
|
|
||||||
|
var error = Assert.Throws<InvalidDataException>(() => _ = gene.CompiledEffects);
|
||||||
|
Assert.Contains("bad", error.Message);
|
||||||
|
Assert.Contains("t", error.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using MrGameEng.Genetics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Genetics.Tests;
|
||||||
|
|
||||||
|
public class GenomeTemplateTests
|
||||||
|
{
|
||||||
|
private static GeneDef Numeric(string name, float min, float max) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DefName = name,
|
||||||
|
Kind = GeneKind.Numeric,
|
||||||
|
Min = min,
|
||||||
|
Max = max,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GeneDef Discrete(string name, int variants = 2) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DefName = name,
|
||||||
|
Kind = GeneKind.Discrete,
|
||||||
|
Variants = variants,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_UsesPerEntryBaseAndSpread_NotGeneDefault()
|
||||||
|
{
|
||||||
|
var gene = Numeric("opt", min: 0f, max: 100f); // GeneDef.Default is 0
|
||||||
|
var template = new GenomeTemplate([
|
||||||
|
new GenomeTemplate.Entry(gene, Base: 50f, Spread: 0.1f),
|
||||||
|
]);
|
||||||
|
var random = new Random(3);
|
||||||
|
|
||||||
|
for (var i = 0; i < 200; i++)
|
||||||
|
{
|
||||||
|
var allele = template.Generate(random)["opt"];
|
||||||
|
Assert.InRange(allele.A, 45f, 55f); // around the template base, not 0
|
||||||
|
Assert.InRange(allele.B, 45f, 55f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_TwoSpecies_DifferInCentre()
|
||||||
|
{
|
||||||
|
var gene = Numeric("opt", 0f, 100f);
|
||||||
|
var oak = new GenomeTemplate([new GenomeTemplate.Entry(gene, 20f, 0f)]);
|
||||||
|
var grass = new GenomeTemplate([new GenomeTemplate.Entry(gene, 80f, 0f)]);
|
||||||
|
|
||||||
|
Assert.Equal(20f, oak.Generate(new Random(1)).Express(gene), 3);
|
||||||
|
Assert.Equal(80f, grass.Generate(new Random(1)).Express(gene), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_CoversEntries_AndDrivesBreeding()
|
||||||
|
{
|
||||||
|
var template = new GenomeTemplate([
|
||||||
|
new GenomeTemplate.Entry(Numeric("a", 0f, 10f), 5f, 0.1f),
|
||||||
|
new GenomeTemplate.Entry(Discrete("morph"), 0f, 0f),
|
||||||
|
]);
|
||||||
|
var random = new Random(9);
|
||||||
|
var a = template.Generate(random);
|
||||||
|
var b = template.Generate(random);
|
||||||
|
|
||||||
|
var child = Genome.Breed(a, b, template.Registry, random);
|
||||||
|
|
||||||
|
Assert.True(child.Has("a"));
|
||||||
|
Assert.True(child.Has("morph"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Breed_MutationChanceOverride_ForcesMutation()
|
||||||
|
{
|
||||||
|
var gene = Numeric("a", min: -100f, max: 100f);
|
||||||
|
var registry = new Dictionary<string, GeneDef> { ["a"] = gene };
|
||||||
|
var parent = new Genome { ["a"] = new Allele(10f, 10f) };
|
||||||
|
|
||||||
|
// Override chance to 1 → every inherited allele mutates away from 10.
|
||||||
|
var moved = false;
|
||||||
|
for (var seed = 0; seed < 30 && !moved; seed++)
|
||||||
|
{
|
||||||
|
var child = Genome.Breed(
|
||||||
|
parent,
|
||||||
|
parent,
|
||||||
|
registry,
|
||||||
|
new Random(seed),
|
||||||
|
mutationChance: 1f
|
||||||
|
);
|
||||||
|
moved = child["a"].A != 10f || child["a"].B != 10f;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(moved, "with overridden mutationChance=1 the allele should mutate");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Breed_MutationChanceZero_KeepsAlleles()
|
||||||
|
{
|
||||||
|
var gene = Numeric("a", -100f, 100f);
|
||||||
|
var registry = new Dictionary<string, GeneDef> { ["a"] = gene };
|
||||||
|
var parent = new Genome { ["a"] = new Allele(10f, 10f) };
|
||||||
|
|
||||||
|
var child = Genome.Breed(parent, parent, registry, new Random(5), mutationChance: 0f);
|
||||||
|
|
||||||
|
Assert.Equal(10f, child["a"].A);
|
||||||
|
Assert.Equal(10f, child["a"].B);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,4 +143,73 @@ public sealed class DefDatabaseTests : IDisposable
|
|||||||
Assert.Throws<KeyNotFoundException>(() => database.Get<AnimalDef>("Dodo"));
|
Assert.Throws<KeyNotFoundException>(() => database.Get<AnimalDef>("Dodo"));
|
||||||
Assert.False(database.TryGet<AnimalDef>("Dodo", out _));
|
Assert.False(database.TryGet<AnimalDef>("Dodo", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mod WriteMod(string fileName, string json)
|
||||||
|
{
|
||||||
|
var id = $"mod{_modCounter++:D2}";
|
||||||
|
var modDir = Path.Combine(_root, id);
|
||||||
|
Directory.CreateDirectory(Path.Combine(modDir, "Defs"));
|
||||||
|
File.WriteAllText(Path.Combine(modDir, "Defs", fileName), json);
|
||||||
|
return new Mod(new ModInfo { Id = id }, modDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Patch_SetsFields_OnDefsMatchingNamePattern()
|
||||||
|
{
|
||||||
|
var defs = WriteDefsMod(
|
||||||
|
"""
|
||||||
|
{ "type": "Animal", "defs": [
|
||||||
|
{ "defName": "Wolf", "speed": 9 },
|
||||||
|
{ "defName": "WolfPup", "speed": 4 },
|
||||||
|
{ "defName": "Bear", "speed": 6 }
|
||||||
|
]}
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
var patch = WriteMod(
|
||||||
|
"patches.json",
|
||||||
|
"""{ "type": "Patch", "patches": [ { "defType": "Animal", "match": "Wolf.*", "set": { "legs": 6 } } ] }"""
|
||||||
|
);
|
||||||
|
|
||||||
|
var database = LoadAnimals(defs, patch);
|
||||||
|
|
||||||
|
Assert.Equal(6, database.Get<AnimalDef>("Wolf").Legs); // matched
|
||||||
|
Assert.Equal(6, database.Get<AnimalDef>("WolfPup").Legs); // matched
|
||||||
|
Assert.Equal(4, database.Get<AnimalDef>("Bear").Legs); // unmatched → default
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Patch_UnknownDefType_Throws()
|
||||||
|
{
|
||||||
|
var patch = WriteMod(
|
||||||
|
"patches.json",
|
||||||
|
"""{ "type": "Patch", "patches": [ { "defType": "Ghost", "match": ".*", "set": {} } ] }"""
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidDataException>(() => LoadAnimals(patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validator_RejectsField_NotMatchingPattern()
|
||||||
|
{
|
||||||
|
var database = new DefDatabase();
|
||||||
|
database.RegisterType<AnimalDef>("Animal");
|
||||||
|
database.RegisterValidator("Animal", "defName", "^[A-Z]");
|
||||||
|
var bad = WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "wolf" } ] }""");
|
||||||
|
|
||||||
|
var error = Assert.Throws<InvalidDataException>(() => database.Load([bad]));
|
||||||
|
Assert.Contains("wolf", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validator_Passes_WhenFieldMatches()
|
||||||
|
{
|
||||||
|
var database = new DefDatabase();
|
||||||
|
database.RegisterType<AnimalDef>("Animal");
|
||||||
|
database.RegisterValidator("Animal", "defName", "^[A-Z]");
|
||||||
|
database.Load([
|
||||||
|
WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 7 } ] }"""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(7f, database.Get<AnimalDef>("Wolf").Speed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,33 @@ public class CalendarTests
|
|||||||
Assert.Equal(0.5f, calendar.DayProgress, 5);
|
Assert.Equal(0.5f, calendar.DayProgress, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HourAndMinute_DecomposeTimeOfDay()
|
||||||
|
{
|
||||||
|
var clock = new GameClock();
|
||||||
|
var calendar = new Calendar(clock, secondsPerDay: 1440f); // one minute per in-game minute
|
||||||
|
|
||||||
|
clock.Advance(14 * 60 + 30); // 14:30
|
||||||
|
|
||||||
|
Assert.Equal(14, calendar.Hour);
|
||||||
|
Assert.Equal(30, calendar.Minute);
|
||||||
|
Assert.Equal(14 * 60 + 30, calendar.MinuteOfDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StartDay_OffsetsTheTimeOfDay()
|
||||||
|
{
|
||||||
|
var clock = new GameClock();
|
||||||
|
var calendar = new Calendar(clock, secondsPerDay: 10f, startDay: 7.0 / 24); // begin at 07:00
|
||||||
|
|
||||||
|
Assert.Equal(1, calendar.Day);
|
||||||
|
Assert.Equal(7, calendar.Hour);
|
||||||
|
Assert.Equal(0, calendar.Minute);
|
||||||
|
|
||||||
|
clock.Advance(5f); // half a day later → 19:00
|
||||||
|
Assert.Equal(19, calendar.Hour);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Pause_DoesNotAdvanceTheCalendar()
|
public void Pause_DoesNotAdvanceTheCalendar()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using MrGameEng.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core.Tests;
|
||||||
|
|
||||||
|
public class ClimateTests
|
||||||
|
{
|
||||||
|
private static (GameClock Clock, Climate Climate) Make(ClimateSettings settings)
|
||||||
|
{
|
||||||
|
var clock = new GameClock();
|
||||||
|
var calendar = new Calendar(clock, secondsPerDay: 10f);
|
||||||
|
return (clock, new Climate(calendar, settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClimateSettings Seasonal =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
DaysPerYear = 60,
|
||||||
|
MeanTemperature = 10f,
|
||||||
|
SeasonalAmplitude = 20f,
|
||||||
|
DailyAmplitude = 0f,
|
||||||
|
WarmestDay = 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NonPositiveYear_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
new Climate(new Calendar(new GameClock(), 10f), Seasonal with { DaysPerYear = 0 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Temperature_PeaksOnWarmestDay_AndBottomsHalfYearLater()
|
||||||
|
{
|
||||||
|
var (clock, climate) = Make(Seasonal);
|
||||||
|
|
||||||
|
clock.Advance(15 * 10f); // day 15 = warmest
|
||||||
|
Assert.Equal(30f, climate.Temperature, 2); // mean 10 + amplitude 20
|
||||||
|
|
||||||
|
clock.Advance(30 * 10f); // +30 days = half a 60-day year later
|
||||||
|
Assert.Equal(-10f, climate.Temperature, 2); // mean 10 - amplitude 20
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DailySwing_WarmerAtNoonThanMidnight()
|
||||||
|
{
|
||||||
|
var settings = Seasonal with { SeasonalAmplitude = 0f, DailyAmplitude = 6f };
|
||||||
|
var (clock, climate) = Make(settings);
|
||||||
|
|
||||||
|
clock.Advance(5f); // day 0, noon (DayProgress 0.5)
|
||||||
|
Assert.Equal(16f, climate.Temperature, 2); // mean 10 + daily 6
|
||||||
|
|
||||||
|
clock.Advance(5f); // day 1, midnight (DayProgress 0)
|
||||||
|
Assert.Equal(4f, climate.Temperature, 2); // mean 10 - daily 6
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Season_FollowsTheQuarterOfTheYear()
|
||||||
|
{
|
||||||
|
var (clock, climate) = Make(Seasonal); // 60-day year → 15 days per season
|
||||||
|
|
||||||
|
Assert.Equal(Season.Spring, climate.Season);
|
||||||
|
clock.Advance(20 * 10f);
|
||||||
|
Assert.Equal(Season.Summer, climate.Season);
|
||||||
|
clock.Advance(15 * 10f);
|
||||||
|
Assert.Equal(Season.Autumn, climate.Season);
|
||||||
|
clock.Advance(15 * 10f);
|
||||||
|
Assert.Equal(Season.Winter, climate.Season);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void YearAndDayOfYear_RollOver()
|
||||||
|
{
|
||||||
|
var (clock, climate) = Make(Seasonal);
|
||||||
|
|
||||||
|
Assert.Equal(1, climate.Year);
|
||||||
|
Assert.Equal(0, climate.DayOfYear);
|
||||||
|
|
||||||
|
clock.Advance(60 * 10f); // exactly one year
|
||||||
|
Assert.Equal(2, climate.Year);
|
||||||
|
Assert.Equal(0, climate.DayOfYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StartDayOfYear_ShiftsTheSeasonalPhase()
|
||||||
|
{
|
||||||
|
// Begin the world already at the warmest day (15): the curve peaks at clock 0.
|
||||||
|
var (_, climate) = Make(Seasonal with { StartDayOfYear = 15 });
|
||||||
|
|
||||||
|
Assert.Equal(30f, climate.Temperature, 2); // mean 10 + amplitude 20, at the peak
|
||||||
|
Assert.Equal(15, climate.DayOfYear); // day-of-year reflects the offset
|
||||||
|
Assert.Equal(Season.Summer, climate.Season); // day 15 of a 60-day year = start of summer
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using MrGameEng.Formulas;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core.Tests;
|
||||||
|
|
||||||
|
public class FormulaTests
|
||||||
|
{
|
||||||
|
private static IFormulaContext Vars(Dictionary<string, float> values) =>
|
||||||
|
new DelegateFormulaContext(name =>
|
||||||
|
values.TryGetValue(name, out var v)
|
||||||
|
? v
|
||||||
|
: throw new FormulaException($"unknown '{name}'")
|
||||||
|
);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1 + 2 * 3", 7f)] // precedence
|
||||||
|
[InlineData("(1 + 2) * 3", 9f)] // parentheses
|
||||||
|
[InlineData("10 - 2 - 3", 5f)] // left associativity
|
||||||
|
[InlineData("-2 + 5", 3f)] // unary minus
|
||||||
|
[InlineData("2 * -3", -6f)] // unary minus after operator
|
||||||
|
[InlineData("7 % 3", 1f)] // modulo
|
||||||
|
[InlineData("2.5 * 4", 10f)] // decimals
|
||||||
|
public void Evaluate_Arithmetic_RespectsPrecedence(string expr, float expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("min(3, 5)", 3f)]
|
||||||
|
[InlineData("max(3, 5)", 5f)]
|
||||||
|
[InlineData("clamp(12, 0, 10)", 10f)]
|
||||||
|
[InlineData("clamp(-4, 0, 10)", 0f)]
|
||||||
|
[InlineData("lerp(0, 10, 0.25)", 2.5f)]
|
||||||
|
[InlineData("pow(2, 10)", 1024f)]
|
||||||
|
[InlineData("abs(-7)", 7f)]
|
||||||
|
[InlineData("floor(3.9)", 3f)]
|
||||||
|
[InlineData("ceil(3.1)", 4f)]
|
||||||
|
[InlineData("step(5, 7)", 1f)]
|
||||||
|
[InlineData("step(5, 2)", 0f)]
|
||||||
|
public void Evaluate_Functions_ComputeExpected(string expr, float expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_Constants_AreBuiltIn()
|
||||||
|
{
|
||||||
|
Assert.Equal(MathF.PI, Formula.Compile("pi").Evaluate(), 5);
|
||||||
|
Assert.Equal(MathF.Tau, Formula.Compile("tau").Evaluate(), 5);
|
||||||
|
Assert.Equal(MathF.E, Formula.Compile("e").Evaluate(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("2 < 3", 1f)]
|
||||||
|
[InlineData("3 <= 3", 1f)]
|
||||||
|
[InlineData("3 > 5", 0f)]
|
||||||
|
[InlineData("4 == 4", 1f)]
|
||||||
|
[InlineData("4 != 4", 0f)]
|
||||||
|
[InlineData("1 && 0", 0f)]
|
||||||
|
[InlineData("0 || 2", 1f)]
|
||||||
|
[InlineData("!0", 1f)]
|
||||||
|
[InlineData("!5", 0f)]
|
||||||
|
public void Evaluate_LogicAndComparison_YieldBooleansAsOneOrZero(string expr, float expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("3 > 2 ? 10 : 20", 10f)]
|
||||||
|
[InlineData("3 < 2 ? 10 : 20", 20f)]
|
||||||
|
[InlineData("1 ? 2 ? 3 : 4 : 5", 3f)] // nested ternary
|
||||||
|
public void Evaluate_Ternary_SelectsBranch(string expr, float expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_Variables_ResolvedThroughContext()
|
||||||
|
{
|
||||||
|
var ctx = Vars(new() { ["light"] = 0.8f, ["optimal"] = 0.5f });
|
||||||
|
var formula = Formula.Compile("clamp(1 - abs(light - optimal), 0, 1)");
|
||||||
|
Assert.Equal(0.7f, formula.Evaluate(ctx), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_SameFormulaTwice_IsDeterministic()
|
||||||
|
{
|
||||||
|
var formula = Formula.Compile("sin(t) * 2 + 1");
|
||||||
|
var ctx = Vars(new() { ["t"] = 1.234f });
|
||||||
|
Assert.Equal(formula.Evaluate(ctx), formula.Evaluate(ctx), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1 +")] // dangling operator
|
||||||
|
[InlineData("(1 + 2")] // unbalanced paren
|
||||||
|
[InlineData("1 2")] // trailing token
|
||||||
|
[InlineData("min(1)")] // wrong arity
|
||||||
|
[InlineData("nope(1)")] // unknown function
|
||||||
|
[InlineData("@")] // bad character
|
||||||
|
[InlineData("1 = 2")] // single equals
|
||||||
|
public void Compile_InvalidExpression_Throws(string expr)
|
||||||
|
{
|
||||||
|
Assert.Throws<FormulaException>(() => Formula.Compile(expr));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_UnknownVariableWithoutContext_Throws()
|
||||||
|
{
|
||||||
|
var formula = Formula.Compile("x + 1");
|
||||||
|
Assert.Throws<FormulaException>(() => formula.Evaluate());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group functions (regex aggregation over matching variables) ---
|
||||||
|
|
||||||
|
private sealed class GroupContext(Dictionary<string, float> values) : IFormulaContext
|
||||||
|
{
|
||||||
|
public float Resolve(string name) => values[name];
|
||||||
|
|
||||||
|
public IEnumerable<float> ResolveMatching(Func<string, bool> matches)
|
||||||
|
{
|
||||||
|
foreach (var (name, value) in values)
|
||||||
|
{
|
||||||
|
if (matches(name))
|
||||||
|
{
|
||||||
|
yield return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_GroupFunctions_AggregateMatchingVariables()
|
||||||
|
{
|
||||||
|
var ctx = new GroupContext(
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
["leaf_a"] = 2f,
|
||||||
|
["leaf_b"] = 4f,
|
||||||
|
["leaf_c"] = 6f,
|
||||||
|
["root_a"] = 100f,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(12f, Formula.Compile("gsum('leaf_.*')").Evaluate(ctx), 4); // 2+4+6
|
||||||
|
Assert.Equal(3f, Formula.Compile("gcount('leaf_.*')").Evaluate(ctx), 4);
|
||||||
|
Assert.Equal(4f, Formula.Compile("gavg('leaf_.*')").Evaluate(ctx), 4);
|
||||||
|
Assert.Equal(2f, Formula.Compile("gmin('leaf_.*')").Evaluate(ctx), 4);
|
||||||
|
Assert.Equal(6f, Formula.Compile("gmax('leaf_.*')").Evaluate(ctx), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_GroupFunction_ComposesWithArithmetic()
|
||||||
|
{
|
||||||
|
var ctx = new GroupContext(new() { ["g1"] = 3f, ["g2"] = 5f });
|
||||||
|
Assert.Equal(16f, Formula.Compile("gsum('g.*') * 2").Evaluate(ctx), 4); // (3+5)*2
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_GroupFunction_NoMatches_IsZero()
|
||||||
|
{
|
||||||
|
var ctx = new GroupContext(new() { ["x"] = 1f });
|
||||||
|
Assert.Equal(0f, Formula.Compile("gsum('none_.*')").Evaluate(ctx), 4);
|
||||||
|
Assert.Equal(0f, Formula.Compile("gavg('none_.*')").Evaluate(ctx), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("gsum(leaf)")] // pattern must be a quoted string
|
||||||
|
[InlineData("gsum('[')")] // invalid regex
|
||||||
|
[InlineData("gsum('a' 'b')")] // extra token
|
||||||
|
public void Compile_BadGroupCall_Throws(string expr)
|
||||||
|
{
|
||||||
|
Assert.Throws<FormulaException>(() => Formula.Compile(expr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using MrGameEng.Core;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Core.Tests;
|
||||||
|
|
||||||
|
public class HeadlessHostTests
|
||||||
|
{
|
||||||
|
private sealed class CountingScene : Scene
|
||||||
|
{
|
||||||
|
public int LoadCount;
|
||||||
|
public int UnloadCount;
|
||||||
|
public int UpdateCount;
|
||||||
|
|
||||||
|
protected override void OnLoad() => LoadCount++;
|
||||||
|
|
||||||
|
protected override void OnUnload() => UnloadCount++;
|
||||||
|
|
||||||
|
public override void Update(GameClock clock)
|
||||||
|
{
|
||||||
|
UpdateCount++;
|
||||||
|
base.Update(clock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RunTicks_AdvancesClock_ByExactFixedStep()
|
||||||
|
{
|
||||||
|
var scene = new CountingScene();
|
||||||
|
using var host = new HeadlessHost(new HeadlessHostOptions { TicksPerSecond = 10f }, scene);
|
||||||
|
|
||||||
|
host.RunTicks(30);
|
||||||
|
|
||||||
|
Assert.Equal(0.1f, host.FixedDeltaTime, 3);
|
||||||
|
Assert.Equal(30, host.TickCount);
|
||||||
|
Assert.Equal(3.0, host.Context.Clock.TotalTime, 3);
|
||||||
|
Assert.Equal(30, scene.UpdateCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstTick_LoadsTheInitialScene()
|
||||||
|
{
|
||||||
|
var scene = new CountingScene();
|
||||||
|
using var host = new HeadlessHost(new HeadlessHostOptions(), scene);
|
||||||
|
|
||||||
|
Assert.Equal(0, scene.LoadCount);
|
||||||
|
|
||||||
|
host.Tick();
|
||||||
|
|
||||||
|
Assert.Equal(1, scene.LoadCount);
|
||||||
|
Assert.Same(scene, host.Context.Scenes.Current);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_UnloadsTheActiveScene()
|
||||||
|
{
|
||||||
|
var scene = new CountingScene();
|
||||||
|
var host = new HeadlessHost(new HeadlessHostOptions(), scene);
|
||||||
|
host.Tick();
|
||||||
|
|
||||||
|
host.Dispose();
|
||||||
|
|
||||||
|
Assert.Equal(1, scene.UnloadCount);
|
||||||
|
Assert.Null(host.Context.Scenes.Current);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Run_StopsWhenCancelled()
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var scene = new CancellingScene(cancellation, afterTicks: 5);
|
||||||
|
using var host = new HeadlessHost(new HeadlessHostOptions { Realtime = false }, scene);
|
||||||
|
|
||||||
|
host.Run(cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal(5, scene.UpdateCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TimeScale_StillApplies_OnTopOfFixedStep()
|
||||||
|
{
|
||||||
|
var scene = new CountingScene();
|
||||||
|
using var host = new HeadlessHost(new HeadlessHostOptions { TicksPerSecond = 10f }, scene);
|
||||||
|
host.Context.Clock.TimeScale = 3f;
|
||||||
|
|
||||||
|
host.RunTicks(10);
|
||||||
|
|
||||||
|
Assert.Equal(3.0, host.Context.Clock.TotalTime, 3);
|
||||||
|
Assert.Equal(1.0, host.Context.Clock.UnscaledTotalTime, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonPositiveTickRate_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||||
|
new HeadlessHost(new HeadlessHostOptions { TicksPerSecond = 0f }, new CountingScene())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CancellingScene(CancellationTokenSource cancellation, int afterTicks)
|
||||||
|
: Scene
|
||||||
|
{
|
||||||
|
public int UpdateCount;
|
||||||
|
|
||||||
|
protected override void OnLoad() { }
|
||||||
|
|
||||||
|
public override void Update(GameClock clock)
|
||||||
|
{
|
||||||
|
UpdateCount++;
|
||||||
|
if (UpdateCount >= afterTicks)
|
||||||
|
{
|
||||||
|
cancellation.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Update(clock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,14 @@ public class SceneTransitionTests
|
|||||||
protected override void OnUnload() => UnloadCount++;
|
protected override void OnUnload() => UnloadCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Тайминг-машина живёт в ядре и не зависит от визуала перехода —
|
||||||
|
// тестируем на голой заглушке с длительностями, как у Fade.
|
||||||
|
private sealed class TimedTransition(float outDuration, float inDuration)
|
||||||
|
: Transition(outDuration, inDuration);
|
||||||
|
|
||||||
|
private static Transition Fade(float duration) =>
|
||||||
|
new TimedTransition(duration / 2f, duration / 2f);
|
||||||
|
|
||||||
private static void Tick(EngineContext context, float seconds)
|
private static void Tick(EngineContext context, float seconds)
|
||||||
{
|
{
|
||||||
context.Clock.Advance(seconds);
|
context.Clock.Advance(seconds);
|
||||||
@@ -31,7 +39,7 @@ public class SceneTransitionTests
|
|||||||
Tick(context, 0.016f);
|
Tick(context, 0.016f);
|
||||||
|
|
||||||
// Fade(1.0) → фаза закрытия 0.5 c, фаза открытия 0.5 c.
|
// Fade(1.0) → фаза закрытия 0.5 c, фаза открытия 0.5 c.
|
||||||
context.Scenes.Switch(second, Transition.Fade(1f));
|
context.Scenes.Switch(second, Fade(1f));
|
||||||
Tick(context, 0.2f);
|
Tick(context, 0.2f);
|
||||||
|
|
||||||
Assert.True(context.Scenes.IsTransitioning);
|
Assert.True(context.Scenes.IsTransitioning);
|
||||||
@@ -61,7 +69,7 @@ public class SceneTransitionTests
|
|||||||
Tick(context, 0.016f);
|
Tick(context, 0.016f);
|
||||||
context.Clock.TimeScale = 0f; // игра на паузе
|
context.Clock.TimeScale = 0f; // игра на паузе
|
||||||
|
|
||||||
context.Scenes.Switch(second, Transition.Fade(0.2f));
|
context.Scenes.Switch(second, Fade(0.2f));
|
||||||
Tick(context, 0.15f);
|
Tick(context, 0.15f);
|
||||||
Tick(context, 0.15f);
|
Tick(context, 0.15f);
|
||||||
|
|
||||||
@@ -79,7 +87,7 @@ public class SceneTransitionTests
|
|||||||
context.Scenes.Switch(first);
|
context.Scenes.Switch(first);
|
||||||
Tick(context, 0.016f);
|
Tick(context, 0.016f);
|
||||||
|
|
||||||
context.Scenes.Switch(second, Transition.Fade(1f));
|
context.Scenes.Switch(second, Fade(1f));
|
||||||
Tick(context, 0.1f);
|
Tick(context, 0.1f);
|
||||||
context.Scenes.Switch(third); // передумали, пока экран закрывается
|
context.Scenes.Switch(third); // передумали, пока экран закрывается
|
||||||
|
|
||||||
@@ -99,12 +107,12 @@ public class SceneTransitionTests
|
|||||||
context.Scenes.Switch(first);
|
context.Scenes.Switch(first);
|
||||||
Tick(context, 0.016f);
|
Tick(context, 0.016f);
|
||||||
|
|
||||||
context.Scenes.Switch(second, Transition.Fade(1f)); // 0.5 c закрытие + 0.5 c открытие
|
context.Scenes.Switch(second, Fade(1f)); // 0.5 c закрытие + 0.5 c открытие
|
||||||
Tick(context, 0.6f); // закрыто, своп на second, началось открытие
|
Tick(context, 0.6f); // закрыто, своп на second, началось открытие
|
||||||
Assert.Same(second, context.Scenes.Current);
|
Assert.Same(second, context.Scenes.Current);
|
||||||
|
|
||||||
Tick(context, 0.25f); // открытие наполовину (coverage ~0.5)
|
Tick(context, 0.25f); // открытие наполовину (coverage ~0.5)
|
||||||
context.Scenes.Switch(third, Transition.Fade(1f)); // передумали во время открытия
|
context.Scenes.Switch(third, Fade(1f)); // передумали во время открытия
|
||||||
|
|
||||||
Tick(context, 0.05f); // экран снова закрывается — свопа ещё нет
|
Tick(context, 0.05f); // экран снова закрывается — свопа ещё нет
|
||||||
Assert.Same(second, context.Scenes.Current);
|
Assert.Same(second, context.Scenes.Current);
|
||||||
@@ -124,7 +132,7 @@ public class SceneTransitionTests
|
|||||||
context.Scenes.Switch(first);
|
context.Scenes.Switch(first);
|
||||||
Tick(context, 0.016f);
|
Tick(context, 0.016f);
|
||||||
|
|
||||||
context.Scenes.Switch(second, Transition.Fade(0f));
|
context.Scenes.Switch(second, Fade(0f));
|
||||||
Tick(context, 0.016f);
|
Tick(context, 0.016f);
|
||||||
Tick(context, 0.016f);
|
Tick(context, 0.016f);
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,29 @@ public class CameraMathTests
|
|||||||
AssertVector(new Vector2(0f, 200f), state.ScreenToWorld(Vector2.Zero));
|
AssertVector(new Vector2(0f, 200f), state.ScreenToWorld(Vector2.Zero));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorldCenter_EqualsUnclampedCameraPosition()
|
||||||
|
{
|
||||||
|
var camera = new Camera(new Vector2(640f, 360f), zoom: 2f);
|
||||||
|
|
||||||
|
var state = CameraMath.Compute(camera, 1280, 720, ViewportMapping.Identity);
|
||||||
|
|
||||||
|
AssertVector(camera.Position, state.WorldCenter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorldCenter_ReflectsBoundsClamp_UnlikeRawPosition()
|
||||||
|
{
|
||||||
|
var bounds = new RectF(0f, 0f, 2000f, 1000f);
|
||||||
|
var camera = new Camera(new Vector2(-500f, 500f), bounds: bounds);
|
||||||
|
|
||||||
|
var state = CameraMath.Compute(camera, 800, 600, ViewportMapping.Identity);
|
||||||
|
|
||||||
|
// Raw position is (-500, 500); only X clamps (to half-width 400 from the left world edge),
|
||||||
|
// Y (500) is already inside [300, 700]. The effective centre the view is built around is (400, 500).
|
||||||
|
AssertVector(new Vector2(400f, 500f), state.WorldCenter);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Mapping_CentersVirtualResolutionInWiderWindow()
|
public void Mapping_CentersVirtualResolutionInWiderWindow()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using MrGameEng.Core;
|
||||||
|
using MrGameEng.Lighting;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Lighting.Tests;
|
||||||
|
|
||||||
|
public class DayNightTests
|
||||||
|
{
|
||||||
|
private static (GameClock Clock, DayNight DayNight) Make()
|
||||||
|
{
|
||||||
|
var clock = new GameClock();
|
||||||
|
var calendar = new Calendar(clock, secondsPerDay: 24f); // 1 second = 1 in-game hour
|
||||||
|
return (clock, new DayNight(calendar, DayNightSettings.Default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Daylight_ZeroAtMidnight_OneAtNoon()
|
||||||
|
{
|
||||||
|
var (clock, dayNight) = Make();
|
||||||
|
|
||||||
|
Assert.Equal(0f, dayNight.Daylight, 3); // 00:00
|
||||||
|
|
||||||
|
clock.Advance(12f); // 12:00
|
||||||
|
Assert.Equal(1f, dayNight.Daylight, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Daylight_RisesFromDawnToNoon()
|
||||||
|
{
|
||||||
|
var (clock, dayNight) = Make();
|
||||||
|
|
||||||
|
clock.Advance(6f); // 06:00
|
||||||
|
var dawn = dayNight.Daylight;
|
||||||
|
clock.Advance(3f); // 09:00
|
||||||
|
var mid = dayNight.Daylight;
|
||||||
|
clock.Advance(3f); // 12:00
|
||||||
|
var noon = dayNight.Daylight;
|
||||||
|
|
||||||
|
Assert.True(dawn < mid && mid < noon, "daylight should rise from dawn to noon");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SunShadow_ZeroAtNight_PointsWestInMorning_EastInAfternoon_ShortAtNoon()
|
||||||
|
{
|
||||||
|
var (clock, dayNight) = Make(); // 1s = 1 in-game hour
|
||||||
|
|
||||||
|
Assert.Equal(Vector2.Zero, dayNight.SunShadow(8f)); // 00:00 — night, no sun
|
||||||
|
|
||||||
|
clock.Advance(7f); // 07:00 — low morning sun in the east
|
||||||
|
var morning = dayNight.SunShadow(8f);
|
||||||
|
Assert.True(morning.X < 0f, "morning shadow points west");
|
||||||
|
Assert.True(morning.Length() > 2f, "low sun casts a long shadow");
|
||||||
|
|
||||||
|
clock.Advance(5f); // 12:00 — sun overhead
|
||||||
|
Assert.True(dayNight.SunShadow(8f).Length() < 1f, "noon sun casts almost no shadow");
|
||||||
|
|
||||||
|
clock.Advance(5f); // 17:00 — afternoon sun in the west
|
||||||
|
Assert.True(dayNight.SunShadow(8f).X > 0f, "afternoon shadow points east");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ambient_DarkerAtNightThanAtNoon()
|
||||||
|
{
|
||||||
|
var (clock, dayNight) = Make();
|
||||||
|
|
||||||
|
var night = dayNight.Ambient; // 00:00
|
||||||
|
clock.Advance(12f); // 12:00
|
||||||
|
var noon = dayNight.Ambient;
|
||||||
|
|
||||||
|
Assert.True(night.R < noon.R && night.G < noon.G && night.B < noon.B);
|
||||||
|
Assert.Equal(255, noon.R); // day color is white at noon
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
using MrGameEng.Lighting;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Lighting.Tests;
|
||||||
|
|
||||||
|
public class LightmapBuilderTests
|
||||||
|
{
|
||||||
|
private static float[] Build(
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
float ambient,
|
||||||
|
bool[] occluders,
|
||||||
|
params LightSample[] lights
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var light = new float[width * height];
|
||||||
|
LightmapBuilder.Build(light, width, height, ambient, occluders, lights);
|
||||||
|
return light;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ambient_WithoutOccluders_IsUniform()
|
||||||
|
{
|
||||||
|
var light = Build(4, 4, 0.6f, new bool[16]);
|
||||||
|
Assert.All(light, v => Assert.Equal(0.6f, v, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OccluderCell_IsDarkerThanOpenCell()
|
||||||
|
{
|
||||||
|
var occ = new bool[9];
|
||||||
|
occ[4] = true; // centre cell shaded
|
||||||
|
var light = Build(3, 3, 0.8f, occ);
|
||||||
|
Assert.True(light[4] < light[0]);
|
||||||
|
Assert.Equal(0.8f * LightmapBuilder.OccluderShade, light[4], 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PointLight_IsBrighterNearSourceAndFadesToRadius()
|
||||||
|
{
|
||||||
|
var light = Build(7, 1, 0f, new bool[7], new LightSample(0, 0, 6f, 1f));
|
||||||
|
Assert.True(light[0] > light[2] && light[2] > light[5]);
|
||||||
|
Assert.InRange(light[6], 0f, 0.01f); // на радиусе свет угасает
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Occluder_CastsShadowBehindIt()
|
||||||
|
{
|
||||||
|
var occ = new bool[7];
|
||||||
|
occ[3] = true; // препятствие между источником (0) и дальними клетками
|
||||||
|
var light = Build(7, 1, 0f, occ, new LightSample(0, 0, 10f, 1f));
|
||||||
|
|
||||||
|
Assert.True(light[1] > 0f); // перед препятствием — освещено
|
||||||
|
Assert.Equal(0f, light[5], 5); // за препятствием — тень
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SunShadow_DarkensCellsInTheShadowDirection_FadingFromTheCaster()
|
||||||
|
{
|
||||||
|
var occ = new bool[7];
|
||||||
|
occ[3] = true; // occluder in the middle
|
||||||
|
var light = new float[7];
|
||||||
|
LightmapBuilder.Build(
|
||||||
|
light,
|
||||||
|
7,
|
||||||
|
1,
|
||||||
|
1f,
|
||||||
|
occ,
|
||||||
|
[],
|
||||||
|
sunShadow: new Vector2(3f, 0f),
|
||||||
|
sunShadowStrength: 0.6f
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(1f, light[2], 5); // toward the sun (opposite the shadow) — unshadowed
|
||||||
|
Assert.Equal(LightmapBuilder.OccluderShade, light[3], 5); // occluder cell stays self-shaded
|
||||||
|
Assert.True(light[4] < 1f); // in shadow
|
||||||
|
Assert.True(light[4] < light[6]); // darker near the caster, fading along the shadow
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Values_StayWithinUnitRange()
|
||||||
|
{
|
||||||
|
var light = Build(
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
0.5f,
|
||||||
|
new bool[25],
|
||||||
|
new LightSample(2, 2, 4f, 2f) // яркий свет — проверяем клампинг
|
||||||
|
);
|
||||||
|
Assert.All(light, v => Assert.InRange(v, 0f, 1f));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit.v3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Host\MrGameEng.Host.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using MrGameEng.Net;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net.Tests;
|
||||||
|
|
||||||
|
public class HeartbeatTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task HealthyClient_SurvivesPastIdleTimeout()
|
||||||
|
{
|
||||||
|
var port = FreePort();
|
||||||
|
using var server = new WebSocketServer(
|
||||||
|
port,
|
||||||
|
heartbeatInterval: TimeSpan.FromMilliseconds(100),
|
||||||
|
idleTimeout: TimeSpan.FromMilliseconds(400)
|
||||||
|
);
|
||||||
|
server.Start();
|
||||||
|
|
||||||
|
using var client = await WebSocketClient.ConnectAsync(
|
||||||
|
new Uri($"ws://localhost:{port}/"),
|
||||||
|
new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token
|
||||||
|
);
|
||||||
|
var connection = await WaitFor(
|
||||||
|
() => server.TryAcceptConnection(out var c) ? c : null,
|
||||||
|
"server accept"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Дольше idleTimeout: живой клиент авто-отвечает pong на server-ping и остаётся открыт.
|
||||||
|
await Task.Delay(900, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(connection.IsOpen);
|
||||||
|
Assert.True(client.IsOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SilentPeer_IsDroppedAfterIdleTimeout()
|
||||||
|
{
|
||||||
|
var port = FreePort();
|
||||||
|
using var server = new WebSocketServer(
|
||||||
|
port,
|
||||||
|
heartbeatInterval: TimeSpan.FromMilliseconds(100),
|
||||||
|
idleTimeout: TimeSpan.FromMilliseconds(400)
|
||||||
|
);
|
||||||
|
server.Start();
|
||||||
|
|
||||||
|
// Сырой peer: проходит рукопожатие, но дальше молчит и не отвечает на ping.
|
||||||
|
using var tcp = new TcpClient();
|
||||||
|
await tcp.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken);
|
||||||
|
var request =
|
||||||
|
"GET / HTTP/1.1\r\n"
|
||||||
|
+ "Host: localhost\r\n"
|
||||||
|
+ "Upgrade: websocket\r\n"
|
||||||
|
+ "Connection: Upgrade\r\n"
|
||||||
|
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||||
|
+ "Sec-WebSocket-Version: 13\r\n"
|
||||||
|
+ "\r\n";
|
||||||
|
var bytes = Encoding.ASCII.GetBytes(request);
|
||||||
|
await tcp.GetStream().WriteAsync(bytes, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var connection = await WaitFor(
|
||||||
|
() => server.TryAcceptConnection(out var c) ? c : null,
|
||||||
|
"server accept"
|
||||||
|
);
|
||||||
|
Assert.True(connection.IsOpen);
|
||||||
|
|
||||||
|
// Peer не отвечает pong'ом → активность не обновляется → сервер закрывает по простою.
|
||||||
|
await WaitFor(() => connection.IsOpen ? null : "closed", "idle drop");
|
||||||
|
Assert.False(connection.IsOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T> WaitFor<T>(Func<T?> poll, string what)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 200; i++)
|
||||||
|
{
|
||||||
|
if (poll() is { } result)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException($"Timed out waiting for {what}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FreePort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit.v3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MrGameEng.Net\MrGameEng.Net.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using MrGameEng.Net;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net.Tests;
|
||||||
|
|
||||||
|
public class ReplicationTests
|
||||||
|
{
|
||||||
|
private struct TestPosition : IComponent
|
||||||
|
{
|
||||||
|
public float X;
|
||||||
|
public float Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TestHealth : IComponent
|
||||||
|
{
|
||||||
|
public int Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeConnection : INetConnection
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public bool IsOpen { get; set; } = true;
|
||||||
|
public readonly ConcurrentQueue<byte[]> Sent = new();
|
||||||
|
|
||||||
|
public void Send(ReadOnlySpan<byte> message) => Sent.Enqueue(message.ToArray());
|
||||||
|
|
||||||
|
public bool TryReceive(out byte[] message) => Sent.TryDequeue(out message!);
|
||||||
|
|
||||||
|
public void Close() => IsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReplicationSchema MakeSchema() =>
|
||||||
|
new ReplicationSchema().Register<TestPosition>().Register<TestHealth>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstSnapshot_SpawnsEntities_OnTheClient()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var clientStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||||
|
var connection = new FakeConnection();
|
||||||
|
|
||||||
|
serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 10f, Y = 20f },
|
||||||
|
new TestHealth { Value = 7 }
|
||||||
|
);
|
||||||
|
serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = -3f, Y = 4f }
|
||||||
|
);
|
||||||
|
|
||||||
|
server.Send([connection]);
|
||||||
|
client.Pump(connection);
|
||||||
|
|
||||||
|
Assert.Equal(2, client.EntityCount);
|
||||||
|
var first = FindByNetId(clientStore, 1);
|
||||||
|
Assert.Equal(10f, first.GetComponent<TestPosition>().X);
|
||||||
|
Assert.Equal(7, first.GetComponent<TestHealth>().Value);
|
||||||
|
var second = FindByNetId(clientStore, 2);
|
||||||
|
Assert.False(second.HasComponent<TestHealth>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnchangedWorld_SendsNothing()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var connection = new FakeConnection();
|
||||||
|
serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 1f, Y = 1f }
|
||||||
|
);
|
||||||
|
|
||||||
|
server.Send([connection]);
|
||||||
|
Assert.Single(connection.Sent);
|
||||||
|
|
||||||
|
server.Send([connection]);
|
||||||
|
Assert.Single(connection.Sent); // дельта пустая — второго сообщения нет
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChangedComponent_IsTheOnlyThingResent()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var clientStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||||
|
var connection = new FakeConnection();
|
||||||
|
|
||||||
|
var entity = serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 1f, Y = 1f },
|
||||||
|
new TestHealth { Value = 100 }
|
||||||
|
);
|
||||||
|
server.Send([connection]);
|
||||||
|
client.Pump(connection);
|
||||||
|
|
||||||
|
entity.AddComponent(new TestPosition { X = 5f, Y = 6f }); // здоровье не трогаем
|
||||||
|
server.Send([connection]);
|
||||||
|
|
||||||
|
Assert.True(connection.Sent.TryDequeue(out var delta));
|
||||||
|
// Запись: type(1) + version(1) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
|
||||||
|
Assert.Equal(23, delta!.Length);
|
||||||
|
|
||||||
|
client.Apply(delta);
|
||||||
|
var replicated = FindByNetId(clientStore, 1);
|
||||||
|
Assert.Equal(5f, replicated.GetComponent<TestPosition>().X);
|
||||||
|
Assert.Equal(100, replicated.GetComponent<TestHealth>().Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeletedEntity_DespawnsOnTheClient()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var clientStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||||
|
var connection = new FakeConnection();
|
||||||
|
|
||||||
|
var entity = serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 1f, Y = 1f }
|
||||||
|
);
|
||||||
|
server.Send([connection]);
|
||||||
|
client.Pump(connection);
|
||||||
|
Assert.Equal(1, client.EntityCount);
|
||||||
|
|
||||||
|
entity.DeleteEntity();
|
||||||
|
server.Send([connection]);
|
||||||
|
client.Pump(connection);
|
||||||
|
|
||||||
|
Assert.Equal(0, client.EntityCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LateJoiner_GetsFullState()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var early = new FakeConnection { Id = 1 };
|
||||||
|
var late = new FakeConnection { Id = 2 };
|
||||||
|
|
||||||
|
serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 9f, Y = 9f }
|
||||||
|
);
|
||||||
|
server.Send([early]);
|
||||||
|
server.Send([early, late]); // мир не менялся: early — тишина, late — полный стейт
|
||||||
|
|
||||||
|
Assert.Single(early.Sent);
|
||||||
|
Assert.Single(late.Sent);
|
||||||
|
|
||||||
|
var clientStore = new EntityStore();
|
||||||
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||||
|
client.Pump(late);
|
||||||
|
Assert.Equal(1, client.EntityCount);
|
||||||
|
Assert.Equal(9f, FindByNetId(clientStore, 1).GetComponent<TestPosition>().X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EntitySpawned_FiresOncePerEntity()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var clientStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||||
|
var connection = new FakeConnection();
|
||||||
|
var spawns = 0;
|
||||||
|
client.EntitySpawned += _ => spawns++;
|
||||||
|
|
||||||
|
var entity = serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 1f, Y = 1f }
|
||||||
|
);
|
||||||
|
server.Send([connection]);
|
||||||
|
client.Pump(connection);
|
||||||
|
entity.AddComponent(new TestPosition { X = 2f, Y = 2f });
|
||||||
|
server.Send([connection]);
|
||||||
|
client.Pump(connection);
|
||||||
|
|
||||||
|
Assert.Equal(1, spawns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_DeletesEveryReplicatedEntity()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var clientStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||||
|
var connection = new FakeConnection();
|
||||||
|
serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 1f, Y = 2f }
|
||||||
|
);
|
||||||
|
serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 3f, Y = 4f }
|
||||||
|
);
|
||||||
|
server.Send([connection]);
|
||||||
|
client.Pump(connection);
|
||||||
|
Assert.Equal(2, client.EntityCount);
|
||||||
|
|
||||||
|
client.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(0, client.EntityCount);
|
||||||
|
foreach (var entity in clientStore.Entities)
|
||||||
|
{
|
||||||
|
Assert.False(entity.HasComponent<NetId>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MismatchedProtocolVersion_IsDropped()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var clientStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||||
|
var connection = new FakeConnection();
|
||||||
|
serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 1f, Y = 2f }
|
||||||
|
);
|
||||||
|
server.Send([connection]);
|
||||||
|
Assert.True(connection.Sent.TryDequeue(out var snapshot));
|
||||||
|
|
||||||
|
// Портим байт версии (индекс 1: type=0, version=1) — клиент обязан отбросить снапшот целиком.
|
||||||
|
snapshot![1] = 0xFF;
|
||||||
|
client.Apply(snapshot);
|
||||||
|
|
||||||
|
Assert.Equal(0, client.EntityCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TruncatedSnapshot_IsIgnoredWithoutThrowing()
|
||||||
|
{
|
||||||
|
var serverStore = new EntityStore();
|
||||||
|
var clientStore = new EntityStore();
|
||||||
|
var server = new ReplicationServer(MakeSchema(), serverStore);
|
||||||
|
var client = new ReplicationClient(MakeSchema(), clientStore);
|
||||||
|
var connection = new FakeConnection();
|
||||||
|
serverStore.CreateEntity(
|
||||||
|
new NetId { Value = server.NextNetId() },
|
||||||
|
new TestPosition { X = 5f, Y = 6f },
|
||||||
|
new TestHealth { Value = 9 }
|
||||||
|
);
|
||||||
|
server.Send([connection]);
|
||||||
|
Assert.True(connection.Sent.TryDequeue(out var snapshot));
|
||||||
|
|
||||||
|
// Режем хвост: заголовок и счётчик целы, но данные компонентов оборваны.
|
||||||
|
var truncated = snapshot!.AsSpan(0, snapshot.Length - 6).ToArray();
|
||||||
|
client.Apply(truncated); // не должно бросить
|
||||||
|
|
||||||
|
// Записи могли частично примениться, но клиент остался живым и консистентным.
|
||||||
|
Assert.True(client.EntityCount <= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entity FindByNetId(EntityStore store, int netId)
|
||||||
|
{
|
||||||
|
foreach (var entity in store.Entities)
|
||||||
|
{
|
||||||
|
if (entity.HasComponent<NetId>() && entity.GetComponent<NetId>().Value == netId)
|
||||||
|
{
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Entity with NetId {netId} not found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using MrGameEng.Net;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net.Tests;
|
||||||
|
|
||||||
|
public class WebSocketLoopbackTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAndServer_ExchangeBinaryMessages_OverLoopback()
|
||||||
|
{
|
||||||
|
var port = FreePort();
|
||||||
|
using var server = new WebSocketServer(port);
|
||||||
|
server.Start();
|
||||||
|
|
||||||
|
using var client = await WebSocketClient.ConnectAsync(
|
||||||
|
new Uri($"ws://localhost:{port}/"),
|
||||||
|
new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token
|
||||||
|
);
|
||||||
|
|
||||||
|
var connection = await WaitFor(
|
||||||
|
() => server.TryAcceptConnection(out var c) ? c : null,
|
||||||
|
"server accept"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сервер → клиент.
|
||||||
|
connection.Send([1, 2, 3, 250]);
|
||||||
|
var received = await WaitFor(
|
||||||
|
() => client.TryReceive(out var m) ? m : null,
|
||||||
|
"client receive"
|
||||||
|
);
|
||||||
|
Assert.Equal(new byte[] { 1, 2, 3, 250 }, received);
|
||||||
|
|
||||||
|
// Клиент → сервер (ClientWebSocket маскирует кадры — сервер обязан размаскировать).
|
||||||
|
client.Send([9, 8, 7]);
|
||||||
|
var echoed = await WaitFor(
|
||||||
|
() => connection.TryReceive(out var m) ? m : null,
|
||||||
|
"server receive"
|
||||||
|
);
|
||||||
|
Assert.Equal(new byte[] { 9, 8, 7 }, echoed);
|
||||||
|
|
||||||
|
Assert.True(connection.IsOpen);
|
||||||
|
Assert.True(client.IsOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingTheClient_ClosesTheServerConnection()
|
||||||
|
{
|
||||||
|
var port = FreePort();
|
||||||
|
using var server = new WebSocketServer(port);
|
||||||
|
server.Start();
|
||||||
|
|
||||||
|
var client = await WebSocketClient.ConnectAsync(
|
||||||
|
new Uri($"ws://localhost:{port}/"),
|
||||||
|
new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token
|
||||||
|
);
|
||||||
|
var connection = await WaitFor(
|
||||||
|
() => server.TryAcceptConnection(out var c) ? c : null,
|
||||||
|
"server accept"
|
||||||
|
);
|
||||||
|
|
||||||
|
client.Close();
|
||||||
|
|
||||||
|
await WaitFor(() => connection.IsOpen ? null : "closed", "server-side close");
|
||||||
|
Assert.False(connection.IsOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<T> WaitFor<T>(Func<T?> poll, string what)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 200; i++)
|
||||||
|
{
|
||||||
|
if (poll() is { } result)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException($"Timed out waiting for {what}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FreePort()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using MrGameEng.Net;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.Net.Tests;
|
||||||
|
|
||||||
|
public class WebSocketProtocolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AcceptKey_MatchesRfc6455Example()
|
||||||
|
{
|
||||||
|
// Пример рукопожатия прямо из RFC 6455, раздел 1.3.
|
||||||
|
Assert.Equal(
|
||||||
|
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=",
|
||||||
|
WebSocketProtocol.AcceptKey("dGhlIHNhbXBsZSBub25jZQ==")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(125)]
|
||||||
|
[InlineData(126)]
|
||||||
|
[InlineData(70_000)]
|
||||||
|
public void Frame_Roundtrips_Unmasked(int payloadLength)
|
||||||
|
{
|
||||||
|
var payload = MakePayload(payloadLength);
|
||||||
|
var frame = WebSocketProtocol.EncodeFrame(payload, WebSocketOpcode.Binary);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(frame);
|
||||||
|
Assert.True(
|
||||||
|
WebSocketProtocol.TryReadFrame(stream, out var opcode, out var fin, out var decoded)
|
||||||
|
);
|
||||||
|
Assert.Equal(WebSocketOpcode.Binary, opcode);
|
||||||
|
Assert.True(fin);
|
||||||
|
Assert.Equal(payload, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Frame_Roundtrips_Masked()
|
||||||
|
{
|
||||||
|
var payload = MakePayload(1000);
|
||||||
|
var frame = WebSocketProtocol.EncodeFrame(
|
||||||
|
payload,
|
||||||
|
WebSocketOpcode.Binary,
|
||||||
|
maskKey: [0x12, 0x34, 0x56, 0x78]
|
||||||
|
);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(frame);
|
||||||
|
Assert.True(WebSocketProtocol.TryReadFrame(stream, out _, out _, out var decoded));
|
||||||
|
Assert.Equal(payload, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryReadHandshakeKey_ExtractsHeader()
|
||||||
|
{
|
||||||
|
var request =
|
||||||
|
"GET /chat HTTP/1.1\r\n"
|
||||||
|
+ "Host: localhost\r\n"
|
||||||
|
+ "Upgrade: websocket\r\n"
|
||||||
|
+ "Connection: Upgrade\r\n"
|
||||||
|
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||||
|
+ "Sec-WebSocket-Version: 13\r\n"
|
||||||
|
+ "\r\n";
|
||||||
|
using var stream = new MemoryStream(System.Text.Encoding.ASCII.GetBytes(request));
|
||||||
|
|
||||||
|
Assert.True(WebSocketProtocol.TryReadHandshakeKey(stream, out var key));
|
||||||
|
Assert.Equal("dGhlIHNhbXBsZSBub25jZQ==", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryReadFrame_TruncatedStream_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var frame = WebSocketProtocol.EncodeFrame(MakePayload(100), WebSocketOpcode.Binary);
|
||||||
|
using var stream = new MemoryStream(frame, 0, frame.Length - 10);
|
||||||
|
|
||||||
|
Assert.False(WebSocketProtocol.TryReadFrame(stream, out _, out _, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] MakePayload(int length)
|
||||||
|
{
|
||||||
|
var payload = new byte[length];
|
||||||
|
for (var i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
payload[i] = (byte)(i * 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using MrGameEng.AI;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace MrGameEng.AI.Tests;
|
||||||
|
|
||||||
|
public class SuitabilityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Gaussian_PeaksAtOptimum()
|
||||||
|
{
|
||||||
|
Assert.Equal(1f, Suitability.Gaussian(18f, optimum: 18f, tolerance: 5f), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gaussian_IsSymmetricAroundOptimum()
|
||||||
|
{
|
||||||
|
var below = Suitability.Gaussian(13f, optimum: 18f, tolerance: 5f);
|
||||||
|
var above = Suitability.Gaussian(23f, optimum: 18f, tolerance: 5f);
|
||||||
|
Assert.Equal(below, above, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gaussian_AtOneToleranceAway_IsAboutPoint607()
|
||||||
|
{
|
||||||
|
Assert.Equal(MathF.Exp(-0.5f), Suitability.Gaussian(23f, 18f, 5f), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gaussian_FallsOffWithDistance()
|
||||||
|
{
|
||||||
|
var near = Suitability.Gaussian(20f, 18f, 5f);
|
||||||
|
var far = Suitability.Gaussian(30f, 18f, 5f);
|
||||||
|
Assert.True(far < near && far > 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gaussian_StaysWithinUnitRange()
|
||||||
|
{
|
||||||
|
for (var v = -50f; v <= 50f; v += 1f)
|
||||||
|
{
|
||||||
|
Assert.InRange(Suitability.Gaussian(v, 0f, 7f), 0f, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gaussian_NonPositiveTolerance_IsExactMatch()
|
||||||
|
{
|
||||||
|
Assert.Equal(1f, Suitability.Gaussian(5f, 5f, 0f));
|
||||||
|
Assert.Equal(0f, Suitability.Gaussian(6f, 5f, 0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trapezoid_IsFlatAcrossThePlateau()
|
||||||
|
{
|
||||||
|
Assert.Equal(1f, Suitability.Trapezoid(10f, 0f, 10f, 42f, 58f), 5);
|
||||||
|
Assert.Equal(1f, Suitability.Trapezoid(25f, 0f, 10f, 42f, 58f), 5);
|
||||||
|
Assert.Equal(1f, Suitability.Trapezoid(42f, 0f, 10f, 42f, 58f), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trapezoid_RampsLinearlyOnEachShoulder()
|
||||||
|
{
|
||||||
|
Assert.Equal(0.5f, Suitability.Trapezoid(5f, 0f, 10f, 42f, 58f), 5); // halfway up the cold ramp
|
||||||
|
Assert.Equal(0.5f, Suitability.Trapezoid(50f, 0f, 10f, 42f, 58f), 5); // halfway down the heat ramp
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trapezoid_IsZeroAtAndBeyondTheHardLimits()
|
||||||
|
{
|
||||||
|
Assert.Equal(0f, Suitability.Trapezoid(0f, 0f, 10f, 42f, 58f));
|
||||||
|
Assert.Equal(0f, Suitability.Trapezoid(-5f, 0f, 10f, 42f, 58f));
|
||||||
|
Assert.Equal(0f, Suitability.Trapezoid(58f, 0f, 10f, 42f, 58f));
|
||||||
|
Assert.Equal(0f, Suitability.Trapezoid(70f, 0f, 10f, 42f, 58f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trapezoid_DegenerateShoulder_IsAHardStep()
|
||||||
|
{
|
||||||
|
// optimalLow == min: full suitability immediately above the lower limit, zero at/below it.
|
||||||
|
Assert.Equal(1f, Suitability.Trapezoid(5f, 0f, 0f, 10f, 20f));
|
||||||
|
Assert.Equal(0f, Suitability.Trapezoid(0f, 0f, 0f, 10f, 20f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trapezoid_StaysWithinUnitRange()
|
||||||
|
{
|
||||||
|
for (var v = -50f; v <= 80f; v += 1f)
|
||||||
|
{
|
||||||
|
Assert.InRange(Suitability.Trapezoid(v, 0f, 10f, 42f, 58f), 0f, 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user