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 @@
-
latestenable
@@ -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