Compare commits

..
4 Commits
Author SHA1 Message Date
Leonid PershinandClaude Fable 5 3438ed77f6 Add MrGameEng.Net: WebSocket transport + delta component replication
CI / build-test (push) Successful in 1m16s
Browsers can't speak UDP, so WebSocket is the engine's one transport.
The server side is a dependency-free RFC 6455 implementation over
TcpListener (handshake, frame codec with masking and fragmentation,
ping/pong — unit-tested against the RFC example vectors); the client
wraps ClientWebSocket, which works on desktop and maps to the browser
WebSocket in Blazor WASM. Both sit behind the poll-based INetConnection
so simulation systems drain messages from their own thread; client
sends are chained fire-and-forget (no blocking — wasm-safe).

Replication is server-authoritative: games register unmanaged component
types in a ReplicationSchema (same order both sides, up to 32 types),
ReplicationServer snapshots entities carrying NetId once per send and
ships each connection only the components that changed since its last
snapshot — a reliable ordered transport needs no acks for deltas. New
connections receive the full state through the same path; despawns are
tracked by set difference. ReplicationClient applies snapshots to a
local EntityStore and raises EntitySpawned so the game can decorate
replicated entities with presentation components.

Covered by 16 tests including a real loopback exchange between
WebSocketClient and WebSocketServer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:35:57 +03:00
Leonid PershinandClaude Opus 4.8 10898b08a0 Add formula engine: data-driven expression evaluator (Core)
CI / build-test (push) Successful in 1m19s
Keystone for the gene system. A Formula compiles a string from a def
(lexer -> recursive-descent parser -> tree of Func<IFormulaContext,float>)
once, then evaluates allocation-free against a variable context.

