Remove the sample project: LittleSim is the engine showcase now
CI / build-test (push) Failing after 1m4s

The MrGameEng.Sample demo game is deleted along with its assets; the
LittleSim god-sim (which vendors this engine as a submodule) takes over
as the living showcase where every engine feature is demonstrated.
CLAUDE.md, README and docs updated; the demonstration rule now points
to LittleSim, and the stress-scene perf figure is kept as a historical
reference measurement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 15:25:02 +03:00
co-authored by Claude Fable 5
parent a395e58458
commit 19ab3cbbc0
18 changed files with 27 additions and 947 deletions
+7 -4
View File
@@ -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<T>`), never string paths.
- New engine functionality goes into the matching module, or a new
`MrGameEng.<Area>` 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
-17
View File
@@ -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}
+4
View File
@@ -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).
+14 -7
View File
@@ -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/`).
## Зафиксированные версии
| Зависимость | Версия | Назначение |
+2 -1
View File
@@ -3,7 +3,8 @@
Базовый движок (ядро, рендер, ассеты с кодогенерацией, ввод, звук, демо) реализован —
текущие возможности описаны в [architecture.md](architecture.md). Здесь — только планы.
Пункт считается завершённым, когда покрыт тестами и показан в `MrGameEng.Sample`;
Пункт считается завершённым, когда покрыт тестами и показан в игре
[LittleSim](https://gitea.hsrv.site/mrleo1nid/LittleSim) — витрине движка;
после завершения он убирается отсюда и фиксируется в architecture.md.
## Ближайшее
@@ -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
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
<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.Atlases\MrGameEng.Atlases.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Pathfinding\MrGameEng.Pathfinding.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Collisions\MrGameEng.Collisions.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.UI\MrGameEng.UI.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Assets\**\*.*" />
<None Include="Assets\**\*.*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
-16
View File
@@ -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();
-46
View File
@@ -1,46 +0,0 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Sample;
/// <summary>Действия игрока, привязанные к клавишам через ActionMap.</summary>
public enum SampleAction
{
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
Jump,
Pause,
ToggleMusic,
SwitchScene,
}
/// <summary>Скорость для движущихся сущностей сэмпла.</summary>
public struct Velocity : IComponent
{
public Vector2 Value;
}
/// <summary>Слои рендера сэмпла; регистрируются один раз на общий Renderer2D.</summary>
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;
}
}
-219
View File
@@ -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;
/// <summary>Управление игроком (WASD/стрелки) и прыжок-писк на пробел.</summary>
public sealed class PlayerControlSystem(
Entity player, ActionMap<SampleAction> actions, AudioManager audio, SoundEffect beep,
MrGameEng.DevConsole.DevConsole console) : BaseSystem
{
protected override void OnUpdateGroup()
{
if (console.IsOpen)
{
return;
}
ref var transform = ref player.GetComponent<Transform2D>();
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);
}
}
}
/// <summary>Камера следует за игроком; колесо — зум, Q/E — поворот.</summary>
public sealed class CameraControlSystem(Entity cameraEntity, Entity player, InputManager input) : BaseSystem
{
protected override void OnUpdateGroup()
{
ref var camera = ref cameraEntity.GetComponent<Camera>();
var target = player.GetComponent<Transform2D>().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;
}
}
/// <summary>
/// Отскок сущностей со скоростью от границ мира. На больших количествах работа режется
/// на сегменты по всем ядрам (чанк Friflo держит весь архетип — сам по себе он слишком крупный).
/// </summary>
public sealed class BounceSystem(RectF bounds) : QuerySystem<Transform2D, Velocity>
{
private const int ParallelThreshold = 8192;
private const int SegmentSize = 8192;
private readonly List<(Chunk<Transform2D> Transforms, Chunk<Velocity> 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<Transform2D> transforms, Chunk<Velocity> 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);
}
}
}
}
/// <summary>Пауза (P), музыка (M), переключение сцены с переходом (Tab).</summary>
public sealed class SceneHotkeysSystem(
EngineContext context, ActionMap<SampleAction> actions, Func<Scene> nextScene, Transition transition) : BaseSystem
{
protected override void OnUpdateGroup()
{
if (context.Services.Get<MrGameEng.DevConsole.DevConsole>().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<AudioManager>().Music;
if (music.IsPlaying)
{
music.Pause();
}
else
{
music.Resume();
}
}
if (actions.IsPressed(SampleAction.SwitchScene) && !context.Scenes.IsTransitioning)
{
context.Scenes.Switch(nextScene(), transition);
}
}
}
/// <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;
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<GameWindow>().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)";
}
}
}
/// <summary>Общая настройка ввода для сцен сэмпла.</summary>
public static class SampleInput
{
public static ActionMap<SampleAction> CreateActions(InputManager input) =>
new ActionMap<SampleAction>(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);
}
@@ -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;
/// <summary>
/// Интерактивная демо-сцена: ассеты через сгенерированные хендлы, анимация, слои
/// (мир + Y-sort + screen-space HUD), камера с зумом/поворотом, ввод, звук и музыка.
/// WASD — игрок, колесо — зум, Q/E — поворот, Space — звук, P — пауза, M — музыка, Tab — стресс-сцена.
/// </summary>
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<AssetManager>() ?? Context.UseAssets();
var audio = Context.Services.GetOrDefault<AudioManager>() ?? 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));
}
}
}
@@ -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;
/// <summary>
/// Демо поиска пути и коллизий: ЛКМ ставит цель — толпа агентов стекается к ней по
/// flow field, жёлтым подсвечен A*-путь из левого верхнего угла, столкнувшиеся агенты
/// вспыхивают красным. Tab/'main' — назад.
/// </summary>
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<Point> _path = [];
private readonly List<Entity> _pathMarkers = [];
private FlowFieldBuilder _builder = null!;
private GridPathfinder _pathfinder = null!;
private Texture2DRegion _white = null!;
private Entity _goalMarker;
/// <summary>Состояние агента: время красной вспышки после столкновения.</summary>
private struct AgentState : IComponent
{
public float Flash;
}
protected override void OnLoad()
{
var assets = Context.Services.GetOrDefault<AssetManager>() ?? 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<Transform2D>().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;
}
}
}
/// <summary>Случайные прямоугольные стены; старт A* и центр всегда свободны.</summary>
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<Transform2D, AgentState>
{
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<AgentState>())
{
entity.GetComponent<AgentState>().Flash = 0.25f;
}
}
}
private sealed class FlashDecaySystem : QuerySystem<AgentState, Sprite>
{
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;
}
}
}
}
}
@@ -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;
/// <summary>
/// Стресс-сцена: 100 000 спрайтов скачут в мире 4000×4000. Колесо — зум (чем дальше,
/// тем больше спрайтов в кадре), Tab — обратно в основную сцену. Цель: 60 FPS.
/// </summary>
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<AssetManager>();
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));
}
/// <summary>Только зум колесом — чтобы регулировать число видимых спрайтов.</summary>
private sealed class StressCameraSystem(Entity cameraEntity, InputManager input)
: Friflo.Engine.ECS.Systems.BaseSystem
{
protected override void OnUpdateGroup()
{
ref var camera = ref cameraEntity.GetComponent<Camera>();
camera.Zoom = Math.Clamp(camera.Zoom * (1f + input.WheelDelta * 0.001f), 0.05f, 5f);
}
}
}