diff --git a/CLAUDE.md b/CLAUDE.md index 04d747f..6a86892 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,11 +11,15 @@ Keep them up to date when architecture or conventions change. ``` src/ MrGameEng.* engine libraries (one per functional area) -samples/ MrGameEng.Sample — demo game showcasing every engine feature tests/ xUnit test projects, one per engine library +tools/ CLI tools (atlas packer) docs/ architecture, conventions, roadmap (Russian) ``` +The engine has no sample project: the **LittleSim** game +(https://gitea.hsrv.site/mrleo1nid/LittleSim, this repo as the `engine/` submodule) +is the living showcase — new engine features are demonstrated there. + 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), @@ -38,7 +42,6 @@ depends on `Graphics` (Texture2DRegion) and `Assets` (loader registration); ``` dotnet build MrGameEng.sln dotnet test MrGameEng.sln -dotnet run --project samples/MrGameEng.Sample ``` ## Architecture rules @@ -61,8 +64,8 @@ dotnet run --project samples/MrGameEng.Sample only through generated typed handles (`AssetRef`), never string paths. - New engine functionality goes into the matching module, or a new `MrGameEng.` library if it is a distinct area — never into `Core` by default. -- Every public engine feature must be demonstrated in `MrGameEng.Sample` - and covered by tests where logic is testable without a GPU. +- Every public engine feature must be covered by tests where logic is testable + without a GPU, and demonstrated in the LittleSim game (the engine's showcase). ## Code conventions diff --git a/MrGameEng.sln b/MrGameEng.sln index fc2374f..1e184bc 100644 --- a/MrGameEng.sln +++ b/MrGameEng.sln @@ -7,10 +7,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Core", "src\MrGameEng.Core\MrGameEng.Core.csproj", "{09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Sample", "samples\MrGameEng.Sample\MrGameEng.Sample.csproj", "{E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Core.Tests", "tests\MrGameEng.Core.Tests\MrGameEng.Core.Tests.csproj", "{2E9A054C-77A1-484B-BBFF-51FC40CD7D87}" @@ -79,18 +75,6 @@ Global {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|x64.Build.0 = Release|Any CPU {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|x86.ActiveCfg = Release|Any CPU {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B}.Release|x86.Build.0 = Release|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|x64.ActiveCfg = Debug|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|x64.Build.0 = Debug|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|x86.ActiveCfg = Debug|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Debug|x86.Build.0 = Debug|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|Any CPU.Build.0 = Release|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|x64.ActiveCfg = Release|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|x64.Build.0 = Release|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|x86.ActiveCfg = Release|Any CPU - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139}.Release|x86.Build.0 = Release|Any CPU {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E9A054C-77A1-484B-BBFF-51FC40CD7D87}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -349,7 +333,6 @@ Global EndGlobalSection GlobalSection(NestedProjects) = preSolution {09FF1FD5-7CCD-4A0B-AC0C-4910A2B5DF6B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {E6C4D3FA-4FEF-4FA0-9AB3-42928E576139} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {2E9A054C-77A1-484B-BBFF-51FC40CD7D87} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {F3C7CE85-EF6B-4CEE-9042-50B8710EF656} = {0AB3BF05-4346-4AA6-1389-037BE0695223} diff --git a/README.md b/README.md index dde80c1..8d5b669 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ and [Friflo.Engine.ECS](https://github.com/friflo/Friflo.Engine.ECS) — ECS-fir Developer documentation (Russian): [docs/architecture.md](docs/architecture.md), [docs/roadmap.md](docs/roadmap.md). +Showcase: the [LittleSim](https://gitea.hsrv.site/mrleo1nid/LittleSim) game — a god-sim +built on this engine (vendored there as the `engine/` submodule) that demonstrates +every engine feature. + ## License MIT — see [LICENSE](LICENSE). diff --git a/docs/architecture.md b/docs/architecture.md index ae9612d..bb92500 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,8 +20,10 @@ аллоцировать память. Проверяется бенчмарками и code review. 3. **Модульность.** Движок разбит на библиотеки по функциональным областям. Игра подключает только то, что использует. -4. **Всё демонстрируется.** Каждая публичная возможность движка показана - в `MrGameEng.Sample` и покрыта тестами (где логика тестируема без GPU). +4. **Всё демонстрируется.** Каждая публичная возможность движка покрыта тестами + (где логика тестируема без GPU) и показана в игре + [LittleSim](https://gitea.hsrv.site/mrleo1nid/LittleSim) — живой витрине движка + (движок подключён туда сабмодулем `engine/`). ## Модули @@ -276,13 +278,14 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто вдвое больше кадра): загрузка не ждёт, пока GPU дорисует предыдущий кадр; `Discard` — только на перемотке кольца. - **Тайминги фаз** кадра (submit/sort/build/upload/draw, мс) доступны как свойства - `Renderer2D` — выводятся в HUD стресс-сцены; оптимизации делаются только по ним. + `Renderer2D` — выводи их в HUD при оптимизации; решения только по замерам. - Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа, спрайты одного атласа батчатся автоматически. - Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе, - 0 аллокаций на кадр ниже порога параллелизма. Стресс-сцена Sample: 100k сущностей - (~61k в кадре) ≈ 297 FPS в Release. **Производительность измеряется только - в Release** — Debug-сборка медленнее в 5–6 раз (нет инлайнинга JIT). + 0 аллокаций на кадр ниже порога параллелизма. Эталонный замер (стресс-сцена, + до переноса демо в LittleSim): 100k сущностей, ~61k в кадре ≈ 297 FPS в Release. + **Производительность измеряется только в Release** — Debug-сборка медленнее + в 5–6 раз (нет инлайнинга JIT). ## Сцены и переходы @@ -323,11 +326,15 @@ Game.Draw ──► Scene.Draw ──► SystemRoot (Draw-фаза): кам ``` MrGameEng.sln src/ библиотеки движка (MrGameEng.*) -samples/ MrGameEng.Sample — демо всех возможностей tests/ xUnit-тесты, по проекту на библиотеку +tools/ CLI-инструменты (упаковщик атласов) docs/ документация (русский) ``` +Демо-проекта в движке нет: витрина движка — игра +[LittleSim](https://gitea.hsrv.site/mrleo1nid/LittleSim) (движок подключён туда +сабмодулем `engine/`). + ## Зафиксированные версии | Зависимость | Версия | Назначение | diff --git a/docs/roadmap.md b/docs/roadmap.md index 05ed5ba..e97bfbd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,7 +3,8 @@ Базовый движок (ядро, рендер, ассеты с кодогенерацией, ввод, звук, демо) реализован — текущие возможности описаны в [architecture.md](architecture.md). Здесь — только планы. -Пункт считается завершённым, когда покрыт тестами и показан в `MrGameEng.Sample`; +Пункт считается завершённым, когда покрыт тестами и показан в игре +[LittleSim](https://gitea.hsrv.site/mrleo1nid/LittleSim) — витрине движка; после завершения он убирается отсюда и фиксируется в architecture.md. ## Ближайшее diff --git a/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas b/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas deleted file mode 100644 index 0ae5c9b..0000000 --- a/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas +++ /dev/null @@ -1,31 +0,0 @@ -{ - "version": 1, - "name": "Textures", - "pageSize": 2048, - "padding": 2, - "pages": [ - { - "file": "Textures.atlas.0.png", - "width": 256, - "height": 128 - } - ], - "regions": [ - { - "key": "player", - "page": 0, - "x": 68, - "y": 2, - "w": 64, - "h": 32 - }, - { - "key": "shapes", - "page": 0, - "x": 2, - "y": 2, - "w": 64, - "h": 64 - } - ] -} \ No newline at end of file diff --git a/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas.0.png b/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas.0.png deleted file mode 100644 index 6e8632d..0000000 Binary files a/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas.0.png and /dev/null differ diff --git a/samples/MrGameEng.Sample/Assets/Music/theme.ogg b/samples/MrGameEng.Sample/Assets/Music/theme.ogg deleted file mode 100644 index e1a45a2..0000000 Binary files a/samples/MrGameEng.Sample/Assets/Music/theme.ogg and /dev/null differ diff --git a/samples/MrGameEng.Sample/Assets/Sounds/beep.wav b/samples/MrGameEng.Sample/Assets/Sounds/beep.wav deleted file mode 100644 index 2f2e70e..0000000 Binary files a/samples/MrGameEng.Sample/Assets/Sounds/beep.wav and /dev/null differ diff --git a/samples/MrGameEng.Sample/Assets/Textures/player.png b/samples/MrGameEng.Sample/Assets/Textures/player.png deleted file mode 100644 index a5f8e5a..0000000 Binary files a/samples/MrGameEng.Sample/Assets/Textures/player.png and /dev/null differ diff --git a/samples/MrGameEng.Sample/Assets/Textures/shapes.png b/samples/MrGameEng.Sample/Assets/Textures/shapes.png deleted file mode 100644 index 6c86a4c..0000000 Binary files a/samples/MrGameEng.Sample/Assets/Textures/shapes.png and /dev/null differ diff --git a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj deleted file mode 100644 index 7565eca..0000000 --- a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - WinExe - net8.0 - - - - - - - - - - - - - - - - - - - - - - - diff --git a/samples/MrGameEng.Sample/Program.cs b/samples/MrGameEng.Sample/Program.cs deleted file mode 100644 index 15145c3..0000000 --- a/samples/MrGameEng.Sample/Program.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Xna.Framework; -using MrGameEng.Core; -using MrGameEng.Sample.Scenes; - -using var host = new GameHost( - new GameHostOptions - { - Title = "MrGameEng Sample", - Width = 1280, - Height = 720, - ClearColor = new Color(24, 26, 32), - VSync = false, // техдемо: показываем реальный FPS, не ограниченный частотой монитора - }, - new MainScene()); - -host.Run(); diff --git a/samples/MrGameEng.Sample/SampleShared.cs b/samples/MrGameEng.Sample/SampleShared.cs deleted file mode 100644 index 43b0853..0000000 --- a/samples/MrGameEng.Sample/SampleShared.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Friflo.Engine.ECS; -using Microsoft.Xna.Framework; -using MrGameEng.Graphics; - -namespace MrGameEng.Sample; - -/// Действия игрока, привязанные к клавишам через ActionMap. -public enum SampleAction -{ - MoveLeft, - MoveRight, - MoveUp, - MoveDown, - Jump, - Pause, - ToggleMusic, - SwitchScene, -} - -/// Скорость для движущихся сущностей сэмпла. -public struct Velocity : IComponent -{ - public Vector2 Value; -} - -/// Слои рендера сэмпла; регистрируются один раз на общий Renderer2D. -public static class SampleLayers -{ - public static LayerId Actors { get; private set; } - - public static LayerId Ui { get; private set; } - - private static bool _registered; - - public static void EnsureRegistered(Renderer2D renderer) - { - if (_registered) - { - return; - } - - Actors = renderer.Layers.Register("Actors", LayerSpace.World, LayerSortMode.YSort); - Ui = renderer.Layers.Register("UI", LayerSpace.Screen); - _registered = true; - } -} diff --git a/samples/MrGameEng.Sample/SampleSystems.cs b/samples/MrGameEng.Sample/SampleSystems.cs deleted file mode 100644 index 454391c..0000000 --- a/samples/MrGameEng.Sample/SampleSystems.cs +++ /dev/null @@ -1,219 +0,0 @@ -using Friflo.Engine.ECS; -using Friflo.Engine.ECS.Systems; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Audio; -using Microsoft.Xna.Framework.Input; -using MrGameEng.Audio; -using MrGameEng.Core; -using MrGameEng.Graphics; -using MrGameEng.Input; - -namespace MrGameEng.Sample; - -/// Управление игроком (WASD/стрелки) и прыжок-писк на пробел. -public sealed class PlayerControlSystem( - Entity player, ActionMap actions, AudioManager audio, SoundEffect beep, - MrGameEng.DevConsole.DevConsole console) : BaseSystem -{ - protected override void OnUpdateGroup() - { - if (console.IsOpen) - { - return; - } - - ref var transform = ref player.GetComponent(); - var move = new Vector2( - actions.GetAxis(SampleAction.MoveLeft, SampleAction.MoveRight), - actions.GetAxis(SampleAction.MoveUp, SampleAction.MoveDown)); - - if (move != Vector2.Zero) - { - move.Normalize(); - transform.Position += move * 260f * Tick.deltaTime; - } - - if (actions.IsPressed(SampleAction.Jump)) - { - audio.Play(beep); - } - } -} - -/// Камера следует за игроком; колесо — зум, Q/E — поворот. -public sealed class CameraControlSystem(Entity cameraEntity, Entity player, InputManager input) : BaseSystem -{ - protected override void OnUpdateGroup() - { - ref var camera = ref cameraEntity.GetComponent(); - var target = player.GetComponent().Position; - - camera.Position = Vector2.Lerp(camera.Position, target, Math.Min(1f, 6f * Tick.deltaTime)); - camera.Zoom = Math.Clamp(camera.Zoom * (1f + input.WheelDelta * 0.001f), 0.2f, 5f); - - var rotate = (input.IsKeyDown(Keys.E) ? 1f : 0f) - (input.IsKeyDown(Keys.Q) ? 1f : 0f); - camera.Rotation += rotate * 1.2f * Tick.deltaTime; - } -} - -/// -/// Отскок сущностей со скоростью от границ мира. На больших количествах работа режется -/// на сегменты по всем ядрам (чанк Friflo держит весь архетип — сам по себе он слишком крупный). -/// -public sealed class BounceSystem(RectF bounds) : QuerySystem -{ - private const int ParallelThreshold = 8192; - private const int SegmentSize = 8192; - - private readonly List<(Chunk Transforms, Chunk Velocities)> _chunks = []; - private readonly List<(int Chunk, int Start, int Length)> _segments = []; - - protected override void OnUpdate() - { - var delta = Tick.deltaTime; - _chunks.Clear(); - var total = 0; - foreach (var (transforms, velocities, _) in Query.Chunks) - { - _chunks.Add((transforms, velocities)); - total += transforms.Length; - } - - if (total < ParallelThreshold) - { - foreach (var (transforms, velocities) in _chunks) - { - Move(transforms, velocities, 0, transforms.Length, delta); - } - - return; - } - - _segments.Clear(); - for (var c = 0; c < _chunks.Count; c++) - { - var length = _chunks[c].Transforms.Length; - for (var start = 0; start < length; start += SegmentSize) - { - _segments.Add((c, start, Math.Min(SegmentSize, length - start))); - } - } - - Parallel.For(0, _segments.Count, i => - { - var (chunk, start, length) = _segments[i]; - Move(_chunks[chunk].Transforms, _chunks[chunk].Velocities, start, length, delta); - }); - } - - private void Move(Chunk transforms, Chunk velocities, int start, int length, float delta) - { - var t = transforms.Span.Slice(start, length); - var v = velocities.Span.Slice(start, length); - for (var i = 0; i < t.Length; i++) - { - ref var position = ref t[i].Position; - ref var velocity = ref v[i].Value; - position += velocity * delta; - - if (position.X < bounds.Left || position.X > bounds.Right) - { - velocity.X = -velocity.X; - position.X = Math.Clamp(position.X, bounds.Left, bounds.Right); - } - - if (position.Y < bounds.Top || position.Y > bounds.Bottom) - { - velocity.Y = -velocity.Y; - position.Y = Math.Clamp(position.Y, bounds.Top, bounds.Bottom); - } - } - } -} - -/// Пауза (P), музыка (M), переключение сцены с переходом (Tab). -public sealed class SceneHotkeysSystem( - EngineContext context, ActionMap actions, Func nextScene, Transition transition) : BaseSystem -{ - protected override void OnUpdateGroup() - { - if (context.Services.Get().IsOpen) - { - return; - } - - if (actions.IsPressed(SampleAction.Pause)) - { - context.Clock.TimeScale = context.Clock.TimeScale > 0f ? 0f : 1f; - } - - if (actions.IsPressed(SampleAction.ToggleMusic)) - { - var music = context.Services.Get().Music; - if (music.IsPlaying) - { - music.Pause(); - } - else - { - music.Resume(); - } - } - - if (actions.IsPressed(SampleAction.SwitchScene) && !context.Scenes.IsTransitioning) - { - context.Scenes.Switch(nextScene(), transition); - } - } -} - -/// 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; - - protected override void OnUpdateGroup() - { - _accumulated += context.Clock.UnscaledDeltaTime; - _frames++; - if (_accumulated < 0.25f) - { - return; - } - - var fps = _frames / _accumulated; - _accumulated = 0f; - _frames = 0; - 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}\n" + - $"submit {renderer.SubmitMs:F2} | sort {renderer.SortMs:F2} | build {renderer.BuildMs:F2} | " + - $"upload {renderer.UploadMs:F2} | draw {renderer.DrawMs:F2} (ms)"; - } - } -} - -/// Общая настройка ввода для сцен сэмпла. -public static class SampleInput -{ - public static ActionMap CreateActions(InputManager input) => - new ActionMap(input) - .Bind(SampleAction.MoveLeft, Keys.A) - .Bind(SampleAction.MoveLeft, Keys.Left) - .Bind(SampleAction.MoveRight, Keys.D) - .Bind(SampleAction.MoveRight, Keys.Right) - .Bind(SampleAction.MoveUp, Keys.W) - .Bind(SampleAction.MoveUp, Keys.Up) - .Bind(SampleAction.MoveDown, Keys.S) - .Bind(SampleAction.MoveDown, Keys.Down) - .Bind(SampleAction.Jump, Keys.Space) - .Bind(SampleAction.Pause, Keys.P) - .Bind(SampleAction.ToggleMusic, Keys.M) - .Bind(SampleAction.SwitchScene, Keys.Tab); -} diff --git a/samples/MrGameEng.Sample/Scenes/MainScene.cs b/samples/MrGameEng.Sample/Scenes/MainScene.cs deleted file mode 100644 index 3513e16..0000000 --- a/samples/MrGameEng.Sample/Scenes/MainScene.cs +++ /dev/null @@ -1,200 +0,0 @@ -using Friflo.Engine.ECS; -using Microsoft.Xna.Framework; -using MrGameEng.Assets; -using MrGameEng.Atlases; -using MrGameEng.Audio; -using MrGameEng.Core; -using MrGameEng.DevConsole; -using MrGameEng.Graphics; -using MrGameEng.Input; -using MrGameEng.Tilemaps; -using MrGameEng.UI; -using Myra.Graphics2D.UI; - -namespace MrGameEng.Sample.Scenes; - -/// -/// Интерактивная демо-сцена: ассеты через сгенерированные хендлы, анимация, слои -/// (мир + Y-sort + screen-space HUD), камера с зумом/поворотом, ввод, звук и музыка. -/// WASD — игрок, колесо — зум, Q/E — поворот, Space — звук, P — пауза, M — музыка, Tab — стресс-сцена. -/// -public sealed class MainScene : Scene -{ - private const int DecorCount = 1500; - private static readonly RectF WorldBounds = new(-2000f, -2000f, 4000f, 4000f); - - protected override void OnLoad() - { - var assets = Context.Services.GetOrDefault() ?? Context.UseAssets(); - var audio = Context.Services.GetOrDefault() ?? Context.UseAudio(); - var input = this.UseInput(); - var actions = SampleInput.CreateActions(input); - this.UseSpriteAnimation(); - - var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }); - SampleLayers.EnsureRegistered(renderer); - this.UseTilemaps(); - - var playerTexture = assets.Load(GameAssets.Textures.Player); - var shapesTexture = assets.Load(GameAssets.Textures.Shapes); - var beep = assets.Load(GameAssets.Sounds.Beep); - - // Текстурный атлас: Assets/Atlases собран утилитой MrGameEng.AtlasTool из Assets/Textures - // (см. README). Регионы адресуются исходным путём без расширения и батчатся в один draw call. - Context.UseTextureAtlases(); - var atlas = assets.Load(GameAssets.Atlases.Textures); - - var shapeRegions = new[] - { - new Texture2DRegion(shapesTexture, new Rectangle(0, 0, 32, 32)), - new Texture2DRegion(shapesTexture, new Rectangle(32, 0, 32, 32)), - new Texture2DRegion(shapesTexture, new Rectangle(0, 32, 32, 32)), - new Texture2DRegion(shapesTexture, new Rectangle(32, 32, 32, 32)), - }; - - // Тайловая площадка под стартовой зоной: шахматный пол, строится кодом из TileSet/TileGrid. - // Видимые клетки сабмитятся каждый кадр; Depth -10 кладёт пол под декорации. - var floorTiles = new TileSet(); - var floorA = floorTiles.Add(shapeRegions[0], new Color(70, 75, 95)); - var floorB = floorTiles.Add(shapeRegions[2], new Color(55, 60, 78)); - var floorGrid = new TileGrid(24, 14); - for (var y = 0; y < floorGrid.Height; y++) - { - for (var x = 0; x < floorGrid.Width; x++) - { - floorGrid[x, y] = (x + y) % 2 == 0 ? floorA : floorB; - } - } - - Store.CreateEntity(new Tilemap(floorGrid, floorTiles, tileSize: 48f) - { - Origin = new Vector2(-24 * 24f, -14 * 24f), - Depth = -10f, - }); - - // Декорации по всему миру: уезжаешь камерой — попадают под culling (см. заголовок окна). - var random = new Random(42); - for (var i = 0; i < DecorCount; i++) - { - var sprite = new Sprite(shapeRegions[random.Next(shapeRegions.Length)]); - sprite.CenterOrigin(); - sprite.Color = Color.White * 0.35f; - Store.CreateEntity( - new Transform2D( - new Vector2( - random.NextSingle() * WorldBounds.Width + WorldBounds.Left, - random.NextSingle() * WorldBounds.Height + WorldBounds.Top), - rotation: random.NextSingle() * MathF.Tau, - scale: new Vector2(0.5f + random.NextSingle())), - sprite); - } - - // Бродячие "существа" на Y-sort слое: кто ниже на экране — тот ближе. - for (var i = 0; i < 200; i++) - { - var sprite = new Sprite(shapeRegions[random.Next(shapeRegions.Length)], SampleLayers.Actors); - sprite.CenterOrigin(); - var angle = random.NextSingle() * MathF.Tau; - Store.CreateEntity( - new Transform2D(new Vector2(random.Next(-600, 600), random.Next(-400, 400))), - sprite, - new Velocity { Value = new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * (40f + random.Next(80)) }); - } - - // Игрок: анимированный спрайт (2 кадра из атласа player.png), Y-sort слой. - var playerSprite = new Sprite(new Texture2DRegion(playerTexture, new Rectangle(0, 0, 32, 32)), SampleLayers.Actors); - playerSprite.CenterOrigin(); - var blink = new SpriteAnimationClip( - [ - new Texture2DRegion(playerTexture, new Rectangle(0, 0, 32, 32)), - new Texture2DRegion(playerTexture, new Rectangle(32, 0, 32, 32)), - ], framesPerSecond: 3f); - - var player = Store.CreateEntity( - new Transform2D(Vector2.Zero, scale: new Vector2(2f)), - playerSprite, - new SpriteAnimator(blink)); - - // Пара спрайтов из атласа рядом со стартом игрока — вся пара рисуется одним draw call. - var atlasShowcase = new[] { ("player", -80f), ("shapes", 80f) }; - foreach (var (key, offsetX) in atlasShowcase) - { - var sprite = new Sprite(atlas.GetRegion(key), SampleLayers.Actors); - sprite.CenterOrigin(); - Store.CreateEntity(new Transform2D(new Vector2(offsetX, -120f)), sprite); - } - - var camera = Store.CreateEntity(new Camera(Vector2.Zero, zoom: 1f, bounds: WorldBounds)); - - // HUD: золотой квадрат в углу на screen-space слое — не двигается с камерой. - 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; - - // Консоль разработчика (клавиша `) — последней, чтобы рисовалась поверх UI. - var console = this.UseDevConsole(); - console.Register("stress", "switch to the stress scene", (_, _) => - { - if (!Context.Scenes.IsTransitioning) - { - Context.Scenes.Switch(new StressScene(), Transition.Fade(0.8f)); - } - }); - console.Register("main", "switch to the main scene", (_, _) => - { - if (!Context.Scenes.IsTransitioning) - { - Context.Scenes.Switch(new MainScene(), Transition.Fade(0.8f)); - } - }); - console.Register("beep", "play the beep sound", (_, _) => audio.Play(beep)); - console.Register("path", "switch to the pathfinding & collisions demo", (_, _) => - { - if (!Context.Scenes.IsTransitioning) - { - Context.Scenes.Switch(new PathfindingScene(), Transition.Fade(0.6f)); - } - }); - console.Register("atlas", "list texture atlas regions", (c, _) => - { - c.WriteLine($"atlas '{atlas.Name}': {atlas.Pages.Count} page(s), {atlas.Regions.Count} region(s)"); - foreach (var (key, region) in atlas.Regions.OrderBy(r => r.Key, StringComparer.Ordinal)) - { - c.WriteLine($" {key}: {region.Bounds.Width}x{region.Bounds.Height} at ({region.Bounds.X},{region.Bounds.Y})"); - } - }); - - UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep, console)); - 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 StatsSystem(Context, renderer, "Main", statsLabel)); - - var music = audio.Music; - if (!music.IsPlaying) - { - music.Volume = 0.4f; - music.Play(assets.Load(GameAssets.Music.Theme)); - } - } -} diff --git a/samples/MrGameEng.Sample/Scenes/PathfindingScene.cs b/samples/MrGameEng.Sample/Scenes/PathfindingScene.cs deleted file mode 100644 index 3baa2c3..0000000 --- a/samples/MrGameEng.Sample/Scenes/PathfindingScene.cs +++ /dev/null @@ -1,284 +0,0 @@ -using Friflo.Engine.ECS; -using Friflo.Engine.ECS.Systems; -using Microsoft.Xna.Framework; -using MrGameEng.Assets; -using MrGameEng.Collisions; -using MrGameEng.Core; -using MrGameEng.DevConsole; -using MrGameEng.Graphics; -using MrGameEng.Input; -using MrGameEng.Pathfinding; -using MrGameEng.UI; - -namespace MrGameEng.Sample.Scenes; - -/// -/// Демо поиска пути и коллизий: ЛКМ ставит цель — толпа агентов стекается к ней по -/// flow field, жёлтым подсвечен A*-путь из левого верхнего угла, столкнувшиеся агенты -/// вспыхивают красным. Tab/'main' — назад. -/// -public sealed class PathfindingScene : Scene -{ - private const int CellsX = 64; - private const int CellsY = 36; - private const int CellSize = 20; - private const int AgentCount = 250; - private static readonly Point AStarStart = new(1, 1); - - private readonly WallGrid _grid = new(CellsX, CellsY, seed: 99); - private readonly FlowField _field = new(); - private readonly List _path = []; - private readonly List _pathMarkers = []; - private FlowFieldBuilder _builder = null!; - private GridPathfinder _pathfinder = null!; - private Texture2DRegion _white = null!; - private Entity _goalMarker; - - /// Состояние агента: время красной вспышки после столкновения. - private struct AgentState : IComponent - { - public float Flash; - } - - protected override void OnLoad() - { - var assets = Context.Services.GetOrDefault() ?? Context.UseAssets(); - var input = this.UseInput(); - var actions = SampleInput.CreateActions(input); - var renderer = this.UseRenderer2D(new Renderer2DOptions { VirtualResolution = new Point(1280, 720) }); - SampleLayers.EnsureRegistered(renderer); - - var shapesTexture = assets.Load(GameAssets.Textures.Shapes); - _white = new Texture2DRegion(shapesTexture, new Rectangle(40, 41, 14, 14)); // жёлтый квадрат как "белый" регион - _builder = new FlowFieldBuilder(_grid); - _pathfinder = new GridPathfinder(_grid); - - // Стены. - for (var y = 0; y < CellsY; y++) - { - for (var x = 0; x < CellsX; x++) - { - if (!_grid.IsPassable(x, y)) - { - var sprite = new Sprite(_white) { Color = new Color(70, 74, 84), Depth = 0f }; - Store.CreateEntity(CellTransform(x, y), sprite); - } - } - } - - // Агенты на проходимых клетках. - var random = new Random(7); - for (var i = 0; i < AgentCount; i++) - { - var cell = RandomPassableCell(random); - var sprite = new Sprite(_white) { Color = new Color(120, 180, 255), Depth = 1f }; - sprite.CenterOrigin(); - Store.CreateEntity( - new Transform2D(CellCenter(cell), scale: new Vector2(0.45f)), - sprite, - new AgentState(), - Collider.Circle(4f)); - } - - // Маркер цели. - var goalSprite = new Sprite(_white) { Color = new Color(80, 220, 120), Depth = 0.6f }; - _goalMarker = Store.CreateEntity(CellTransform(0, 0), goalSprite); - - var world = this.UseCollisions(cellSize: 16f); - - UpdateSystems.Add(new ClickTargetSystem(this, input, renderer)); - UpdateSystems.Add(new AgentSteerSystem(this)); - UpdateSystems.Add(new CollisionFlashSystem(world)); - UpdateSystems.Add(new FlashDecaySystem()); - UpdateSystems.Add(new SceneHotkeysSystem(Context, actions, () => new MainScene(), Transition.Fade(0.6f))); - - var desktop = this.UseUI(); - var label = new Myra.Graphics2D.UI.Label - { - Text = "ЛКМ — цель: толпа идёт по flow field, жёлтое — путь A*.\nСтолкновения агентов подсвечиваются красным. Tab — назад.", - }; - desktop.Root = new Myra.Graphics2D.UI.VerticalStackPanel { Left = 12, Top = 12 }; - ((Myra.Graphics2D.UI.VerticalStackPanel)desktop.Root).Widgets.Add(label); - - this.UseDevConsole(); - - SetGoal(new Point(CellsX / 2, CellsY / 2)); - } - - private void SetGoal(Point goal) - { - if (!_grid.IsPassable(goal.X, goal.Y)) - { - return; - } - - _builder.Build([goal], _field); - _goalMarker.GetComponent().Position = CellTopLeft(goal); - - foreach (var marker in _pathMarkers) - { - marker.DeleteEntity(); - } - - _pathMarkers.Clear(); - if (_pathfinder.FindPath(AStarStart, goal, _path)) - { - foreach (var cell in _path) - { - var sprite = new Sprite(_white) { Color = new Color(240, 210, 60) * 0.55f, Depth = 0.5f }; - _pathMarkers.Add(Store.CreateEntity(CellTransform(cell.X, cell.Y), sprite)); - } - } - } - - private static Transform2D CellTransform(int x, int y) => - new(CellTopLeft(new Point(x, y)), scale: new Vector2(CellSize / 14f)); - - private static Vector2 CellTopLeft(Point cell) => new(cell.X * CellSize, cell.Y * CellSize); - - private static Vector2 CellCenter(Point cell) => - new(cell.X * CellSize + CellSize / 2f, cell.Y * CellSize + CellSize / 2f); - - private Point RandomPassableCell(Random random) - { - while (true) - { - var cell = new Point(random.Next(CellsX), random.Next(CellsY)); - if (_grid.IsPassable(cell.X, cell.Y)) - { - return cell; - } - } - } - - /// Случайные прямоугольные стены; старт A* и центр всегда свободны. - private sealed class WallGrid : IPathGrid - { - private readonly bool[,] _walls; - - public WallGrid(int width, int height, int seed) - { - Width = width; - Height = height; - _walls = new bool[width, height]; - var random = new Random(seed); - for (var i = 0; i < 70; i++) - { - var w = random.Next(1, 7); - var h = random.Next(1, 7); - var x0 = random.Next(width - w); - var y0 = random.Next(height - h); - for (var y = y0; y < y0 + h; y++) - { - for (var x = x0; x < x0 + w; x++) - { - _walls[x, y] = true; - } - } - } - - for (var y = 0; y < 3; y++) - { - for (var x = 0; x < 3; x++) - { - _walls[AStarStart.X + x, AStarStart.Y + y] = false; - _walls[width / 2 + x - 1, height / 2 + y - 1] = false; - } - } - } - - public int Width { get; } - - public int Height { get; } - - public bool IsPassable(int x, int y) => !_walls[x, y]; - - public float Cost(int x, int y) => 1f; - } - - private sealed class ClickTargetSystem(PathfindingScene scene, InputManager input, Renderer2D renderer) : BaseSystem - { - protected override void OnUpdateGroup() - { - if (!input.IsMousePressed(MouseButton.Left)) - { - return; - } - - var world = renderer.ScreenToWorld(input.MousePosition.ToVector2()); - var cell = new Point((int)(world.X / CellSize), (int)(world.Y / CellSize)); - if (cell.X >= 0 && cell.X < CellsX && cell.Y >= 0 && cell.Y < CellsY) - { - scene.SetGoal(cell); - } - } - } - - private sealed class AgentSteerSystem(PathfindingScene scene) : QuerySystem - { - private const float Speed = 70f; - - protected override void OnUpdate() - { - var delta = Tick.deltaTime; - var field = scene._field; - foreach (var (transforms, _, _) in Query.Chunks) - { - var t = transforms.Span; - for (var i = 0; i < t.Length; i++) - { - ref var position = ref t[i].Position; - var cx = Math.Clamp((int)(position.X / CellSize), 0, CellsX - 1); - var cy = Math.Clamp((int)(position.Y / CellSize), 0, CellsY - 1); - if (!field.IsReachable(cx, cy) || field.DistanceAt(cx, cy) <= 0.6f) - { - continue; // недостижимо или уже у цели - } - - position += field.DirectionAt(cx, cy) * (Speed * delta); - } - } - } - } - - private sealed class CollisionFlashSystem(CollisionWorld world) : BaseSystem - { - protected override void OnUpdateGroup() - { - foreach (var pair in world.Pairs) - { - Flash(pair.A); - Flash(pair.B); - } - } - - private static void Flash(Entity entity) - { - if (entity.HasComponent()) - { - entity.GetComponent().Flash = 0.25f; - } - } - } - - private sealed class FlashDecaySystem : QuerySystem - { - private static readonly Color Calm = new(120, 180, 255); - private static readonly Color Hit = new(235, 70, 60); - - protected override void OnUpdate() - { - var delta = Tick.deltaTime; - foreach (var (states, sprites, _) in Query.Chunks) - { - var a = states.Span; - var s = sprites.Span; - for (var i = 0; i < a.Length; i++) - { - a[i].Flash = Math.Max(0f, a[i].Flash - delta); - s[i].Color = a[i].Flash > 0f ? Hit : Calm; - } - } - } - } -} diff --git a/samples/MrGameEng.Sample/Scenes/StressScene.cs b/samples/MrGameEng.Sample/Scenes/StressScene.cs deleted file mode 100644 index d28a282..0000000 --- a/samples/MrGameEng.Sample/Scenes/StressScene.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Friflo.Engine.ECS; -using Microsoft.Xna.Framework; -using MrGameEng.Assets; -using MrGameEng.Core; -using MrGameEng.DevConsole; -using MrGameEng.Graphics; -using MrGameEng.Input; -using MrGameEng.UI; - -namespace MrGameEng.Sample.Scenes; - -/// -/// Стресс-сцена: 100 000 спрайтов скачут в мире 4000×4000. Колесо — зум (чем дальше, -/// тем больше спрайтов в кадре), Tab — обратно в основную сцену. Цель: 60 FPS. -/// -public sealed class StressScene : Scene -{ - private const int SpriteCount = 100_000; - private static readonly RectF WorldBounds = new(-2000f, -2000f, 4000f, 4000f); - - protected override void OnLoad() - { - var assets = Context.Services.Get(); - var input = this.UseInput(); - var actions = SampleInput.CreateActions(input); - - var renderer = this.UseRenderer2D(); - SampleLayers.EnsureRegistered(renderer); - - var shapesTexture = assets.Load(GameAssets.Textures.Shapes); - var regions = new[] - { - new Texture2DRegion(shapesTexture, new Rectangle(0, 0, 32, 32)), - new Texture2DRegion(shapesTexture, new Rectangle(32, 0, 32, 32)), - new Texture2DRegion(shapesTexture, new Rectangle(0, 32, 32, 32)), - new Texture2DRegion(shapesTexture, new Rectangle(32, 32, 32, 32)), - }; - - var random = new Random(7); - for (var i = 0; i < SpriteCount; i++) - { - var sprite = new Sprite(regions[random.Next(regions.Length)]); - sprite.CenterOrigin(); - var angle = random.NextSingle() * MathF.Tau; - Store.CreateEntity( - new Transform2D( - new Vector2( - random.NextSingle() * WorldBounds.Width + WorldBounds.Left, - random.NextSingle() * WorldBounds.Height + WorldBounds.Top), - scale: new Vector2(0.4f + random.NextSingle() * 0.6f)), - sprite, - new Velocity { Value = new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * (30f + random.Next(150)) }); - } - - 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; - - this.UseDevConsole(); // поверх UI - - 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 StatsSystem(Context, renderer, $"Stress {SpriteCount:N0}", statsLabel)); - } - - /// Только зум колесом — чтобы регулировать число видимых спрайтов. - private sealed class StressCameraSystem(Entity cameraEntity, InputManager input) - : Friflo.Engine.ECS.Systems.BaseSystem - { - protected override void OnUpdateGroup() - { - ref var camera = ref cameraEntity.GetComponent(); - camera.Zoom = Math.Clamp(camera.Zoom * (1f + input.WheelDelta * 0.001f), 0.05f, 5f); - } - } -}