Add MrGameEng.UI module integrating Myra

scene.UseUI() creates a per-scene Myra Desktop and registers
UiRenderSystem last in the draw phase (UI on top, window pixels).
GameHost now exposes itself as a Game service (Myra needs the instance;
useful for games too). Myra was picked over Gum/ImGui for the
FontStashSharp ecosystem fit, pipeline-free assets and maturity; its
internal SpriteBatch use is a documented exception to the engine rule.

Sample: on-screen HUD replaces window-title-only stats — live FPS label,
music volume slider and scene-switch buttons on both scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 04:51:12 +03:00
co-authored by Claude Fable 5
parent a3e6d3bb0a
commit af319d1276
12 changed files with 159 additions and 8 deletions
+3 -1
View File
@@ -18,9 +18,11 @@ docs/ architecture, conventions, roadmap (Russian)
Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched
renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content
pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles).
pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles),
`UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`).
Dependency rule: every module may depend only on `Core`; `Core` depends only on
MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
Documented exception: Myra renders with its own SpriteBatch internally.
## Commands
+1
View File
@@ -6,6 +6,7 @@
<PackageVersion Include="Friflo.Engine.ECS" Version="3.6.0" />
<PackageVersion Include="FontStashSharp.MonoGame" Version="1.5.6" />
<PackageVersion Include="NVorbis" Version="0.10.5" />
<PackageVersion Include="Myra" Version="1.6.1" />
<!-- Source generator -->
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
+15
View File
@@ -31,6 +31,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio", "src\MrGa
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input.Tests", "tests\MrGameEng.Input.Tests\MrGameEng.Input.Tests.csproj", "{0E0710AB-6132-4E64-9AFC-03B0601F92C6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI", "src\MrGameEng.UI\MrGameEng.UI.csproj", "{17EB97D5-DCF8-47DF-B810-DA45AE314170}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -173,6 +175,18 @@ Global
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x64.Build.0 = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.ActiveCfg = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x64.ActiveCfg = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x64.Build.0 = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x86.ActiveCfg = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x86.Build.0 = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|Any CPU.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x64.ActiveCfg = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x64.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.ActiveCfg = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -189,5 +203,6 @@ Global
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0E0710AB-6132-4E64-9AFC-03B0601F92C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{17EB97D5-DCF8-47DF-B810-DA45AE314170} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
+17
View File
@@ -33,6 +33,7 @@
| `MrGameEng.Audio` | Звуковые эффекты и музыка |
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
@@ -52,6 +53,21 @@ MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит
от других модулей движка.
## UI (Myra)
Игровой UI — интеграция [Myra](https://github.com/rds1983/Myra) (выбор: экосистема
FontStashSharp, не требует Content Pipeline, зрелая и поддерживаемая):
- `scene.UseUI()` в `OnLoad` **после** `UseRenderer2D()` — создаёт Myra `Desktop`
(свой на сцену) и регистрирует `UiRenderSystem` последней в Draw-фазе.
- UI строится кодом через `desktop.Root`; ввод (мышь/клавиатура) Myra обрабатывает
сама во время рендера, в оконных пикселях.
- Текст рисуется встроенным шрифтом Myra; свои шрифты — `FontSystem` через `AssetManager`.
- **Документированное исключение из правил**: Myra рисует собственным SpriteBatch
(запрет на SpriteBatch относится к коду движка, не к сторонним библиотекам).
Если UI станет узким местом, её рендер можно перевести на наш батчер
через `IMyraRenderer` (бэклог).
## Загрузка ресурсов (без Content Pipeline)
MGCB / Content Pipeline **не используется**. Все ресурсы лежат в папке `Assets/`
@@ -198,4 +214,5 @@ docs/ документация (русский)
| Friflo.Engine.ECS | 3.6.0 | ECS |
| FontStashSharp.MonoGame | 1.5.6 | Шрифты (ttf) в рантайме |
| NVorbis | 0.10.5 | Декодирование ogg |
| Myra | 1.6.1 | Игровой UI |
| dotnet-mgfxc (dotnet tool) | 3.8.4.1 | Компиляция шейдеров при сборке |
+3 -1
View File
@@ -18,7 +18,9 @@
- Physics2D (выбор библиотеки: Aether.Physics2D / своя)
- Tilemap (поддержка Tiled)
- Particles
- UI
- UI: загрузка MML-разметки Myra через AssetManager + хендлы в кодогенераторе
- UI: рендер Myra через наш батчер (`IMyraRenderer`), если UI станет узким местом по draw call'ам
- DevTools-модуль на ImGui.NET: инспектор сущностей, дебаг-панели
- Бенчмарки BenchmarkDotNet для систем (сейчас производительность контролируется стресс-сценой)
- Spatial hash для culling на очень больших мирах (если профилирование покажет необходимость)
- Параллельная запись вершин (Parallel.For по чанкам), если упрёмся в CPU на ещё больших сценах
@@ -11,6 +11,7 @@
<ProjectReference Include="..\..\src\MrGameEng.Input\MrGameEng.Input.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
+10 -4
View File
@@ -113,8 +113,9 @@ public sealed class SceneHotkeysSystem(
}
}
/// <summary>FPS и статистика рендера в заголовке окна (обновляется 4 раза в секунду).</summary>
public sealed class TitleStatsSystem(EngineContext context, Renderer2D renderer, string sceneName) : BaseSystem
/// <summary>FPS и статистика рендера: в HUD-лейбл и в заголовок окна (4 раза в секунду).</summary>
public sealed class StatsSystem(
EngineContext context, Renderer2D renderer, string sceneName, Myra.Graphics2D.UI.Label? hudLabel = null) : BaseSystem
{
private float _accumulated;
private int _frames;
@@ -131,8 +132,13 @@ public sealed class TitleStatsSystem(EngineContext context, Renderer2D renderer,
var fps = _frames / _accumulated;
_accumulated = 0f;
_frames = 0;
context.Services.Get<GameWindow>().Title =
$"MrGameEng Sample — {sceneName} | {fps:F0} FPS | sprites: {renderer.SubmittedSprites} | culled: {renderer.CulledSprites} | draw calls: {renderer.DrawCalls}";
var stats =
$"{fps:F0} FPS | sprites: {renderer.SubmittedSprites} | culled: {renderer.CulledSprites} | draw calls: {renderer.DrawCalls}";
context.Services.Get<GameWindow>().Title = $"MrGameEng Sample — {sceneName} | {stats}";
if (hudLabel is not null)
{
hudLabel.Text = $"{sceneName}\n{stats}";
}
}
}
+24 -1
View File
@@ -5,6 +5,8 @@ using MrGameEng.Audio;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Input;
using MrGameEng.UI;
using Myra.Graphics2D.UI;
namespace MrGameEng.Sample.Scenes;
@@ -90,11 +92,32 @@ public sealed class MainScene : Scene
var hud = new Sprite(shapeRegions[3], SampleLayers.Ui);
Store.CreateEntity(new Transform2D(new Vector2(16f, 16f)), hud);
// UI (Myra): HUD со статистикой, слайдер громкости, кнопка перехода. После UseRenderer2D!
var desktop = this.UseUI();
var statsLabel = new Label();
var volumeSlider = new HorizontalSlider { Minimum = 0f, Maximum = 1f, Value = audio.Music.Volume, Width = 180 };
volumeSlider.ValueChanged += (_, _) => audio.Music.Volume = volumeSlider.Value;
var switchButton = new Button { Content = new Label { Text = "Stress scene (Tab)" } };
switchButton.Click += (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new StressScene(), Transition.Fade(0.8f));
}
};
var panel = new VerticalStackPanel { Left = 12, Top = 12, Spacing = 6 };
panel.Widgets.Add(statsLabel);
panel.Widgets.Add(new Label { Text = "Music volume (M — pause)" });
panel.Widgets.Add(volumeSlider);
panel.Widgets.Add(switchButton);
desktop.Root = panel;
UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep));
UpdateSystems.Add(new BounceSystem(WorldBounds));
UpdateSystems.Add(new CameraControlSystem(camera, player, input));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new StressScene(), Transition.Fade(0.8f)));
UpdateSystems.Add(new TitleStatsSystem(Context, renderer, "Main"));
UpdateSystems.Add(new StatsSystem(Context, renderer, "Main", statsLabel));
var music = audio.Music;
if (!music.IsPlaying)
+20 -1
View File
@@ -4,6 +4,7 @@ using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.Graphics;
using MrGameEng.Input;
using MrGameEng.UI;
namespace MrGameEng.Sample.Scenes;
@@ -52,10 +53,28 @@ public sealed class StressScene : Scene
var camera = Store.CreateEntity(new Camera(Vector2.Zero, zoom: 0.3f));
var desktop = this.UseUI();
var statsLabel = new Myra.Graphics2D.UI.Label();
var backButton = new Myra.Graphics2D.UI.Button
{
Content = new Myra.Graphics2D.UI.Label { Text = "Back to main (Tab)" },
};
backButton.Click += (_, _) =>
{
if (!Context.Scenes.IsTransitioning)
{
Context.Scenes.Switch(new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue));
}
};
var panel = new Myra.Graphics2D.UI.VerticalStackPanel { Left = 12, Top = 12, Spacing = 6 };
panel.Widgets.Add(statsLabel);
panel.Widgets.Add(backButton);
desktop.Root = panel;
UpdateSystems.Add(new BounceSystem(WorldBounds));
UpdateSystems.Add(new StressCameraSystem(camera, input));
UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Wipe(0.8f, Color.DarkSlateBlue)));
UpdateSystems.Add(new TitleStatsSystem(Context, renderer, $"Stress {SpriteCount:N0}"));
UpdateSystems.Add(new StatsSystem(Context, renderer, $"Stress {SpriteCount:N0}", statsLabel));
}
/// <summary>Только зум колесом — чтобы регулировать число видимых спрайтов.</summary>
+1
View File
@@ -47,6 +47,7 @@ public class GameHost : Game
Window.AllowUserResizing = _options.AllowResizing;
Context.AttachGraphicsDevice(GraphicsDevice);
Context.Services.Add(Window);
Context.Services.Add<Game>(this);
base.Initialize();
Context.Scenes.Switch(_initialScene);
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Myra" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+49
View File
@@ -0,0 +1,49 @@
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Myra;
using Myra.Graphics2D.UI;
using MrGameEng.Core;
namespace MrGameEng.UI;
/// <summary>
/// Draw system rendering the scene's Myra <see cref="Desktop"/>. Must run after the scene's
/// world rendering (register UI last in the draw phase) — the UI is drawn on top in window
/// pixels and also processes mouse/keyboard interaction during render.
/// </summary>
public sealed class UiRenderSystem : BaseSystem
{
private readonly Desktop _desktop;
/// <summary>Creates the system for <paramref name="desktop"/>.</summary>
public UiRenderSystem(Desktop desktop) => _desktop = desktop;
/// <inheritdoc />
protected override void OnUpdateGroup() => _desktop.Render();
}
/// <summary>Wires the UI module (Myra) into a <see cref="Scene"/>.</summary>
public static class SceneUiExtensions
{
private static bool _environmentInitialized;
/// <summary>
/// Creates a Myra <see cref="Desktop"/> for this scene and registers
/// <see cref="UiRenderSystem"/> in the draw phase. Call from <c>OnLoad</c>
/// <b>after</b> <c>UseRenderer2D()</c> so the UI draws on top of the world.
/// Build the UI by assigning <see cref="Desktop.Root"/>.
/// </summary>
public static Desktop UseUI(this Scene scene)
{
// Геттер MyraEnvironment.Game бросает исключение, пока Game не задан — проверять через ??= нельзя.
if (!_environmentInitialized)
{
MyraEnvironment.Game = scene.Context.Services.Get<Game>();
_environmentInitialized = true;
}
var desktop = new Desktop();
scene.DrawSystems.Add(new UiRenderSystem(desktop));
return desktop;
}
}