From 2ac074004a18d9814c565440b3d0ecc87337458e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 18:43:06 +0300 Subject: [PATCH] Split the platform out of Core: new Host library + HeadlessHost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 29 +++-- Directory.Build.props | 2 - Directory.Packages.props | 2 +- MrGameEng.sln | 30 +++++ docs/architecture.md | 38 ++++-- src/MrGameEng.Audio/MrGameEng.Audio.csproj | 27 ++-- src/MrGameEng.Content/Assets/AssetManager.cs | 5 +- .../Atlases/AtlasesEngineExtensions.cs | 3 +- src/MrGameEng.Core/EngineContext.cs | 41 ++---- src/MrGameEng.Core/GameClock.cs | 2 +- src/MrGameEng.Core/HeadlessHost.cs | 107 ++++++++++++++++ src/MrGameEng.Core/HeadlessHostOptions.cs | 15 +++ src/MrGameEng.Core/MrGameEng.Core.csproj | 2 +- src/MrGameEng.Core/SceneManager.cs | 37 +++--- src/MrGameEng.Core/ServiceRegistry.cs | 9 +- src/MrGameEng.Core/Transition.cs | 57 +-------- .../EngineContextGraphicsExtensions.cs | 19 +++ src/MrGameEng.Graphics/Lighting/Lighting.cs | 2 +- .../MrGameEng.Graphics.csproj | 30 +++-- .../SceneGraphicsExtensions.cs | 2 +- .../GameHost.cs | 38 +++++- .../GameHostOptions.cs | 2 +- .../Input/ActionMap.cs | 0 .../Input/InputManager.cs | 0 .../Input/InputSystem.cs | 0 src/MrGameEng.Host/MrGameEng.Host.csproj | 17 +++ .../TransitionRenderer.cs | 8 +- src/MrGameEng.Host/Transitions.cs | 66 ++++++++++ .../MrGameEng.Core.Tests/HeadlessHostTests.cs | 117 ++++++++++++++++++ .../SceneTransitionTests.cs | 20 ++- .../Input/ActionMapTests.cs | 0 .../Input/InputManagerTests.cs | 0 .../MrGameEng.Host.Tests.csproj | 17 +++ 33 files changed, 560 insertions(+), 184 deletions(-) create mode 100644 src/MrGameEng.Core/HeadlessHost.cs create mode 100644 src/MrGameEng.Core/HeadlessHostOptions.cs create mode 100644 src/MrGameEng.Graphics/EngineContextGraphicsExtensions.cs rename src/{MrGameEng.Core => MrGameEng.Host}/GameHost.cs (65%) rename src/{MrGameEng.Core => MrGameEng.Host}/GameHostOptions.cs (97%) rename src/{MrGameEng.Core => MrGameEng.Host}/Input/ActionMap.cs (100%) rename src/{MrGameEng.Core => MrGameEng.Host}/Input/InputManager.cs (100%) rename src/{MrGameEng.Core => MrGameEng.Host}/Input/InputSystem.cs (100%) create mode 100644 src/MrGameEng.Host/MrGameEng.Host.csproj rename src/{MrGameEng.Core => MrGameEng.Host}/TransitionRenderer.cs (88%) create mode 100644 src/MrGameEng.Host/Transitions.cs create mode 100644 tests/MrGameEng.Core.Tests/HeadlessHostTests.cs rename tests/{MrGameEng.Core.Tests => MrGameEng.Host.Tests}/Input/ActionMapTests.cs (100%) rename tests/{MrGameEng.Core.Tests => MrGameEng.Host.Tests}/Input/InputManagerTests.cs (100%) create mode 100644 tests/MrGameEng.Host.Tests/MrGameEng.Host.Tests.csproj diff --git a/CLAUDE.md b/CLAUDE.md index ba2e277..182c298 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,13 +27,18 @@ 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** @@ -70,9 +75,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 diff --git a/Directory.Build.props b/Directory.Build.props index 5292dfc..02c8c2e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,4 @@ - latest enable @@ -13,5 +12,4 @@ true - diff --git a/Directory.Packages.props b/Directory.Packages.props index 3057a12..8199149 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,4 +16,4 @@ - \ No newline at end of file + diff --git a/MrGameEng.sln b/MrGameEng.sln index 1345c81..b93040a 100644 --- a/MrGameEng.sln +++ b/MrGameEng.sln @@ -39,6 +39,10 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -229,6 +233,30 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -249,5 +277,7 @@ 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} EndGlobalSection EndGlobal diff --git a/docs/architecture.md b/docs/architecture.md index 39416b9..b700d30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,8 @@ | Библиотека (сборка) | Фичи (неймспейсы) и ответственность | |------------------------------|------------------------------------------------------------| -| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps | +| `MrGameEng.Core` | Платформо-независимое ядро (зависит только от Friflo): `EngineContext`, `EntityStore`, `SystemRoot`, сцены и переходы (тайминг — `Transition`, `SceneManager`; визуал переходов — в `Host`), время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл, `ServiceRegistry`, `Log`. **`HeadlessHost`** — цикл без окна и GPU на фиксированном тике (`Tick`/`RunTicks`/`Run` с realtime-пейсингом): дедикейтед-серверы, батч-симуляция, тесты | +| `MrGameEng.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`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента | @@ -69,19 +70,27 @@ ### Правило зависимостей ``` -MrGameEng.Audio ─┐ -MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL - │ └──► Friflo.Engine.ECS +MrGameEng.Host ─┐ +MrGameEng.Audio ─┤ +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 +354,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` во время перехода заменяет целевую сцену, не перезапуская переход. diff --git a/src/MrGameEng.Audio/MrGameEng.Audio.csproj b/src/MrGameEng.Audio/MrGameEng.Audio.csproj index 8968c4b..8ce207f 100644 --- a/src/MrGameEng.Audio/MrGameEng.Audio.csproj +++ b/src/MrGameEng.Audio/MrGameEng.Audio.csproj @@ -1,13 +1,14 @@ - - - net8.0 - - - - - - - - - - + + + net8.0 + + + + + + + + + + + diff --git a/src/MrGameEng.Content/Assets/AssetManager.cs b/src/MrGameEng.Content/Assets/AssetManager.cs index 7bfd713..d10ffca 100644 --- a/src/MrGameEng.Content/Assets/AssetManager.cs +++ b/src/MrGameEng.Content/Assets/AssetManager.cs @@ -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)); } /// Wires the assets module into the engine. diff --git a/src/MrGameEng.Content/Atlases/AtlasesEngineExtensions.cs b/src/MrGameEng.Content/Atlases/AtlasesEngineExtensions.cs index 1d3214b..c02fd8c 100644 --- a/src/MrGameEng.Content/Atlases/AtlasesEngineExtensions.cs +++ b/src/MrGameEng.Content/Atlases/AtlasesEngineExtensions.cs @@ -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(); - assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path)); + assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GetGraphicsDevice(), path)); } } diff --git a/src/MrGameEng.Core/EngineContext.cs b/src/MrGameEng.Core/EngineContext.cs index f8a8241..dedc7a8 100644 --- a/src/MrGameEng.Core/EngineContext.cs +++ b/src/MrGameEng.Core/EngineContext.cs @@ -1,10 +1,11 @@ -using Microsoft.Xna.Framework.Graphics; - namespace MrGameEng.Core; /// -/// Root object handed to scenes and systems: time, scene manager, services and graphics device. -/// Created by ; 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 ); can also be +/// created standalone for unit tests. Platform resources such as the graphics device are +/// published through by hosts that have them — the core itself has +/// no platform dependencies. /// public sealed class EngineContext { @@ -17,38 +18,16 @@ public sealed class EngineContext /// Registry of module services (input, audio, assets, …). public ServiceRegistry Services { get; } = new(); - /// - /// The graphics device. Available once the host is initialized; - /// throws when accessed in a headless context (unit tests). - /// - public GraphicsDevice GraphicsDevice => - _graphicsDevice - ?? throw new InvalidOperationException( - "GraphicsDevice is not available (headless context)." - ); - - /// True when a graphics device is attached. - public bool HasGraphicsDevice => _graphicsDevice is not null; - - private GraphicsDevice? _graphicsDevice; - - /// Creates a context. Games normally never create one themselves — does. + /// Creates a context. Games normally never create one themselves — the host does. public EngineContext() { Scenes = new SceneManager(this); } - internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device; - /// - /// Disposes everything the context owns: registered services - /// and the transition renderer. (the host itself, also a - /// registered service) is skipped — it is being disposed by the caller already. - /// Called by . + /// Disposes everything the context owns: registered services. + /// Instances in (the host itself, platform resources the host + /// disposes on its own) are skipped. Called by hosts on shutdown. /// - internal void DisposeOwnedResources(object except) - { - Scenes.DisposeRenderer(); - Services.DisposeServices(except); - } + internal void DisposeOwnedResources(params object[] except) => Services.DisposeServices(except); } diff --git a/src/MrGameEng.Core/GameClock.cs b/src/MrGameEng.Core/GameClock.cs index 9e53d32..7cb9336 100644 --- a/src/MrGameEng.Core/GameClock.cs +++ b/src/MrGameEng.Core/GameClock.cs @@ -2,7 +2,7 @@ namespace MrGameEng.Core; /// /// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter. -/// Advanced once per frame by . +/// Advanced once per frame (or per fixed tick) by the host. /// public sealed class GameClock { diff --git a/src/MrGameEng.Core/HeadlessHost.cs b/src/MrGameEng.Core/HeadlessHost.cs new file mode 100644 index 0000000..b98795b --- /dev/null +++ b/src/MrGameEng.Core/HeadlessHost.cs @@ -0,0 +1,107 @@ +using System.Diagnostics; + +namespace MrGameEng.Core; + +/// +/// 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 , 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 × +/// seconds. +/// +public sealed class HeadlessHost : IDisposable +{ + /// Engine context shared with scenes and systems. + public EngineContext Context { get; } = new(); + + /// Fixed simulation step in seconds: 1 / . + public float FixedDeltaTime { get; } + + /// Ticks completed since the host was created. + public long TickCount => Context.Clock.FrameCount; + + // Отстав сильнее этого, Run ресинкается с настоящим временем вместо лавины тиков. + private const double MaxLagSeconds = 1.0; + + private readonly HeadlessHostOptions _options; + + /// Creates a host that starts with . The scene + /// loads on the first tick, mirroring the windowed host's deferred switch. + 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); + } + + /// Advances the world by exactly one fixed tick. + public void Tick() + { + Context.Clock.Advance(FixedDeltaTime); + Context.Scenes.Update(Context.Clock); + } + + /// Advances the world by ticks as fast as possible. + public void RunTicks(long count) + { + for (long i = 0; i < count; i++) + { + Tick(); + } + } + + /// + /// Runs until is cancelled. With + /// 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 + /// and is accurate to a few milliseconds, not exact. + /// + 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; + } + } + } + + /// Unloads the active scene and disposes context-owned services. + public void Dispose() + { + Context.Scenes.Switch(null); + Context.Scenes.ApplyPending(); + Context.DisposeOwnedResources(); + } +} diff --git a/src/MrGameEng.Core/HeadlessHostOptions.cs b/src/MrGameEng.Core/HeadlessHostOptions.cs new file mode 100644 index 0000000..c4865f0 --- /dev/null +++ b/src/MrGameEng.Core/HeadlessHostOptions.cs @@ -0,0 +1,15 @@ +namespace MrGameEng.Core; + +/// Loop settings for . +public sealed class HeadlessHostOptions +{ + /// Fixed simulation rate in ticks per second. Must be positive. + public float TicksPerSecond { get; set; } = 60f; + + /// + /// When true, paces ticks to the wall clock (a dedicated + /// server); when false it runs flat out (batch simulation). + /// always runs flat out regardless of this setting. + /// + public bool Realtime { get; set; } = true; +} diff --git a/src/MrGameEng.Core/MrGameEng.Core.csproj b/src/MrGameEng.Core/MrGameEng.Core.csproj index 3556987..131eabd 100644 --- a/src/MrGameEng.Core/MrGameEng.Core.csproj +++ b/src/MrGameEng.Core/MrGameEng.Core.csproj @@ -4,11 +4,11 @@ - + diff --git a/src/MrGameEng.Core/SceneManager.cs b/src/MrGameEng.Core/SceneManager.cs index f9b6ce0..c71661d 100644 --- a/src/MrGameEng.Core/SceneManager.cs +++ b/src/MrGameEng.Core/SceneManager.cs @@ -22,13 +22,24 @@ public sealed class SceneManager /// True while a transition is covering or revealing. public bool IsTransitioning => _state != State.Idle; + /// The transition currently covering or revealing, or null while idle. + /// Hosts that render overlays read this together with + /// and during their draw phase. + public Transition? ActiveTransition => _state == State.Idle ? null : _transition; + + /// Coverage of the active transition: 0 = scene fully visible, 1 = fully covered. + public float TransitionCoverage => Math.Clamp(_coverage, 0f, 1f); + + /// Phase of the active transition. Meaningful only while . + 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); } - /// Draws the active scene and the transition overlay on top. Called by the host. - 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); - } - - /// Disposes the lazily created transition renderer. Called on host shutdown. - internal void DisposeRenderer() - { - _renderer?.Dispose(); - _renderer = null; - } + /// Draws the active scene. Transition overlays are rendered by the host on top, + /// from and . + public void Draw(GameClock clock) => Current?.Draw(clock); internal void ApplyPending() { diff --git a/src/MrGameEng.Core/ServiceRegistry.cs b/src/MrGameEng.Core/ServiceRegistry.cs index fa03963..5b75e25 100644 --- a/src/MrGameEng.Core/ServiceRegistry.cs +++ b/src/MrGameEng.Core/ServiceRegistry.cs @@ -40,16 +40,17 @@ public sealed class ServiceRegistry /// /// Disposes every registered service (each instance once, even - /// when registered under several types) and clears the registry. - /// is skipped. Called on host shutdown. + /// when registered under several types) and clears the registry. Instances in + /// are skipped. Called on host shutdown. /// - internal void DisposeServices(object? except = null) + internal void DisposeServices(params object[] except) { + var skipped = new HashSet(except, ReferenceEqualityComparer.Instance); var disposed = new HashSet(ReferenceEqualityComparer.Instance); foreach (var service in _services.Values) { if ( - !ReferenceEquals(service, except) + !skipped.Contains(service) && service is IDisposable disposable && disposed.Add(service) ) diff --git a/src/MrGameEng.Core/Transition.cs b/src/MrGameEng.Core/Transition.cs index 3725727..49cafbf 100644 --- a/src/MrGameEng.Core/Transition.cs +++ b/src/MrGameEng.Core/Transition.cs @@ -1,5 +1,3 @@ -using Microsoft.Xna.Framework; - namespace MrGameEng.Core; /// Phase of a scene transition. @@ -13,10 +11,12 @@ public enum TransitionPhase } /// -/// Visual transition between scenes. The scene switch itself happens at full coverage, -/// so a slow OnLoad of the next scene is hidden behind the overlay. -/// Transitions are stateless and reusable; progress is tracked by . -/// 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 OnLoad of the next scene is hidden behind the overlay. +/// Progress is tracked by 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 OverlayTransition and the +/// Transitions factories); headless hosts simply let transitions pass invisibly. /// public abstract class Transition { @@ -32,49 +32,4 @@ public abstract class Transition OutDuration = Math.Max(0f, outDuration); InDuration = Math.Max(0f, inDuration); } - - /// - /// Draws the overlay. is 0 (scene fully visible) to - /// 1 (scene fully covered); tells which side of the switch this is. - /// - public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase); - - /// Fade through a solid color (black by default). Total duration is split between out and in. - public static Transition Fade(float duration = 0.6f, Color? color = null) => - new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black); - - /// A curtain wiping across the screen (black by default). Total duration is split between out and in. - 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); - } - } - } } diff --git a/src/MrGameEng.Graphics/EngineContextGraphicsExtensions.cs b/src/MrGameEng.Graphics/EngineContextGraphicsExtensions.cs new file mode 100644 index 0000000..2e0a056 --- /dev/null +++ b/src/MrGameEng.Graphics/EngineContextGraphicsExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Xna.Framework.Graphics; +using MrGameEng.Core; + +namespace MrGameEng.Graphics; + +/// Graphics-side accessors for the platform-free . +public static class EngineContextGraphicsExtensions +{ + /// + /// Returns the service published by a windowed host. + /// Throws in a headless context, where no graphics device exists. + /// + public static GraphicsDevice GetGraphicsDevice(this EngineContext context) => + context.Services.GetOrDefault() + ?? throw new InvalidOperationException( + "GraphicsDevice is not available: no windowed host has published it " + + "(headless context, or graphics are not initialized yet)." + ); +} diff --git a/src/MrGameEng.Graphics/Lighting/Lighting.cs b/src/MrGameEng.Graphics/Lighting/Lighting.cs index 0c65066..fdb6b5b 100644 --- a/src/MrGameEng.Graphics/Lighting/Lighting.cs +++ b/src/MrGameEng.Graphics/Lighting/Lighting.cs @@ -42,7 +42,7 @@ public static class SceneLightmapExtensions Func occluders ) { - var device = scene.Context.GraphicsDevice; + var device = scene.Context.GetGraphicsDevice(); var lightmap = new Lightmap(device, width, height, cellSize, origin); var lighting = new Lighting(lightmap); scene.Context.Services.Add(lighting); diff --git a/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj b/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj index e9067e1..37ba22c 100644 --- a/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj +++ b/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj @@ -1,13 +1,17 @@ - - - net8.0 - - - - - - - - - - + + + net8.0 + + + + + + + + + + + + + + diff --git a/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs b/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs index 8e9dd6f..889b871 100644 --- a/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs +++ b/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs @@ -22,7 +22,7 @@ public static class SceneGraphicsExtensions var renderer = services.GetOrDefault(); 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) diff --git a/src/MrGameEng.Core/GameHost.cs b/src/MrGameEng.Host/GameHost.cs similarity index 65% rename from src/MrGameEng.Core/GameHost.cs rename to src/MrGameEng.Host/GameHost.cs index 6f4566c..2cde0a7 100644 --- a/src/MrGameEng.Core/GameHost.cs +++ b/src/MrGameEng.Host/GameHost.cs @@ -1,11 +1,16 @@ using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MrGameEng.Core; -namespace MrGameEng.Core; +namespace MrGameEng.Host; /// -/// The engine's game loop host. Wraps MonoGame's : owns the -/// , advances the and drives the -/// active scene's update and draw phases. +/// The engine's windowed game-loop host. Wraps MonoGame's : owns the +/// , advances the , drives the active +/// scene's update and draw phases and renders scene-transition overlays. The +/// is published as a service so graphics modules can reach it +/// through the context. For a loop without a window or GPU see +/// in the core. /// public class GameHost : Game { @@ -17,6 +22,7 @@ public class GameHost : Game private readonly GameHostOptions _options; private readonly Scene _initialScene; + private TransitionRenderer? _transitionRenderer; /// Creates a host that starts with . 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(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 + ); + } + /// 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); diff --git a/src/MrGameEng.Core/GameHostOptions.cs b/src/MrGameEng.Host/GameHostOptions.cs similarity index 97% rename from src/MrGameEng.Core/GameHostOptions.cs rename to src/MrGameEng.Host/GameHostOptions.cs index 7b5b2dd..5798d1e 100644 --- a/src/MrGameEng.Core/GameHostOptions.cs +++ b/src/MrGameEng.Host/GameHostOptions.cs @@ -1,6 +1,6 @@ using Microsoft.Xna.Framework; -namespace MrGameEng.Core; +namespace MrGameEng.Host; /// Window and loop settings for . public sealed class GameHostOptions diff --git a/src/MrGameEng.Core/Input/ActionMap.cs b/src/MrGameEng.Host/Input/ActionMap.cs similarity index 100% rename from src/MrGameEng.Core/Input/ActionMap.cs rename to src/MrGameEng.Host/Input/ActionMap.cs diff --git a/src/MrGameEng.Core/Input/InputManager.cs b/src/MrGameEng.Host/Input/InputManager.cs similarity index 100% rename from src/MrGameEng.Core/Input/InputManager.cs rename to src/MrGameEng.Host/Input/InputManager.cs diff --git a/src/MrGameEng.Core/Input/InputSystem.cs b/src/MrGameEng.Host/Input/InputSystem.cs similarity index 100% rename from src/MrGameEng.Core/Input/InputSystem.cs rename to src/MrGameEng.Host/Input/InputSystem.cs diff --git a/src/MrGameEng.Host/MrGameEng.Host.csproj b/src/MrGameEng.Host/MrGameEng.Host.csproj new file mode 100644 index 0000000..eb83a2e --- /dev/null +++ b/src/MrGameEng.Host/MrGameEng.Host.csproj @@ -0,0 +1,17 @@ + + + net8.0 + + + + + + + + + + + + + + diff --git a/src/MrGameEng.Core/TransitionRenderer.cs b/src/MrGameEng.Host/TransitionRenderer.cs similarity index 88% rename from src/MrGameEng.Core/TransitionRenderer.cs rename to src/MrGameEng.Host/TransitionRenderer.cs index 8a63a7a..f1b8be2 100644 --- a/src/MrGameEng.Core/TransitionRenderer.cs +++ b/src/MrGameEng.Host/TransitionRenderer.cs @@ -1,11 +1,11 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; -namespace MrGameEng.Core; +namespace MrGameEng.Host; /// -/// Minimal overlay renderer handed to : fills rectangles in -/// normalized screen coordinates (0..1 on both axes) over the rendered scene. +/// Minimal overlay renderer handed to : fills rectangles +/// in normalized screen coordinates (0..1 on both axes) over the rendered scene. /// 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]; - /// Disposes the GPU effect. Called by on shutdown. + /// Disposes the GPU effect. Called by on shutdown. public void Dispose() => _effect.Dispose(); internal TransitionRenderer(GraphicsDevice device) diff --git a/src/MrGameEng.Host/Transitions.cs b/src/MrGameEng.Host/Transitions.cs new file mode 100644 index 0000000..b05147d --- /dev/null +++ b/src/MrGameEng.Host/Transitions.cs @@ -0,0 +1,66 @@ +using Microsoft.Xna.Framework; +using MrGameEng.Core; + +namespace MrGameEng.Host; + +/// +/// A scene transition that draws a full-screen overlay through a +/// . The timing state machine lives in +/// ; renders the overlay each draw while a +/// transition is active. Transitions are stateless and reusable. +/// +public abstract class OverlayTransition : Transition +{ + /// Creates a transition with explicit phase durations. + protected OverlayTransition(float outDuration, float inDuration) + : base(outDuration, inDuration) { } + + /// + /// Draws the overlay. is 0 (scene fully visible) to + /// 1 (scene fully covered); tells which side of the switch this is. + /// + public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase); +} + +/// Factories for the built-in visual scene transitions. +public static class Transitions +{ + /// Fade through a solid color (black by default). Total duration is split between out and in. + public static Transition Fade(float duration = 0.6f, Color? color = null) => + new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black); + + /// A curtain wiping across the screen (black by default). Total duration is split between out and in. + 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); + } + } + } +} diff --git a/tests/MrGameEng.Core.Tests/HeadlessHostTests.cs b/tests/MrGameEng.Core.Tests/HeadlessHostTests.cs new file mode 100644 index 0000000..0889e6c --- /dev/null +++ b/tests/MrGameEng.Core.Tests/HeadlessHostTests.cs @@ -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(() => + 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); + } + } +} diff --git a/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs b/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs index 5c7b5a2..34f3fe2 100644 --- a/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs +++ b/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs @@ -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); diff --git a/tests/MrGameEng.Core.Tests/Input/ActionMapTests.cs b/tests/MrGameEng.Host.Tests/Input/ActionMapTests.cs similarity index 100% rename from tests/MrGameEng.Core.Tests/Input/ActionMapTests.cs rename to tests/MrGameEng.Host.Tests/Input/ActionMapTests.cs diff --git a/tests/MrGameEng.Core.Tests/Input/InputManagerTests.cs b/tests/MrGameEng.Host.Tests/Input/InputManagerTests.cs similarity index 100% rename from tests/MrGameEng.Core.Tests/Input/InputManagerTests.cs rename to tests/MrGameEng.Host.Tests/Input/InputManagerTests.cs diff --git a/tests/MrGameEng.Host.Tests/MrGameEng.Host.Tests.csproj b/tests/MrGameEng.Host.Tests/MrGameEng.Host.Tests.csproj new file mode 100644 index 0000000..3daada2 --- /dev/null +++ b/tests/MrGameEng.Host.Tests/MrGameEng.Host.Tests.csproj @@ -0,0 +1,17 @@ + + + net8.0 + Exe + false + + + + + + + + + + + +