Compare commits

..
2 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 10898b08a0 Add formula engine: data-driven expression evaluator (Core)
CI / build-test (push) Successful in 1m19s
Keystone for the gene system. A Formula compiles a string from a def
(lexer -> recursive-descent parser -> tree of Func<IFormulaContext,float>)
once, then evaluates allocation-free against a variable context.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:04:34 +03:00
40 changed files with 1352 additions and 184 deletions
+19 -10
View File
@@ -27,13 +27,18 @@ is the living showcase — new engine features are demonstrated there.
Engine libraries (each feature is a namespaced subfolder of its host):
- **`Core`** — game loop, ECS world, scenes, time (`GameClock` with `TimeScale`; `GameSpeed`
for discrete pause/1×/3×/6× speed control over the clock, `context.UseGameSpeed(...)`;
`Calendar` turning scaled time into in-game days, `context.UseCalendar(secondsPerDay)`;
`Climate` — continuous seasonal/daily temperature and season over the calendar,
`context.UseClimate(settings)`),
plus **Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
`Core/Input/`). Depends only on MonoGame and Friflo.Engine.ECS.
- **`Core`** — the platform-free kernel: ECS world, scenes and transition timing, time
(`GameClock` with `TimeScale`; `GameSpeed` for discrete pause/1×/3×/6× speed control over
the clock, `context.UseGameSpeed(...)`; `Calendar` turning scaled time into in-game days,
`context.UseCalendar(secondsPerDay)`; `Climate` — continuous seasonal/daily temperature
and season over the calendar, `context.UseClimate(settings)`), services, logging, and
**`HeadlessHost`** — a fixed-timestep loop without a window or GPU (dedicated servers,
batch simulation, tests). Depends only on Friflo.Engine.ECS — no MonoGame, no platform.
- **`Host`** — the windowed MonoGame host: `GameHost` (wraps `Game`, owns the window and
`GraphicsDeviceManager`, publishes `GraphicsDevice` as a service), visual scene
transitions (`OverlayTransition`, `Transitions.Fade/Wipe`, `TransitionRenderer`), plus
**Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
`Host/Input/`). Nothing depends on `Host` except the game itself. → `Core`.
- **`Graphics`** — custom batched renderer, camera, sprites, plus **Tilemaps**
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
@@ -70,9 +75,13 @@ Engine libraries (each feature is a namespaced subfolder of its host):
netstandard2.0 analyzer.
Dependency rule: a library may depend only on `Core` and `Graphics`; `Core` depends only
on MonoGame and Friflo.Engine.ECS. Features grouped into one library share its package
set (e.g. `Content` carries both FontStash and StbImage) — keep optional/heavy deps
(Myra, NVorbis) in their own library so the rest of the engine stays free of them.
on Friflo.Engine.ECS (no MonoGame — the simulation must run headless). MonoGame is pulled
in by the platform/graphics libraries (`Host`, `Graphics`, `Audio`). Platform resources
(e.g. `GraphicsDevice`) are published by hosts as services in `EngineContext.Services`;
graphics code reaches the device via `context.GetGraphicsDevice()` (extension in
`Graphics`). Features grouped into one library share its package set (e.g. `Content`
carries both FontStash and StbImage) — keep optional/heavy deps (Myra, NVorbis) in their
own library so the rest of the engine stays free of them.
## Commands
-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>
+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
+25 -13
View File
@@ -33,7 +33,8 @@
| Библиотека (сборка) | Фичи (неймспейсы) и ответственность |
|------------------------------|------------------------------------------------------------|
| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps |
| `MrGameEng.Core` | Платформо-независимое ядро (зависит только от Friflo): `EngineContext`, `EntityStore`, `SystemRoot`, сцены и переходы (тайминг — `Transition`, `SceneManager`; визуал переходов — в `Host`), время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл, `ServiceRegistry`, `Log`. **`HeadlessHost`** — цикл без окна и GPU на фиксированном тике (`Tick`/`RunTicks`/`Run` с realtime-пейсингом): дедикейтед-серверы, батч-симуляция, тесты |
| `MrGameEng.Host` | Оконный MonoGame-хост: `GameHost` (обёртка над `Game` — цикл, окно, `GraphicsDeviceManager`; публикует `GraphicsDevice` сервисом в контексте), визуальные переходы сцен (`OverlayTransition`, фабрики `Transitions.Fade/Wipe`, `TransitionRenderer`). **Input** (`MrGameEng.Input`, `Host/Input/`): клавиатура, мышь, геймпад, action maps |
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar``Renderer2D.AmbientLight`, `scene.UseDayNight(renderer)`; по-клеточный лайтмап — `LightmapBuilder` (амбиент × окклюзия + точечные `PointLight` с трассировкой теней), накладывается multiply поверх мира через `scene.UseLighting(...)`, сэмплируется `Lighting.SampleAt` |
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
@@ -69,19 +70,27 @@
### Правило зависимостей
```
MrGameEng.Audio ─┐
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
│ └──► Friflo.Engine.ECS
MrGameEng.Host ─┐
MrGameEng.Audio ─┤
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
MrGameEng.Content ─┤
MrGameEng.Simulation ─┤ (Content — за Texture2DRegion,
MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер)
```
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от MonoGame
и Friflo. Если двум библиотекам нужен общий тип — он переезжает в `Core` (или, если это
графический тип, в `Graphics`). `Content` тянет `Graphics` (атласы выдают
`Texture2DRegion`); `Simulation` тянет `Graphics` (`Collisions` использует `Transform2D`,
`RectF`); `UI` — только `Core` (Myra рисует своим SpriteBatch).
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от Friflo —
ни MonoGame, ни другой платформы: благодаря этому симуляция запускается и без окна
(`HeadlessHost`). MonoGame (`MonoGame.Framework.DesktopGL`) тянут платформенные и
графические библиотеки: `Host`, `Graphics`, `Audio` (и транзитивно их потребители).
Платформенные ресурсы (например `GraphicsDevice`) хосты публикуют сервисами в
`EngineContext.Services`; графический код достаёт девайс через
`context.GetGraphicsDevice()` (расширение в `Graphics`). На `Host` не зависит никто,
кроме самой игры — это точка входа оконной платформы. Если двум библиотекам нужен общий
тип — он переезжает в `Core` (или, если это графический тип, в `Graphics`). `Content`
тянет `Graphics` (атласы выдают `Texture2DRegion`); `Simulation` тянет `Graphics`
(`Collisions` использует `Transform2D`, `RectF`); `UI` — только `Core` (Myra рисует
своим SpriteBatch).
Фичи, собранные в одну библиотеку, делят её набор пакетов (например, `Content` несёт и
FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в
@@ -345,12 +354,15 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
следующего кадра (сцена никогда не выгружается посреди собственного кадра).
- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом:
- `Scenes.Switch(scene, Transitions.Fade(0.5f))` — переключение с визуальным переходом:
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)`
(шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill`
в нормализованных координатах экрана).
- Тайминг-машина (`Transition` — длительности фаз, покрытие) живёт в `Core` и работает
и в headless-контексте; визуал — в `Host`: встроенные `Transitions.Fade(duration, color)`
и `Transitions.Wipe(duration, color)` (шторка). Свои — наследованием от
`OverlayTransition` (рисование через `TransitionRenderer.Fill` в нормализованных
координатах экрана); `GameHost` рисует оверлей поверх сцены, читая
`Scenes.ActiveTransition`/`TransitionCoverage`/`TransitionPhase`.
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
@@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
<PackageReference Include="NVorbis" />
</ItemGroup>
+3 -2
View File
@@ -2,6 +2,7 @@ using FontStashSharp;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Assets;
@@ -103,7 +104,7 @@ public sealed class AssetManager : IDisposable
{
using var stream = File.OpenRead(path);
return Texture2D.FromStream(
context.GraphicsDevice,
context.GetGraphicsDevice(),
stream,
DefaultColorProcessors.PremultiplyAlpha
);
@@ -123,7 +124,7 @@ public sealed class AssetManager : IDisposable
}
private static Effect LoadEffect(EngineContext context, string path) =>
new(context.GraphicsDevice, File.ReadAllBytes(path));
new(context.GetGraphicsDevice(), File.ReadAllBytes(path));
}
/// <summary>Wires the assets module into the engine.</summary>
@@ -1,5 +1,6 @@
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Atlases;
@@ -14,6 +15,6 @@ public static class AtlasesEngineExtensions
public static void UseTextureAtlases(this EngineContext context)
{
var assets = context.Services.Get<AssetManager>();
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GetGraphicsDevice(), path));
}
}
+10 -31
View File
@@ -1,10 +1,11 @@
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Core;
/// <summary>
/// Root object handed to scenes and systems: time, scene manager, services and graphics device.
/// Created by <see cref="GameHost"/>; can also be created standalone for headless tests.
/// Root object handed to scenes and systems: time, scene manager and services.
/// Created by a host (the MonoGame game host or <see cref="HeadlessHost"/>); can also be
/// created standalone for unit tests. Platform resources such as the graphics device are
/// published through <see cref="Services"/> by hosts that have them — the core itself has
/// no platform dependencies.
/// </summary>
public sealed class EngineContext
{
@@ -17,38 +18,16 @@ public sealed class EngineContext
/// <summary>Registry of module services (input, audio, assets, …).</summary>
public ServiceRegistry Services { get; } = new();
/// <summary>
/// The graphics device. Available once the host is initialized;
/// throws when accessed in a headless context (unit tests).
/// </summary>
public GraphicsDevice GraphicsDevice =>
_graphicsDevice
?? throw new InvalidOperationException(
"GraphicsDevice is not available (headless context)."
);
/// <summary>True when a graphics device is attached.</summary>
public bool HasGraphicsDevice => _graphicsDevice is not null;
private GraphicsDevice? _graphicsDevice;
/// <summary>Creates a context. Games normally never create one themselves — <see cref="GameHost"/> does.</summary>
/// <summary>Creates a context. Games normally never create one themselves — the host does.</summary>
public EngineContext()
{
Scenes = new SceneManager(this);
}
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
/// <summary>
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services
/// and the transition renderer. <paramref name="except"/> (the host itself, also a
/// registered service) is skipped — it is being disposed by the caller already.
/// Called by <see cref="GameHost.Dispose(bool)"/>.
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services.
/// Instances in <paramref name="except"/> (the host itself, platform resources the host
/// disposes on its own) are skipped. Called by hosts on shutdown.
/// </summary>
internal void DisposeOwnedResources(object except)
{
Scenes.DisposeRenderer();
Services.DisposeServices(except);
}
internal void DisposeOwnedResources(params object[] except) => Services.DisposeServices(except);
}
+60
View File
@@ -0,0 +1,60 @@
namespace MrGameEng.Formulas;
/// <summary>
/// A compiled arithmetic expression — the keystone of the data-driven gene system. A formula is
/// parsed once from a string in a def (e.g. a gene effect on a trait) into a delegate tree, then
/// evaluated many times against an <see cref="IFormulaContext"/> that supplies variable values
/// (gene values, environment readings, other traits). Evaluation is deterministic and
/// allocation-free; all the work happens in <see cref="Compile"/>.
///
/// <para>Grammar: numbers, variables, the constants <c>pi</c>/<c>tau</c>/<c>e</c>, operators
/// <c>+ - * / %</c>, comparisons <c>&lt; &gt; &lt;= &gt;= == !=</c>, logical <c>&amp;&amp; || !</c>,
/// the ternary <c>cond ? a : b</c>, and functions <c>abs sign floor ceil round sqrt exp log sin cos
/// tan min max pow clamp lerp step</c>. Comparisons and logical operators yield <c>1</c>/<c>0</c>.</para>
/// </summary>
public sealed class Formula
{
private static readonly IFormulaContext Empty = new EmptyContext();
private readonly Func<IFormulaContext, float> _root;
private Formula(string source, Func<IFormulaContext, float> root)
{
Source = source;
_root = root;
}
/// <summary>The original expression text this formula was compiled from.</summary>
public string Source { get; }
/// <summary>
/// Parses and compiles <paramref name="expression"/>. Throws <see cref="FormulaException"/> on any
/// lexical or syntactic error, with the offending position.
/// </summary>
public static Formula Compile(string expression)
{
ArgumentNullException.ThrowIfNull(expression);
var tokens = FormulaLexer.Tokenize(expression);
var root = new FormulaParser(tokens).ParseProgram();
return new Formula(expression, root);
}
/// <summary>Evaluates the formula, resolving variables through <paramref name="context"/>.</summary>
public float Evaluate(IFormulaContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _root(context);
}
/// <summary>
/// Evaluates a formula that references no variables. Throws <see cref="FormulaException"/> if it
/// turns out to reference one.
/// </summary>
public float Evaluate() => _root(Empty);
private sealed class EmptyContext : IFormulaContext
{
public float Resolve(string name) =>
throw new FormulaException($"No context to resolve variable '{name}'.");
}
}
@@ -0,0 +1,28 @@
namespace MrGameEng.Formulas;
/// <summary>
/// Supplies variable values to a compiled <see cref="Formula"/>. A context maps a variable name
/// (a gene value, an environment reading, another trait…) to a number; the formula engine itself
/// is data-agnostic, so any consumer can back this with whatever lookup it owns.
/// </summary>
public interface IFormulaContext
{
/// <summary>
/// Returns the value bound to <paramref name="name"/>. Throw (e.g. <see cref="FormulaException"/>)
/// if the name is unknown — the engine does not invent a default.
/// </summary>
float Resolve(string name);
}
/// <summary>An <see cref="IFormulaContext"/> backed by a lookup delegate — handy for tests and ad-hoc use.</summary>
public sealed class DelegateFormulaContext : IFormulaContext
{
private readonly Func<string, float> _resolve;
/// <summary>Wraps <paramref name="resolve"/>; it is called once per variable reference per evaluation.</summary>
public DelegateFormulaContext(Func<string, float> resolve) =>
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
/// <inheritdoc />
public float Resolve(string name) => _resolve(name);
}
@@ -0,0 +1,12 @@
namespace MrGameEng.Formulas;
/// <summary>
/// Thrown when a <see cref="Formula"/> cannot be lexed or parsed, or when a compiled formula
/// references a variable the context cannot resolve at evaluation time.
/// </summary>
public sealed class FormulaException : Exception
{
/// <summary>Creates the exception with a human-readable <paramref name="message"/>.</summary>
public FormulaException(string message)
: base(message) { }
}
@@ -0,0 +1,90 @@
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
namespace MrGameEng.Formulas;
/// <summary>
/// The built-in function set available inside formulas. Each entry validates its argument count at
/// compile time and returns a <see cref="Node"/> that evaluates its operands then the math. Kept
/// deterministic and side-effect-free so formulas stay pure.
/// </summary>
internal static class FormulaFunctions
{
public static Node Build(string name, List<Node> args)
{
switch (name)
{
case "abs":
return Unary(name, args, MathF.Abs);
case "sign":
return Unary(name, args, x => MathF.Sign(x));
case "floor":
return Unary(name, args, MathF.Floor);
case "ceil":
return Unary(name, args, MathF.Ceiling);
case "round":
return Unary(name, args, MathF.Round);
case "sqrt":
return Unary(name, args, MathF.Sqrt);
case "exp":
return Unary(name, args, MathF.Exp);
case "log":
return args.Count == 2
? Binary(name, args, MathF.Log)
: Unary(name, args, MathF.Log);
case "sin":
return Unary(name, args, MathF.Sin);
case "cos":
return Unary(name, args, MathF.Cos);
case "tan":
return Unary(name, args, MathF.Tan);
case "min":
return Binary(name, args, MathF.Min);
case "max":
return Binary(name, args, MathF.Max);
case "pow":
return Binary(name, args, MathF.Pow);
case "clamp":
return Ternary(name, args, (x, lo, hi) => Math.Clamp(x, lo, hi));
case "lerp":
return Ternary(name, args, (a, b, t) => a + (b - a) * t);
case "step":
return Binary(name, args, (edge, x) => x < edge ? 0f : 1f);
default:
throw new FormulaException($"Unknown function '{name}'.");
}
}
private static Node Unary(string name, List<Node> args, Func<float, float> op)
{
Require(name, args, 1);
var a = args[0];
return ctx => op(a(ctx));
}
private static Node Binary(string name, List<Node> args, Func<float, float, float> op)
{
Require(name, args, 2);
var a = args[0];
var b = args[1];
return ctx => op(a(ctx), b(ctx));
}
private static Node Ternary(string name, List<Node> args, Func<float, float, float, float> op)
{
Require(name, args, 3);
var a = args[0];
var b = args[1];
var c = args[2];
return ctx => op(a(ctx), b(ctx), c(ctx));
}
private static void Require(string name, List<Node> args, int count)
{
if (args.Count != count)
{
throw new FormulaException(
$"Function '{name}' expects {count} argument(s) but got {args.Count}."
);
}
}
}
+217
View File
@@ -0,0 +1,217 @@
using System.Globalization;
namespace MrGameEng.Formulas;
internal enum TokenType
{
Number,
Identifier,
Plus,
Minus,
Star,
Slash,
Percent,
LParen,
RParen,
Comma,
Less,
Greater,
LessEqual,
GreaterEqual,
EqualEqual,
NotEqual,
And,
Or,
Not,
Question,
Colon,
End,
}
internal readonly struct Token(TokenType type, int position, float number = 0f, string text = "")
{
public TokenType Type { get; } = type;
public int Position { get; } = position;
public float Number { get; } = number;
public string Text { get; } = text;
}
/// <summary>
/// Turns a formula string into a flat token list. Pure and allocation-light; recognizes numbers,
/// identifiers, the arithmetic/comparison/logical operators and the punctuation the parser needs.
/// </summary>
internal static class FormulaLexer
{
public static List<Token> Tokenize(string source)
{
var tokens = new List<Token>();
var i = 0;
while (i < source.Length)
{
var c = source[i];
if (char.IsWhiteSpace(c))
{
i++;
continue;
}
if (
char.IsDigit(c)
|| (c == '.' && i + 1 < source.Length && char.IsDigit(source[i + 1]))
)
{
var start = i;
while (i < source.Length && (char.IsDigit(source[i]) || source[i] == '.'))
{
i++;
}
var span = source.AsSpan(start, i - start);
if (
!float.TryParse(
span,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var value
)
)
{
throw new FormulaException(
$"Invalid number '{span.ToString()}' at position {start}."
);
}
tokens.Add(new Token(TokenType.Number, start, value));
continue;
}
if (char.IsLetter(c) || c == '_')
{
var start = i;
while (i < source.Length && (char.IsLetterOrDigit(source[i]) || source[i] == '_'))
{
i++;
}
tokens.Add(
new Token(TokenType.Identifier, start, text: source.Substring(start, i - start))
);
continue;
}
var pos = i;
switch (c)
{
case '+':
tokens.Add(new Token(TokenType.Plus, pos));
i++;
break;
case '-':
tokens.Add(new Token(TokenType.Minus, pos));
i++;
break;
case '*':
tokens.Add(new Token(TokenType.Star, pos));
i++;
break;
case '/':
tokens.Add(new Token(TokenType.Slash, pos));
i++;
break;
case '%':
tokens.Add(new Token(TokenType.Percent, pos));
i++;
break;
case '(':
tokens.Add(new Token(TokenType.LParen, pos));
i++;
break;
case ')':
tokens.Add(new Token(TokenType.RParen, pos));
i++;
break;
case ',':
tokens.Add(new Token(TokenType.Comma, pos));
i++;
break;
case '?':
tokens.Add(new Token(TokenType.Question, pos));
i++;
break;
case ':':
tokens.Add(new Token(TokenType.Colon, pos));
i++;
break;
case '<':
i = AddMaybeEqual(tokens, source, i, TokenType.LessEqual, TokenType.Less);
break;
case '>':
i = AddMaybeEqual(tokens, source, i, TokenType.GreaterEqual, TokenType.Greater);
break;
case '=':
if (Next(source, i) == '=')
{
tokens.Add(new Token(TokenType.EqualEqual, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '==' at position {pos}.");
case '!':
if (Next(source, i) == '=')
{
tokens.Add(new Token(TokenType.NotEqual, pos));
i += 2;
break;
}
tokens.Add(new Token(TokenType.Not, pos));
i++;
break;
case '&':
if (Next(source, i) == '&')
{
tokens.Add(new Token(TokenType.And, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '&&' at position {pos}.");
case '|':
if (Next(source, i) == '|')
{
tokens.Add(new Token(TokenType.Or, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '||' at position {pos}.");
default:
throw new FormulaException($"Unexpected character '{c}' at position {pos}.");
}
}
tokens.Add(new Token(TokenType.End, source.Length));
return tokens;
}
private static int AddMaybeEqual(
List<Token> tokens,
string source,
int i,
TokenType withEqual,
TokenType plain
)
{
if (Next(source, i) == '=')
{
tokens.Add(new Token(withEqual, i));
return i + 2;
}
tokens.Add(new Token(plain, i));
return i + 1;
}
private static char Next(string source, int i) => i + 1 < source.Length ? source[i + 1] : '\0';
}
@@ -0,0 +1,273 @@
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
namespace MrGameEng.Formulas;
/// <summary>
/// Recursive-descent parser that compiles a token list straight into a tree of <see cref="Node"/>
/// delegates. Parsing (and therefore all closure allocation) happens once; evaluating the returned
/// node is allocation-free. Precedence, low to high: ternary, <c>||</c>, <c>&amp;&amp;</c>, equality,
/// comparison, additive, multiplicative, unary, primary.
/// </summary>
internal sealed class FormulaParser(List<Token> tokens)
{
private const float True = 1f;
private const float False = 0f;
private int _pos;
public Node ParseProgram()
{
var node = ParseTernary();
Expect(TokenType.End);
return node;
}
private Node ParseTernary()
{
var condition = ParseOr();
if (!Match(TokenType.Question))
{
return condition;
}
var whenTrue = ParseTernary();
Expect(TokenType.Colon);
var whenFalse = ParseTernary();
return ctx => condition(ctx) != False ? whenTrue(ctx) : whenFalse(ctx);
}
private Node ParseOr()
{
var left = ParseAnd();
while (Match(TokenType.Or))
{
var right = ParseAnd();
var l = left;
left = ctx => l(ctx) != False || right(ctx) != False ? True : False;
}
return left;
}
private Node ParseAnd()
{
var left = ParseEquality();
while (Match(TokenType.And))
{
var right = ParseEquality();
var l = left;
left = ctx => l(ctx) != False && right(ctx) != False ? True : False;
}
return left;
}
private Node ParseEquality()
{
var left = ParseComparison();
while (true)
{
if (Match(TokenType.EqualEqual))
{
var right = ParseComparison();
var l = left;
left = ctx => l(ctx) == right(ctx) ? True : False;
}
else if (Match(TokenType.NotEqual))
{
var right = ParseComparison();
var l = left;
left = ctx => l(ctx) != right(ctx) ? True : False;
}
else
{
return left;
}
}
}
private Node ParseComparison()
{
var left = ParseAdditive();
while (true)
{
if (Match(TokenType.Less))
{
left = Compare(left, ParseAdditive(), (a, b) => a < b);
}
else if (Match(TokenType.LessEqual))
{
left = Compare(left, ParseAdditive(), (a, b) => a <= b);
}
else if (Match(TokenType.Greater))
{
left = Compare(left, ParseAdditive(), (a, b) => a > b);
}
else if (Match(TokenType.GreaterEqual))
{
left = Compare(left, ParseAdditive(), (a, b) => a >= b);
}
else
{
return left;
}
}
}
private Node ParseAdditive()
{
var left = ParseMultiplicative();
while (true)
{
if (Match(TokenType.Plus))
{
var right = ParseMultiplicative();
var l = left;
left = ctx => l(ctx) + right(ctx);
}
else if (Match(TokenType.Minus))
{
var right = ParseMultiplicative();
var l = left;
left = ctx => l(ctx) - right(ctx);
}
else
{
return left;
}
}
}
private Node ParseMultiplicative()
{
var left = ParseUnary();
while (true)
{
if (Match(TokenType.Star))
{
var right = ParseUnary();
var l = left;
left = ctx => l(ctx) * right(ctx);
}
else if (Match(TokenType.Slash))
{
var right = ParseUnary();
var l = left;
left = ctx => l(ctx) / right(ctx);
}
else if (Match(TokenType.Percent))
{
var right = ParseUnary();
var l = left;
left = ctx => l(ctx) % right(ctx);
}
else
{
return left;
}
}
}
private Node ParseUnary()
{
if (Match(TokenType.Minus))
{
var operand = ParseUnary();
return ctx => -operand(ctx);
}
if (Match(TokenType.Plus))
{
return ParseUnary();
}
if (Match(TokenType.Not))
{
var operand = ParseUnary();
return ctx => operand(ctx) != False ? False : True;
}
return ParsePrimary();
}
private Node ParsePrimary()
{
var token = Current;
if (Match(TokenType.Number))
{
var value = token.Number;
return _ => value;
}
if (Match(TokenType.LParen))
{
var inner = ParseTernary();
Expect(TokenType.RParen);
return inner;
}
if (Match(TokenType.Identifier))
{
return Peek(TokenType.LParen) ? ParseCall(token.Text) : ParseName(token.Text);
}
throw new FormulaException($"Unexpected token at position {token.Position}.");
}
private Node ParseName(string name)
{
switch (name)
{
case "pi":
return _ => MathF.PI;
case "tau":
return _ => MathF.Tau;
case "e":
return _ => MathF.E;
default:
return ctx => ctx.Resolve(name);
}
}
private Node ParseCall(string name)
{
Expect(TokenType.LParen);
var args = new List<Node>();
if (!Peek(TokenType.RParen))
{
do
{
args.Add(ParseTernary());
} while (Match(TokenType.Comma));
}
Expect(TokenType.RParen);
return FormulaFunctions.Build(name, args);
}
private static Node Compare(Node left, Node right, Func<float, float, bool> op) =>
ctx => op(left(ctx), right(ctx)) ? True : False;
private Token Current => tokens[_pos];
private bool Peek(TokenType type) => Current.Type == type;
private bool Match(TokenType type)
{
if (Current.Type != type)
{
return false;
}
_pos++;
return true;
}
private void Expect(TokenType type)
{
if (!Match(type))
{
throw new FormulaException($"Expected {type} at position {Current.Position}.");
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ namespace MrGameEng.Core;
/// <summary>
/// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter.
/// Advanced once per frame by <see cref="GameHost"/>.
/// Advanced once per frame (or per fixed tick) by the host.
/// </summary>
public sealed class GameClock
{
+107
View File
@@ -0,0 +1,107 @@
using System.Diagnostics;
namespace MrGameEng.Core;
/// <summary>
/// A game-loop host without a window, GPU or any platform dependency: drives the active
/// scene's update phase on a fixed timestep. Suits dedicated servers, batch simulation and
/// tests. Scenes still own draw systems — they are simply never run, and platform services
/// (graphics device, window) are absent from <see cref="EngineContext.Services"/>, so only
/// platform-free modules can be used. The fixed step makes simulation time independent of
/// wall-clock jitter: N ticks always advance the world by exactly N ×
/// <see cref="FixedDeltaTime"/> seconds.
/// </summary>
public sealed class HeadlessHost : IDisposable
{
/// <summary>Engine context shared with scenes and systems.</summary>
public EngineContext Context { get; } = new();
/// <summary>Fixed simulation step in seconds: 1 / <see cref="HeadlessHostOptions.TicksPerSecond"/>.</summary>
public float FixedDeltaTime { get; }
/// <summary>Ticks completed since the host was created.</summary>
public long TickCount => Context.Clock.FrameCount;
// Отстав сильнее этого, Run ресинкается с настоящим временем вместо лавины тиков.
private const double MaxLagSeconds = 1.0;
private readonly HeadlessHostOptions _options;
/// <summary>Creates a host that starts with <paramref name="initialScene"/>. The scene
/// loads on the first tick, mirroring the windowed host's deferred switch.</summary>
public HeadlessHost(HeadlessHostOptions options, Scene initialScene)
{
if (options.TicksPerSecond <= 0f)
{
throw new ArgumentOutOfRangeException(
nameof(options),
"TicksPerSecond must be positive."
);
}
_options = options;
FixedDeltaTime = 1f / options.TicksPerSecond;
Context.Scenes.Switch(initialScene);
}
/// <summary>Advances the world by exactly one fixed tick.</summary>
public void Tick()
{
Context.Clock.Advance(FixedDeltaTime);
Context.Scenes.Update(Context.Clock);
}
/// <summary>Advances the world by <paramref name="count"/> ticks as fast as possible.</summary>
public void RunTicks(long count)
{
for (long i = 0; i < count; i++)
{
Tick();
}
}
/// <summary>
/// Runs until <paramref name="cancellationToken"/> is cancelled. With
/// <see cref="HeadlessHostOptions.Realtime"/> ticks are paced to the wall clock — the
/// loop sleeps when ahead and, having fallen more than a second behind, resyncs instead
/// of bursting a catch-up avalanche. Pacing relies on <see cref="Thread.Sleep(TimeSpan)"/>
/// and is accurate to a few milliseconds, not exact.
/// </summary>
public void Run(CancellationToken cancellationToken = default)
{
if (!_options.Realtime)
{
while (!cancellationToken.IsCancellationRequested)
{
Tick();
}
return;
}
var wallClock = Stopwatch.StartNew();
var nextTickAt = 0.0;
while (!cancellationToken.IsCancellationRequested)
{
Tick();
nextTickAt += FixedDeltaTime;
var ahead = nextTickAt - wallClock.Elapsed.TotalSeconds;
if (ahead > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(ahead));
}
else if (-ahead > MaxLagSeconds)
{
nextTickAt = wallClock.Elapsed.TotalSeconds;
}
}
}
/// <summary>Unloads the active scene and disposes context-owned services.</summary>
public void Dispose()
{
Context.Scenes.Switch(null);
Context.Scenes.ApplyPending();
Context.DisposeOwnedResources();
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace MrGameEng.Core;
/// <summary>Loop settings for <see cref="HeadlessHost"/>.</summary>
public sealed class HeadlessHostOptions
{
/// <summary>Fixed simulation rate in ticks per second. Must be positive.</summary>
public float TicksPerSecond { get; set; } = 60f;
/// <summary>
/// When true, <see cref="HeadlessHost.Run"/> paces ticks to the wall clock (a dedicated
/// server); when false it runs flat out (batch simulation). <see cref="HeadlessHost.RunTicks"/>
/// always runs flat out regardless of this setting.
/// </summary>
public bool Realtime { get; set; } = true;
}
+1 -1
View File
@@ -4,11 +4,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
<PackageReference Include="Friflo.Engine.ECS" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
<InternalsVisibleTo Include="MrGameEng.Host" />
</ItemGroup>
</Project>
+15 -22
View File
@@ -22,13 +22,24 @@ public sealed class SceneManager
/// <summary>True while a transition is covering or revealing.</summary>
public bool IsTransitioning => _state != State.Idle;
/// <summary>The transition currently covering or revealing, or null while idle.
/// Hosts that render overlays read this together with <see cref="TransitionCoverage"/>
/// and <see cref="TransitionPhase"/> during their draw phase.</summary>
public Transition? ActiveTransition => _state == State.Idle ? null : _transition;
/// <summary>Coverage of the active transition: 0 = scene fully visible, 1 = fully covered.</summary>
public float TransitionCoverage => Math.Clamp(_coverage, 0f, 1f);
/// <summary>Phase of the active transition. Meaningful only while <see cref="IsTransitioning"/>.</summary>
public TransitionPhase TransitionPhase =>
_state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
private readonly EngineContext _context;
private Scene? _pending;
private bool _hasPending;
private Transition? _transition;
private State _state;
private float _coverage;
private TransitionRenderer? _renderer;
internal SceneManager(EngineContext context) => _context = context;
@@ -100,27 +111,9 @@ public sealed class SceneManager
Current?.Update(clock);
}
/// <summary>Draws the active scene and the transition overlay on top. Called by the host.</summary>
public void Draw(GameClock clock)
{
Current?.Draw(clock);
if (_state == State.Idle || !_context.HasGraphicsDevice)
{
return;
}
_renderer ??= new TransitionRenderer(_context.GraphicsDevice);
var phase = _state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
_transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase);
}
/// <summary>Disposes the lazily created transition renderer. Called on host shutdown.</summary>
internal void DisposeRenderer()
{
_renderer?.Dispose();
_renderer = null;
}
/// <summary>Draws the active scene. Transition overlays are rendered by the host on top,
/// from <see cref="ActiveTransition"/> and <see cref="TransitionCoverage"/>.</summary>
public void Draw(GameClock clock) => Current?.Draw(clock);
internal void ApplyPending()
{
+5 -4
View File
@@ -40,16 +40,17 @@ public sealed class ServiceRegistry
/// <summary>
/// Disposes every registered <see cref="IDisposable"/> service (each instance once, even
/// when registered under several types) and clears the registry. <paramref name="except"/>
/// is skipped. Called on host shutdown.
/// when registered under several types) and clears the registry. Instances in
/// <paramref name="except"/> are skipped. Called on host shutdown.
/// </summary>
internal void DisposeServices(object? except = null)
internal void DisposeServices(params object[] except)
{
var skipped = new HashSet<object>(except, ReferenceEqualityComparer.Instance);
var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance);
foreach (var service in _services.Values)
{
if (
!ReferenceEquals(service, except)
!skipped.Contains(service)
&& service is IDisposable disposable
&& disposed.Add(service)
)
+6 -51
View File
@@ -1,5 +1,3 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core;
/// <summary>Phase of a scene transition.</summary>
@@ -13,10 +11,12 @@ public enum TransitionPhase
}
/// <summary>
/// Visual transition between scenes. The scene switch itself happens at full coverage,
/// so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
/// Transitions are stateless and reusable; progress is tracked by <see cref="SceneManager"/>.
/// Runs on unscaled time, so it works while gameplay is paused.
/// Timing of a visual transition between scenes. The scene switch itself happens at full
/// coverage, so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
/// Progress is tracked by <see cref="SceneManager"/> on unscaled time, so transitions work
/// while gameplay is paused. The core only times the phases — how the overlay looks is
/// defined by the host (the MonoGame host's <c>OverlayTransition</c> and the
/// <c>Transitions</c> factories); headless hosts simply let transitions pass invisibly.
/// </summary>
public abstract class Transition
{
@@ -32,49 +32,4 @@ public abstract class Transition
OutDuration = Math.Max(0f, outDuration);
InDuration = Math.Max(0f, inDuration);
}
/// <summary>
/// Draws the overlay. <paramref name="coverage"/> is 0 (scene fully visible) to
/// 1 (scene fully covered); <paramref name="phase"/> tells which side of the switch this is.
/// </summary>
public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase);
/// <summary>Fade through a solid color (black by default). Total duration is split between out and in.</summary>
public static Transition Fade(float duration = 0.6f, Color? color = null) =>
new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
/// <summary>A curtain wiping across the screen (black by default). Total duration is split between out and in.</summary>
public static Transition Wipe(float duration = 0.6f, Color? color = null) =>
new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
) => renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
}
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
)
{
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
if (phase == TransitionPhase.Out)
{
renderer.Fill(0f, 0f, coverage, 1f, color);
}
else
{
renderer.Fill(1f - coverage, 0f, coverage, 1f, color);
}
}
}
}
@@ -0,0 +1,19 @@
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace MrGameEng.Graphics;
/// <summary>Graphics-side accessors for the platform-free <see cref="EngineContext"/>.</summary>
public static class EngineContextGraphicsExtensions
{
/// <summary>
/// Returns the <see cref="GraphicsDevice"/> service published by a windowed host.
/// Throws in a headless context, where no graphics device exists.
/// </summary>
public static GraphicsDevice GetGraphicsDevice(this EngineContext context) =>
context.Services.GetOrDefault<GraphicsDevice>()
?? throw new InvalidOperationException(
"GraphicsDevice is not available: no windowed host has published it "
+ "(headless context, or graphics are not initialized yet)."
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ public static class SceneLightmapExtensions
Func<bool[]> occluders
)
{
var device = scene.Context.GraphicsDevice;
var device = scene.Context.GetGraphicsDevice();
var lightmap = new Lightmap(device, width, height, cellSize, origin);
var lighting = new Lighting(lightmap);
scene.Context.Services.Add(lighting);
@@ -3,6 +3,10 @@
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
</ItemGroup>
@@ -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);
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
using MrGameEng.Formulas;
using Xunit;
namespace MrGameEng.Core.Tests;
public class FormulaTests
{
private static IFormulaContext Vars(Dictionary<string, float> values) =>
new DelegateFormulaContext(name =>
values.TryGetValue(name, out var v)
? v
: throw new FormulaException($"unknown '{name}'")
);
[Theory]
[InlineData("1 + 2 * 3", 7f)] // precedence
[InlineData("(1 + 2) * 3", 9f)] // parentheses
[InlineData("10 - 2 - 3", 5f)] // left associativity
[InlineData("-2 + 5", 3f)] // unary minus
[InlineData("2 * -3", -6f)] // unary minus after operator
[InlineData("7 % 3", 1f)] // modulo
[InlineData("2.5 * 4", 10f)] // decimals
public void Evaluate_Arithmetic_RespectsPrecedence(string expr, float expected)
{
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
}
[Theory]
[InlineData("min(3, 5)", 3f)]
[InlineData("max(3, 5)", 5f)]
[InlineData("clamp(12, 0, 10)", 10f)]
[InlineData("clamp(-4, 0, 10)", 0f)]
[InlineData("lerp(0, 10, 0.25)", 2.5f)]
[InlineData("pow(2, 10)", 1024f)]
[InlineData("abs(-7)", 7f)]
[InlineData("floor(3.9)", 3f)]
[InlineData("ceil(3.1)", 4f)]
[InlineData("step(5, 7)", 1f)]
[InlineData("step(5, 2)", 0f)]
public void Evaluate_Functions_ComputeExpected(string expr, float expected)
{
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
}
[Fact]
public void Evaluate_Constants_AreBuiltIn()
{
Assert.Equal(MathF.PI, Formula.Compile("pi").Evaluate(), 5);
Assert.Equal(MathF.Tau, Formula.Compile("tau").Evaluate(), 5);
Assert.Equal(MathF.E, Formula.Compile("e").Evaluate(), 5);
}
[Theory]
[InlineData("2 < 3", 1f)]
[InlineData("3 <= 3", 1f)]
[InlineData("3 > 5", 0f)]
[InlineData("4 == 4", 1f)]
[InlineData("4 != 4", 0f)]
[InlineData("1 && 0", 0f)]
[InlineData("0 || 2", 1f)]
[InlineData("!0", 1f)]
[InlineData("!5", 0f)]
public void Evaluate_LogicAndComparison_YieldBooleansAsOneOrZero(string expr, float expected)
{
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
}
[Theory]
[InlineData("3 > 2 ? 10 : 20", 10f)]
[InlineData("3 < 2 ? 10 : 20", 20f)]
[InlineData("1 ? 2 ? 3 : 4 : 5", 3f)] // nested ternary
public void Evaluate_Ternary_SelectsBranch(string expr, float expected)
{
Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4);
}
[Fact]
public void Evaluate_Variables_ResolvedThroughContext()
{
var ctx = Vars(new() { ["light"] = 0.8f, ["optimal"] = 0.5f });
var formula = Formula.Compile("clamp(1 - abs(light - optimal), 0, 1)");
Assert.Equal(0.7f, formula.Evaluate(ctx), 4);
}
[Fact]
public void Evaluate_SameFormulaTwice_IsDeterministic()
{
var formula = Formula.Compile("sin(t) * 2 + 1");
var ctx = Vars(new() { ["t"] = 1.234f });
Assert.Equal(formula.Evaluate(ctx), formula.Evaluate(ctx), 6);
}
[Theory]
[InlineData("1 +")] // dangling operator
[InlineData("(1 + 2")] // unbalanced paren
[InlineData("1 2")] // trailing token
[InlineData("min(1)")] // wrong arity
[InlineData("nope(1)")] // unknown function
[InlineData("@")] // bad character
[InlineData("1 = 2")] // single equals
public void Compile_InvalidExpression_Throws(string expr)
{
Assert.Throws<FormulaException>(() => Formula.Compile(expr));
}
[Fact]
public void Evaluate_UnknownVariableWithoutContext_Throws()
{
var formula = Formula.Compile("x + 1");
Assert.Throws<FormulaException>(() => formula.Evaluate());
}
}
@@ -0,0 +1,117 @@
using MrGameEng.Core;
using Xunit;
namespace MrGameEng.Core.Tests;
public class HeadlessHostTests
{
private sealed class CountingScene : Scene
{
public int LoadCount;
public int UnloadCount;
public int UpdateCount;
protected override void OnLoad() => LoadCount++;
protected override void OnUnload() => UnloadCount++;
public override void Update(GameClock clock)
{
UpdateCount++;
base.Update(clock);
}
}
[Fact]
public void RunTicks_AdvancesClock_ByExactFixedStep()
{
var scene = new CountingScene();
using var host = new HeadlessHost(new HeadlessHostOptions { TicksPerSecond = 10f }, scene);
host.RunTicks(30);
Assert.Equal(0.1f, host.FixedDeltaTime, 3);
Assert.Equal(30, host.TickCount);
Assert.Equal(3.0, host.Context.Clock.TotalTime, 3);
Assert.Equal(30, scene.UpdateCount);
}
[Fact]
public void FirstTick_LoadsTheInitialScene()
{
var scene = new CountingScene();
using var host = new HeadlessHost(new HeadlessHostOptions(), scene);
Assert.Equal(0, scene.LoadCount);
host.Tick();
Assert.Equal(1, scene.LoadCount);
Assert.Same(scene, host.Context.Scenes.Current);
}
[Fact]
public void Dispose_UnloadsTheActiveScene()
{
var scene = new CountingScene();
var host = new HeadlessHost(new HeadlessHostOptions(), scene);
host.Tick();
host.Dispose();
Assert.Equal(1, scene.UnloadCount);
Assert.Null(host.Context.Scenes.Current);
}
[Fact]
public void Run_StopsWhenCancelled()
{
using var cancellation = new CancellationTokenSource();
var scene = new CancellingScene(cancellation, afterTicks: 5);
using var host = new HeadlessHost(new HeadlessHostOptions { Realtime = false }, scene);
host.Run(cancellation.Token);
Assert.Equal(5, scene.UpdateCount);
}
[Fact]
public void TimeScale_StillApplies_OnTopOfFixedStep()
{
var scene = new CountingScene();
using var host = new HeadlessHost(new HeadlessHostOptions { TicksPerSecond = 10f }, scene);
host.Context.Clock.TimeScale = 3f;
host.RunTicks(10);
Assert.Equal(3.0, host.Context.Clock.TotalTime, 3);
Assert.Equal(1.0, host.Context.Clock.UnscaledTotalTime, 3);
}
[Fact]
public void NonPositiveTickRate_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new HeadlessHost(new HeadlessHostOptions { TicksPerSecond = 0f }, new CountingScene())
);
}
private sealed class CancellingScene(CancellationTokenSource cancellation, int afterTicks)
: Scene
{
public int UpdateCount;
protected override void OnLoad() { }
public override void Update(GameClock clock)
{
UpdateCount++;
if (UpdateCount >= afterTicks)
{
cancellation.Cancel();
}
base.Update(clock);
}
}
}
@@ -15,6 +15,14 @@ public class SceneTransitionTests
protected override void OnUnload() => UnloadCount++;
}
// Тайминг-машина живёт в ядре и не зависит от визуала перехода —
// тестируем на голой заглушке с длительностями, как у Fade.
private sealed class TimedTransition(float outDuration, float inDuration)
: Transition(outDuration, inDuration);
private static Transition Fade(float duration) =>
new TimedTransition(duration / 2f, duration / 2f);
private static void Tick(EngineContext context, float seconds)
{
context.Clock.Advance(seconds);
@@ -31,7 +39,7 @@ public class SceneTransitionTests
Tick(context, 0.016f);
// Fade(1.0) → фаза закрытия 0.5 c, фаза открытия 0.5 c.
context.Scenes.Switch(second, Transition.Fade(1f));
context.Scenes.Switch(second, Fade(1f));
Tick(context, 0.2f);
Assert.True(context.Scenes.IsTransitioning);
@@ -61,7 +69,7 @@ public class SceneTransitionTests
Tick(context, 0.016f);
context.Clock.TimeScale = 0f; // игра на паузе
context.Scenes.Switch(second, Transition.Fade(0.2f));
context.Scenes.Switch(second, Fade(0.2f));
Tick(context, 0.15f);
Tick(context, 0.15f);
@@ -79,7 +87,7 @@ public class SceneTransitionTests
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(1f));
context.Scenes.Switch(second, Fade(1f));
Tick(context, 0.1f);
context.Scenes.Switch(third); // передумали, пока экран закрывается
@@ -99,12 +107,12 @@ public class SceneTransitionTests
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(1f)); // 0.5 c закрытие + 0.5 c открытие
context.Scenes.Switch(second, Fade(1f)); // 0.5 c закрытие + 0.5 c открытие
Tick(context, 0.6f); // закрыто, своп на second, началось открытие
Assert.Same(second, context.Scenes.Current);
Tick(context, 0.25f); // открытие наполовину (coverage ~0.5)
context.Scenes.Switch(third, Transition.Fade(1f)); // передумали во время открытия
context.Scenes.Switch(third, Fade(1f)); // передумали во время открытия
Tick(context, 0.05f); // экран снова закрывается — свопа ещё нет
Assert.Same(second, context.Scenes.Current);
@@ -124,7 +132,7 @@ public class SceneTransitionTests
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(0f));
context.Scenes.Switch(second, Fade(0f));
Tick(context, 0.016f);
Tick(context, 0.016f);
@@ -0,0 +1,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>