diff --git a/CLAUDE.md b/CLAUDE.md
index 199d93a..2735945 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/Directory.Packages.props b/Directory.Packages.props
index cb0521f..c340355 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,6 +6,7 @@
+
diff --git a/MrGameEng.sln b/MrGameEng.sln
index f0c85a7..c525fa7 100644
--- a/MrGameEng.sln
+++ b/MrGameEng.sln
@@ -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
diff --git a/docs/architecture.md b/docs/architecture.md
index 276ba19..eacf85a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -33,6 +33,7 @@
| `MrGameEng.Audio` | Звуковые эффекты и музыка |
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef` |
| `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 | Компиляция шейдеров при сборке |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index c8a05c6..b3ed178 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -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 на ещё больших сценах
diff --git a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj
index f398dab..16e4820 100644
--- a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj
+++ b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj
@@ -11,6 +11,7 @@
+
diff --git a/samples/MrGameEng.Sample/SampleSystems.cs b/samples/MrGameEng.Sample/SampleSystems.cs
index f61e9b5..c104f8c 100644
--- a/samples/MrGameEng.Sample/SampleSystems.cs
+++ b/samples/MrGameEng.Sample/SampleSystems.cs
@@ -113,8 +113,9 @@ public sealed class SceneHotkeysSystem(
}
}
-/// FPS и статистика рендера в заголовке окна (обновляется 4 раза в секунду).
-public sealed class TitleStatsSystem(EngineContext context, Renderer2D renderer, string sceneName) : BaseSystem
+/// FPS и статистика рендера: в HUD-лейбл и в заголовок окна (4 раза в секунду).
+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().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().Title = $"MrGameEng Sample — {sceneName} | {stats}";
+ if (hudLabel is not null)
+ {
+ hudLabel.Text = $"{sceneName}\n{stats}";
+ }
}
}
diff --git a/samples/MrGameEng.Sample/Scenes/MainScene.cs b/samples/MrGameEng.Sample/Scenes/MainScene.cs
index 3177bc5..8835203 100644
--- a/samples/MrGameEng.Sample/Scenes/MainScene.cs
+++ b/samples/MrGameEng.Sample/Scenes/MainScene.cs
@@ -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)
diff --git a/samples/MrGameEng.Sample/Scenes/StressScene.cs b/samples/MrGameEng.Sample/Scenes/StressScene.cs
index 8473029..51a88c0 100644
--- a/samples/MrGameEng.Sample/Scenes/StressScene.cs
+++ b/samples/MrGameEng.Sample/Scenes/StressScene.cs
@@ -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));
}
/// Только зум колесом — чтобы регулировать число видимых спрайтов.
diff --git a/src/MrGameEng.Core/GameHost.cs b/src/MrGameEng.Core/GameHost.cs
index d266b07..fe789b4 100644
--- a/src/MrGameEng.Core/GameHost.cs
+++ b/src/MrGameEng.Core/GameHost.cs
@@ -47,6 +47,7 @@ public class GameHost : Game
Window.AllowUserResizing = _options.AllowResizing;
Context.AttachGraphicsDevice(GraphicsDevice);
Context.Services.Add(Window);
+ Context.Services.Add(this);
base.Initialize();
Context.Scenes.Switch(_initialScene);
}
diff --git a/src/MrGameEng.UI/MrGameEng.UI.csproj b/src/MrGameEng.UI/MrGameEng.UI.csproj
new file mode 100644
index 0000000..b87d0bc
--- /dev/null
+++ b/src/MrGameEng.UI/MrGameEng.UI.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net8.0
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MrGameEng.UI/SceneUiExtensions.cs b/src/MrGameEng.UI/SceneUiExtensions.cs
new file mode 100644
index 0000000..0e0a521
--- /dev/null
+++ b/src/MrGameEng.UI/SceneUiExtensions.cs
@@ -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;
+
+///
+/// Draw system rendering the scene's Myra . 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.
+///
+public sealed class UiRenderSystem : BaseSystem
+{
+ private readonly Desktop _desktop;
+
+ /// Creates the system for .
+ public UiRenderSystem(Desktop desktop) => _desktop = desktop;
+
+ ///
+ protected override void OnUpdateGroup() => _desktop.Render();
+}
+
+/// Wires the UI module (Myra) into a .
+public static class SceneUiExtensions
+{
+ private static bool _environmentInitialized;
+
+ ///
+ /// Creates a Myra for this scene and registers
+ /// in the draw phase. Call from OnLoad
+ /// after UseRenderer2D() so the UI draws on top of the world.
+ /// Build the UI by assigning .
+ ///
+ public static Desktop UseUI(this Scene scene)
+ {
+ // Геттер MyraEnvironment.Game бросает исключение, пока Game не задан — проверять через ??= нельзя.
+ if (!_environmentInitialized)
+ {
+ MyraEnvironment.Game = scene.Context.Services.Get();
+ _environmentInitialized = true;
+ }
+
+ var desktop = new Desktop();
+ scene.DrawSystems.Add(new UiRenderSystem(desktop));
+ return desktop;
+ }
+}