Compare commits
4
Commits
d0104df304
...
3438ed77f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3438ed77f6 | ||
|
|
10898b08a0 | ||
|
|
2ac074004a | ||
|
|
79d406a9f4 |
@@ -27,19 +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,
|
||||||
`Climate` — continuous seasonal/daily temperature and season over the calendar,
|
`context.UseCalendar(secondsPerDay)`; `Climate` — continuous seasonal/daily temperature
|
||||||
`context.UseClimate(settings)`),
|
and season over the calendar, `context.UseClimate(settings)`), services, logging, and
|
||||||
plus **Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
|
**`HeadlessHost`** — a fixed-timestep loop without a window or GPU (dedicated servers,
|
||||||
`Core/Input/`). Depends only on MonoGame and Friflo.Engine.ECS.
|
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/`) and **Lighting**
|
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
|
||||||
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
|
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
|
||||||
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; a sampleable
|
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; plus a per-cell
|
||||||
`DayNight.SampleAt` for the simulation — point lights and shadows land here later). → `Core`.
|
**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
|
||||||
@@ -57,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
|
||||||
@@ -68,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>
|
||||||
|
|||||||
@@ -16,4 +16,4 @@
|
|||||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</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(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл. **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`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar` → `Renderer2D.AmbientLight` на World-слоях, `scene.UseDayNight(renderer)`, сэмплируемый `DayNight.SampleAt` для симуляции |
|
| `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` во время перехода заменяет целевую сцену, не перезапуская переход.
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="NVorbis" />
|
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||||
</ItemGroup>
|
<PackageReference Include="NVorbis" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
<ItemGroup>
|
||||||
</ItemGroup>
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
</Project>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,28 @@
|
|||||||
|
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>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,217 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace MrGameEng.Formulas;
|
||||||
|
|
||||||
|
internal enum TokenType
|
||||||
|
{
|
||||||
|
Number,
|
||||||
|
Identifier,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,273 @@
|
|||||||
|
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);
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,120 @@
|
|||||||
|
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, then adds each point light with grid-traced occlusion.
|
||||||
|
/// Values end clamped to <c>[0, 1]</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static void Build(
|
||||||
|
float[] light,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
float ambient,
|
||||||
|
ReadOnlySpan<bool> occluders,
|
||||||
|
IReadOnlyList<LightSample> lights
|
||||||
|
)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < light.Length; i++)
|
||||||
|
{
|
||||||
|
light[i] = occluders[i] ? ambient * OccluderShade : ambient;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем
|
||||||
|
// промежуточные клетки на окклюдер (концы исключены).
|
||||||
|
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,164 @@
|
|||||||
|
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.18f; // ночь тусклая, но не чёрная (лунный свет)
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
_lightmap.Upload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draws the lightmap over the world as a single texture quad with multiply blending and linear
|
||||||
|
/// filtering — the world darkens by the grid and shadows read soft. Registered after the sprite flush
|
||||||
|
/// (so it lands on the drawn scene) and before the screen-space UI (so the HUD stays at full brightness).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LightmapRenderSystem : BaseSystem
|
||||||
|
{
|
||||||
|
private static readonly BlendState Multiply = new()
|
||||||
|
{
|
||||||
|
ColorSourceBlend = Blend.DestinationColor,
|
||||||
|
ColorDestinationBlend = Blend.Zero,
|
||||||
|
AlphaSourceBlend = Blend.DestinationAlpha,
|
||||||
|
AlphaDestinationBlend = Blend.Zero,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly GraphicsDevice _device;
|
||||||
|
private readonly Renderer2D _renderer;
|
||||||
|
private readonly Lightmap _lightmap;
|
||||||
|
private readonly BasicEffect _effect;
|
||||||
|
private readonly VertexPositionTexture[] _quad;
|
||||||
|
|
||||||
|
internal LightmapRenderSystem(GraphicsDevice device, Renderer2D renderer, Lightmap lightmap)
|
||||||
|
{
|
||||||
|
_device = device;
|
||||||
|
_renderer = renderer;
|
||||||
|
_lightmap = lightmap;
|
||||||
|
_effect = new BasicEffect(device)
|
||||||
|
{
|
||||||
|
TextureEnabled = true,
|
||||||
|
VertexColorEnabled = false,
|
||||||
|
World = Matrix.Identity,
|
||||||
|
};
|
||||||
|
|
||||||
|
var x0 = lightmap.Origin.X;
|
||||||
|
var y0 = lightmap.Origin.Y;
|
||||||
|
var x1 = x0 + lightmap.Width * lightmap.CellSize;
|
||||||
|
var y1 = y0 + lightmap.Height * lightmap.CellSize;
|
||||||
|
_quad =
|
||||||
|
[
|
||||||
|
new VertexPositionTexture(new Vector3(x0, y0, 0f), new Vector2(0f, 0f)),
|
||||||
|
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
|
||||||
|
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
|
||||||
|
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
|
||||||
|
new VertexPositionTexture(new Vector3(x1, y1, 0f), new Vector2(1f, 1f)),
|
||||||
|
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnUpdateGroup()
|
||||||
|
{
|
||||||
|
var camera = _renderer.Camera;
|
||||||
|
_effect.View = camera.View;
|
||||||
|
_effect.Projection = camera.Projection;
|
||||||
|
_effect.Texture = _lightmap.Texture;
|
||||||
|
|
||||||
|
var previousViewport = _device.Viewport;
|
||||||
|
var mapping = camera.Mapping;
|
||||||
|
_device.Viewport = new Viewport(
|
||||||
|
(int)MathF.Round(mapping.Offset.X),
|
||||||
|
(int)MathF.Round(mapping.Offset.Y),
|
||||||
|
(int)MathF.Round(camera.VirtualWidth * mapping.Scale),
|
||||||
|
(int)MathF.Round(camera.VirtualHeight * mapping.Scale)
|
||||||
|
);
|
||||||
|
_device.BlendState = Multiply;
|
||||||
|
_device.SamplerStates[0] = SamplerState.LinearClamp;
|
||||||
|
_device.DepthStencilState = DepthStencilState.None;
|
||||||
|
_device.RasterizerState = RasterizerState.CullNone;
|
||||||
|
|
||||||
|
foreach (var pass in _effect.CurrentTechnique.Passes)
|
||||||
|
{
|
||||||
|
pass.Apply();
|
||||||
|
_device.DrawUserPrimitives(PrimitiveType.TriangleList, _quad, 0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_device.Viewport = previousViewport;
|
||||||
|
_device.BlendState = BlendState.AlphaBlend;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
using Microsoft.Xna.Framework;
|
||||||
|
|
||||||
|
namespace MrGameEng.Lighting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A point light: an entity with this component plus a <see cref="MrGameEng.Graphics.Transform2D"/>
|
||||||
|
/// adds light around its world position, attenuating to zero at <see cref="Radius"/> and casting
|
||||||
|
/// grid-traced shadows behind occluders. Picked up by the lighting system into the lightmap.
|
||||||
|
/// </summary>
|
||||||
|
public struct PointLight : IComponent
|
||||||
|
{
|
||||||
|
/// <summary>Reach of the light in world units (light fades to zero at this distance).</summary>
|
||||||
|
public float Radius;
|
||||||
|
|
||||||
|
/// <summary>Light tint (reserved for colored lights; intensity currently drives brightness).</summary>
|
||||||
|
public Color Color;
|
||||||
|
|
||||||
|
/// <summary>Peak brightness added at the light's centre (0..1+).</summary>
|
||||||
|
public float Intensity;
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
|
<PackageReference Include="MonoGame.Framework.DesktopGL" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,92 @@
|
|||||||
|
using Friflo.Engine.ECS;
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
|
||||||
|
/// <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>Applies one snapshot message to the local store.</summary>
|
||||||
|
public void Apply(byte[] message)
|
||||||
|
{
|
||||||
|
using var reader = new BinaryReader(new MemoryStream(message));
|
||||||
|
if (reader.ReadByte() != ReplicationMessage.Snapshot)
|
||||||
|
{
|
||||||
|
return; // незнакомый тип сообщения — пропускаем, это не снапшот
|
||||||
|
}
|
||||||
|
|
||||||
|
var slots = _schema.Slots;
|
||||||
|
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);
|
||||||
|
slot.Apply(entity, data, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawned)
|
||||||
|
{
|
||||||
|
EntitySpawned?.Invoke(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,174 @@
|
|||||||
|
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);
|
||||||
|
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;
|
||||||
|
internal const byte OpUpsert = 0;
|
||||||
|
internal const byte OpDespawn = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,279 @@
|
|||||||
|
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; pings are answered automatically.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WebSocketServer : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The port the server listens on.</summary>
|
||||||
|
public int Port { 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.</summary>
|
||||||
|
public WebSocketServer(int port)
|
||||||
|
{
|
||||||
|
Port = port;
|
||||||
|
_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);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
private readonly TcpClient _client;
|
||||||
|
private readonly NetworkStream _stream;
|
||||||
|
private readonly ConcurrentQueue<byte[]> _inbox = new();
|
||||||
|
private readonly object _sendLock = new();
|
||||||
|
private volatile bool _closed;
|
||||||
|
|
||||||
|
internal ServerConnection(int id, TcpClient client)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
_client = client;
|
||||||
|
_stream = client.GetStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void StartReceiveLoop() => Task.Run(ReceiveLoop);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (opcode)
|
||||||
|
{
|
||||||
|
case WebSocketOpcode.Ping:
|
||||||
|
lock (_sendLock)
|
||||||
|
{
|
||||||
|
var pong = WebSocketProtocol.EncodeFrame(
|
||||||
|
payload,
|
||||||
|
WebSocketOpcode.Pong
|
||||||
|
);
|
||||||
|
_stream.Write(pong, 0, pong.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
case WebSocketOpcode.Pong:
|
||||||
|
continue;
|
||||||
|
case WebSocketOpcode.Close:
|
||||||
|
lock (_sendLock)
|
||||||
|
{
|
||||||
|
var close = WebSocketProtocol.EncodeFrame(
|
||||||
|
[],
|
||||||
|
WebSocketOpcode.Close
|
||||||
|
);
|
||||||
|
_stream.Write(close, 0, close.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opcode != WebSocketOpcode.Continuation)
|
||||||
|
{
|
||||||
|
pendingOpcode = opcode;
|
||||||
|
pending.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.AddRange(payload);
|
||||||
|
if (fin && pendingOpcode == WebSocketOpcode.Binary)
|
||||||
|
{
|
||||||
|
_inbox.Enqueue(pending.ToArray());
|
||||||
|
pending.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// обрыв соединения — штатный путь завершения цикла
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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 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,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,200 @@
|
|||||||
|
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) + count(4) + netId(4) + op(1) + mask(4) + TestPosition(8) — без TestHealth.
|
||||||
|
Assert.Equal(22, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user