Supports + - * / %, comparisons, && || !, ternary, the constants
pi/tau/e, and functions abs sign floor ceil round sqrt exp log sin cos
tan min max pow clamp lerp step. Deterministic and side-effect-free so
gene-effect formulas stay pure. Covered by FormulaTests (parsing,
precedence, functions, logic/ternary, variables, errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:26:06 +03:00
Leonid PershinandClaude Fable 5 2ac074004a Split the platform out of Core: new Host library + HeadlessHost
CI / build-test (push) Successful in 1m24s
Core now depends only on Friflo.Engine.ECS — no MonoGame, no platform.
The windowed MonoGame host moves to the new MrGameEng.Host library:
GameHost, GameHostOptions, visual scene transitions (OverlayTransition,
Transitions.Fade/Wipe, TransitionRenderer) and the Input feature
(namespace MrGameEng.Input is unchanged). Transition timing stays in
Core (SceneManager exposes ActiveTransition/TransitionCoverage/
TransitionPhase; the host draws the overlay). EngineContext loses its
GraphicsDevice property: hosts publish the device as a service and
graphics code reads it via context.GetGraphicsDevice() in Graphics.

Core gains HeadlessHost: a fixed-timestep loop without a window or GPU
(Tick/RunTicks, Run with wall-clock pacing and lag resync) for dedicated
servers, batch simulation and tests. Graphics and Audio now carry their
own MonoGame.Framework.DesktopGL reference instead of inheriting it
from Core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:04:34 +03:00
Leonid PershinandClaude Opus 4.8 79d406a9f4 Add 2D lightmap: occlusion shadows and point lights
CI / build-test (push) Successful in 1m14s
Extend the Lighting module with a per-cell lightmap. LightmapBuilder (pure,
tested) fills an ambient base, shades occluder cells, and adds point lights
that attenuate with distance and are blocked by occluders between source and
cell (grid-traced soft shadows). Lightmap holds the grid plus a greyscale
texture (Upload) and a bilinear SampleAt for the simulation; a PointLight
component places lights in the world. LightmapSystem rebuilds the grid a few
times a second (ambient from the day/night cycle with a night floor, occluders
from a game-supplied grid, point lights from the ECS); LightmapRenderSystem
multiplies the lightmap over the world after the sprite flush and under the
HUD. Wired via scene.UseLighting(...), sampled through Lighting.SampleAt. Docs
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:40:17 +03:00
58 changed files with 3367 additions and 186 deletions
+30 -12
View File
@@ -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):
- **`Core`** — game loop, ECS world, scenes, time (`GameClock` with `TimeScale`; `GameSpeed`
for discrete pause/1×/3×/6× speed control over the clock, `context.UseGameSpeed(...)`;
`Calendar` turning scaled time into in-game days, `context.UseCalendar(secondsPerDay)`;
`Climate` — continuous seasonal/daily temperature and season over the calendar,
`context.UseClimate(settings)`),
plus **Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
`Core/Input/`). Depends only on MonoGame and Friflo.Engine.ECS.
- **`Core`** — the platform-free kernel: ECS world, scenes and transition timing, time
(`GameClock` with `TimeScale`; `GameSpeed` for discrete pause/1×/3×/6× speed control over
the clock, `context.UseGameSpeed(...)`; `Calendar` turning scaled time into in-game days,
`context.UseCalendar(secondsPerDay)`; `Climate` — continuous seasonal/daily temperature
and season over the calendar, `context.UseClimate(settings)`), services, logging, and
**`HeadlessHost`** — a fixed-timestep loop without a window or GPU (dedicated servers,
batch simulation, tests). Depends only on Friflo.Engine.ECS — no MonoGame, no platform.
- **`Host`** — the windowed MonoGame host: `GameHost` (wraps `Game`, owns the window and
`GraphicsDeviceManager`, publishes `GraphicsDevice` as a service), visual scene
transitions (`OverlayTransition`, `Transitions.Fade/Wipe`, `TransitionRenderer`), plus
**Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
`Host/Input/`). Nothing depends on `Host` except the game itself. → `Core`.
- **`Graphics`** — custom batched renderer, camera, sprites, plus **Tilemaps**
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; a sampleable
`DayNight.SampleAt` for the simulation point lights and shadows land here later). → `Core`.
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; plus a per-cell
**lightmap**`LightmapBuilder` (ambient × occlusion + point lights with grid-traced shadows),
`PointLight` component, multiplied over the world via `scene.UseLighting(...)`, sampleable with
`Lighting.SampleAt` for the simulation). → `Core`.
- **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume`
(one knob for effects + music). → `Core`.
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
@@ -57,6 +64,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
`Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`).
- **`Net`** — multiplayer building blocks, browser-compatible by design: a dependency-free
RFC 6455 WebSocket server over `TcpListener` (browsers can't speak UDP, so WebSocket is
the engine's one transport), a `ClientWebSocket`-based client (works in Blazor WASM),
both behind the poll-based `INetConnection`; server-authoritative component replication
(`ReplicationSchema` of unmanaged components, `ReplicationServer` sending per-connection
deltas — no acks needed over a reliable ordered transport, `ReplicationClient` applying
snapshots to a local store, `NetId`). → `Core`.
- **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`),
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
@@ -68,9 +82,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
netstandard2.0 analyzer.
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
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.
on Friflo.Engine.ECS (no MonoGame — the simulation must run headless). MonoGame is pulled
in by the platform/graphics libraries (`Host`, `Graphics`, `Audio`). Platform resources
(e.g. `GraphicsDevice`) are published by hosts as services in `EngineContext.Services`;
graphics code reaches the device via `context.GetGraphicsDevice()` (extension in
`Graphics`). Features grouped into one library share its package set (e.g. `Content`
carries both FontStash and StbImage) — keep optional/heavy deps (Myra, NVorbis) in their
own library so the rest of the engine stays free of them.
## Commands
-2
View File
@@ -1,5 +1,4 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
@@ -13,5 +12,4 @@
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('MrGameEng.')) AND !$(MSBuildProjectName.EndsWith('.Tests')) AND !$(MSBuildProjectName.EndsWith('.Generator')) AND !$(MSBuildProjectName.EndsWith('.Sample'))">
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
+1 -1
View File
@@ -16,4 +16,4 @@
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
</Project>
+60
View File
@@ -39,6 +39,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "tests
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{4407F6E6-0B65-41A3-ADFA-B78684A9B918}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "src\MrGameEng.Host\MrGameEng.Host.csproj", "{59818072-0D2B-4007-A50F-1343FA189EC6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "src\MrGameEng.Net\MrGameEng.Net.csproj", "{A4E754C7-C5FD-43A2-B345-34152D3A22D1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x64.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x64.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x86.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x86.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|Any CPU.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x64.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x64.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x86.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x86.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x64.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x64.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x86.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x86.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|Any CPU.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -249,5 +305,9 @@ Global
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{7F7D9641-2409-40CB-88A9-56BCE8C90A45} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{59818072-0D2B-4007-A50F-1343FA189EC6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{A4E754C7-C5FD-43A2-B345-34152D3A22D1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+28 -14
View File
@@ -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.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar``Renderer2D.AmbientLight` на World-слоях, `scene.UseDayNight(renderer)`, сэмплируемый `DayNight.SampleAt` для симуляции |
| `MrGameEng.Core` | Платформо-независимое ядро (зависит только от Friflo): `EngineContext`, `EntityStore`, `SystemRoot`, сцены и переходы (тайминг — `Transition`, `SceneManager`; визуал переходов — в `Host`), время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл, `ServiceRegistry`, `Log`. **`HeadlessHost`** — цикл без окна и GPU на фиксированном тике (`Tick`/`RunTicks`/`Run` с realtime-пейсингом): дедикейтед-серверы, батч-симуляция, тесты |
| `MrGameEng.Host` | Оконный MonoGame-хост: `GameHost` (обёртка над `Game` — цикл, окно, `GraphicsDeviceManager`; публикует `GraphicsDevice` сервисом в контексте), визуальные переходы сцен (`OverlayTransition`, фабрики `Transitions.Fade/Wipe`, `TransitionRenderer`). **Input** (`MrGameEng.Input`, `Host/Input/`): клавиатура, мышь, геймпад, action maps |
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar``Renderer2D.AmbientLight`, `scene.UseDayNight(renderer)`; по-клеточный лайтмап — `LightmapBuilder` (амбиент × окклюзия + точечные `PointLight` с трассировкой теней), накладывается multiply поверх мира через `scene.UseLighting(...)`, сэмплируется `Lighting.SampleAt` |
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
| `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast |
| `MrGameEng.Net` | Мультиплеер, совместимый с браузером по построению: WebSocket-сервер (RFC 6455 поверх `TcpListener`, без зависимостей — браузер не умеет UDP, поэтому транспорт движка один — WebSocket), клиент на `ClientWebSocket` (работает в Blazor WASM), оба за poll-интерфейсом `INetConnection`; server-authoritative репликация компонентов: `ReplicationSchema` (unmanaged-компоненты, до 32 типов), `ReplicationServer` (пер-соединенческие дельты против последнего отправленного — ack не нужны поверх надёжного упорядоченного транспорта), `ReplicationClient` (применение снапшотов в локальный `EntityStore`), компонент `NetId` |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг. **DevConsole** (`MrGameEng.DevConsole`): ингейм-консоль — логи `Log`, команды, история, автодополнение. **Inspector** (`MrGameEng.Inspector`): ECS-дебагер в духе Chrome DevTools — дерево сущностей по архетипам, компоненты/поля с правкой простых полей, выбор кликом по миру с подсветкой, вкладка перфа рендера (`scene.UseInspector(renderer)`, F1) |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) |
@@ -69,19 +71,28 @@
### Правило зависимостей
```
MrGameEng.Audio ─┐
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
│ └──► Friflo.Engine.ECS
MrGameEng.Host ─┐
MrGameEng.Audio ─┤
MrGameEng.Net ─┤
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
MrGameEng.Content ─┤
MrGameEng.Simulation ─┤ (Content — за Texture2DRegion,
MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер)
```
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от MonoGame
и Friflo. Если двум библиотекам нужен общий тип — он переезжает в `Core` (или, если это
графический тип, в `Graphics`). `Content` тянет `Graphics` (атласы выдают
`Texture2DRegion`); `Simulation` тянет `Graphics` (`Collisions` использует `Transform2D`,
`RectF`); `UI` — только `Core` (Myra рисует своим SpriteBatch).
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от Friflo —
ни MonoGame, ни другой платформы: благодаря этому симуляция запускается и без окна
(`HeadlessHost`). MonoGame (`MonoGame.Framework.DesktopGL`) тянут платформенные и
графические библиотеки: `Host`, `Graphics`, `Audio` (и транзитивно их потребители).
Платформенные ресурсы (например `GraphicsDevice`) хосты публикуют сервисами в
`EngineContext.Services`; графический код достаёт девайс через
`context.GetGraphicsDevice()` (расширение в `Graphics`). На `Host` не зависит никто,
кроме самой игры — это точка входа оконной платформы. Если двум библиотекам нужен общий
тип — он переезжает в `Core` (или, если это графический тип, в `Graphics`). `Content`
тянет `Graphics` (атласы выдают `Texture2DRegion`); `Simulation` тянет `Graphics`
(`Collisions` использует `Transform2D`, `RectF`); `UI` — только `Core` (Myra рисует
своим SpriteBatch).
Фичи, собранные в одну библиотеку, делят её набор пакетов (например, `Content` несёт и
FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в
@@ -345,12 +356,15 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
следующего кадра (сцена никогда не выгружается посреди собственного кадра).
- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом:
- `Scenes.Switch(scene, Transitions.Fade(0.5f))` — переключение с визуальным переходом:
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)`
(шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill`
в нормализованных координатах экрана).
- Тайминг-машина (`Transition` — длительности фаз, покрытие) живёт в `Core` и работает
и в headless-контексте; визуал — в `Host`: встроенные `Transitions.Fade(duration, color)`
и `Transitions.Wipe(duration, color)` (шторка). Свои — наследованием от
`OverlayTransition` (рисование через `TransitionRenderer.Fill` в нормализованных
координатах экрана); `GameHost` рисует оверлей поверх сцены, читая
`Scenes.ActiveTransition`/`TransitionCoverage`/`TransitionPhase`.
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
+14 -13
View File
@@ -1,13 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NVorbis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
<PackageReference Include="NVorbis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+3 -2
View File
@@ -2,6 +2,7 @@ using FontStashSharp;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Assets;
@@ -103,7 +104,7 @@ public sealed class AssetManager : IDisposable
{
using var stream = File.OpenRead(path);
return Texture2D.FromStream(
context.GraphicsDevice,
context.GetGraphicsDevice(),
stream,
DefaultColorProcessors.PremultiplyAlpha
);
@@ -123,7 +124,7 @@ public sealed class AssetManager : IDisposable
}
private static Effect LoadEffect(EngineContext context, string path) =>
new(context.GraphicsDevice, File.ReadAllBytes(path));
new(context.GetGraphicsDevice(), File.ReadAllBytes(path));
}
/// <summary>Wires the assets module into the engine.</summary>
@@ -1,5 +1,6 @@
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Atlases;
@@ -14,6 +15,6 @@ public static class AtlasesEngineExtensions
public static void UseTextureAtlases(this EngineContext context)
{
var assets = context.Services.Get<AssetManager>();
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GetGraphicsDevice(), path));
}
}
+10 -31
View File
@@ -1,10 +1,11 @@
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Core;
/// <summary>
/// Root object handed to scenes and systems: time, scene manager, services and graphics device.
/// Created by <see cref="GameHost"/>; can also be created standalone for headless tests.
/// Root object handed to scenes and systems: time, scene manager and services.
/// Created by a host (the MonoGame game host or <see cref="HeadlessHost"/>); can also be
/// created standalone for unit tests. Platform resources such as the graphics device are
/// published through <see cref="Services"/> by hosts that have them — the core itself has
/// no platform dependencies.
/// </summary>
public sealed class EngineContext
{
@@ -17,38 +18,16 @@ public sealed class EngineContext
/// <summary>Registry of module services (input, audio, assets, …).</summary>
public ServiceRegistry Services { get; } = new();
/// <summary>
/// The graphics device. Available once the host is initialized;
/// throws when accessed in a headless context (unit tests).
/// </summary>
public GraphicsDevice GraphicsDevice =>
_graphicsDevice
?? throw new InvalidOperationException(
"GraphicsDevice is not available (headless context)."
);
/// <summary>True when a graphics device is attached.</summary>
public bool HasGraphicsDevice => _graphicsDevice is not null;
private GraphicsDevice? _graphicsDevice;
/// <summary>Creates a context. Games normally never create one themselves — <see cref="GameHost"/> does.</summary>
/// <summary>Creates a context. Games normally never create one themselves — the host does.</summary>
public EngineContext()
{
Scenes = new SceneManager(this);
}
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
/// <summary>
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services
/// and the transition renderer. <paramref name="except"/> (the host itself, also a
/// registered service) is skipped — it is being disposed by the caller already.
/// Called by <see cref="GameHost.Dispose(bool)"/>.
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services.
/// Instances in <paramref name="except"/> (the host itself, platform resources the host
/// disposes on its own) are skipped. Called by hosts on shutdown.
/// </summary>
internal void DisposeOwnedResources(object except)
{
Scenes.DisposeRenderer();
Services.DisposeServices(except);
}
internal void DisposeOwnedResources(params object[] except) => Services.DisposeServices(except);
}
+60
View File
@@ -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>&lt; &gt; &lt;= &gt;= == !=</c>, logical <c>&amp;&amp; || !</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}."
);
}
}
}
+217
View File
@@ -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>&amp;&amp;</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}.");
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ namespace MrGameEng.Core;
/// <summary>
/// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter.
/// Advanced once per frame by <see cref="GameHost"/>.
/// Advanced once per frame (or per fixed tick) by the host.
/// </summary>
public sealed class GameClock
{
+107
View File
@@ -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();
}
}
+15
View File
@@ -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;
}
+1 -1
View File
@@ -4,11 +4,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
<PackageReference Include="Friflo.Engine.ECS" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
<InternalsVisibleTo Include="MrGameEng.Host" />
</ItemGroup>
</Project>
+15 -22
View File
@@ -22,13 +22,24 @@ public sealed class SceneManager
/// <summary>True while a transition is covering or revealing.</summary>
public bool IsTransitioning => _state != State.Idle;
/// <summary>The transition currently covering or revealing, or null while idle.
/// Hosts that render overlays read this together with <see cref="TransitionCoverage"/>
/// and <see cref="TransitionPhase"/> during their draw phase.</summary>
public Transition? ActiveTransition => _state == State.Idle ? null : _transition;
/// <summary>Coverage of the active transition: 0 = scene fully visible, 1 = fully covered.</summary>
public float TransitionCoverage => Math.Clamp(_coverage, 0f, 1f);
/// <summary>Phase of the active transition. Meaningful only while <see cref="IsTransitioning"/>.</summary>
public TransitionPhase TransitionPhase =>
_state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
private readonly EngineContext _context;
private Scene? _pending;
private bool _hasPending;
private Transition? _transition;
private State _state;
private float _coverage;
private TransitionRenderer? _renderer;
internal SceneManager(EngineContext context) => _context = context;
@@ -100,27 +111,9 @@ public sealed class SceneManager
Current?.Update(clock);
}
/// <summary>Draws the active scene and the transition overlay on top. Called by the host.</summary>
public void Draw(GameClock clock)
{
Current?.Draw(clock);
if (_state == State.Idle || !_context.HasGraphicsDevice)
{
return;
}
_renderer ??= new TransitionRenderer(_context.GraphicsDevice);
var phase = _state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
_transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase);
}
/// <summary>Disposes the lazily created transition renderer. Called on host shutdown.</summary>
internal void DisposeRenderer()
{
_renderer?.Dispose();
_renderer = null;
}
/// <summary>Draws the active scene. Transition overlays are rendered by the host on top,
/// from <see cref="ActiveTransition"/> and <see cref="TransitionCoverage"/>.</summary>
public void Draw(GameClock clock) => Current?.Draw(clock);
internal void ApplyPending()
{
+5 -4
View File
@@ -40,16 +40,17 @@ public sealed class ServiceRegistry
/// <summary>
/// Disposes every registered <see cref="IDisposable"/> service (each instance once, even
/// when registered under several types) and clears the registry. <paramref name="except"/>
/// is skipped. Called on host shutdown.
/// when registered under several types) and clears the registry. Instances in
/// <paramref name="except"/> are skipped. Called on host shutdown.
/// </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);
foreach (var service in _services.Values)
{
if (
!ReferenceEquals(service, except)
!skipped.Contains(service)
&& service is IDisposable disposable
&& disposed.Add(service)
)
+6 -51
View File
@@ -1,5 +1,3 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core;
/// <summary>Phase of a scene transition.</summary>
@@ -13,10 +11,12 @@ public enum TransitionPhase
}
/// <summary>
/// Visual transition between scenes. The scene switch itself happens at full coverage,
/// so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
/// Transitions are stateless and reusable; progress is tracked by <see cref="SceneManager"/>.
/// Runs on unscaled time, so it works while gameplay is paused.
/// Timing of a visual transition between scenes. The scene switch itself happens at full
/// coverage, so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
/// Progress is tracked by <see cref="SceneManager"/> on unscaled time, so transitions work
/// while gameplay is paused. The core only times the phases — how the overlay looks is
/// defined by the host (the MonoGame host's <c>OverlayTransition</c> and the
/// <c>Transitions</c> factories); headless hosts simply let transitions pass invisibly.
/// </summary>
public abstract class Transition
{
@@ -32,49 +32,4 @@ public abstract class Transition
OutDuration = Math.Max(0f, outDuration);
InDuration = Math.Max(0f, inDuration);
}
/// <summary>
/// Draws the overlay. <paramref name="coverage"/> is 0 (scene fully visible) to
/// 1 (scene fully covered); <paramref name="phase"/> tells which side of the switch this is.
/// </summary>
public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase);
/// <summary>Fade through a solid color (black by default). Total duration is split between out and in.</summary>
public static Transition Fade(float duration = 0.6f, Color? color = null) =>
new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
/// <summary>A curtain wiping across the screen (black by default). Total duration is split between out and in.</summary>
public static Transition Wipe(float duration = 0.6f, Color? color = null) =>
new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
) => renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
}
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
)
{
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
if (phase == TransitionPhase.Out)
{
renderer.Fill(0f, 0f, coverage, 1f, color);
}
else
{
renderer.Fill(1f - coverage, 0f, coverage, 1f, color);
}
}
}
}
@@ -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">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
@@ -22,7 +22,7 @@ public static class SceneGraphicsExtensions
var renderer = services.GetOrDefault<Renderer2D>();
if (renderer is null)
{
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
renderer = new Renderer2D(scene.Context.GetGraphicsDevice(), options);
services.Add(renderer);
}
else if (options is not null)
@@ -1,11 +1,16 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace MrGameEng.Core;
namespace MrGameEng.Host;
/// <summary>
/// The engine's game loop host. Wraps MonoGame's <see cref="Game"/>: owns the
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/> and drives the
/// active scene's update and draw phases.
/// The engine's windowed game-loop host. Wraps MonoGame's <see cref="Game"/>: owns the
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/>, drives the active
/// scene's update and draw phases and renders scene-transition overlays. The
/// <see cref="GraphicsDevice"/> is published as a service so graphics modules can reach it
/// through the context. For a loop without a window or GPU see
/// <see cref="HeadlessHost"/> in the core.
/// </summary>
public class GameHost : Game
{
@@ -17,6 +22,7 @@ public class GameHost : Game
private readonly GameHostOptions _options;
private readonly Scene _initialScene;
private TransitionRenderer? _transitionRenderer;
/// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary>
public GameHost(GameHostOptions options, Scene initialScene)
@@ -48,7 +54,7 @@ public class GameHost : Game
{
Window.Title = _options.Title;
Window.AllowUserResizing = _options.AllowResizing;
Context.AttachGraphicsDevice(GraphicsDevice);
Context.Services.Add(GraphicsDevice);
Context.Services.Add(Window);
Context.Services.Add<Game>(this);
base.Initialize();
@@ -68,9 +74,25 @@ public class GameHost : Game
{
GraphicsDevice.Clear(_options.ClearColor);
Context.Scenes.Draw(Context.Clock);
DrawTransitionOverlay();
base.Draw(gameTime);
}
private void DrawTransitionOverlay()
{
if (Context.Scenes.ActiveTransition is not OverlayTransition transition)
{
return;
}
_transitionRenderer ??= new TransitionRenderer(GraphicsDevice);
transition.Draw(
_transitionRenderer,
Context.Scenes.TransitionCoverage,
Context.Scenes.TransitionPhase
);
}
/// <inheritdoc />
protected override void OnExiting(object sender, ExitingEventArgs args)
{
@@ -84,7 +106,11 @@ public class GameHost : Game
{
if (disposing)
{
Context.DisposeOwnedResources(except: this);
_transitionRenderer?.Dispose();
_transitionRenderer = null;
// GraphicsDevice зарегистрирован как сервис, но им владеет MonoGame:
// base.Dispose сам его освобождает, реестру трогать нельзя.
Context.DisposeOwnedResources(this, GraphicsDevice);
}
base.Dispose(disposing);
@@ -1,6 +1,6 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core;
namespace MrGameEng.Host;
/// <summary>Window and loop settings for <see cref="GameHost"/>.</summary>
public sealed class GameHostOptions
+17
View File
@@ -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.Graphics;
namespace MrGameEng.Core;
namespace MrGameEng.Host;
/// <summary>
/// Minimal overlay renderer handed to <see cref="Transition.Draw"/>: fills rectangles in
/// normalized screen coordinates (0..1 on both axes) over the rendered scene.
/// Minimal overlay renderer handed to <see cref="OverlayTransition.Draw"/>: fills rectangles
/// in normalized screen coordinates (0..1 on both axes) over the rendered scene.
/// </summary>
public sealed class TransitionRenderer : IDisposable
{
@@ -13,7 +13,7 @@ public sealed class TransitionRenderer : IDisposable
private readonly BasicEffect _effect;
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();
internal TransitionRenderer(GraphicsDevice device)
+66
View File
@@ -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);
}
}
}
}
+25
View File
@@ -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();
}
+18
View File
@@ -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>
+14
View File
@@ -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;
}
+141
View File
@@ -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();
}
}
}
+248
View File
@@ -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;
}
}
+279
View File
@@ -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();
}
}
}
}
+112
View File
@@ -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++;
}
// Тайминг-машина живёт в ядре и не зависит от визуала перехода —
// тестируем на голой заглушке с длительностями, как у 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)
{
context.Clock.Advance(seconds);
@@ -31,7 +39,7 @@ public class SceneTransitionTests
Tick(context, 0.016f);
// 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);
Assert.True(context.Scenes.IsTransitioning);
@@ -61,7 +69,7 @@ public class SceneTransitionTests
Tick(context, 0.016f);
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);
@@ -79,7 +87,7 @@ public class SceneTransitionTests
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(1f));
context.Scenes.Switch(second, Fade(1f));
Tick(context, 0.1f);
context.Scenes.Switch(third); // передумали, пока экран закрывается
@@ -99,12 +107,12 @@ public class SceneTransitionTests
context.Scenes.Switch(first);
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, началось открытие
Assert.Same(second, context.Scenes.Current);
Tick(context, 0.25f); // открытие наполовину (coverage ~0.5)
context.Scenes.Switch(third, Transition.Fade(1f)); // передумали во время открытия
context.Scenes.Switch(third, Fade(1f)); // передумали во время открытия
Tick(context, 0.05f); // экран снова закрывается — свопа ещё нет
Assert.Same(second, context.Scenes.Current);
@@ -124,7 +132,7 @@ public class SceneTransitionTests
context.Scenes.Switch(first);
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);
@@ -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;
}
}