Introduce a Calendar in Core that turns scaled clock time into whole in-game days plus a fraction-of-day, with a configurable SecondsPerDay. Pausing or changing TimeScale slows or stops it automatically. Pure read-side and deterministic, registered via context.UseCalendar(...), mirroring GameSpeed. Covered by xUnit tests; docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
34 KiB
Архитектура mrgameeng
Обзор
mrgameeng — кроссплатформенный 2D игровой движок на базе MonoGame 3.8.4 (DesktopGL: Windows, Linux, macOS), .NET 8, C#.
Ключевое архитектурное решение — ECS-first: движок плотно интегрирует Friflo.Engine.ECS (v3.6) как ядро модели объектов. Это самая производительная ECS-библиотека в .NET по результатам Ecs.CSharp.Benchmark: ноль аллокаций в горячих путях, archetype-хранилище, встроенные системы, события и индексы.
Принципы
- Данные отдельно от логики. Всё состояние игры — в компонентах
(
struct : IComponent), вся логика — в системах (QuerySystem<T>). НикакихUpdate()у игровых объектов и иерархий наследования сущностей. - Ноль аллокаций в кадре. Системы, выполняющиеся каждый кадр, не должны аллоцировать память. Проверяется бенчмарками и code review.
- Модульность. Движок разбит на библиотеки по функциональным областям. Игра подключает только то, что использует.
- Всё демонстрируется. Каждая публичная возможность движка покрыта тестами
(где логика тестируема без GPU) и показана в игре
LittleSim — живой витрине движка
(движок подключён туда сабмодулем
engine/).
Модули
Библиотеки сгруппированы по архитектурной роли, а не по одной на фичу. Каждая фича —
подпапка библиотеки-хоста со своим неймспейсом MrGameEng.<Фича> (неймспейс не
привязан к сборке), поэтому using MrGameEng.Tilemaps; и т.п. работают и после слияния.
| Библиотека (сборка) | Фичи (неймспейсы) и ответственность |
|---|---|
MrGameEng.Core |
Игровой цикл (хост над Game), EntityStore, SystemRoot, сцены, время (GameClock.TimeScale; GameSpeed — дискретная скорость пауза/1×/3×/6× поверх часов; Calendar — игровые дни поверх масштабированного времени, context.UseCalendar(...)), жизненный цикл. Input (MrGameEng.Input, Core/Input/): клавиатура, мышь, геймпад, action maps |
MrGameEng.Graphics |
Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. Tilemaps (MrGameEng.Tilemaps, Graphics/Tilemaps/): тайловые карты кодом — TileGrid + TileSet + компонент Tilemap, отрисовка видимых клеток через батчер |
MrGameEng.Audio |
Звуковые эффекты и музыка (NVorbis); AudioManager с SoundVolume/MasterVolume (одна ручка на эффекты и музыку) |
MrGameEng.Content |
Пайплайн контента. Assets (MrGameEng.Assets): runtime-загрузка без Content Pipeline, кэш, AssetRef<T>. Atlases (MrGameEng.Atlases): текстурные атласы — сборка из дерева картинок (AtlasBuilder) и рантайм-загрузка (TextureAtlas), CLI tools/MrGameEng.AtlasTool. Mods (MrGameEng.Mods): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
MrGameEng.Simulation |
Детерминированные геймплей-примитивы без данных мира. Pathfinding (MrGameEng.Pathfinding): A*, Dijkstra, BFS, flow fields по гриду. AI (MrGameEng.AI): utility-ИИ — кривые отклика, соображения, действия, выбор (UtilityAi<TContext>), Blackboard. Collisions (MrGameEng.Collisions): компонент Collider, spatial hash, пары/запросы/raycast |
MrGameEng.UI |
Игровой UI на Myra: Desktop на сцену, виджеты, скининг. DevConsole (MrGameEng.DevConsole): ингейм-консоль — логи Log, команды, история, автодополнение |
MrGameEng.Assets.Generator |
Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) |
Планируемые области (по мере развития): Physics2D, Particles — добавляются как
подпапки подходящей библиотеки или новой библиотекой, если это новая роль/тяжёлая
зависимость.
MrGameEng.Mods
Контент игры описывается модами — папками вида Mods/<Id> с метаданными в
About/About.json (id, имя, версия, зависимости). ModLoader.Load упорядочивает моды
детерминированно: зависимости раньше зависимых, при равенстве — по алфавиту id. Поздний
мод переопределяет ранние во всех системах контента. Сама игра поставляет свой контент
как мод Core — любой другой мод может переопределить её данные.
- Дефы (
Defs/**/*.json): файл-конверт{ "type": "<ключ>", "defs": [ … ] }; CLR-тип на ключ регистрирует игра (DefDatabase.RegisterType<T>). Поля:defName(уникален в типе; одноимённый деф позднего мода полностью заменяет ранний),parent(поля родителя как основа, свои — поверх; вложенные объекты заменяются целиком),abstract(только родитель, в базу не попадает; не наследуется),label. - Локализация (
Languages/<код>/**/*.json): плоские словари ключ→строка;LanguageManagerпереключает язык на лету, недостающие ключи берёт из языка по умолчанию, в крайнем случае возвращает сам ключ. - Деревья контента:
ModContentTree.Build(mods, "Textures")сливает одноимённые папки всех модов (поздний мод побеждает по относительному пути) — результат кормится, например, вAtlasBuilder.Build(options, sources)для инкрементальной сборки атласов при старте игры.
Правило зависимостей
MrGameEng.Audio ─┐
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
│ └──► Friflo.Engine.ECS
MrGameEng.Content ─┤
MrGameEng.Simulation ─┴──► MrGameEng.Graphics (Content — за Texture2DRegion,
MrGameEng.UI ──► MrGameEng.Core Simulation — за Transform2D/RectF)
Библиотека зависит только от Core и Graphics. Core зависит только от MonoGame
и Friflo. Если двум библиотекам нужен общий тип — он переезжает в Core (или, если это
графический тип, в Graphics). Content тянет Graphics (атласы выдают
Texture2DRegion); Simulation тянет Graphics (Collisions использует Transform2D,
RectF); UI — только Core (Myra рисует своим SpriteBatch).
Фичи, собранные в одну библиотеку, делят её набор пакетов (например, Content несёт и
FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в
отдельных библиотеках, чтобы остальной движок их не тянул.
MrGameEng.Assets.Generator — особый случай: это анализатор (netstandard2.0),
он подключается к проекту игры как Analyzer, в рантайме не участвует и не зависит
от других модулей движка.
Консоль разработчика
MrGameEng.DevConsole — ингейм-консоль (тогглинг клавишей `):
- Перехватывает всё, что пишется через статический
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 (выбор: экосистема FontStashSharp, не требует Content Pipeline, зрелая и поддерживаемая):
scene.UseUI()вOnLoadпослеUseRenderer2D()— создаёт MyraDesktop(свой на сцену) и регистрирует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 игры).
По дереву папок он генерирует статический класс с типизированными хендлами,
тип выводится из расширения файла:
// 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-штампом — повторные запросы не аллоцируют и не чистят массивы. Один экземпляр на систему; результаты детерминированы.
ИИ агентов (utility)
MrGameEng.AI — примитивы для принятия решений агентами (жители LittleSim).
Зависит только от Core, не владеет данными мира и не привязан к ECS: всё
параметризовано контекстом TContext, который игра передаёт сама (снимок восприятия,
хендл сущности, blackboard — что угодно). Основан на Infinite-Axis Utility System:
ResponseCurve(value type) — кривая отклика, нормализованный вход[0,1]→ полезность[0,1]:Linear,Polynomial(степень),Logistic(S-кривая),SmoothStep. Вход и выход клампятся. Внимание:default(ResponseCurve)имеет нулевой наклон (всегда 0) — для тождества используйтеResponseCurve.Identity.Consideration<TContext>— одно соображение: читает сырое значение из контекста, нормирует по диапазону[min,max]и прогоняет через кривую.UtilityAction<TContext>— действие из набора соображений. Очки = произведение соображений ×Weight; любой ноль ветирует действие. Компенсирующий множитель (make-up value) убирает смещение произведения многих факторов вниз.UtilityAi<TContext>— reasoner:Select(детерминированно лучшее действие, при равенстве — первое) иSelectWeighted(random)(рулетка по очкам для разнообразия, воспроизводимо при seed). Очки пишутся в переиспользуемый буфер — повторные вычисления не аллоцируют; один экземпляр на вид агента, не потокобезопасен.Blackboard— типизированная рабочая память агента (Set/TryGet/GetOrDefault) для холодных путей (восприятие, планирование).
Витрина в LittleSim: PawnDecisionSystem выбирает «бродить/отдыхать» по энергии
жителя, PawnNeedsSystem тратит/восстанавливает энергию, уставшие темнеют
(PawnAppearanceSystem). Команда консоли ai [energy] печатает очки и расклад
отдыхающих/блуждающих.
Коллизии
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 аллокаций на кадр ниже порога параллелизма. Эталонный замер (стресс-сцена, до переноса демо в LittleSim): 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.*)
tests/ xUnit-тесты, по проекту на библиотеку
tools/ CLI-инструменты (упаковщик атласов)
docs/ документация (русский)
Демо-проекта в движке нет: витрина движка — игра
LittleSim (движок подключён туда
сабмодулем engine/).
Зафиксированные версии
| Зависимость | Версия | Назначение |
|---|---|---|
| .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 | Компиляция шейдеров при сборке |