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): Engine libraries (each feature is a namespaced subfolder of its host):
- **`Core`** — game loop, ECS world, scenes, time (`GameClock` with `TimeScale`; `GameSpeed` - **`Core`** — the platform-free kernel: ECS world, scenes and transition timing, time
for discrete pause/1×/3×/6× speed control over the clock, `context.UseGameSpeed(...)`; (`GameClock` with `TimeScale`; `GameSpeed` for discrete pause/1×/3×/6× speed control over
`Calendar` turning scaled time into in-game days, `context.UseCalendar(secondsPerDay)`; the clock, `context.UseGameSpeed(...)`; `Calendar` turning scaled time into in-game days,
`Climate` — continuous seasonal/daily temperature and season over the calendar, `context.UseCalendar(secondsPerDay)`; `Climate` — continuous seasonal/daily temperature
`context.UseClimate(settings)`), and season over the calendar, `context.UseClimate(settings)`), services, logging, and
plus **Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in **`HeadlessHost`** — a fixed-timestep loop without a window or GPU (dedicated servers,
`Core/Input/`). Depends only on MonoGame and Friflo.Engine.ECS. batch simulation, tests). Depends only on Friflo.Engine.ECS — no MonoGame, no platform.
- **`Host`** — the windowed MonoGame host: `GameHost` (wraps `Game`, owns the window and
`GraphicsDeviceManager`, publishes `GraphicsDevice` as a service), visual scene
transitions (`OverlayTransition`, `Transitions.Fade/Wipe`, `TransitionRenderer`), plus
**Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
`Host/Input/`). Nothing depends on `Host` except the game itself. → `Core`.
- **`Graphics`** — custom batched renderer, camera, sprites, plus **Tilemaps** - **`Graphics`** — custom batched renderer, camera, sprites, plus **Tilemaps**
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher, (`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting** `scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving (`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; a sampleable `Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; plus a per-cell
`DayNight.SampleAt` for the simulation point lights and shadows land here later). → `Core`. **lightmap**`LightmapBuilder` (ambient × occlusion + point lights with grid-traced shadows),
`PointLight` component, multiplied over the world via `scene.UseLighting(...)`, sampleable with
`Lighting.SampleAt` for the simulation). → `Core`.
- **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume` - **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume`
(one knob for effects + music). → `Core`. (one knob for effects + music). → `Core`.
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime - **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
@@ -68,9 +75,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
netstandard2.0 analyzer. netstandard2.0 analyzer.
Dependency rule: a library may depend only on `Core` and `Graphics`; `Core` depends only Dependency rule: a library may depend only on `Core` and `Graphics`; `Core` depends only
on MonoGame and Friflo.Engine.ECS. Features grouped into one library share its package on Friflo.Engine.ECS (no MonoGame — the simulation must run headless). MonoGame is pulled
set (e.g. `Content` carries both FontStash and StbImage) — keep optional/heavy deps in by the platform/graphics libraries (`Host`, `Graphics`, `Audio`). Platform resources
(Myra, NVorbis) in their own library so the rest of the engine stays free of them. (e.g. `GraphicsDevice`) are published by hosts as services in `EngineContext.Services`;
graphics code reaches the device via `context.GetGraphicsDevice()` (extension in
`Graphics`). Features grouped into one library share its package set (e.g. `Content`
carries both FontStash and StbImage) — keep optional/heavy deps (Myra, NVorbis) in their
own library so the rest of the engine stays free of them.
## Commands ## Commands
-2
View File
@@ -1,5 +1,4 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@@ -13,5 +12,4 @@
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('MrGameEng.')) AND !$(MSBuildProjectName.EndsWith('.Tests')) AND !$(MSBuildProjectName.EndsWith('.Generator')) AND !$(MSBuildProjectName.EndsWith('.Sample'))"> <PropertyGroup Condition="$(MSBuildProjectName.StartsWith('MrGameEng.')) AND !$(MSBuildProjectName.EndsWith('.Tests')) AND !$(MSBuildProjectName.EndsWith('.Generator')) AND !$(MSBuildProjectName.EndsWith('.Sample'))">
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+1 -1
View File
@@ -16,4 +16,4 @@
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+30
View File
@@ -39,6 +39,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "tests
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{4407F6E6-0B65-41A3-ADFA-B78684A9B918}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{4407F6E6-0B65-41A3-ADFA-B78684A9B918}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "src\MrGameEng.Host\MrGameEng.Host.csproj", "{59818072-0D2B-4007-A50F-1343FA189EC6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.ActiveCfg = Release|Any CPU {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.ActiveCfg = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.Build.0 = Release|Any CPU {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x64.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x64.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x86.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x86.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|Any CPU.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x64.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x64.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x86.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x86.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x64.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x64.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x86.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x86.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|Any CPU.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -249,5 +277,7 @@ Global
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{7F7D9641-2409-40CB-88A9-56BCE8C90A45} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {7F7D9641-2409-40CB-88A9-56BCE8C90A45} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{59818072-0D2B-4007-A50F-1343FA189EC6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection EndGlobalSection
EndGlobal 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.Core` | Платформо-независимое ядро (зависит только от Friflo): `EngineContext`, `EntityStore`, `SystemRoot`, сцены и переходы (тайминг — `Transition`, `SceneManager`; визуал переходов — в `Host`), время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл, `ServiceRegistry`, `Log`. **`HeadlessHost`** — цикл без окна и GPU на фиксированном тике (`Tick`/`RunTicks`/`Run` с realtime-пейсингом): дедикейтед-серверы, батч-симуляция, тесты |
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar``Renderer2D.AmbientLight` на World-слоях, `scene.UseDayNight(renderer)`, сэмплируемый `DayNight.SampleAt` для симуляции | | `MrGameEng.Host` | Оконный MonoGame-хост: `GameHost` (обёртка над `Game` — цикл, окно, `GraphicsDeviceManager`; публикует `GraphicsDevice` сервисом в контексте), визуальные переходы сцен (`OverlayTransition`, фабрики `Transitions.Fade/Wipe`, `TransitionRenderer`). **Input** (`MrGameEng.Input`, `Host/Input/`): клавиатура, мышь, геймпад, action maps |
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar``Renderer2D.AmbientLight`, `scene.UseDayNight(renderer)`; по-клеточный лайтмап — `LightmapBuilder` (амбиент × окклюзия + точечные `PointLight` с трассировкой теней), накладывается multiply поверх мира через `scene.UseLighting(...)`, сэмплируется `Lighting.SampleAt` |
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) | | `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента | | `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
| `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast | | `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast |
@@ -69,19 +70,27 @@
### Правило зависимостей ### Правило зависимостей
``` ```
MrGameEng.Audio ─┐ MrGameEng.Host ─┐
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL MrGameEng.Audio ─┤
│ └──► Friflo.Engine.ECS MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
MrGameEng.Content ─┤ MrGameEng.Content ─┤
MrGameEng.Simulation ─┤ (Content — за Texture2DRegion, MrGameEng.Simulation ─┤ (Content — за Texture2DRegion,
MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер) MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер)
``` ```
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от MonoGame Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от Friflo —
и Friflo. Если двум библиотекам нужен общий тип — он переезжает в `Core` (или, если это ни MonoGame, ни другой платформы: благодаря этому симуляция запускается и без окна
графический тип, в `Graphics`). `Content` тянет `Graphics` (атласы выдают (`HeadlessHost`). MonoGame (`MonoGame.Framework.DesktopGL`) тянут платформенные и
`Texture2DRegion`); `Simulation` тянет `Graphics` (`Collisions` использует `Transform2D`, графические библиотеки: `Host`, `Graphics`, `Audio` (и транзитивно их потребители).
`RectF`); `UI` — только `Core` (Myra рисует своим SpriteBatch). Платформенные ресурсы (например `GraphicsDevice`) хосты публикуют сервисами в
`EngineContext.Services`; графический код достаёт девайс через
`context.GetGraphicsDevice()` (расширение в `Graphics`). На `Host` не зависит никто,
кроме самой игры — это точка входа оконной платформы. Если двум библиотекам нужен общий
тип — он переезжает в `Core` (или, если это графический тип, в `Graphics`). `Content`
тянет `Graphics` (атласы выдают `Texture2DRegion`); `Simulation` тянет `Graphics`
(`Collisions` использует `Transform2D`, `RectF`); `UI` — только `Core` (Myra рисует
своим SpriteBatch).
Фичи, собранные в одну библиотеку, делят её набор пакетов (например, `Content` несёт и Фичи, собранные в одну библиотеку, делят её набор пакетов (например, `Content` несёт и
FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в
@@ -345,12 +354,15 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала - `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
следующего кадра (сцена никогда не выгружается посреди собственного кадра). следующего кадра (сцена никогда не выгружается посреди собственного кадра).
- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом: - `Scenes.Switch(scene, Transitions.Fade(0.5f))` — переключение с визуальным переходом:
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия. фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном. Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)` - Тайминг-машина (`Transition` — длительности фаз, покрытие) живёт в `Core` и работает
(шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill` и в headless-контексте; визуал — в `Host`: встроенные `Transitions.Fade(duration, color)`
в нормализованных координатах экрана). и `Transitions.Wipe(duration, color)` (шторка). Свои — наследованием от
`OverlayTransition` (рисование через `TransitionRenderer.Fill` в нормализованных
координатах экрана); `GameHost` рисует оверлей поверх сцены, читая
`Scenes.ActiveTransition`/`TransitionCoverage`/`TransitionPhase`.
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`). - Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход. - Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
+14 -13
View File
@@ -1,13 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NVorbis" /> <PackageReference Include="MonoGame.Framework.DesktopGL" />
</ItemGroup> <PackageReference Include="NVorbis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" /> <ItemGroup>
</ItemGroup> <ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</Project> </ItemGroup>
</Project>
+3 -2
View File
@@ -2,6 +2,7 @@ using FontStashSharp;
using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Assets; namespace MrGameEng.Assets;
@@ -103,7 +104,7 @@ public sealed class AssetManager : IDisposable
{ {
using var stream = File.OpenRead(path); using var stream = File.OpenRead(path);
return Texture2D.FromStream( return Texture2D.FromStream(
context.GraphicsDevice, context.GetGraphicsDevice(),
stream, stream,
DefaultColorProcessors.PremultiplyAlpha DefaultColorProcessors.PremultiplyAlpha
); );
@@ -123,7 +124,7 @@ public sealed class AssetManager : IDisposable
} }
private static Effect LoadEffect(EngineContext context, string path) => private static Effect LoadEffect(EngineContext context, string path) =>
new(context.GraphicsDevice, File.ReadAllBytes(path)); new(context.GetGraphicsDevice(), File.ReadAllBytes(path));
} }
/// <summary>Wires the assets module into the engine.</summary> /// <summary>Wires the assets module into the engine.</summary>
@@ -1,5 +1,6 @@
using MrGameEng.Assets; using MrGameEng.Assets;
using MrGameEng.Core; using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Atlases; namespace MrGameEng.Atlases;
@@ -14,6 +15,6 @@ public static class AtlasesEngineExtensions
public static void UseTextureAtlases(this EngineContext context) public static void UseTextureAtlases(this EngineContext context)
{ {
var assets = context.Services.Get<AssetManager>(); var assets = context.Services.Get<AssetManager>();
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path)); assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GetGraphicsDevice(), path));
} }
} }
+10 -31
View File
@@ -1,10 +1,11 @@
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Core; namespace MrGameEng.Core;
/// <summary> /// <summary>
/// Root object handed to scenes and systems: time, scene manager, services and graphics device. /// Root object handed to scenes and systems: time, scene manager and services.
/// Created by <see cref="GameHost"/>; can also be created standalone for headless tests. /// Created by a host (the MonoGame game host or <see cref="HeadlessHost"/>); can also be
/// created standalone for unit tests. Platform resources such as the graphics device are
/// published through <see cref="Services"/> by hosts that have them — the core itself has
/// no platform dependencies.
/// </summary> /// </summary>
public sealed class EngineContext public sealed class EngineContext
{ {
@@ -17,38 +18,16 @@ public sealed class EngineContext
/// <summary>Registry of module services (input, audio, assets, …).</summary> /// <summary>Registry of module services (input, audio, assets, …).</summary>
public ServiceRegistry Services { get; } = new(); public ServiceRegistry Services { get; } = new();
/// <summary> /// <summary>Creates a context. Games normally never create one themselves — the host does.</summary>
/// The graphics device. Available once the host is initialized;
/// throws when accessed in a headless context (unit tests).
/// </summary>
public GraphicsDevice GraphicsDevice =>
_graphicsDevice
?? throw new InvalidOperationException(
"GraphicsDevice is not available (headless context)."
);
/// <summary>True when a graphics device is attached.</summary>
public bool HasGraphicsDevice => _graphicsDevice is not null;
private GraphicsDevice? _graphicsDevice;
/// <summary>Creates a context. Games normally never create one themselves — <see cref="GameHost"/> does.</summary>
public EngineContext() public EngineContext()
{ {
Scenes = new SceneManager(this); Scenes = new SceneManager(this);
} }
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
/// <summary> /// <summary>
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services /// Disposes everything the context owns: registered <see cref="IDisposable"/> services.
/// and the transition renderer. <paramref name="except"/> (the host itself, also a /// Instances in <paramref name="except"/> (the host itself, platform resources the host
/// registered service) is skipped — it is being disposed by the caller already. /// disposes on its own) are skipped. Called by hosts on shutdown.
/// Called by <see cref="GameHost.Dispose(bool)"/>.
/// </summary> /// </summary>
internal void DisposeOwnedResources(object except) internal void DisposeOwnedResources(params object[] except) => Services.DisposeServices(except);
{
Scenes.DisposeRenderer();
Services.DisposeServices(except);
}
} }
+1 -1
View File
@@ -2,7 +2,7 @@ namespace MrGameEng.Core;
/// <summary> /// <summary>
/// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter. /// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter.
/// Advanced once per frame by <see cref="GameHost"/>. /// Advanced once per frame (or per fixed tick) by the host.
/// </summary> /// </summary>
public sealed class GameClock public sealed class GameClock
{ {
+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> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
<PackageReference Include="Friflo.Engine.ECS" /> <PackageReference Include="Friflo.Engine.ECS" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Core.Tests" /> <InternalsVisibleTo Include="MrGameEng.Core.Tests" />
<InternalsVisibleTo Include="MrGameEng.Host" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+15 -22
View File
@@ -22,13 +22,24 @@ public sealed class SceneManager
/// <summary>True while a transition is covering or revealing.</summary> /// <summary>True while a transition is covering or revealing.</summary>
public bool IsTransitioning => _state != State.Idle; public bool IsTransitioning => _state != State.Idle;
/// <summary>The transition currently covering or revealing, or null while idle.
/// Hosts that render overlays read this together with <see cref="TransitionCoverage"/>
/// and <see cref="TransitionPhase"/> during their draw phase.</summary>
public Transition? ActiveTransition => _state == State.Idle ? null : _transition;
/// <summary>Coverage of the active transition: 0 = scene fully visible, 1 = fully covered.</summary>
public float TransitionCoverage => Math.Clamp(_coverage, 0f, 1f);
/// <summary>Phase of the active transition. Meaningful only while <see cref="IsTransitioning"/>.</summary>
public TransitionPhase TransitionPhase =>
_state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
private readonly EngineContext _context; private readonly EngineContext _context;
private Scene? _pending; private Scene? _pending;
private bool _hasPending; private bool _hasPending;
private Transition? _transition; private Transition? _transition;
private State _state; private State _state;
private float _coverage; private float _coverage;
private TransitionRenderer? _renderer;
internal SceneManager(EngineContext context) => _context = context; internal SceneManager(EngineContext context) => _context = context;
@@ -100,27 +111,9 @@ public sealed class SceneManager
Current?.Update(clock); Current?.Update(clock);
} }
/// <summary>Draws the active scene and the transition overlay on top. Called by the host.</summary> /// <summary>Draws the active scene. Transition overlays are rendered by the host on top,
public void Draw(GameClock clock) /// from <see cref="ActiveTransition"/> and <see cref="TransitionCoverage"/>.</summary>
{ public void Draw(GameClock clock) => Current?.Draw(clock);
Current?.Draw(clock);
if (_state == State.Idle || !_context.HasGraphicsDevice)
{
return;
}
_renderer ??= new TransitionRenderer(_context.GraphicsDevice);
var phase = _state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
_transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase);
}
/// <summary>Disposes the lazily created transition renderer. Called on host shutdown.</summary>
internal void DisposeRenderer()
{
_renderer?.Dispose();
_renderer = null;
}
internal void ApplyPending() internal void ApplyPending()
{ {
+5 -4
View File
@@ -40,16 +40,17 @@ public sealed class ServiceRegistry
/// <summary> /// <summary>
/// Disposes every registered <see cref="IDisposable"/> service (each instance once, even /// Disposes every registered <see cref="IDisposable"/> service (each instance once, even
/// when registered under several types) and clears the registry. <paramref name="except"/> /// when registered under several types) and clears the registry. Instances in
/// is skipped. Called on host shutdown. /// <paramref name="except"/> are skipped. Called on host shutdown.
/// </summary> /// </summary>
internal void DisposeServices(object? except = null) internal void DisposeServices(params object[] except)
{ {
var skipped = new HashSet<object>(except, ReferenceEqualityComparer.Instance);
var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance); var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance);
foreach (var service in _services.Values) foreach (var service in _services.Values)
{ {
if ( if (
!ReferenceEquals(service, except) !skipped.Contains(service)
&& service is IDisposable disposable && service is IDisposable disposable
&& disposed.Add(service) && disposed.Add(service)
) )
+6 -51
View File
@@ -1,5 +1,3 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core; namespace MrGameEng.Core;
/// <summary>Phase of a scene transition.</summary> /// <summary>Phase of a scene transition.</summary>
@@ -13,10 +11,12 @@ public enum TransitionPhase
} }
/// <summary> /// <summary>
/// Visual transition between scenes. The scene switch itself happens at full coverage, /// Timing of a visual transition between scenes. The scene switch itself happens at full
/// so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay. /// coverage, so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
/// Transitions are stateless and reusable; progress is tracked by <see cref="SceneManager"/>. /// Progress is tracked by <see cref="SceneManager"/> on unscaled time, so transitions work
/// Runs on unscaled time, so it works while gameplay is paused. /// while gameplay is paused. The core only times the phases — how the overlay looks is
/// defined by the host (the MonoGame host's <c>OverlayTransition</c> and the
/// <c>Transitions</c> factories); headless hosts simply let transitions pass invisibly.
/// </summary> /// </summary>
public abstract class Transition public abstract class Transition
{ {
@@ -32,49 +32,4 @@ public abstract class Transition
OutDuration = Math.Max(0f, outDuration); OutDuration = Math.Max(0f, outDuration);
InDuration = Math.Max(0f, inDuration); InDuration = Math.Max(0f, inDuration);
} }
/// <summary>
/// Draws the overlay. <paramref name="coverage"/> is 0 (scene fully visible) to
/// 1 (scene fully covered); <paramref name="phase"/> tells which side of the switch this is.
/// </summary>
public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase);
/// <summary>Fade through a solid color (black by default). Total duration is split between out and in.</summary>
public static Transition Fade(float duration = 0.6f, Color? color = null) =>
new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
/// <summary>A curtain wiping across the screen (black by default). Total duration is split between out and in.</summary>
public static Transition Wipe(float duration = 0.6f, Color? color = null) =>
new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
) => renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
}
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
)
{
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
if (phase == TransitionPhase.Out)
{
renderer.Fill(0f, 0f, coverage, 1f, color);
}
else
{
renderer.Fill(1f - coverage, 0f, coverage, 1f, color);
}
}
}
} }
@@ -0,0 +1,19 @@
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace MrGameEng.Graphics;
/// <summary>Graphics-side accessors for the platform-free <see cref="EngineContext"/>.</summary>
public static class EngineContextGraphicsExtensions
{
/// <summary>
/// Returns the <see cref="GraphicsDevice"/> service published by a windowed host.
/// Throws in a headless context, where no graphics device exists.
/// </summary>
public static GraphicsDevice GetGraphicsDevice(this EngineContext context) =>
context.Services.GetOrDefault<GraphicsDevice>()
?? throw new InvalidOperationException(
"GraphicsDevice is not available: no windowed host has published it "
+ "(headless context, or graphics are not initialized yet)."
);
}
@@ -0,0 +1,60 @@
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// Scene lighting service: owns the <see cref="Lightmap"/> and exposes a sampleable local light for
/// the simulation. Built by <see cref="SceneLightmapExtensions.UseLighting"/>, which also registers
/// the systems that rebuild the lightmap and multiply it over the scene.
/// </summary>
public sealed class Lighting
{
/// <summary>The light grid rendered over the world and sampled by the simulation.</summary>
public Lightmap Lightmap { get; }
/// <summary>Creates the service over <paramref name="lightmap"/>.</summary>
public Lighting(Lightmap lightmap) => Lightmap = lightmap;
/// <summary>Local light in <c>[0, 1]</c> at a world point (e.g. for plant growth under canopy).</summary>
public float SampleAt(Vector2 world) => Lightmap.SampleAt(world);
}
/// <summary>Wires the 2D lightmap (day/night × occlusion + point lights) into a <see cref="Scene"/>.</summary>
public static class SceneLightmapExtensions
{
/// <summary>
/// Builds a <see cref="Lighting"/> service for a <paramref name="width"/>×<paramref name="height"/>
/// cell world and registers the rebuild and composite systems. Ambient comes from
/// <paramref name="dayNight"/>; <paramref name="occluders"/> supplies the current occluder grid
/// (row-major, length width×height) each rebuild. Call after <c>UseRenderer2D</c>/<c>UseTilemaps</c>
/// and before <c>UseUI</c> so the lightmap composites over the world but under the HUD.
/// </summary>
public static Lighting UseLighting(
this Scene scene,
Renderer2D renderer,
DayNight dayNight,
int width,
int height,
float cellSize,
Vector2 origin,
Func<bool[]> occluders
)
{
var device = scene.Context.GetGraphicsDevice();
var lightmap = new Lightmap(device, width, height, cellSize, origin);
var lighting = new Lighting(lightmap);
scene.Context.Services.Add(lighting);
RegisterUnloadDispose(scene, lightmap);
scene.UpdateSystems.Add(
new LightmapSystem(scene.Store, lightmap, dayNight, occluders, cellSize, origin)
);
scene.DrawSystems.Add(new LightmapRenderSystem(device, renderer, lightmap));
return lighting;
}
private static void RegisterUnloadDispose(Scene scene, Lightmap lightmap) =>
scene.RegisterUnload(lightmap.Dispose);
}
@@ -0,0 +1,85 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// A per-cell light grid backed by a greyscale <see cref="Texture2D"/>. <see cref="LightmapBuilder"/>
/// fills <see cref="Light"/>; <see cref="Upload"/> pushes it to the texture (the renderer multiplies
/// it over the world for soft, cell-resolution shadows); <see cref="SampleAt"/> reads bilinearly for
/// the simulation (local light at a plant). The grid maps cell (x,y) to world
/// <c>Origin + (x+0.5, y+0.5)·CellSize</c>.
/// </summary>
public sealed class Lightmap : IDisposable
{
/// <summary>Grid width in cells.</summary>
public int Width { get; }
/// <summary>Grid height in cells.</summary>
public int Height { get; }
/// <summary>World size of one cell.</summary>
public float CellSize { get; }
/// <summary>World position of cell (0,0)'s top-left.</summary>
public Vector2 Origin { get; }
/// <summary>Per-cell light in <c>[0, 1]</c>, row-major. Filled by <see cref="LightmapBuilder"/>.</summary>
public float[] Light { get; }
/// <summary>The greyscale light texture (one texel per cell), updated by <see cref="Upload"/>.</summary>
public Texture2D Texture { get; }
private readonly Color[] _pixels;
/// <summary>Creates a lightmap grid and its backing texture on <paramref name="device"/>.</summary>
public Lightmap(GraphicsDevice device, int width, int height, float cellSize, Vector2 origin)
{
Width = width;
Height = height;
CellSize = cellSize;
Origin = origin;
Light = new float[width * height];
_pixels = new Color[width * height];
Texture = new Texture2D(device, width, height);
Array.Fill(Light, 1f);
Upload();
}
/// <summary>Writes the current <see cref="Light"/> grid into the texture as greyscale.</summary>
public void Upload()
{
for (var i = 0; i < Light.Length; i++)
{
var b = (byte)(Math.Clamp(Light[i], 0f, 1f) * 255f);
_pixels[i] = new Color(b, b, b, (byte)255);
}
Texture.SetData(_pixels);
}
/// <summary>Bilinearly samples the light at a world point; clamps at the edges.</summary>
public float SampleAt(Vector2 world)
{
var fx = (world.X - Origin.X) / CellSize - 0.5f;
var fy = (world.Y - Origin.Y) / CellSize - 0.5f;
fx = Math.Clamp(fx, 0f, Width - 1f);
fy = Math.Clamp(fy, 0f, Height - 1f);
var x0 = (int)fx;
var y0 = (int)fy;
var x1 = Math.Min(x0 + 1, Width - 1);
var y1 = Math.Min(y0 + 1, Height - 1);
var tx = fx - x0;
var ty = fy - y0;
var top = Lerp(Light[y0 * Width + x0], Light[y0 * Width + x1], tx);
var bottom = Lerp(Light[y1 * Width + x0], Light[y1 * Width + x1], tx);
return Lerp(top, bottom, ty);
}
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
/// <inheritdoc />
public void Dispose() => Texture.Dispose();
}
@@ -0,0 +1,120 @@
namespace MrGameEng.Lighting;
/// <summary>One point light projected onto the light grid: a cell position, a radius in cells and an intensity.</summary>
public readonly record struct LightSample(int X, int Y, float Radius, float Intensity);
/// <summary>
/// Builds a per-cell light grid (CPU, GPU-free, testable): an ambient base dimmed under occluders,
/// plus point lights that attenuate with distance and are blocked by occluders between the source
/// and the cell (grid-traced shadows). Pure data — a <see cref="Lightmap"/> turns the grid into a
/// texture and the simulation samples it for local light.
/// </summary>
public static class LightmapBuilder
{
/// <summary>How much of the ambient light reaches a cell that is itself an occluder (canopy/shade).</summary>
public const float OccluderShade = 0.35f;
/// <summary>
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with
/// ambient light, shading occluder cells, then adds each point light with grid-traced occlusion.
/// Values end clamped to <c>[0, 1]</c>.
/// </summary>
public static void Build(
float[] light,
int width,
int height,
float ambient,
ReadOnlySpan<bool> occluders,
IReadOnlyList<LightSample> lights
)
{
for (var i = 0; i < light.Length; i++)
{
light[i] = occluders[i] ? ambient * OccluderShade : ambient;
}
foreach (var l in lights)
{
if (l.Radius <= 0f || l.Intensity <= 0f)
{
continue;
}
var r = (int)MathF.Ceiling(l.Radius);
var minX = Math.Max(0, l.X - r);
var maxX = Math.Min(width - 1, l.X + r);
var minY = Math.Max(0, l.Y - r);
var maxY = Math.Min(height - 1, l.Y + r);
for (var y = minY; y <= maxY; y++)
{
for (var x = minX; x <= maxX; x++)
{
var dx = x - l.X;
var dy = y - l.Y;
var d = MathF.Sqrt(dx * dx + dy * dy);
if (d > l.Radius)
{
continue;
}
if (!Visible(l.X, l.Y, x, y, occluders, width))
{
continue; // в тени за препятствием
}
light[y * width + x] += l.Intensity * (1f - d / l.Radius);
}
}
}
for (var i = 0; i < light.Length; i++)
{
light[i] = Math.Clamp(light[i], 0f, 1f);
}
}
// Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем
// промежуточные клетки на окклюдер (концы исключены).
private static bool Visible(
int x0,
int y0,
int x1,
int y1,
ReadOnlySpan<bool> occluders,
int width
)
{
var dx = Math.Abs(x1 - x0);
var dy = Math.Abs(y1 - y0);
var sx = x0 < x1 ? 1 : -1;
var sy = y0 < y1 ? 1 : -1;
var err = dx - dy;
var x = x0;
var y = y0;
while (true)
{
if (x == x1 && y == y1)
{
return true;
}
if ((x != x0 || y != y0) && occluders[y * width + x])
{
return false;
}
var e2 = 2 * err;
if (e2 > -dy)
{
err -= dy;
x += sx;
}
if (e2 < dx)
{
err += dx;
y += sy;
}
}
}
}
@@ -0,0 +1,164 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// Rebuilds the lightmap a few times a second: ambient from the day/night cycle, occluder cells from
/// the game-supplied grid and point lights from the ECS, then uploads it to the texture. Throttled by
/// frame skipping — the light changes slowly, so 10 Hz looks smooth and keeps cost low.
/// </summary>
public sealed class LightmapSystem : BaseSystem
{
private const int RebuildEvery = 6; // ~10 Гц при 60 fps
private const float NightFloor = 0.18f; // ночь тусклая, но не чёрная (лунный свет)
private readonly Lightmap _lightmap;
private readonly DayNight _dayNight;
private readonly Func<bool[]> _occluders;
private readonly float _cellSize;
private readonly Vector2 _origin;
private readonly ArchetypeQuery<Transform2D, PointLight> _query;
private readonly List<LightSample> _lights = [];
private int _frame;
internal LightmapSystem(
EntityStore store,
Lightmap lightmap,
DayNight dayNight,
Func<bool[]> occluders,
float cellSize,
Vector2 origin
)
{
_lightmap = lightmap;
_dayNight = dayNight;
_occluders = occluders;
_cellSize = cellSize;
_origin = origin;
_query = store.Query<Transform2D, PointLight>();
}
/// <inheritdoc />
protected override void OnUpdateGroup()
{
if (++_frame < RebuildEvery)
{
return;
}
_frame = 0;
_lights.Clear();
foreach (var (transforms, lights, _) in _query.Chunks)
{
var t = transforms.Span;
var l = lights.Span;
for (var i = 0; i < t.Length; i++)
{
if (l[i].Radius <= 0f || l[i].Intensity <= 0f)
{
continue;
}
var cx = (int)((t[i].Position.X - _origin.X) / _cellSize);
var cy = (int)((t[i].Position.Y - _origin.Y) / _cellSize);
_lights.Add(new LightSample(cx, cy, l[i].Radius / _cellSize, l[i].Intensity));
}
}
var ambient = NightFloor + (1f - NightFloor) * _dayNight.Intensity;
LightmapBuilder.Build(
_lightmap.Light,
_lightmap.Width,
_lightmap.Height,
ambient,
_occluders(),
_lights
);
_lightmap.Upload();
}
}
/// <summary>
/// Draws the lightmap over the world as a single texture quad with multiply blending and linear
/// filtering — the world darkens by the grid and shadows read soft. Registered after the sprite flush
/// (so it lands on the drawn scene) and before the screen-space UI (so the HUD stays at full brightness).
/// </summary>
public sealed class LightmapRenderSystem : BaseSystem
{
private static readonly BlendState Multiply = new()
{
ColorSourceBlend = Blend.DestinationColor,
ColorDestinationBlend = Blend.Zero,
AlphaSourceBlend = Blend.DestinationAlpha,
AlphaDestinationBlend = Blend.Zero,
};
private readonly GraphicsDevice _device;
private readonly Renderer2D _renderer;
private readonly Lightmap _lightmap;
private readonly BasicEffect _effect;
private readonly VertexPositionTexture[] _quad;
internal LightmapRenderSystem(GraphicsDevice device, Renderer2D renderer, Lightmap lightmap)
{
_device = device;
_renderer = renderer;
_lightmap = lightmap;
_effect = new BasicEffect(device)
{
TextureEnabled = true,
VertexColorEnabled = false,
World = Matrix.Identity,
};
var x0 = lightmap.Origin.X;
var y0 = lightmap.Origin.Y;
var x1 = x0 + lightmap.Width * lightmap.CellSize;
var y1 = y0 + lightmap.Height * lightmap.CellSize;
_quad =
[
new VertexPositionTexture(new Vector3(x0, y0, 0f), new Vector2(0f, 0f)),
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
new VertexPositionTexture(new Vector3(x1, y1, 0f), new Vector2(1f, 1f)),
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
];
}
/// <inheritdoc />
protected override void OnUpdateGroup()
{
var camera = _renderer.Camera;
_effect.View = camera.View;
_effect.Projection = camera.Projection;
_effect.Texture = _lightmap.Texture;
var previousViewport = _device.Viewport;
var mapping = camera.Mapping;
_device.Viewport = new Viewport(
(int)MathF.Round(mapping.Offset.X),
(int)MathF.Round(mapping.Offset.Y),
(int)MathF.Round(camera.VirtualWidth * mapping.Scale),
(int)MathF.Round(camera.VirtualHeight * mapping.Scale)
);
_device.BlendState = Multiply;
_device.SamplerStates[0] = SamplerState.LinearClamp;
_device.DepthStencilState = DepthStencilState.None;
_device.RasterizerState = RasterizerState.CullNone;
foreach (var pass in _effect.CurrentTechnique.Passes)
{
pass.Apply();
_device.DrawUserPrimitives(PrimitiveType.TriangleList, _quad, 0, 2);
}
_device.Viewport = previousViewport;
_device.BlendState = BlendState.AlphaBlend;
}
}
@@ -0,0 +1,21 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
namespace MrGameEng.Lighting;
/// <summary>
/// A point light: an entity with this component plus a <see cref="MrGameEng.Graphics.Transform2D"/>
/// adds light around its world position, attenuating to zero at <see cref="Radius"/> and casting
/// grid-traced shadows behind occluders. Picked up by the lighting system into the lightmap.
/// </summary>
public struct PointLight : IComponent
{
/// <summary>Reach of the light in world units (light fades to zero at this distance).</summary>
public float Radius;
/// <summary>Light tint (reserved for colored lights; intensity currently drives brightness).</summary>
public Color Color;
/// <summary>Peak brightness added at the light's centre (0..1+).</summary>
public float Intensity;
}
@@ -1,13 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" /> <PackageReference Include="MonoGame.Framework.DesktopGL" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" /> <InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
</ItemGroup> </ItemGroup>
</Project>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
@@ -22,7 +22,7 @@ public static class SceneGraphicsExtensions
var renderer = services.GetOrDefault<Renderer2D>(); var renderer = services.GetOrDefault<Renderer2D>();
if (renderer is null) if (renderer is null)
{ {
renderer = new Renderer2D(scene.Context.GraphicsDevice, options); renderer = new Renderer2D(scene.Context.GetGraphicsDevice(), options);
services.Add(renderer); services.Add(renderer);
} }
else if (options is not null) else if (options is not null)
@@ -1,11 +1,16 @@
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace MrGameEng.Core; namespace MrGameEng.Host;
/// <summary> /// <summary>
/// The engine's game loop host. Wraps MonoGame's <see cref="Game"/>: owns the /// The engine's windowed game-loop host. Wraps MonoGame's <see cref="Game"/>: owns the
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/> and drives the /// <see cref="EngineContext"/>, advances the <see cref="GameClock"/>, drives the active
/// active scene's update and draw phases. /// scene's update and draw phases and renders scene-transition overlays. The
/// <see cref="GraphicsDevice"/> is published as a service so graphics modules can reach it
/// through the context. For a loop without a window or GPU see
/// <see cref="HeadlessHost"/> in the core.
/// </summary> /// </summary>
public class GameHost : Game public class GameHost : Game
{ {
@@ -17,6 +22,7 @@ public class GameHost : Game
private readonly GameHostOptions _options; private readonly GameHostOptions _options;
private readonly Scene _initialScene; private readonly Scene _initialScene;
private TransitionRenderer? _transitionRenderer;
/// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary> /// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary>
public GameHost(GameHostOptions options, Scene initialScene) public GameHost(GameHostOptions options, Scene initialScene)
@@ -48,7 +54,7 @@ public class GameHost : Game
{ {
Window.Title = _options.Title; Window.Title = _options.Title;
Window.AllowUserResizing = _options.AllowResizing; Window.AllowUserResizing = _options.AllowResizing;
Context.AttachGraphicsDevice(GraphicsDevice); Context.Services.Add(GraphicsDevice);
Context.Services.Add(Window); Context.Services.Add(Window);
Context.Services.Add<Game>(this); Context.Services.Add<Game>(this);
base.Initialize(); base.Initialize();
@@ -68,9 +74,25 @@ public class GameHost : Game
{ {
GraphicsDevice.Clear(_options.ClearColor); GraphicsDevice.Clear(_options.ClearColor);
Context.Scenes.Draw(Context.Clock); Context.Scenes.Draw(Context.Clock);
DrawTransitionOverlay();
base.Draw(gameTime); base.Draw(gameTime);
} }
private void DrawTransitionOverlay()
{
if (Context.Scenes.ActiveTransition is not OverlayTransition transition)
{
return;
}
_transitionRenderer ??= new TransitionRenderer(GraphicsDevice);
transition.Draw(
_transitionRenderer,
Context.Scenes.TransitionCoverage,
Context.Scenes.TransitionPhase
);
}
/// <inheritdoc /> /// <inheritdoc />
protected override void OnExiting(object sender, ExitingEventArgs args) protected override void OnExiting(object sender, ExitingEventArgs args)
{ {
@@ -84,7 +106,11 @@ public class GameHost : Game
{ {
if (disposing) if (disposing)
{ {
Context.DisposeOwnedResources(except: this); _transitionRenderer?.Dispose();
_transitionRenderer = null;
// GraphicsDevice зарегистрирован как сервис, но им владеет MonoGame:
// base.Dispose сам его освобождает, реестру трогать нельзя.
Context.DisposeOwnedResources(this, GraphicsDevice);
} }
base.Dispose(disposing); base.Dispose(disposing);
@@ -1,6 +1,6 @@
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
namespace MrGameEng.Core; namespace MrGameEng.Host;
/// <summary>Window and loop settings for <see cref="GameHost"/>.</summary> /// <summary>Window and loop settings for <see cref="GameHost"/>.</summary>
public sealed class GameHostOptions public sealed class GameHostOptions
+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;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Core; namespace MrGameEng.Host;
/// <summary> /// <summary>
/// Minimal overlay renderer handed to <see cref="Transition.Draw"/>: fills rectangles in /// Minimal overlay renderer handed to <see cref="OverlayTransition.Draw"/>: fills rectangles
/// normalized screen coordinates (0..1 on both axes) over the rendered scene. /// in normalized screen coordinates (0..1 on both axes) over the rendered scene.
/// </summary> /// </summary>
public sealed class TransitionRenderer : IDisposable public sealed class TransitionRenderer : IDisposable
{ {
@@ -13,7 +13,7 @@ public sealed class TransitionRenderer : IDisposable
private readonly BasicEffect _effect; private readonly BasicEffect _effect;
private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6]; private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6];
/// <summary>Disposes the GPU effect. Called by <see cref="SceneManager"/> on shutdown.</summary> /// <summary>Disposes the GPU effect. Called by <see cref="GameHost"/> on shutdown.</summary>
public void Dispose() => _effect.Dispose(); public void Dispose() => _effect.Dispose();
internal TransitionRenderer(GraphicsDevice device) internal TransitionRenderer(GraphicsDevice device)
+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++; protected override void OnUnload() => UnloadCount++;
} }
// Тайминг-машина живёт в ядре и не зависит от визуала перехода —
// тестируем на голой заглушке с длительностями, как у Fade.
private sealed class TimedTransition(float outDuration, float inDuration)
: Transition(outDuration, inDuration);
private static Transition Fade(float duration) =>
new TimedTransition(duration / 2f, duration / 2f);
private static void Tick(EngineContext context, float seconds) private static void Tick(EngineContext context, float seconds)
{ {
context.Clock.Advance(seconds); context.Clock.Advance(seconds);
@@ -31,7 +39,7 @@ public class SceneTransitionTests
Tick(context, 0.016f); Tick(context, 0.016f);
// Fade(1.0) → фаза закрытия 0.5 c, фаза открытия 0.5 c. // Fade(1.0) → фаза закрытия 0.5 c, фаза открытия 0.5 c.
context.Scenes.Switch(second, Transition.Fade(1f)); context.Scenes.Switch(second, Fade(1f));
Tick(context, 0.2f); Tick(context, 0.2f);
Assert.True(context.Scenes.IsTransitioning); Assert.True(context.Scenes.IsTransitioning);
@@ -61,7 +69,7 @@ public class SceneTransitionTests
Tick(context, 0.016f); Tick(context, 0.016f);
context.Clock.TimeScale = 0f; // игра на паузе context.Clock.TimeScale = 0f; // игра на паузе
context.Scenes.Switch(second, Transition.Fade(0.2f)); context.Scenes.Switch(second, Fade(0.2f));
Tick(context, 0.15f); Tick(context, 0.15f);
Tick(context, 0.15f); Tick(context, 0.15f);
@@ -79,7 +87,7 @@ public class SceneTransitionTests
context.Scenes.Switch(first); context.Scenes.Switch(first);
Tick(context, 0.016f); Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(1f)); context.Scenes.Switch(second, Fade(1f));
Tick(context, 0.1f); Tick(context, 0.1f);
context.Scenes.Switch(third); // передумали, пока экран закрывается context.Scenes.Switch(third); // передумали, пока экран закрывается
@@ -99,12 +107,12 @@ public class SceneTransitionTests
context.Scenes.Switch(first); context.Scenes.Switch(first);
Tick(context, 0.016f); Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(1f)); // 0.5 c закрытие + 0.5 c открытие context.Scenes.Switch(second, Fade(1f)); // 0.5 c закрытие + 0.5 c открытие
Tick(context, 0.6f); // закрыто, своп на second, началось открытие Tick(context, 0.6f); // закрыто, своп на second, началось открытие
Assert.Same(second, context.Scenes.Current); Assert.Same(second, context.Scenes.Current);
Tick(context, 0.25f); // открытие наполовину (coverage ~0.5) Tick(context, 0.25f); // открытие наполовину (coverage ~0.5)
context.Scenes.Switch(third, Transition.Fade(1f)); // передумали во время открытия context.Scenes.Switch(third, Fade(1f)); // передумали во время открытия
Tick(context, 0.05f); // экран снова закрывается — свопа ещё нет Tick(context, 0.05f); // экран снова закрывается — свопа ещё нет
Assert.Same(second, context.Scenes.Current); Assert.Same(second, context.Scenes.Current);
@@ -124,7 +132,7 @@ public class SceneTransitionTests
context.Scenes.Switch(first); context.Scenes.Switch(first);
Tick(context, 0.016f); Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(0f)); context.Scenes.Switch(second, Fade(0f));
Tick(context, 0.016f); Tick(context, 0.016f);
Tick(context, 0.016f); Tick(context, 0.016f);
@@ -0,0 +1,69 @@
using MrGameEng.Lighting;
using Xunit;
namespace MrGameEng.Lighting.Tests;
public class LightmapBuilderTests
{
private static float[] Build(
int width,
int height,
float ambient,
bool[] occluders,
params LightSample[] lights
)
{
var light = new float[width * height];
LightmapBuilder.Build(light, width, height, ambient, occluders, lights);
return light;
}
[Fact]
public void Ambient_WithoutOccluders_IsUniform()
{
var light = Build(4, 4, 0.6f, new bool[16]);
Assert.All(light, v => Assert.Equal(0.6f, v, 5));
}
[Fact]
public void OccluderCell_IsDarkerThanOpenCell()
{
var occ = new bool[9];
occ[4] = true; // centre cell shaded
var light = Build(3, 3, 0.8f, occ);
Assert.True(light[4] < light[0]);
Assert.Equal(0.8f * LightmapBuilder.OccluderShade, light[4], 5);
}
[Fact]
public void PointLight_IsBrighterNearSourceAndFadesToRadius()
{
var light = Build(7, 1, 0f, new bool[7], new LightSample(0, 0, 6f, 1f));
Assert.True(light[0] > light[2] && light[2] > light[5]);
Assert.InRange(light[6], 0f, 0.01f); // на радиусе свет угасает
}
[Fact]
public void Occluder_CastsShadowBehindIt()
{
var occ = new bool[7];
occ[3] = true; // препятствие между источником (0) и дальними клетками
var light = Build(7, 1, 0f, occ, new LightSample(0, 0, 10f, 1f));
Assert.True(light[1] > 0f); // перед препятствием — освещено
Assert.Equal(0f, light[5], 5); // за препятствием — тень
}
[Fact]
public void Values_StayWithinUnitRange()
{
var light = Build(
5,
5,
0.5f,
new bool[25],
new LightSample(2, 2, 4f, 2f) // яркий свет — проверяем клампинг
);
Assert.All(light, v => Assert.InRange(v, 0f, 1f));
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Host\MrGameEng.Host.csproj" />
</ItemGroup>
</Project>