18-engine.md границы Tile2D, проекции 2D, формат проекта, второй проект 19-agent.md инструменты --tool, скиллы, терминал в редакторе 20-gdd.md акты, петли, рост отряда, закон масштаба, бюджет 200 часов 21-damage.md семь стихий, статусы, десять реакций, формулы, триггеры 22-loot.md редкости, аффиксы, артефакты, зачарования, материалы 23-endgame.md Бездна, мутации, лидерборды, 262 ачивки Плюс правки 01/02/07/15/16/17 под переехавшие пути и новые правила забега. Дизайнерская часть держится на одном законе: угроза растёт квадратично, сила отряда — линейно с затуханием, разрыв закрывается ЗНАНИЕМ, а не числами. Из него выведены и экономика опыта (41 млн против 44 млн дохода), и то, почему реакции считаются от глубины, а не от урона оружия, и почему в Бездне множитель HP разрешён, а во всём авторском контенте запрещён. .claude/commands: скиллы /tile2d, /room, /floor — правила этого движка, которые нельзя вывести из кода. Главное записано первым: не отчитываться об успехе без прохода --tool validate. Числа в этих документах — вход для инструментов, а не украшение. Расхождение между таблицей и выводом инструмента означает, что неправ документ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
18 KiB
Markdown
267 lines
18 KiB
Markdown
# 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
|
||
```
|
||
|
||

|
||
|
||
Раскладка кадра 480×270: верхняя панель — операции над комнатами, холст —
|
||
изометрия комнаты **тем же кодом, которым рисует игра**, правая панель — кисти,
|
||
свойства и палитра из каталога, нижняя полоса — таблица выбранной точки спавна.
|
||
|
||

|
||
|
||
| Действие | Мышь | Клавиши |
|
||
|---|---|---|
|
||
| рисовать / стирать | ЛКМ / ПКМ | — |
|
||
| кисть | панель справа | `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` **отдельным процессом**. Не
|
||
встроенным режимом: иначе редактору пришлось бы знать правила игры, и вся
|
||
граница между целями сборки была бы декоративной.
|
||
|
||
### Предпросмотр сборки
|
||
|
||

|
||
|
||
`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`.
|