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; + } +}