# 15 — Движок и редактор комнат ## Зачем это отдельно До этой вехи проект был одним исполняемым файлом. Теперь их три, и граница между ними проведена **линковкой**, а не договорённостью: ``` tile2d (STATIC) engine/ — не знает про правила боя и ни про одну игру по имени ├── squad_proto.exe игра Descent: src/ + projects/descent/ └── tile2d_editor.exe редактор комнат: editor/ ``` `tile2d_editor` линкуется **только** с движком. Поэтому «редактор полез в правила игры» — это ошибка сборки, а не то, что надо заметить на ревью. Это единственная причина, по которой редактор сделан отдельным приложением, а не режимом игры: режим внутри игры видит `EnemyKind`, `Agent` и `g_tune` целиком, и удержать его от этого нечем. ### Что лежит в движке Всё, что лежит в `engine/`. Раньше состав задавался списком в [CMakeLists.txt](../CMakeLists.txt), потому что каталоги `src/render` и `src/world` делили обе цели; теперь у движка свой каталог, и решение «это движок» принимается тем, куда положен файл ([18-engine.md](18-engine.md)). | Файл | О чём | |---|---| | `engine/config.h` | размеры кадра, тайла и карты: то, что нельзя менять без пересборки | | `engine/framebuffer` | софтверный буфер RGBA8, световая рампа, блит на экран | | `engine/sfa_library` | чтение библиотеки SpriteForge | | `engine/projection` | ЕДИНСТВЕННОЕ место, где движок знает про изометрию | | `engine/view2d.h` | камера, список отрисовки, сортировка по глубине | | `engine/project` | манифест проекта: пути, вид отображения, кривая глубины | | `engine/tilemap` | карта, DDA-рейкаст, circle-vs-AABB | | `engine/pixel` | ромб, обводка силуэта, `ShadeColor` | | `engine/tileset` | пол и стены по темам отделки | | `engine/theme` | сколько тем и как они называются | | `engine/catalog` | каталог размещаемых сущностей | | `engine/room` | формат комнаты-шаблона и набор комнат | | `engine/level_gen` | сборка этажа из комнат по сиду | | `engine/progression` | кривая глубины: что означает этаж N ([16-descent.md](16-descent.md)) | | `engine/microfont` | растровый шрифт 3×5 для софтверного буфера | | `engine/text_parse.h` | разбор строчных текстовых форматов | Чего в движке НЕТ и быть не может: путей к содержимому. Каталог комнат и каталог сущностей называет проект, поэтому один и тот же `tile2d_editor` открывает и `projects/descent`, и `projects/hollow`. ## Каталог сущностей — мост между движком и игрой [projects/descent/catalog/entities.txt](../projects/descent/catalog/entities.txt): ``` entity shambler class=monster depth=1 danger=1.0 color=116,52,52 sprite=shambler_walk name=SHAMBLER entity crate class=prop depth=1 color=104,76,46 sprite=prop_crate name=CRATE entity plate class=loot tier=3 depth=9 color=140,146,156 sprite=item_icon_plate name=PLATE ``` Редактор знает про сущность ровно это: строковый id, класс, цвет, радиус, имя ассета SpriteForge и на какой глубине она встречается. Что такое `SHAMBLER` и чем он бьёт — знание игры, и живёт оно в `sim/target.cpp`. Связывает одно с другим единственная таблица — [src/sim/spawn_catalog.cpp](../src/sim/spawn_catalog.cpp). **Добавить тварь** = строка в каталоге + запись в `EnemyDefOf` + строка в таблице перевода. Формат комнат и редактор при этом не меняются вообще. Формат `ключ=значение`, а не позиционный: свойства сущности прирастают (глубина, цена, тир), и каждое новое ломало бы все уже написанные строки. Смысл ключей — в [16-descent.md](16-descent.md). Цвет и радиус в каталоге не для красоты: ими редактор рисует сущность, пока для неё не собран спрайт. Пустая палитра не должна мешать планировать уровень. ## Комната — это шаблон, а не кусок карты [projects/descent/rooms/arena.room](../projects/descent/rooms/arena.room): ``` name arena size 14 12 weight 3 вес при выборе генератором: «как часто встречается» theme 0 набор тайлов отделки start 0 годится ли как стартовая комната отряда depth 1 0 с какого этажа и по какой (0 — до бесконечности) tiles ####++####++## '.' пол '#' стена '+' дверь #............# ... spawn 4 4 0.90 shambler:4 rusher:3 spitter:2 spawn 6 1 0.40 crate:2 barrel:1 ``` Ключевое в формате — **точка спавна не хранит, кто в ней стоит**. Она хранит шанс срабатывания и таблицу весов по id каталога. Одна и та же комната, поставленная дважды, населена по-разному; уровень из восьми комнат читается как уровень, а не как одна кишка, повторённая девять раз. Ограничения формата и почему они такие: - размер от 5×5 до 14×14 — генератор ставит комнаты в слоты по 16 тайлов, и комната размером в слот не оставила бы коридорам места; - двери только на рамке и не в углах — коридор тянется НАРУЖУ, а из угла непонятно, в какую сторону; - точка спавна только на полу; точка с пустой таблицей в файл не пишется. Текстовый построчный формат выбран потому, что комнаты лежат в git и правка планировки обязана читаться в diff. ## Сборка уровня [engine/level_gen.h](../engine/level_gen.h). Карта 48×48 делится на сетку 3×3 слотов по 16 тайлов. 1. в каждый слот выбирается комната по весу; 2. если ни одной стартовой не выпало — одна ставится принудительно; 3. вся карта заливается камнем, комнаты отпечатываются по центрам слотов; 4. по слотам строится **остовное дерево** (обход в глубину со случайным порядком соседей) — оно гарантирует, что из стартовой достижима каждая; 5. поверх дерева добавляются лишние связи с шансом 1/3: без них уровень — дерево-кишка, с шансом 1 — решётка без тупиков; 6. связи режутся коридорами шириной 2 от двери к двери, тремя отрезками по осям (нет двери на нужной стороне — стена пробивается посередине); 7. разыгрываются точки спавна; 8. заливкой от точки старта считается достижимость, недостижимые точки снимаются и **попадают в отчёт**. Шаг 8 — не перестраховка: он поймал замурованную внутреннюю камеру у `storage` в тот же день, когда её нарисовали. Тварь в отрезанном кармане не дойдёт до отряда, отряд не дойдёт до неё, а счётчик зачистки будет вечно показывать недобитого противника. ### Про детерминизм RNG крутится **только здесь**, один раз, при сборке уровня. К моменту первого `Game::Step` всё уже разложено, и правило «в симуляции ни одного вызова RNG» ([02-architecture.md](02-architecture.md)) не нарушено. Один и тот же набор комнат плюс один и тот же сид дают побайтово ту же карту и то же население. Броски по каждой точке спавна делаются **всегда**, даже когда точка не сработает: иначе последовательность уводило бы вбок и сид перестал бы значить одно и то же. `--accept` и `--headless` набор комнат не подключают никогда — критерии меряются на прежней захардкоженной карте, иначе цифры не с чем сравнивать. ## Темы отделки Слой `Tilemap::theme[]` — отдельный от `tiles[]` намеренно: проходимость и внешний вид это два разных вопроса, и смешав их, мы получили бы отдельный тип тайла на каждое сочетание. Симуляция этот слой не читает вообще. Тем четыре: `STEEL`, `RUST`, `FLESH`, `FROST`. Пока они получаются перекраской базового набора по каналам — это честная заглушка, а не временное решение: она даёт различимые материалы, не требуя ни одного собранного ассета. Когда для темы соберут свой тайлсет, поменяется `engine/tileset.cpp`, а формат комнат и уже нарисованные комнаты — нет. ## Редактор ``` build/bin/tile2d_editor.exe ``` ![редактор комнат](editor-room.png) Раскладка кадра 480×270: верхняя панель — операции над комнатами, холст — изометрия комнаты **тем же кодом, которым рисует игра**, правая панель — кисти, свойства и палитра из каталога, нижняя полоса — таблица выбранной точки спавна. ![таблица весов точки спавна](editor-spawn.png) | Действие | Мышь | Клавиши | |---|---|---| | рисовать / стирать | ЛКМ / ПКМ | — | | кисть | панель справа | `1`–`5` | | сущность палитры | вкладки `MOB`/`PROP`/`LOOT`, колесо | `Q` / `E` | | тема отделки | кнопка темы | `T` | | диапазон глубин комнаты | счётчики `D` | — | | соседняя комната набора | `<` `>` | `[` `]` | | новая комната | `NEW` | `N` | | сохранить / перечитать | `SAVE` / `RELOAD` | `Ctrl+S` / `Ctrl+R` | | сборка этажа / новый сид | `LEVEL` / `SEED` | `F5` / `F6` | | этаж предпросмотра | `D-` / `D+` | `PgUp` / `PgDn` | | проверить в бою | `PLAY` | — | | сдвиг холста | — | стрелки, `Home` | Точка спавна ставится одним нажатием, а не мазком: кисть, тянущаяся по сетке, засеяла бы полкомнаты. `PLAY` запускает `squad_proto --rooms --seed N` **отдельным процессом**. Не встроенным режимом: иначе редактору пришлось бы знать правила игры, и вся граница между целями сборки была бы декоративной. ### Предпросмотр сборки ![сборка уровня](editor-level.png) `F5` показывает этаж целиком видом **сверху**: 48×48 тайлов в изометрии — это полторы тысячи пикселей по ширине, и на 480×270 они не помещаются даже близко. Предпросмотр отвечает на вопрос «какая вышла планировка», а не «как это выглядит», и для этого вид сверху честнее. В строке над картой — сид, число комнат, коридоров и сущностей. Всё, из-за чего уровень собрался не так, как задумано (срезано потолком, недостижимо от старта), печатается отдельной строкой и только когда оно не ноль: постоянная строка «0 ошибок» перестаёт читаться через минуту работы. ### Самопроверка набора ``` tile2d_editor.exe --check ``` Аналог `squad_proto --accept`, только для содержимого: набор комнат — такой же исходник, как код, и ломаться он обязан на проверке. ``` ROOM SIZE DOORS SPAWNS THEME STATE arena 14x12 12 6 STEEL OK ... [PASS] сид 42 комнат 9, коридоров 9, сущностей 31, достижимо 859, ... ``` Проверяются: разбор каталога, разбор каждой комнаты, `RoomProblem` (двери, пол, точки), **круговой прогон через запись** и сборка уровня на пяти сидах. Круговой прогон здесь не для полноты: записывающий путь — единственный, которым редактор может уничтожить работу. Читающий проверяется сам собой при каждом запуске, пишущий — только тут. ## Запуск игры на собранном уровне ``` squad_proto.exe --rooms [каталог] собрать уровень из комнат squad_proto.exe --seed N сид сборки; без него 1 ``` Без `--rooms` игра работает ровно как раньше, на захардкоженной карте. Набор не нашёлся или не собрался — откат на неё же: прототип обязан запускаться на голом репозитории. Собранный уровень населяет себя сам: захардкоженная расстановка мишеней и тварей относится к другой карте, и мешать их — верный способ получить тварь в стене на пустом месте. Кто и когда меняет сид: | Действие | Сид | Почему | |---|---|---| | `NEW RAID` в меню | следующий | новая вылазка — новый уровень | | `TRY AGAIN` после поражения | тот же | это переигрывание, а не новый заход | | `R` в бою | тот же | на карте, которая меняется под руками, нечего отлаживать | «Следующий» считается тем же линейным конгруэнтным шагом, что и кнопка `SEED` в редакторе: сид, подобранный в редакторе, обязан повторяться в игре. ## Что сюда ещё не входит - **Предметы на полу** появились отдельной вехой и раскладываются генератором по тиру этажа, а не ставятся в комнате руками ([16-descent.md](16-descent.md)). - **Поворот комнат при сборке.** Комната ставится как нарисована. Повороты удвоили бы разнообразие, но автор комнаты перестал бы понимать, что увидит. - **Свои тайлсеты на тему.** Пока перекраска базового набора; место для сборки из SpriteForge уже есть — `TileSet::LoadFromForge`.