CI / build-test (push) Failing after 1m5s
Pathfinding (depends on Core only): GridPathfinder with A* (octile/
Manhattan heuristic), Dijkstra and BFS over a game-implemented
IPathGrid; 4/8 connectivity, diagonals never cut corners. FlowField +
FlowFieldBuilder (multi-source Dijkstra) give crowds O(1) steering per
agent per frame. All buffers are grid-sized once and invalidated by a
generation stamp - repeated queries allocate nothing and clear nothing.
Collisions (Graphics exception: Transform2D, RectF): Collider component
(circle/AABB, offset, two-way layer masks), CollisionWorld - a uniform
spatial hash on flat arrays rebuilt from scratch each tick (O(n) for
movers, zero alloc after warm-up, deterministic pair order), pair
collection, QueryAabb and closest-hit Raycast. scene.UseCollisions()
registers CollisionSystem after movement systems.
30 new tests (string-map mazes, cost weighting, corner cutting, flow
descent; pair/mask/query/raycast). Sample gains a PathfindingScene
('path' console command): click to set the goal, 250 agents follow the
flow field, the A* path is highlighted, colliding agents flash red.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
344 lines
27 KiB
Markdown
344 lines
27 KiB
Markdown
# Архитектура mrgameeng
|
||
|
||
## Обзор
|
||
|
||
**mrgameeng** — кроссплатформенный 2D игровой движок на базе MonoGame 3.8.4
|
||
(DesktopGL: Windows, Linux, macOS), .NET 8, C#.
|
||
|
||
Ключевое архитектурное решение — **ECS-first**: движок плотно интегрирует
|
||
[Friflo.Engine.ECS](https://github.com/friflo/Friflo.Engine.ECS) (v3.6) как ядро
|
||
модели объектов. Это самая производительная ECS-библиотека в .NET по результатам
|
||
[Ecs.CSharp.Benchmark](https://github.com/Doraku/Ecs.CSharp.Benchmark): ноль аллокаций
|
||
в горячих путях, archetype-хранилище, встроенные системы, события и индексы.
|
||
|
||
## Принципы
|
||
|
||
1. **Данные отдельно от логики.** Всё состояние игры — в компонентах
|
||
(`struct : IComponent`), вся логика — в системах (`QuerySystem<T>`).
|
||
Никаких `Update()` у игровых объектов и иерархий наследования сущностей.
|
||
2. **Ноль аллокаций в кадре.** Системы, выполняющиеся каждый кадр, не должны
|
||
аллоцировать память. Проверяется бенчмарками и code review.
|
||
3. **Модульность.** Движок разбит на библиотеки по функциональным областям.
|
||
Игра подключает только то, что использует.
|
||
4. **Всё демонстрируется.** Каждая публичная возможность движка показана
|
||
в `MrGameEng.Sample` и покрыта тестами (где логика тестируема без GPU).
|
||
|
||
## Модули
|
||
|
||
| Библиотека | Ответственность |
|
||
|------------------------------|------------------------------------------------------------|
|
||
| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время, жизненный цикл |
|
||
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои |
|
||
| `MrGameEng.Input` | Абстракция ввода: клавиатура, мышь, геймпад; action maps |
|
||
| `MrGameEng.Audio` | Звуковые эффекты и музыка |
|
||
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
|
||
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
|
||
| `MrGameEng.Atlases` | Текстурные атласы: офлайн-сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`); CLI — `tools/MrGameEng.AtlasTool` |
|
||
| `MrGameEng.Tilemaps` | Тайловые карты, создаваемые кодом: `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер |
|
||
| `MrGameEng.Pathfinding` | Поиск пути по гриду: A*, Dijkstra, BFS и flow fields для толп; чистая логика без зависимостей |
|
||
| `MrGameEng.Collisions` | Определение столкновений: компонент `Collider`, spatial hash, пары/запросы/raycast |
|
||
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
|
||
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
|
||
|
||
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
|
||
|
||
### Правило зависимостей
|
||
|
||
```
|
||
MrGameEng.Graphics ─┐
|
||
MrGameEng.Input ─┤
|
||
MrGameEng.Audio ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
|
||
MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
|
||
```
|
||
|
||
Модули зависят **только от `Core`** и никогда друг от друга. `Core` зависит только
|
||
от MonoGame и Friflo. Если двум модулям нужен общий тип — он переезжает в `Core`.
|
||
|
||
Документированные исключения: `MrGameEng.Atlases` зависит от `Graphics`
|
||
(выдаёт `Texture2DRegion`) и от `Assets` (регистрирует загрузчик в `AssetManager`) —
|
||
атлас по своей природе склейка этих двух областей; `MrGameEng.Tilemaps` зависит от
|
||
`Graphics` (рисует регионы через рендерер и слои).
|
||
|
||
`MrGameEng.Assets.Generator` — особый случай: это анализатор (netstandard2.0),
|
||
он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит
|
||
от других модулей движка.
|
||
|
||
## Консоль разработчика
|
||
|
||
`MrGameEng.DevConsole` — ингейм-консоль (тогглинг клавишей <code>`</code>):
|
||
|
||
- Перехватывает всё, что пишется через статический `MrGameEng.Core.Log`
|
||
(Debug/Info/Warning/Error; движок логирует ключевые события сам).
|
||
- Команды: `console.Register(name, description, handler)`; встроенные —
|
||
`help`, `clear`, `echo`, `timescale`, `close`, `quit`; игра добавляет свои.
|
||
- История ввода (↑/↓), автодополнение по Tab, скролл (PageUp/PageDown/End/колесо).
|
||
- Производительность: кольцевой буфер строк (2048), отрисовка — один Label,
|
||
текст которого перестраивается только при изменении (`Revision`); при закрытой
|
||
консоли — ноль работы в кадре.
|
||
- Ядро (`DevConsole`) — чистая логика без Myra, полностью покрыта headless-тестами;
|
||
Myra-обвязка отдельно. `scene.UseDevConsole()` — последним в `OnLoad`, чтобы
|
||
консоль рисовалась поверх UI. Сервис общий, живёт между сценами.
|
||
|
||
## 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/`
|
||
проекта игры как сырые файлы (копируются в output при сборке) и грузятся в рантайме:
|
||
|
||
| Тип | Форматы | Как грузим |
|
||
|----------------|-----------|---------------------------------------------------------|
|
||
| Текстуры | png, jpg | `Texture2D.FromFile` |
|
||
| Шрифты | ttf | FontStashSharp (растеризация в рантайме, динамический атлас) |
|
||
| Звуки | wav | `SoundEffect.FromFile` |
|
||
| Музыка | ogg | Стриминг через NVorbis + `DynamicSoundEffectInstance` |
|
||
| Шейдеры | fx | Компиляция `dotnet-mgfxc` на этапе сборки (MSBuild target), в рантайме грузится байткод `.mgfx` |
|
||
|
||
`AssetManager` (в `MrGameEng.Assets`) грузит ресурсы по типизированному хендлу
|
||
`AssetRef<T>` (путь + тип), кэширует по пути и владеет временем жизни (Dispose
|
||
при выгрузке сцены/игры).
|
||
|
||
### Кодогенератор хендлов
|
||
|
||
`MrGameEng.Assets.Generator` — Roslyn **incremental source generator**. Файлы из
|
||
`Assets/` передаются ему через `AdditionalFiles` (один glob в `.csproj` игры).
|
||
По дереву папок он генерирует статический класс с типизированными хендлами,
|
||
тип выводится из расширения файла:
|
||
|
||
```csharp
|
||
// Assets/Textures/player.png →
|
||
public static partial class GameAssets
|
||
{
|
||
public static class Textures
|
||
{
|
||
public static readonly AssetRef<Texture2D> Player = new("Textures/player.png");
|
||
}
|
||
}
|
||
|
||
// использование: Texture2D tex = assets.Load(GameAssets.Textures.Player);
|
||
```
|
||
|
||
Обращение к ресурсу по строковому пути в коде игры — запрещено соглашением;
|
||
строки существуют только внутри сгенерированного кода.
|
||
|
||
## Текстурные атласы
|
||
|
||
`MrGameEng.Atlases` превращает дерево отдельных картинок в атласы и грузит их в рантайме.
|
||
Спрайты с регионами одной страницы атласа батчер сливает в один draw call.
|
||
|
||
### Сборка (билд-тайм, без GPU)
|
||
|
||
`AtlasBuilder.Build(AtlasBuildOptions)` — чистый CPU (StbImageSharp/StbImageWriteSharp):
|
||
|
||
- Источник сканируется рекурсивно (png/jpg/jpeg/bmp); картинки группируются в атласы
|
||
по первым `GroupDepth` папкам относительного пути (0 — один атлас на всё).
|
||
- Упаковка — детерминированный shelf-packer (`ShelfPacker`): сортировка по высоте,
|
||
полки, страницы до `MaxPageSize`² (по умолчанию 2048), зазор `Padding` (2 px),
|
||
размер страницы подрезается до степени двойки; негабаритные картинки получают
|
||
отдельную страницу под себя.
|
||
- Выход: страницы `<Имя>.atlas.<N>.png` + метаданные `<Имя>.atlas` (JSON: страницы,
|
||
регионы с ключами и прямоугольниками). Ключ региона — путь от корня источника без
|
||
расширения (`Things/Pawn/Animal/Fox`).
|
||
- Инкрементальность: группа пересобирается только если изменились исходники, состав
|
||
файлов или параметры сборки; атласы исчезнувших групп удаляются из выходной папки.
|
||
|
||
CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <источник> <выход>
|
||
[--group-depth N] [--page-size N] [--padding N] [--root-name Имя] [--force]`.
|
||
|
||
### Загрузка (рантайм)
|
||
|
||
- `context.UseTextureAtlases()` регистрирует загрузчик `TextureAtlas` в `AssetManager`;
|
||
кодогенератор выдаёт хендлы `AssetRef<TextureAtlas>` для файлов `.atlas`
|
||
(страницы `*.atlas.N.png` собственных Texture2D-хендлов не получают).
|
||
- `TextureAtlas` владеет страницами (premultiplied alpha, как все текстуры движка)
|
||
и отдаёт регионы: `atlas.GetRegion("Things/Pawn/Animal/Fox")` → `Texture2DRegion`,
|
||
готовый для `Sprite`.
|
||
|
||
## Тайловые карты
|
||
|
||
`MrGameEng.Tilemaps` — тайловые карты, создаваемые **кодом** (загрузка Tiled — в бэклоге):
|
||
|
||
- `TileSet` — словарь тайлов: `Add(region, tint?)` возвращает id; id 0 зарезервирован
|
||
под «пусто». Регион + тинт позволяют строить тайлсеты и из текстур атласа,
|
||
и из тонированной белой текстуры.
|
||
- `TileGrid` — плотная сетка `ushort`-id с проверкой границ; заполняется генератором
|
||
мира, мутируется в рантайме (изменение видно со следующего кадра).
|
||
- Компонент `Tilemap` (`struct : IComponent`): грид + тайлсет + `Origin`, `TileSize`,
|
||
слой, `Depth`, общий тинт карты. Обычная сущность — карт может быть несколько
|
||
(земля, декор поверх).
|
||
- `scene.UseTilemaps()` (после `UseRenderer2D()`) вставляет `TilemapRenderSystem`
|
||
в Draw-фазу перед flush. Система считает видимый диапазон клеток по cull-rect
|
||
камеры (`TilemapMath.VisibleCells`) и сабмитит только его: стоимость кадра зависит
|
||
от экрана, а не от размера грида. Тайлы батчатся со спрайтами по обычному порядку
|
||
слой → depth → текстура: пол из одной текстуры атласа — один draw call.
|
||
|
||
## Поиск пути
|
||
|
||
`MrGameEng.Pathfinding` — алгоритмы поиска пути по гриду; зависит только от Core
|
||
и не владеет данными мира — игра реализует `IPathGrid` (Width/Height,
|
||
`IsPassable`, `Cost ≥ 1`) поверх своего рельефа или `TileGrid`.
|
||
|
||
- `GridPathfinder.FindPath(start, goal, path, algorithm)` — **A\*** (octile/Manhattan
|
||
эвристика), **Dijkstra** (с учётом цены клеток) и **BFS** (быстрейший для
|
||
равномерных гридов, цену игнорирует). Связность 4 или 8; диагонали никогда
|
||
не срезают углы.
|
||
- `FlowFieldBuilder.Build(goals, field)` — multi-source Dijkstra строит **flow field**:
|
||
дистанция + нормализованное направление на клетку. Толпа любого размера дальше
|
||
стоит O(1) на агента в кадр — основной инструмент для масс агентов (LittleSim).
|
||
- Оптимизация под ECS: все буферы созданы один раз под размер грида и
|
||
инвалидируются generation-штампом — повторные запросы не аллоцируют и не чистят
|
||
массивы. Один экземпляр на систему; результаты детерминированы.
|
||
|
||
## Коллизии
|
||
|
||
`MrGameEng.Collisions` — определение столкновений (без разрешения физики — она в бэклоге):
|
||
|
||
- Компонент `Collider` (`struct : IComponent`): круг или AABB (без вращения),
|
||
`Offset`, битовые маски `Layer`/`CollidesWith` (пара регистрируется, только если
|
||
маски согласны в обе стороны). Создание через `Collider.Circle(r)` / `Collider.Box(w,h)`.
|
||
- `CollisionWorld` — uniform spatial hash на плоских массивах (головы бакетов +
|
||
связные списки индексов), **перестраивается с нуля каждый тик** за O(n): для
|
||
движущихся сущностей это дешевле инкрементальных обновлений. Ноль аллокаций
|
||
после прогрева; порядок пар детерминирован (порядок чанков Friflo).
|
||
- `scene.UseCollisions(cellSize)` добавляет `CollisionSystem` — регистрируй её
|
||
**после** систем движения; системы, читающие `world.Pairs`, — после неё.
|
||
- Запросы для игровых систем: `Pairs` (пересекающиеся пары кадра),
|
||
`QueryAabb(rect, span)`, `Raycast(from, to, mask)` (ближайшее попадание).
|
||
- `cellSize` подбирается под типичный размер коллайдера; крупные коллайдеры
|
||
занимают несколько ячеек — корректность не страдает, страдает константа.
|
||
|
||
## Рендеринг
|
||
|
||
`SpriteBatch` в движке **не используется** — в `MrGameEng.Graphics` свой батчер,
|
||
спроектированный под ECS.
|
||
|
||
### Камера
|
||
|
||
Ортографическая 2D-камера — из коробки, отдельная сущность с компонентом `Camera`:
|
||
|
||
- Позиция, зум, поворот, опциональные границы мира (clamp).
|
||
- Строит ортографическую view-projection матрицу; виртуальное разрешение
|
||
с letterbox/scale под размер окна.
|
||
- Активная камера — одна на сцену. Утилиты `ScreenToWorld` / `WorldToScreen`.
|
||
|
||
### Слои (render layers)
|
||
|
||
- Слои регистрируются явно при настройке рендерера: имя + порядок отрисовки
|
||
(например, `Background → World → Foreground → UI`).
|
||
- У каждого слоя — **пространство**: `World` (через трансформ камеры) или
|
||
`Screen` (screen-space, без камеры — HUD, UI).
|
||
- Сортировка внутри слоя — по `float depth` спрайта; слой можно переключить
|
||
в режим **Y-sort** (depth = позиция Y, для top-down игр).
|
||
- Спрайт ссылается на слой компактным id (`LayerId`), не строкой.
|
||
|
||
### Culling
|
||
|
||
- Перед записью вершин AABB спрайта (с учётом rotation/scale) проверяется против
|
||
world-прямоугольника камеры; невидимые сущности не попадают в батчер.
|
||
- Screen-space слои не куллятся по камере (они всегда на экране).
|
||
- Culling — простой broad-phase в draw-системе, без пространственных структур;
|
||
если профилирование покажет, что на больших мирах его не хватает,
|
||
добавим spatial hash отдельным этапом.
|
||
|
||
### Батчер
|
||
|
||
- Draw-системы итерируют чанки Friflo (`Chunks<Position, Sprite, ...>`) и пишут
|
||
вершины напрямую в CPU-буфер батчера — без промежуточных списков и аллокаций.
|
||
- Порядок сортировки: **слой → depth (или Y) → текстура**; спрайты с одной
|
||
текстурой сливаются в один draw call (динамический vertex buffer + общий
|
||
quad index buffer).
|
||
- Сортировка — **стабильный LSD radix sort**: спрайты с равным ключом сохраняют
|
||
порядок сабмита между кадрами (нет мерцания), сложность O(n); проходы по
|
||
одинаковым у всех ключей разрядам пропускаются.
|
||
- Горячий путь без тригонометрии, корней и делений: для спрайтов без поворота
|
||
SinCos не вычисляется; радиус culling-окружности и UV-координаты
|
||
предрассчитаны в `Texture2DRegion`.
|
||
- **Параллелизм**: выше `Renderer2DOptions.ParallelThreshold` (по умолчанию 8192)
|
||
подача спрайтов и построение вершин идут на всех ядрах. Работа режется на
|
||
сегменты по 4096 сущностей (чанк Friflo держит весь архетип — сам по себе он
|
||
слишком крупный для распределения); сегменты сливаются в детерминированном
|
||
порядке, поэтому стабильность сортировки сохраняется. Ниже порога — прежний
|
||
однопоточный путь без аллокаций.
|
||
- Vertex buffer пишется **кольцом** (`SetDataOptions.NoOverwrite`, GPU-буфер
|
||
вдвое больше кадра): загрузка не ждёт, пока GPU дорисует предыдущий кадр;
|
||
`Discard` — только на перемотке кольца.
|
||
- **Тайминги фаз** кадра (submit/sort/build/upload/draw, мс) доступны как свойства
|
||
`Renderer2D` — выводятся в HUD стресс-сцены; оптимизации делаются только по ним.
|
||
- Текстурные атласы — первоклассный гражданин: `Sprite` хранит регион атласа,
|
||
спрайты одного атласа батчатся автоматически.
|
||
- Цель по производительности: ≥100k спрайтов при 60 FPS на среднем десктопе,
|
||
0 аллокаций на кадр ниже порога параллелизма. Стресс-сцена Sample: 100k сущностей
|
||
(~61k в кадре) ≈ 297 FPS в Release. **Производительность измеряется только
|
||
в Release** — Debug-сборка медленнее в 5–6 раз (нет инлайнинга JIT).
|
||
|
||
## Сцены и переходы
|
||
|
||
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
|
||
следующего кадра (сцена никогда не выгружается посреди собственного кадра).
|
||
- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом:
|
||
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
|
||
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
|
||
- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)`
|
||
(шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill`
|
||
в нормализованных координатах экрана).
|
||
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
|
||
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
|
||
|
||
## Интеграция ECS
|
||
|
||
- На каждую сцену создаётся свой `EntityStore` (мир) и **два** `SystemRoot`:
|
||
`UpdateSystems` (логика) и `DrawSystems` (рендер). Порядок систем детерминирован
|
||
и задаётся явно при регистрации.
|
||
- Модули подключаются к сцене extension-методами из `OnLoad`:
|
||
`scene.UseRenderer2D()`, `scene.UseInput()`, `scene.UseSpriteAnimation()`,
|
||
`context.UseAssets()`, `context.UseAudio()`. Сервисы модулей живут в
|
||
`EngineContext.Services` (общие между сценами), системы — в сцене.
|
||
- Базовый трансформ — один компонент `Transform2D` (position + rotation + scale):
|
||
один массив на чанк дешевле трёх отдельных компонентов.
|
||
- Модули поставляют готовые компоненты и системы
|
||
(например, `Sprite` + `SpriteRenderSystem` из `Graphics`), игра добавляет свои.
|
||
|
||
## Кадр (frame pipeline)
|
||
|
||
```
|
||
Game.Update ──► Scene.Update ──► SystemRoot (Update-фаза): input → логика игры → анимации
|
||
Game.Draw ──► Scene.Draw ──► SystemRoot (Draw-фаза): камера → culling → запись вершин в батчер → flush (draw calls)
|
||
```
|
||
|
||
## Структура репозитория
|
||
|
||
```
|
||
MrGameEng.sln
|
||
src/ библиотеки движка (MrGameEng.*)
|
||
samples/ MrGameEng.Sample — демо всех возможностей
|
||
tests/ xUnit-тесты, по проекту на библиотеку
|
||
docs/ документация (русский)
|
||
```
|
||
|
||
## Зафиксированные версии
|
||
|
||
| Зависимость | Версия | Назначение |
|
||
|---------------------------------|----------|----------------------------------|
|
||
| .NET (target framework) | net8.0 | |
|
||
| MonoGame.Framework.DesktopGL | 3.8.4.1 | Базовый фреймворк |
|
||
| Friflo.Engine.ECS | 3.6.0 | ECS |
|
||
| FontStashSharp.MonoGame | 1.5.6 | Шрифты (ttf) в рантайме |
|
||
| NVorbis | 0.10.5 | Декодирование ogg |
|
||
| Myra | 1.6.1 | Игровой UI |
|
||
| StbImageSharp | 2.30.15 | Декодирование картинок при сборке атласов |
|
||
| StbImageWriteSharp | 1.16.7 | Запись PNG-страниц атласов |
|
||
| dotnet-mgfxc (dotnet tool) | 3.8.4.1 | Компиляция шейдеров при сборке |
|