Compare commits

..
2 Commits
Author SHA1 Message Date
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
38 changed files with 1083 additions and 186 deletions
+23 -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
@@ -68,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
-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>
+30
View File
@@ -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
+26 -14
View File
@@ -33,8 +33,9 @@
| Библиотека (сборка) | Фичи (неймспейсы) и ответственность |
|------------------------------|------------------------------------------------------------|
| `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 |
@@ -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` во время перехода заменяет целевую сцену, не перезапуская переход.
+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);
}
+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);
}
}
}
}
@@ -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>