Документация: границы движка, работа через модель, дизайн игры до лейтгейма

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>
This commit is contained in:
z.kirill 2026-08-13 21:07:45 +03:00
parent 7d6266aaf3
commit 4382af2322
66 changed files with 2950 additions and 22 deletions

View File

@ -0,0 +1,21 @@
# Generate a modular agent sprite
Argument: `$ARGUMENTS` is `component animation` (for example `armor_vest idle`).
1. Read `assets/agents/catalog.yaml`. Do not invent dimensions or direction order.
2. Run `python tools/agent_assets.py prompt <component> <animation>` and use that exact art brief.
3. Prefer the local Blender pipeline. Create the shared rig once if missing:
`python tools/agent_assets.py blender-init`. Render the requested aligned layer with:
`python tools/agent_assets.py blender-render <component> <animation>`.
Blender is at `C:\Program Files\Blender Foundation\Blender 5.2\blender.exe` and the
CLI finds it automatically (or accepts `--blender`). Never create a separate scene,
camera, scale or rig for equipment.
For a whole requested loadout or release set use
`python tools/agent_assets.py blender-render-all <components...> --animations idle walk attack`;
existing sheets are cached unless `--force` is explicitly requested.
4. If a raster image generator is available, request one 4x2 magenta-key board per animation frame, then run `python tools/agent_assets.py ingest <component> <animation> <boards...>` in frame order. Never hand-crop cells.
5. Run `python tools/agent_assets.py validate --strict`, then
`powershell -ExecutionPolicy Bypass -File tools/build_agent_assets.ps1`.
6. Inspect only with SpriteForge `validate` and `ascii`; do not open every generated frame. A human may request a contact sheet.
Visual lock: lean realistic covert operator, fitted dark sci-fi gear, Splinter Cell / Metal Gear / STALKER silhouette. Never bulky power armor, astronaut, space marine, chibi or Among Us. Canvas is 70x90 per direction, Diablo-style isometric orthographic view.

48
.claude/commands/floor.md Normal file
View File

@ -0,0 +1,48 @@
# Tune the difficulty curve
Request: `$ARGUMENTS`
The whole slope of the game lives in one place: `projects/<project>/project.txt`. The shape
of the curve is code (`engine/progression.cpp`), its coefficients are data. Read
`docs/16-descent.md` for what each number means and why it is shaped that way.
## Measure before changing
```bash
tile2d_editor --tool curve 1 20 # what depth means today, floor by floor
tile2d_editor --tool floor <depth> 1 # one floor: budget spent, population, loot
tile2d_editor --tool floor <depth> 42 # same depth, another seed — variance matters
```
Change one line of `project.txt`, run the same commands, and compare. A tuning change with
no before/after numbers is an opinion.
## What the numbers are for
| key | meaning | the trap |
|---|---|---|
| `danger` | base, linear growth, quadratic tail | pure linear reads as "same, but more"; pure exponential ends the run where the player just started understanding the rules |
| `density` | how often spawn points fire, and the cap | above the cap every point always fires, so floors stop differing |
| `loot` | drops and how fast tier climbs | tier must lag danger, or the squad is dressed for the rest of the game by floor ten |
| `sight` | how fast vision shrinks, and the floor | below the floor it stops being frightening and becomes "can't see where to go" |
| `xp` | experience multiplier per floor | must grow slower than danger, or skipping floors becomes the only sane play |
| `themes` | where each finish band starts | must ascend; the last band is open-ended on purpose |
Do **not** add creature HP or damage multipliers to make depth harder. "The same shambler
with triple HP" is not scarier, it is longer. Depth changes *who* comes out and how many.
## Verify
```bash
tile2d_editor --tool validate # set still covers the depths you moved
squad_proto --project <name> --descend 8 # the curve survives an actual run
```
Watch for `ЗАМЕЧАНИЕ` in validate: the floor assembled correctly but the room set no longer
keeps up with the curve. That means "draw more rooms", not "lower the curve" — unless the
curve is what you were asked to change.
## Report
The exact lines you changed, and the `curve` table before and after for the depths that
matter. Name the floors where behaviour changed most.

49
.claude/commands/room.md Normal file
View File

@ -0,0 +1,49 @@
# Author or repair a room
Request: `$ARGUMENTS`
A room is a text file: `projects/<project>/rooms/<name>.room`. Edit it directly — there is
no binary step. Format and reasoning: `docs/15-engine-and-editor.md`.
## Before writing
1. `tile2d_editor --tool rooms` — what the set already has, and at which depths.
2. `tile2d_editor --tool ascii <similar-room>` — read a working room of the same kind.
3. `tile2d_editor --tool catalog monster <depth>` — what may be placed at that depth, with
its cost in the danger budget.
A new room must answer "what does this one do that the others don't": a wide arena, a
corridor where the formation cannot turn, a pocket that forces the squad to split. Another
rectangle with spawn points is not a room, it is filler.
## Hard constraints
- size between 5×5 and 14×14 (`ROOM_SLOT` is 16, the room must leave rock around it);
- doors only on the border, and at least one — a room without doors is never connected;
- **every floor tile must be reachable from the doors.** A sealed inner chamber looks
perfectly normal in the grid and silently makes the floor easier: whatever spawns inside
can neither reach the squad nor be killed. This has already happened twice in this repo
(`storage`, then `maw`);
- spawn points stand on floor, never in a wall, and their table is never empty;
- ids in the table must exist in that project's catalog.
Spawn tables hold **weights and a chance**, not a creature: `spawn 6 4 0.65 rusher:3 shambler:2`
means "this point fires 65% of the time, and then it is a rusher three times out of five".
Depth decides who is allowed and what the floor can afford; the room only says where.
## Verify
```bash
tile2d_editor --tool room <name> # doors, spawn tables, problems
tile2d_editor --tool ascii <name> # the grid, with spawn points numbered
tile2d_editor --tool validate # the whole set, including depth coverage
tile2d_editor --tool map <depth> 42 # the room inside an assembled floor
```
`validate` returning 1 means the set is broken — fix it before reporting. `map` is how you
see whether the room actually connects: look for its shape and check that the corridors
reach it.
## Report
The room's name, size, what it is for, and the numbers from `validate` and one `map` run.

View File

@ -0,0 +1,25 @@
# Produce a game sprite
Request: `$ARGUMENTS`
Treat this as an asset-production task, not a request to draw a one-off shape in C++.
1. Read only the relevant catalog and documentation:
- agent/body/armor/weapon: `assets/agents/catalog.yaml`, `.claude/commands/agent-sprite.md`;
- monster: `assets/monsters/catalog.yaml`, `docs/14-monsters.md`;
- environment/item: `assets/content_coverage.yaml`, `tools/generate_static_assets.py`.
2. Preserve the visual lock: grounded near-future sci-fi horror and body horror. Agents are
lean covert operators, never bulky space marines. Camera is Diablo-style orthographic
isometry. Agent paper-doll layers are exactly 70x90 and share the existing rig/pivot.
3. Extend a catalog/generator/rig; never commit a single hard-coded runtime Sprite as the
final asset. New agent equipment is one layer per animation, not every loadout combination.
4. Render only the requested/new component. Reuse the existing `.blend`; do not rebuild all
rigs unless their geometry changed.
5. Run `powershell -ExecutionPolicy Bypass -File tools/build_assets.ps1` and fix every strict
validation or coverage failure before reporting success.
6. Verify through text (`sf validate`, `sf ascii`, coverage). Do not open generated PNG/SFA.
If the human asks to inspect it, create a grid using `tools/sprite_preview.py` and give the
path; the human may open that preview.
Report the generated asset IDs, supported animations/directions, build status, and exact
command needed to run the game with `--sf`.

View File

@ -0,0 +1,50 @@
# Work on a Tile2D project
Request: `$ARGUMENTS`
Tile2D is the engine (`engine/`); a game is a project (`projects/<name>/`). The editor
(`tile2d_editor`) belongs to the engine and opens any project. Read `docs/18-engine.md`
before touching engine code, `docs/15-engine-and-editor.md` for the editor and room format.
## The loop
Content is plain text in git — rooms, entity catalog, project manifest. Edit the files
directly. What you cannot get from reading files is **consequences**: whether the room
assembles into a floor, whether a spawn point is reachable, whether the floor spends its
danger budget. That is what the tools are for.
```bash
tile2d_editor --tool help # every tool, with arguments
tile2d_editor --tool project # manifest, paths, difficulty curve
tile2d_editor --tool rooms # the set, with problems flagged
tile2d_editor --tool ascii <room> # room grid as text
tile2d_editor --tool floor <depth> [seed] # assemble a floor, report the numbers
tile2d_editor --tool map <depth> [seed] # that floor as an ASCII map
tile2d_editor --tool curve <from> <to> # what depth means, per floor
tile2d_editor --tool validate # whole set: exit code 1 means broken
```
Every tool takes `--project <name>`; inside the editor terminal `TILE2D_PROJECT` is already
set, so the bare command targets the open project.
## Rules that are easy to break here
1. **Never report success without `--tool validate` passing.** Exit code 1 means broken.
A room that looks right in a diff can still seal a pocket off from its own door.
2. **Verify through text, never by opening generated PNG/SFA** (see CLAUDE.md). Floor
layout is `--tool map`; a screenshot is `squad_proto --clean --shot`.
3. **Entity ids are the game's border, not the engine's.** The engine places any id from
the project catalog; `squad_proto` only turns into a creature the ids listed in
`src/sim/spawn_catalog.cpp`. A new id needs a row there too, or the floor spawns nothing.
4. **On-screen text is Latin.** raylib's built-in font has no Cyrillic and draws it as
question marks. Console output and comments stay Russian.
5. **Acceptance is measured in the sandbox.** `--accept` and `--headless` never load a
project. Do not wire content into them.
6. **The editor links only against the engine.** Anything in `editor/` reaching into
`sim/`, `ai/` or `ui/` is a build error, and that is deliberate.
## Report
State what you changed, paste the numbers that prove it (`validate`, `floor`, `curve`), and
name anything you left broken. If a check fails and you could not fix it, say so — do not
describe the intended state as if it were the measured one.

View File

@ -19,14 +19,92 @@
| [docs/09-acceptance.md](docs/09-acceptance.md) | критерии приёмки (спек 11) и их текущие цифры | приёмка | | [docs/09-acceptance.md](docs/09-acceptance.md) | критерии приёмки (спек 11) и их текущие цифры | приёмка |
| [docs/10-atmosphere.md](docs/10-atmosphere.md) | **хоррор-слой**: свет, туман войны, трассеры, напряжение | картинка и атмосфера | | [docs/10-atmosphere.md](docs/10-atmosphere.md) | **хоррор-слой**: свет, туман войны, трассеры, напряжение | картинка и атмосфера |
| [docs/11-melee.md](docs/11-melee.md) | **ближний бой**: сектор удара, три фазы, свой круг, дуга | клинки, классы оружия | | [docs/11-melee.md](docs/11-melee.md) | **ближний бой**: сектор удара, три фазы, свой круг, дуга | клинки, классы оружия |
| [docs/12-assets.md](docs/12-assets.md) | **ассеты**: конвейер SpriteForge, манифесты, загрузчик, hot reload | добавить или пересобрать спрайт |
| [docs/13-combat.md](docs/13-combat.md) | **бой**: каталог оружия, твари, снаряжение, меню и HUD | оружие, враги, инвентарь, UI |
| [docs/14-monsters.md](docs/14-monsters.md) | **твари-ассеты**: Blender-риг на пять морфологий, листы, проверки | нарисовать или пересобрать тварь |
| [docs/15-engine-and-editor.md](docs/15-engine-and-editor.md) | **движок и редактор**: три цели сборки, каталог сущностей, формат комнат, генератор уровней | делать комнаты, менять генерацию, трогать границу движка |
| [docs/16-descent.md](docs/16-descent.md) | **спуск**: глубина как первичное понятие, бюджет опасности, лут, кривая сложности | менять сложность, добавлять тварей и лут |
| [docs/17-extraction.md](docs/17-extraction.md) | **экстракшен**: эвакуация, профиль и схрон, чекпоинты, что теряется | правила забега, снаряжение между вылазками |
| [docs/18-engine.md](docs/18-engine.md) | **Tile2D**: границы движка, проекции 2D, формат проекта, второй проект | трогать движок, заводить проект, добавлять вид отображения |
| [docs/19-agent.md](docs/19-agent.md) | **движок и ЛЛМ**: инструменты `--tool`, скиллы, терминал в редакторе | работать над проектом через claude/codex |
| [docs/20-gdd.md](docs/20-gdd.md) | **дизайн игры**: акты, петли, рост отряда, закон масштаба, 200 часов | что и зачем строим дальше |
| [docs/21-damage.md](docs/21-damage.md) | **урон**: семь стихий, статусы, реакции, формулы, триггеры | боевая математика |
| [docs/22-loot.md](docs/22-loot.md) | **добыча**: редкости, аффиксы, артефакты, зачарования, материалы | предметы и экономика |
| [docs/23-endgame.md](docs/23-endgame.md) | **лейтгейм**: Бездна, мутации, лидерборды, 262 ачивки | всё после сотого этажа |
## Быстрые факты ## Быстрые факты
- Исходный спек: [SQUAD_PROTOTYPE_SPEC.md](SQUAD_PROTOTYPE_SPEC.md) — источник истины по правилам. - Исходный спек: [SQUAD_PROTOTYPE_SPEC.md](SQUAD_PROTOTYPE_SPEC.md) — источник истины по правилам.
- Сборка: `cmake -S . -B build && cmake --build build --config Release``build/bin/squad_proto.exe`. - Сборка: `cmake -S . -B build && cmake --build build --config Release`.
Целей три: `tile2d` (библиотека), `build/bin/squad_proto.exe` (игра),
`build/bin/tile2d_editor.exe` (редактор комнат).
- **Содержимое щупают инструментами, а не глазами**: `tile2d_editor --tool help`.
Правка делается файлами (комнаты и каталог — текст), последствия читаются
командой: `--tool validate`, `--tool floor`, `--tool map`
([19-agent.md](docs/19-agent.md)). Код возврата 1 = поломка.
- В редакторе снизу терминал (клавиша `` ` ``): те же инструменты, git, сборка и
запросы к `claude`/`codex` прямо оттуда. Открытый проект уходит потомкам
переменной `TILE2D_PROJECT`.
- Самопроверка: `squad_proto.exe --accept` печатает PASS/FAIL по всем измеримым критериям. - Самопроверка: `squad_proto.exe --accept` печатает PASS/FAIL по всем измеримым критериям.
- Все числа — в [src/tuning.h](src/tuning.h). Магических констант по коду быть не должно. Содержимое проверяется отдельно: `tile2d_editor.exe --check` (комнаты и покрытие глубин),
`squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля).
- **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё,
достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)).
- **Закон масштаба:** угроза растёт квадратично, сила отряда — линейно с
затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей).
Числа не должны спасать — иначе хоррор кончается на сороковом этаже
([20-gdd.md](docs/20-gdd.md)).
- **Ни одного чистого улучшения.** Артефакт без цены становится обязательным, а
обязательный предмет — это вырезанный слот ([22-loot.md](docs/22-loot.md)).
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции
`DescribeFloor` ([engine/progression.h](engine/progression.h)); размазывать её
по генератору, каталогу и рендеру нельзя ([16-descent.md](docs/16-descent.md)).
- **Редактор линкуется только с движком.** Обращение из `editor/` к `sim/`,
`ai/` или `ui/` — ошибка сборки, и так задумано ([15-engine-and-editor.md](docs/15-engine-and-editor.md)).
Редактор принадлежит движку: он открывает любой проект (`--project <имя>`) и
ни одной игры по имени не знает.
- Тексты НА ЭКРАНЕ — латиницей: встроенный шрифт raylib кириллицы не знает и
рисует её вопросами. Комментарии и вывод в консоль остаются русскими.
- Числа игры — в [src/tuning.h](src/tuning.h), размеры движка — в
[engine/config.h](engine/config.h). Магических констант по коду быть не должно.
- `src/ai/` не знает про рендер, изометрию и ввод. Это правило не нарушать. - `src/ai/` не знает про рендер, изометрию и ввод. Это правило не нарушать.
- Правило «не бить своих» одно на все классы оружия: у ствола это отрезок, - Правило «не бить своих» одно на все классы оружия: у ствола это отрезок,
у клинка — сектор. Ослаблять его нельзя нигде. у клинка — сектор. Ослаблять его нельзя нигде.
- `RenderGame` принимает `const Game&` — эффекты физически не могут влиять на симуляцию. - `RenderGame` принимает `const Game&` — эффекты физически не могут влиять на симуляцию.
- Приёмка меряется в ПЕСОЧНИЦЕ: `Game::combatMode` выключен, в мире мишени,
на бойцах нет снаряжения. Ломать это нельзя — иначе `--accept` меряет другое.
## Ассеты
Спрайты собирает внешний CLI **SpriteForge** (`C:\Users\uuu\Documents\spriteforge`).
Полный порядок работы — в [docs/12-assets.md](docs/12-assets.md), здесь только запреты:
- игра хранит манифесты `assets/manifests/`, исходники `assets/sources/` и
сгенерированный `generated/spriteforge_assets.h`; собранное лежит в
`build/sprites/` и в git не попадает;
- не открывать `build/sprites/*.sfa`, `.sfcache/`, сгенерированные PNG и
contact sheet: проверка только текстом — `sf list`, `sf describe`, `sf ascii`,
`sf validate`, `sf stats`;
- строковых ID ассетов в C++ нет — только константы `SF_ASSET_*`;
- не править репозиторий SpriteForge ради одной игровой сущности.
# Agent sprite production
For ANY request to create, change, replace, or add game art, follow `.claude/commands/sprite.md`
(the `/sprite` workflow) automatically even when the user did not type the slash command.
Never satisfy an art request with a one-off procedural runtime drawing.
When asked to create an agent, armor, or weapon sprite, use `/agent-sprite` and
`assets/agents/catalog.yaml`; do not draw a one-off procedural humanoid. Agent art is
an aligned paper-doll: body, armor and held weapon are separate 70x90, eight-direction
layers sharing one rig and pivot. Run `tools/build_agent_assets.ps1` after ingestion.
Start the game with `--sf-agents` (or `--sf`) to see the generated layers. Blender is
installed at `C:\Program Files\Blender Foundation\Blender 5.2\blender.exe`.
# Monster sprite production
Enemies do NOT go through the agent paper-doll. They have their own Blender-first
pipeline: `assets/monsters/catalog.yaml`, `tools/blender/monster_rig.py`,
`tools/monster_assets.py` (`monster-init`, `monster-render`, `monster-render-all`,
`validate`, `sync`). Five kinds, five distinct morphologies — never one humanoid
recoloured five times. Asset ids are `<kind>_<animation>`; renaming them breaks
`tools/asset_coverage.py`. Full rules in [docs/14-monsters.md](docs/14-monsters.md).

View File

@ -6,7 +6,12 @@
> отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама > отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама
> расставляется так, чтобы атаковать и не задевать своих. > расставляется так, чтобы атаковать и не задевать своих.
Всё остальное — вне рамок и намеренно не сделано. Это ядро — вехи M0M6, и в его рамках всё остальное намеренно не сделано.
Поверх ядра достроен игровой слой: твари, которые идут и бьют, каталог оружия,
снаряжение с инвентарём, главное меню и HUD ([13-combat.md](13-combat.md)).
Прежняя песочница с мишенями никуда не делась — она живёт под `--sandbox`,
и все критерии приёмки по-прежнему меряются на ней.
## Что есть ## Что есть

View File

@ -6,6 +6,10 @@
- Компилятор с C++17. Проверено на MSVC 14.44 (Visual Studio 2022, x64). - Компилятор с C++17. Проверено на MSVC 14.44 (Visual Studio 2022, x64).
- Интернет при **первой** конфигурации: raylib 6.0 качается через `FetchContent`. - Интернет при **первой** конфигурации: raylib 6.0 качается через `FetchContent`.
Если в системе уже стоит raylib 6.x, `find_package(raylib 6)` возьмёт её и ничего не качает. Если в системе уже стоит raylib 6.x, `find_package(raylib 6)` возьмёт её и ничего не качает.
- Репозиторий SpriteForge рядом с игрой (`../spriteforge`): из него берётся один
заголовок `include/sfa.h`. Лежит в другом месте — передать путь при
конфигурации: `cmake -S . -B build -DSPRITEFORGE_DIR=<путь>`.
Сам CLI `sf` нужен только чтобы пересобирать ассеты ([12-assets.md](12-assets.md)).
## Сборка ## Сборка
@ -14,7 +18,17 @@ cmake -S . -B build # разово; тянет и собирает ra
cmake --build build --config Release --parallel cmake --build build --config Release --parallel
``` ```
Бинарник: `build/bin/squad_proto.exe` (при любом генераторе, включая multi-config). Бинарников три, все в `build/bin` при любом генераторе, включая multi-config:
| Цель | Что это |
|---|---|
| `squad_proto.exe` | игра Descent — один из проектов движка |
| `tile2d_editor.exe` | редактор комнат: инструмент ДВИЖКА, открывает любой проект ([15-engine-and-editor.md](15-engine-and-editor.md)) |
| `tile2d_walk.exe` | пример на движке: открыть проект, собрать этаж, походить ([18-engine.md](18-engine.md)) |
Третья цель, `tile2d`, — сам движок ([18-engine.md](18-engine.md)), статическая
библиотека, на которой стоят обе.
Собрать что-то одно: `cmake --build build --config Release --target tile2d_editor`.
Debug-сборка (`--config Debug`) кладёт бинарник туда же и идёт в 60 fps, так что Debug-сборка (`--config Debug`) кладёт бинарник туда же и идёт в 60 fps, так что
проверять правки стоит именно в ней: MSVC включает проверки итераторов и ловит проверять правки стоит именно в ней: MSVC включает проверки итераторов и ловит
@ -63,6 +77,11 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
| `←` / `→` | в ручном режиме — поворот вектора прицела | | `←` / `→` | в ручном режиме — поворот вектора прицела |
| `Tab` | сменить назначенную цель (внутри конуса прицела) | | `Tab` | сменить назначенную цель (внутри конуса прицела) |
| `1` / `2` / `3` | строй: клин / линия / кольцо | | `1` / `2` / `3` | строй: клин / линия / кольцо |
| `F` | спуститься глубже (стоя на зелёном кольце) |
| `E` | эвакуироваться: забег окончен, добыча в схроне (голубое кольцо) |
| `Esc` | пауза (из меню — выход) |
| `I` | экран отряда: снаряжение и сумки |
| `F11` | окно / безрамочный на весь экран |
| `F1` | оверлей вкл/выкл | | `F1` | оверлей вкл/выкл |
| `F2` | линии огня | | `F2` | линии огня |
| `F3` | кандидаты решателя и их оценки | | `F3` | кандидаты решателя и их оценки |
@ -71,8 +90,11 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
| `F6` | показать диски обзора | | `F6` | показать диски обзора |
| `[` / `]` | выбрать параметр тюнинга | | `[` / `]` | выбрать параметр тюнинга |
| `-` / `=` | изменить выбранный параметр | | `-` / `=` | изменить выбранный параметр |
| `R` | сброс уровня | | `R` | пересобрать ТОТ ЖЕ этаж заново |
| `Esc` | выход |
По умолчанию окно разворачивается безрамочно на весь монитор, а кадр 480x270
масштабируется ЦЕЛЫМ множителем и центрируется — остаток уходит в чёрные поля.
Дробное растяжение размывало бы пиксель: одни строки удваивались бы, соседние нет.
Стрельба **всегда автоматическая**, клавиши огня нет ни в одном режиме. Стрельба **всегда автоматическая**, клавиши огня нет ни в одном режиме.
Ближний бой тоже: бойцы с клинком сами доходят до цели и бьют, пока она внутри Ближний бой тоже: бойцы с клинком сами доходят до цели и бьют, пока она внутри
@ -96,6 +118,24 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
| `--set имя=значение` | подкрутить любой параметр из `TUNABLES` без пересборки | | `--set имя=значение` | подкрутить любой параметр из `TUNABLES` без пересборки |
| `--shot [кадров] [файл]` | снять скриншот через N кадров и выйти | | `--shot [кадров] [файл]` | снять скриншот через N кадров и выйти |
| `--clean` | стартовать без оверлея — чистый кадр для скриншотов | | `--clean` | стартовать без оверлея — чистый кадр для скриншотов |
| `--sf-tiles` | пол и стены из библиотеки SpriteForge + hot reload по `sf watch` |
| `--sf-enemies` | анимированные твари оттуда же (цикл шага вместо статичного спрайта) |
| `--sf` | и то, и другое разом |
| `--sprites` | что загрузчик видит в `build/sprites`, и выход |
| `--windowed` | окно 1440x810 вместо безрамочного на весь экран |
| `--sandbox` | прежняя песочница: мишени вместо тварей, отряд без снаряжения |
| `--ui-shot <экран>` | снять кадр с `menu` / `squad` / `pause` / `defeat` |
| `--rooms [проект]` | играть в проект (по умолчанию свой, `descent`) вместо захардкоженной карты |
| `--project <имя>` | то же явно: имя каталога в `projects/` или путь к нему |
| `--view <iso\|topdown>` | перебить вид отображения из манифеста проекта (отладочное) |
| `--seed N` | сид сборки уровня (по умолчанию 1) |
| `--depth N` | начать не с первого этажа (для проверок) |
| `--descend N` | прогон забега на N этажей без окна |
| `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты |
| `--no-profile` | не читать и не писать профиль |
`--rooms` на приёмку не влияет: `--accept` и `--headless` проект не подключают
вообще и меряют на прежней карте ([15-engine-and-editor.md](15-engine-and-editor.md)).
Примеры: Примеры:
@ -106,7 +146,43 @@ squad_proto.exe --headless 300 --no-solver --no-laneavoid # каким про
squad_proto.exe --headless 300 --melee 0 # чистый отряд стрелков squad_proto.exe --headless 300 --melee 0 # чистый отряд стрелков
squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0 squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0
squad_proto.exe --clean --shot 220 melee.png # кадр без отладки squad_proto.exe --clean --shot 220 melee.png # кадр без отладки
squad_proto.exe --sf-tiles # тайлы из build/sprites
squad_proto.exe --sf # тайлы + анимированные твари
squad_proto.exe --rooms --seed 42 # уровень из projects/descent/rooms
squad_proto.exe --project hollow # второй проект: вид сверху
squad_proto.exe --rooms --view topdown # свой проект чужим видом
``` ```
Редактор комнат — отдельное приложение с собственными ключами:
```bash
tile2d_editor.exe --project descent # правка комнат игры
tile2d_editor.exe --project hollow # другой проект
tile2d_editor.exe --check --project descent # проверить набор без окна, PASS/FAIL
tile2d_editor.exe --check --project hollow # то же для второго проекта, ЕГО кривой
tile2d_editor.exe --room arena # открыть конкретную комнату
tile2d_editor.exe --level 42 # сразу показать сборку уровня по сиду
```
Без `--project` редактор берёт единственный найденный проект. Проектов в
репозитории уже два, поэтому имя обязательно: молча взятый «первый попавшийся»
читался бы как «открылось не то». Игре имя можно не давать — она знает своё
(`descent`), и `--rooms` открывает именно его.
Ассеты для `--sf*` собираются отдельной командой и в git не попадают. Палитра
у библиотеки общая, поэтому манифесты собираются ВМЕСТЕ — иначе цвета тварей
легли бы на палитру, посчитанную по одному полу:
```bash
sf build assets/manifests/environment.yaml assets/manifests/enemies.yaml \
--output build/sprites --cache-dir .sfcache --jobs 8 --rebuild-palette
sf codegen --build-dir build/sprites --output generated/spriteforge_assets.h
```
Осечка на любом шаге оставляет процедурный набор целиком: полусобранный хуже
прежнего. Поэтому `--sf` — это улучшение картинки, а не условие запуска.
Порядок работы с манифестами — [12-assets.md](12-assets.md).
Код прогонов — [src/debug/harness.cpp](../src/debug/harness.cpp), маршрут и сценарии описаны там же. Код прогонов — [src/debug/harness.cpp](../src/debug/harness.cpp), маршрут и сценарии описаны там же.
Что означают цифры в выводе — [06-debug-and-tuning.md](06-debug-and-tuning.md). Что означают цифры в выводе — [06-debug-and-tuning.md](06-debug-and-tuning.md).

View File

@ -2,26 +2,68 @@
## Карта модулей ## Карта модулей
Целей сборки три, и граница между ними — линковка, а не договорённость
([15-engine-and-editor.md](15-engine-and-editor.md)):
``` ```
tile2d (STATIC) engine/ — окно и цикл, кадр, проекции 2D, тайлы, комнаты, проекты
├── squad_proto.exe игра Descent: src/ + projects/descent/
├── tile2d_editor.exe редактор комнат: editor/, инструмент ДВИЖКА
└── tile2d_walk.exe пример: samples/walk/, второй потребитель движка
```
Движок лежит в своём каталоге не для красоты: состав `tile2d` определяется тем,
куда положен файл, и попасть в движок случайно нельзя
([18-engine.md](18-engine.md)).
```
engine/ ДВИЖОК Tile2D: про правила боя не знает ничего
config.h размеры кадра, тайла и карты — то, что нельзя менять без пересборки
app.h/.cpp окно, кадр, fixed timestep. Цикл остаётся у приложения
math.h Vec2, отрезки, DistancePointToSegment, углы. Header-only
hash.h детерминированные хеши: один и тот же пол в каждом прогоне
projection.h/.cpp ЕДИНСТВЕННОЕ место, где движок знает про изометрию
view2d.h камера, список отрисовки, сортировка по глубине
framebuffer.h/.cpp софтверный буфер RGBA8, блит (в т.ч. со светом), аддитивные примитивы
pixel.h/.cpp ромб, обводка силуэта, ShadeColor
microfont.h/.cpp растровый шрифт 3x5: номер этажа формой не покажешь
theme.h сколько тем отделки и как они называются
tileset.h/.cpp пол и стены по темам; ими рисуют и игра, и редактор
catalog.h/.cpp каталог размещаемых сущностей (данные, не enum)
room.h/.cpp формат комнаты-шаблона, набор комнат
level_gen.h/.cpp сборка этажа из комнат по сиду. ЕДИНСТВЕННЫЙ RNG
progression.h/.cpp что означает глубина N; коэффициенты приходят из проекта
project.h/.cpp манифест проекта: пути, вид отображения, кривая
tilemap.h/.cpp карта, DDA-рейкаст, circle-vs-AABB
tilemap_draw.h/.cpp показать карту тайлсетом; дефолт для проектов без света
sfa_library.h/.cpp чтение библиотеки SpriteForge (см. docs/12-assets.md)
text_parse.h разбор строчных текстовых форматов движка
editor/ РЕДАКТОР: отдельный exe, линкуется ТОЛЬКО с движком
main.cpp окно, ключи, --check
editor.h/.cpp холст, кисти, палитра, таблицы весов, предпросмотр
samples/walk/ ПРИМЕР: минимальное приложение на движке (docs/18-engine.md)
projects/ СОДЕРЖИМОЕ. Игра — один из проектов, а не единственный
descent/ project.txt, rooms/, catalog/ — эта игра
hollow/ второй проект: вид сверху, своя кривая
src/ src/
main.cpp окно, ввод, главный цикл, fixed timestep. Игровой логики нет main.cpp окно, ввод, главный цикл, fixed timestep. Игровой логики нет
game.h/.cpp склейка: карта + отряд + мишени + пули + метрики, порядок шага game.h/.cpp склейка: карта + отряд + мишени + пули + метрики, порядок шага
tuning.h ВСЕ числа прототипа + таблица runtime-подкрутки tuning.h ВСЕ числа ИГРЫ + таблица runtime-подкрутки
core/ core/
math.h Vec2, отрезки, DistancePointToSegment, углы. Header-only
input.h InputState — ввод, влияющий на симуляцию input.h InputState — ввод, влияющий на симуляцию
render/ render/
view.h/.cpp ВСЁ render-only состояние + RenderGame(const Game&, ...) view.h/.cpp ВСЁ render-only состояние + RenderGame(const Game&, ...)
framebuffer.h/.cpp софтверный буфер RGBA8, блит (в т.ч. со светом), аддитивные примитивы
sprites.h/.cpp процедурная генерация всех спрайтов при старте sprites.h/.cpp процедурная генерация всех спрайтов при старте
iso.h проекция мир↔экран, камера, ключ сортировки по глубине hud.h/.cpp полосы отряда поверх кадра: ни одной буквы текста
scene.h/.cpp проход по полу + общий список сущностей и стен с сортировкой scene.h/.cpp проход по полу + общий список сущностей и стен с сортировкой
lighting.h/.cpp свет и туман войны (см. docs/10-atmosphere.md) lighting.h/.cpp свет и туман войны (см. docs/10-atmosphere.md)
fx.h/.cpp трассеры, вспышки дула, искры попаданий fx.h/.cpp трассеры, вспышки дула, искры попаданий
post.h/.cpp напряжение (Dread) и слитый пост-проход post.h/.cpp напряжение (Dread) и слитый пост-проход
noise.h единственное место render-only псевдослучайности noise.h единственное место render-only псевдослучайности
world/
tilemap.h/.cpp карта, генерация уровня, DDA-рейкаст, circle-vs-AABB
sim/ sim/
loadout.h класс оружия, параметры ствола и клинка, фазы удара loadout.h класс оружия, параметры ствола и клинка, фазы удара
agent.h/.cpp агент, слои движения (seek/separation/targetPush/wallAvoid) agent.h/.cpp агент, слои движения (seek/separation/targetPush/wallAvoid)
@ -29,12 +71,17 @@ src/
target.h/.cpp мишени, респавн, отдача от удара и возврат на место target.h/.cpp мишени, респавн, отдача от удара и возврат на место
bullet.h/.cpp снаряды, коллизии с тайлами и капсулами bullet.h/.cpp снаряды, коллизии с тайлами и капсулами
events.h кольцевой журнал боевых событий: единственный канал к эффектам events.h кольцевой журнал боевых событий: единственный канал к эффектам
items.h каталог предметов и инвентарь бойца (docs/13-combat.md)
enemy.h/.cpp поведение тварей: заметил -> идёт -> замах -> удар
spawn_catalog.h/.cpp единственный перевод id движка в EnemyKind игры
ai/ ai/
firing_solver.h/.cpp LaneClear, выбор цели, решатель огневых позиций firing_solver.h/.cpp LaneClear, выбор цели, решатель огневых позиций
lane_registry.h/.cpp активные линии огня и секторы клинков, импульс расступания lane_registry.h/.cpp активные линии огня и секторы клинков, импульс расступания
melee.h/.cpp цель ближнего боя, подход вплотную, чистота сектора melee.h/.cpp цель ближнего боя, подход вплотную, чистота сектора
ui/
menu.h/.cpp меню, пауза, экран отряда, поражение (второе место с raylib)
debug/ debug/
overlay.h/.cpp оверлей F1F4 поверх кадра (единственное место с raylib-примитивами) overlay.h/.cpp оверлей F1F4 поверх кадра (raylib-примитивы)
metrics.h/.cpp FRIENDLY_HITS, FIRE UPTIME, SETTLE TIME, журнал попаданий по своим metrics.h/.cpp FRIENDLY_HITS, FIRE UPTIME, SETTLE TIME, журнал попаданий по своим
harness.h/.cpp прогоны --headless и --accept harness.h/.cpp прогоны --headless и --accept
``` ```
@ -51,12 +98,28 @@ src/
тряска камеры физически не могут изменить симуляцию — это проверяет компилятор, тряска камеры физически не могут изменить симуляцию — это проверяет компилятор,
а не внимательность. Всё render-only состояние живёт в `View`, которое а не внимательность. Всё render-only состояние живёт в `View`, которое
headless-прогоны не создают вообще. headless-прогоны не создают вообще.
2. **`ai/` не знает ничего** про рендер, изометрию и ввод. Только `core/`, `sim/`, `world/`. 2. **`ai/` не знает ничего** про рендер, проекцию и ввод. Только `core/`, `sim/`
3. **`sim/` и `world/` работают в декартовом мире** (top-down), тайл = 1.0 юнита. и тайлмап движка.
Изометрия существует только в `render/` и в оверлее. 3. **`sim/` и тайлмап работают в декартовом мире**, тайл = 1.0 юнита. Про то,
ромбами картинка или квадратами, знает одна `engine/projection.h`; всё
остальное считает через неё ([18-engine.md](18-engine.md)).
4. Ввод в экранных осях переводится в мировое направление ровно в одном месте — 4. Ввод в экранных осях переводится в мировое направление ровно в одном месте —
`Game::Step` через `iso::ScreenDirToWorld`. Дальше по симуляции экранных осей нет. `Game::Step` через `v2d::ScreenDirToWorld`. Дальше по симуляции экранных осей нет.
5. `tuning.h` не зависит ни от чего; его включают все. 5. `tuning.h` — числа ИГРЫ, и от движка он зависит ровно одним включением
`engine/config.h`. Обратной стрелки нет: движок не включает из игры ничего,
и это проверяет компилятор — `src/` просто не лежит в путях включения `tile2d`.
6. **Боевой режим не касается приёмки.** Твари, снаряжение и урон по бойцам
живут под `Game::combatMode`; `--accept` и `--headless` его не включают
([13-combat.md](13-combat.md)). Мишень `EnemyKind::DUMMY` ведёт себя ровно
как до этой вехи.
7. **Движок не знает про правила боя.** `engine/` и `editor/` не
включают ничего из `sim/`, `ai/` и `ui/`, и это проверяет компоновщик:
редактор просто не линкуется с ними. Содержимое игры доходит до редактора
данными — каталог сущностей и файлы комнат ([15-engine-and-editor.md](15-engine-and-editor.md)).
8. **Формат `.sfa` знает только `engine/sfa_library`.** Остальной рендер видит
обычный `Sprite`, и ему безразлично, процедурный тот или собранный. Игра без
собранных ассетов запускается и считает те же цифры: базовый набор спрайтов
процедурный и ни от одного файла на диске не зависит ([12-assets.md](12-assets.md)).
## Порядок одного шага симуляции ## Порядок одного шага симуляции
@ -86,6 +149,9 @@ src/
- Фиксированный шаг 1/60, накопитель времени, максимум 5 шагов за кадр. - Фиксированный шаг 1/60, накопитель времени, максимум 5 шагов за кадр.
- Ни одного вызова RNG в симуляции. Уровень и расстановка мишеней захардкожены. - Ни одного вызова RNG в симуляции. Уровень и расстановка мишеней захардкожены.
Уровень, собранный из комнат (`--rooms`), этого не меняет: RNG крутится один
раз, в `engine/level_gen.cpp`, до первого `Game::Step`, и полностью определён
сидом. `--accept` и `--headless` набор комнат не подключают вообще.
- Вырожденные случаи разрешаются по индексу слота, а не случайно - Вырожденные случаи разрешаются по индексу слота, а не случайно
(например, расталкивание двух агентов, стоящих в одной точке). (например, расталкивание двух агентов, стоящих в одной точке).
- Камера обновляется в рендере по реальному времени кадра и на симуляцию не влияет. - Камера обновляется в рендере по реальному времени кадра и на симуляцию не влияет.

View File

@ -2,7 +2,7 @@
## Фреймбуфер ## Фреймбуфер
[src/render/framebuffer.h](../src/render/framebuffer.h) — `std::vector<uint32_t>` 480×270. [engine/framebuffer.h](../engine/framebuffer.h) — `std::vector<uint32_t>` 480×270.
Формат — RGBA8, в памяти байты идут `R,G,B,A`, значит little-endian `uint32` это Формат — RGBA8, в памяти байты идут `R,G,B,A`, значит little-endian `uint32` это
`A<<24 | B<<16 | G<<8 | R`. Помощник `RGBA(r,g,b,a)` собирает цвет правильно. `A<<24 | B<<16 | G<<8 | R`. Помощник `RGBA(r,g,b,a)` собирает цвет правильно.
@ -26,7 +26,7 @@
## Изометрия ## Изометрия
[src/render/iso.h](../src/render/iso.h). Тайл 32×16: [engine/view2d.h](../engine/view2d.h). Тайл 32×16:
``` ```
sx = (wx - wy) * 16 sx = (wx - wy) * 16

View File

@ -2,7 +2,7 @@
## Тайлмап ## Тайлмап
[src/world/tilemap.h](../src/world/tilemap.h). Сетка 48×48, тайл `EMPTY` или `WALL`. [engine/tilemap.h](../engine/tilemap.h). Сетка 48×48, тайл `EMPTY` или `WALL`.
Тайл = 1.0 юнита, координаты сущностей — `float`. Тайл = 1.0 юнита, координаты сущностей — `float`.
**Вне карты считается стеной.** Это убирает граничные проверки из рейкаста и коллизий. **Вне карты считается стеной.** Это убирает граничные проверки из рейкаста и коллизий.

View File

@ -12,6 +12,23 @@
| **M5** | ручное прицеливание, конус ±20°, назначенная цель, `Tab` | сценарий `11.7` в `--accept`: 0 попаданий по своим при активной назначенной цели | | **M5** | ручное прицеливание, конус ±20°, назначенная цель, `Tab` | сценарий `11.7` в `--accept`: 0 попаданий по своим при активной назначенной цели |
| **M6** | оверлей F1F4, метрики, журнал попаданий по своим, runtime-тюнинг `[ ] - =`, `--set` | скриншот боевого кадра: зелёные линии у всех пятерых, FRIENDLY_HITS 0, uptime 99 % | | **M6** | оверлей F1F4, метрики, журнал попаданий по своим, runtime-тюнинг `[ ] - =`, `--set` | скриншот боевого кадра: зелёные линии у всех пятерых, FRIENDLY_HITS 0, uptime 99 % |
| **M7** | ближний бой: классы оружия, сектор удара, три фазы, свой больший круг, дуга; отзывчивый диск на поводке | `--accept`: M7.1M7.3 PASS, 81 взмах / 81 попадание / 23 убийства за 30 с, FRIENDLY_HITS 0; все критерии спека 11 по-прежнему PASS | | **M7** | ближний бой: классы оружия, сектор удара, три фазы, свой больший круг, дуга; отзывчивый диск на поводке | `--accept`: M7.1M7.3 PASS, 81 взмах / 81 попадание / 23 убийства за 30 с, FRIENDLY_HITS 0; все критерии спека 11 по-прежнему PASS |
| **M8** | конвейер ассетов SpriteForge: манифесты, загрузчик `.sfa` в игровой `Sprite`, hot reload по `sf watch`, dev-режимы `--sprites` и `--sf-tiles` | `sf validate` 0 ошибок, `--sprites` читает все три ассета, тайлы подменены на живой игре без перезапуска ([12-assets.md](12-assets.md)) |
| **M9** | игровой слой: 5 стволов и 3 клинка, 4 вида тварей с телеграфом удара, снаряжение с инвентарём, HUD, меню, экран отряда, безрамочный полный экран | `--accept` PASS без изменений (бой живёт под `combatMode`, приёмка — в песочнице), 36 с боя без потерь в отряде ([13-combat.md](13-combat.md)) |
| **M10** | разрушаемая обстановка (ящик, бочка, шкафчик) в пуле `Target` с последним приоритетом цели; силуэты тварей вместо капсул + `LURKER`; варианты плиты пола; анимированные твари из SpriteForge (`--sf-enemies` / `--sf`) | `--accept` PASS без изменений (обстановки в песочнице нет), метки целей в бою — только твари при живом противнике рядом; `sf validate` 0 ошибок на 8 ассетах, `--sprites` читает все шесть циклов шага ([13-combat.md](13-combat.md), [12-assets.md](12-assets.md)) |
| **M11** | движок отдельной целью сборки (`tile2d`) и редактор комнат отдельным приложением (`tile2d_editor.exe`); каталог размещаемых сущностей данными; формат комнаты-шаблона с таблицами весов спавна; сборка уровня из комнат по сиду; темы отделки слоем `Tilemap::theme[]` | `tile2d_editor --check`: 8 комнат OK, круговой прогон через запись сходится, 5 сидов без недостижимых и срезанных сущностей; `--accept` PASS без изменений (набор комнат приёмка не подключает) ([15-engine-and-editor.md](15-engine-and-editor.md)) |
| **M12** | спуск как жанр: глубина первичным понятием движка (`DescribeFloor`), бюджет опасности вместо голого шанса, лут на полу с пассивным подбором, точка спуска и перенос отряда между этажами, сжатие обзора с глубиной, растровый шрифт 3x5 для номера этажа | `tile2d_editor --check`: покрытие глубин 1..16 и сборка на 1/3/6/10/16/24 без ошибок, бюджет выбирается полностью; `--descend 8`: сумки доезжают вниз (8→30 предметов); `--accept` PASS без изменений ([16-descent.md](16-descent.md)) |
| **M13** | экстракшен: эвакуация там, где вошли, профиль со схроном и снаряжением между вылазками, чекпоинты каждые 5 этажей, экран итога с вынесенным и потерянным, сборы на экране отряда | `--descend 7`: сумки доезжают вниз, эвакуация уносит 40 предметов, профиль сходится после записи; `--accept` PASS без изменений ([17-extraction.md](17-extraction.md)) |
| **M14** | Tile2D как самостоятельный движок: `engine/` своим каталогом и целью, `engine/config.h` вместо зависимости от `tuning.h`, проекция отдельным швом и второй вид отображения (сверху), формат проекта с путями и кривой глубины, игра стала проектом `projects/descent` | компилятор нашёл три зависимости движка от игры (`tuning.h`, `MAX_TARGETS`, `SQUAD_START_*`) — все сняты; `tile2d_editor --check` и `--descend` проходят на ОБОИХ проектах, на втором сразу нашлась глухая камера в комнате `maw`; `--accept` PASS без изменений ([18-engine.md](18-engine.md)) |
| **M15** | движок и языковая модель: инструменты `--tool` (проект, комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора), скиллы `.claude/commands/{tile2d,room,floor}.md`, терминал в редакторе с режимами shell/claude/codex и внешним интерактивным CLI | `--tool validate` даёт код 0 на обоих проектах и код 1 на подложенной поломке; `claude -p` отвечает из терминала редактора (docs/editor-terminal.png); `--accept` PASS без изменений ([19-agent.md](19-agent.md)) |
| **M16** | цикл в движке: `App` (окно, безрамочный режим, кадр, fixed timestep с интерполяцией) вместо двух копий в игре и редакторе; `DrawTilemap` — движок научился показывать свою карту; третье приложение `samples/walk` | игра и редактор перешли на `App` без изменений в поведении: `--accept` PASS, забеги и `--tool validate` те же; пример собирает и рисует этаж, не линкуясь ни с чем из `src/` ([18-engine.md](18-engine.md)) |
| **M17** | вес: у каждого предмета своя тяжесть, два порога переноски (до нормы свободно, дальше медленнее, выше предела не поднимет), сброс вещи под ноги правой кнопкой, нагрузка в HUD и на экране отряда | проверка правила в `--descend`: налегке 6.9 → 100% скорости, с грузом 12.9 → 63%, отказ приходит по ВЕСУ, а не по ячейкам; две первые калибровки порогов провалились молча и были пойманы этой же проверкой; `--accept` PASS без изменений ([17-extraction.md](17-extraction.md)) |
| **M18** | ресурс вместо таймера: конечный боезапас на вылазку (перезарядка тянет из запаса, запас едет вниз), пачка патронов как находка, расходники применяются сами — бинт вне схватки, патроны когда стрелять нечем | проверка правил в `--descend`: запас 40 → 16 при магазине 24, бинт лечит 33 → 58, обе вещи уходят из сумки; проверка нашла, что боец с клинком не тратил найденные патроны при запасном стволе в сумке; `--accept` PASS без изменений — в песочнице боезапас бесконечен намеренно ([17-extraction.md](17-extraction.md)) |
| **M19** | сумка одна на отряд (16 ячеек, вес делится на живых), надетое остаётся личным; подсказка под курсором на каждой вещи и на каждой строке характеристик | `--descend 8`: из сумки в схрон доезжают все 16, эвакуация выносит 26; проверка переноса поймала, что опыт с расходниками разбирал сумку до замера, и что массив виджетов экрана переполнился — из схрона молча пропали ячейки; `--accept` PASS без изменений ([17-extraction.md](17-extraction.md)) |
| **M20** | опыт тратится: четыре ресурсных улучшения отряда (патроны, переноска, обзор, живучесть) в профиле, экран UPGRADES, цена растёт с уровнем | проверка в `--descend`: покупка списывает опыт, здоровье 110 → 122 и запас 120 → 140 на следующей вылазке; проверка дважды ломала сам прогон (вызывала NewRun посреди забега) и была отправлена в конец; `--accept` PASS — приёмка профиля не подключает ([17-extraction.md](17-extraction.md)) |
## Что находилось по ходу и чинилось ## Что находилось по ходу и чинилось

View File

@ -25,7 +25,7 @@ Clear(чёрный) ступень 0 рампы == цве
## Почему свет вплавлен в блит, а не сделан пост-проходом ## Почему свет вплавлен в блит, а не сделан пост-проходом
Соблазн очевидный: пройти по 129600 пикселям, для каждого получить мировую точку Соблазн очевидный: пройти по 129600 пикселям, для каждого получить мировую точку
через `iso::ScreenToWorld` и умножить на освещённость. Так делать нельзя. через `v2d::ScreenToWorld` и умножить на освещённость. Так делать нельзя.
Спрайт стены имеет привязку `ay = TILE_HALF_H + TILE_H = 24`, то есть верхняя Спрайт стены имеет привязку `ay = TILE_HALF_H + TILE_H = 24`, то есть верхняя
грань поднята на 16 px над ромбом своего тайла. Обратная проекция этого пикселя грань поднята на 16 px над ромбом своего тайла. Обратная проекция этого пикселя

211
docs/12-assets.md Normal file
View File

@ -0,0 +1,211 @@
# 12. Ассеты: конвейер SpriteForge
## Что где лежит
SpriteForge — внешний инструмент, отдельный репозиторий:
`C:\Users\uuu\Documents\spriteforge`. Игра его исходники не трогает и ничего
под конкретную игровую сущность в нём не правит.
Игра хранит только:
| Путь | Что это | В git |
|---|---|---|
| `assets/manifests/*.yaml` | описания ассетов — единственный источник истины | да |
| `assets/sources/` | исходные PNG и риги для бэкендов `import` / `blender` | да |
| `generated/spriteforge_assets.h` | числовые ID ассетов, вывод `sf codegen` | да |
| `build/sprites/` | собранные `.sfa`, `palettes.sfp`, `index.json` | нет |
| `.sfcache/` | контентный кеш сборки, одноразовый | нет |
Собранная библиотека живёт внутри `build/`, поэтому полная очистка каталога
сборки сносит и её. Восстановление — одна команда `sf build`; кеш `.sfcache/`
лежит отдельно и переживает такую чистку, так что пересборка почти мгновенная.
## Установка
```powershell
cd C:\Users\uuu\Documents\spriteforge
python -m pip install -e .
sf version
```
Установка editable: правки в SpriteForge видны игре без переустановки.
Если `sf` не находится, каталог скриптов Python не в PATH — либо добавить
`%APPDATA%\Python\Python313\Scripts`, либо звать `python -m spriteforge.cli`.
## Добавить ассет
Порядок ровно такой, шаги не пропускать:
1. Поправить манифест в `assets/manifests/`.
2. План сборки, без записи файлов:
```powershell
sf build assets\manifests\environment.yaml --asset stone_altar --dry-run
```
3. Собрать только этот ассет:
```powershell
sf build assets\manifests\environment.yaml --asset stone_altar `
--output build\sprites --cache-dir .sfcache
```
4. Проверить текстом:
```powershell
sf validate assets\manifests\environment.yaml --build-dir build\sprites
sf ascii stone_altar --build-dir build\sprites
```
5. Обновить числовые ID:
```powershell
sf codegen --build-dir build\sprites --output generated\spriteforge_assets.h
```
Сборка всей библиотеки:
```powershell
sf build assets\manifests\environment.yaml --output build\sprites `
--cache-dir .sfcache --jobs 8
sf validate assets\manifests\environment.yaml --build-dir build\sprites
sf codegen --build-dir build\sprites --output generated\spriteforge_assets.h
```
`--rebuild-palette` пересчитывает общую палитру библиотеки и допустим только
при полной сборке без фильтров: частичная пересборка палитры обесценила бы
индексы в незатронутых ассетах.
## Контроль результата
Агент проверяет сборку **только текстом**: `sf list`, `sf describe`, `sf ascii`,
`sf validate`, `sf stats`. `sf contact-sheet` делает PNG для человека — агент
его не открывает.
Со стороны C++ есть своя проверка: `squad_proto.exe --sprites` печатает, что
загрузчик реально видит в библиотеке.
```
build/sprites: 3 ассет(ов)
ID HASH FRAMES SIZE PIVOT
floor_slab 78D3F27E 1 30x16 15,8
floor_slab_dim 30CD3FBB 1 30x16 15,8
wall_block 892CB17B 1 30x32 15,31
```
## Загрузчик
`engine/sfa_library.h` — единственное место игры, знающее про формат `.sfa`.
Заголовок загрузчика `sfa.h` берётся из репозитория SpriteForge, копии в игре
нет; путь задаётся кешируемой переменной CMake:
```powershell
cmake -S . -B build -DSPRITEFORGE_DIR=<путь к репозиторию spriteforge>
```
По умолчанию `SPRITEFORGE_DIR` = `../spriteforge` рядом с игрой.
Слой ровно один: кадр `.sfa` (индексы палитры + RLE по строкам) превращается в
обычный игровой `Sprite` с альфа-тестом. Дальше по коду разницы между
процедурным спрайтом и собранным нет: тот же `Blit`, тот же свет, та же
сортировка.
**Строковых ID ассетов в коде игры нет.** Обращение идёт по константам
`SF_ASSET_*` из `generated/spriteforge_assets.h`. Строка живёт ровно в двух
местах: в манифесте и в имени файла `<asset_id>.sfa`, по которому загрузчик и
собирает свой индекс.
### Холст и точка привязки
SpriteForge обрезает кадр по силуэту, поэтому размер кадра плывёт вместе с
содержимым: ромб 32x16 приходит как 30x16 с pivot (15,8). Для декора это
безразлично, для тайлов — нет: `ShadeQuad` из `Lighting::QuadFor` считается в
координатах спрайта и ждёт ромб на своём месте (docs/10-atmosphere.md).
Съехавший на пиксель холст увёл бы свет пола относительно света стен.
Поэтому `SpriteLibrary::Read` принимает `FramePlacement`: холст нужного размера
и точка, в которую на нём ложится pivot кадра. Тайлы просят канонический
`TILE_W x TILE_H`, остальное берётся в натуральную величину.
## Тайлы из библиотеки и hot reload
По умолчанию весь набор спрайтов процедурный и ни от одного файла не зависит —
`--accept` и `--headless` считают то же, что считали раньше. Пол и стены можно
подменить собранными:
```powershell
build\bin\squad_proto.exe --sf-tiles
```
Осечка на любом шаге (нет библиотеки, нет ассета, битый файл) оставляет
процедурный набор целиком: полусобранный хуже прежнего.
Бойцы и клинки остаются процедурными сознательно: там цвет занят номером агента
и раздаётся из кода, а не из манифеста.
## Анимированные твари
```powershell
build\bin\squad_proto.exe --sf-enemies # только твари
build\bin\squad_proto.exe --sf # тайлы и твари разом
```
Манифест — `assets/manifests/enemies.yaml`, по одному ассету `<kind>_walk` на
вид. **Один ассет = один клип.** Бэкенд `import` имён клипов не читает и всегда
кладёт единственную анимацию `default`, поэтому `idle` и `attack`, когда до них
дойдёт дело, станут отдельными ассетами `<kind>_idle`, а не второй анимацией
внутри существующего. Правку SpriteForge ради этого заводить не нужно.
### Откуда берутся листы
`assets/sources/make_enemy_sheets.py` — детерминированный генератор:
```powershell
python assets\sources\make_enemy_sheets.py
```
Он рисует **ту же геометрию**, что процедурный запасной вариант в
`src/render/sprites.cpp` (объединение эллипсов, свет по верхне-левой кромке,
обводка по силуэту), и лишь сдвигает конечности по фазе шага. Это не удобство,
а требование: разъедься силуэты — и `--sf-enemies` менял бы не плавность
движения, а саму тварь, которую игрок узнаёт по пятну. Цвета в скрипте — копия
`EnemyDef` из `src/sim/target.cpp`, и разойтись им нельзя по той же причине.
Ни одного случайного числа: перегенерация даёт побитово тот же PNG.
### Кто и что хранит
Кадры лежат в `SpriteLibrary` в одном экземпляре на ассет. `SpriteSet` держит
только ID (`enemyWalk[kind]`, 0 — «рисуй процедурный»), а фазу клипа — `View`:
- `View::enemyAnim[]``Animator` на каждую цель;
- `View::enemyPrevPos[]` — позиция прошлого кадра.
Фаза шага живёт в `View`, а не в `Target`, ровно по правилу
[02-architecture.md](02-architecture.md): попав в симуляцию, она поехала бы в
цифрах приёмки. Шаг проигрывается **только когда тварь реально идёт**
скорости у `Target` нет (`vel` — это отдача от удара), поэтому движение
определяется сравнением позиций. Иначе цикл крутился бы у стоящего плевка,
который по замыслу держит дистанцию, и читался бы как ошибка.
`LoadEnemies` — всё или ничего: одна анимированная тварь среди четырёх
статичных выглядит хуже пяти статичных.
Watch-режим в отдельном терминале:
```powershell
sf watch assets\manifests\environment.yaml --output build\sprites `
--cache-dir .sfcache --jobs 8
```
После каждой успешной пересборки SpriteForge атомарно переписывает
`build/sprites/reload.json`. Игра опрашивает его раз в кадр и перечитывает
библиотеку целиком. Новая библиотека собирается рядом и подменяет старую только
если собралась целиком — недописанная пересборка не гасит картинку.
Кровь при перезагрузке сбрасывается по той же причине, по которой она не
переживает смену уровня: она запечена в спрайты пола, а пол уже другой
(docs/10-atmosphere.md).
## Чего не делать
- не открывать `build/sprites/*.sfa`, `.sfcache/`, сгенерированные PNG и
contact sheet — для проверки есть текстовые команды;
- не редактировать `generated/spriteforge_assets.h` руками: это вывод
`sf codegen`;
- не заводить строковые ID ассетов в C++;
- не править репозиторий SpriteForge ради одной игровой сущности: если не
хватает генератора — это отдельная задача в его репозитории.

243
docs/13-combat.md Normal file
View File

@ -0,0 +1,243 @@
# 13. Бой: оружие, твари, снаряжение, экраны
Прототип из вех M0M6 проверял ОТРЯД: строй, линии огня, правило «не бить
своих». Противника там не было — были мишени, которые стоят и не отвечают.
Эта веха достраивает вторую половину: твари, которые идут и бьют, каталог
оружия, снаряжение и экраны вне боя.
![вылазка](raid.png)
## Два режима, и это важно
| Режим | Что в мире | Кто им пользуется |
|---|---|---|
| боевой (по умолчанию) | твари 5 видов, на бойцах комплекты снаряжения | игра |
| песочница (`--sandbox`) | прежние мишени, отряд без снаряжения | ручные проверки |
| приёмка (`--accept`, `--headless`) | всегда песочница | замеры критериев |
Разделение не косметическое. Броня несёт множитель скорости, а на скорости
движения меряются критерии 11.4 и 11.5; твари бьют, а от урона меняется состав
живых. Поэтому `Game::combatMode` выключен во всех прогонах приёмки, мишень
`EnemyKind::DUMMY` ведёт себя ровно как раньше, и `--accept` считает те же
цифры, что считал до этой вехи ([09-acceptance.md](09-acceptance.md)).
## Оружие
Каталог — [src/sim/weapon_defs.h](../src/sim/weapon_defs.h). Карабин и сабля
по-прежнему читают числа из `tuning.h`: это оружие по умолчанию, и все прежние
ручки оверлея крутят именно их ([08-decisions.md](08-decisions.md)).
| Ствол | Дальность | Интервал | Урон | Магазин | Чем отличается |
|---|---|---|---|---|---|
| CARBINE | 7.2 | 0.35 | 12 | 24 | штатный: ровный во всём |
| PISTOL | 4.6 | 0.22 | 8 | 7 | ближе и слабее, зато быстрая перезарядка |
| SMG | 5.4 | 0.09 | 6 | 40 | шквал; увод копится быстрее, чем оседает |
| SHOTGUN | 3.4 | 0.85 | 26 x6 | 5 | залп дробин, в упор рвёт |
| MAGNUM | 8.6 | 0.95 | 78 | 6 | дальше всех и валит с ног, но раз в секунду |
| Клинок | Досягаемость | Дуга | Урон | Чем отличается |
|---|---|---|---|---|
| SABER | 1.40 | 100° | 130 | штатный |
| KNIFE | 1.00 | 55° | 62 | почти без замаха; узкий сектор чаще чист |
| CLEAVER | 1.65 | 135° | 240 | тяжёлый; занести его в тесноте негде |
**Картечь и правило 6.3.** Дробины уходят в ТОТ ЖЕ конус `SpreadRad`, который
проверен на своих перед спуском. Правило не ослаблено ни на градус — просто у
дробовика конус широкий, и выстрел отсекается заметно чаще. Это его цена, а не
исключение из правила.
## Твари
Каталог — [src/sim/target.cpp](../src/sim/target.cpp), поведение —
[src/sim/enemy.cpp](../src/sim/enemy.cpp).
| Вид | HP | Скорость | Урон | Замах | Атака | Роль |
|---|---|---|---|---|---|---|
| SHAMBLER | 210 | 0.95 | 13 | 0.42 | SWIPE | основа толпы; страшен числом |
| RUSHER | 110 | 3.3 | 14 | 0.28 | LUNGE | добегает до перезарядки |
| SPITTER | 110 | 1.15 | 16 | 0.55 | SPIT | единственный, кто достаёт издалека |
| BRUTE | 520 | 1.05 | 46 | 0.95 | SLAM | разбирать вдвоём |
| LURKER | 150 | 1.90 | 9/тик | 0.50 | GRAB | вырывает бойца из строя |
Поведение намеренно простое: заметил ближайшего живого бойца — идёт — дошёл —
**замахивается** — бьёт. Замах (`windupTimer`) виден всегда: тело подсвечивается
и глаза вспыхивают ровным светом даже в темноте. Между замахом и ударом у
игрока есть время увести бойца. Без телеграфа удар твари неотличим от случайной
потери здоровья, и бой перестаёт быть боем.
Ось удара на замахе НЕ доводится: тварь бьёт туда, куда замахнулась. Ровно это
и делает уклонение возможным.
### Пять атак — пять разных задач игроку
Один удар на всех делал любую тварь одинаковой: подошла — сняла здоровье.
Разная ГЕОМЕТРИЯ (`AttackKind`, [src/sim/target.h](../src/sim/target.h)) — то
единственное, ради чего отряд приходится расставлять по-разному.
| Атака | Форма | Что требует от игрока |
|---|---|---|
| SWIPE | сектор `swipeArcDeg` (100° у бредущего) в пределах `attackRange` | уходить **вбок**: назад от короткого удара не успеть |
| LUNGE | рывок на `lungeDist` (3.4) за `lungeTime` (0.20 с); урон всем, кого задело по пути | освобождать **ось**, а не точку: на прямой броска безопасных мест нет |
| SPIT | снаряд `SpawnHostileBullet`, летит `spitRange` (8.4) — дальше, чем плевок «достаёт» | **единственная** атака, которую перекрывает стена |
| SLAM | круг `slamRadius` (2.15) вокруг твари | не толпиться возле громилы: второй боец платит наравне с целью |
| GRAB | захват на `grabTime` (2.6 с), урон тикает раз в `grabTick` (0.45 с) | вытаскивать своего **огнём** |
Рывок — это движение по времени, а не телепорт: позиция бегуна меняется каждый
шаг и упирается в стены как обычно (`map.ResolveCircle`). Каждого бойца один
рывок задевает не больше одного раза (`lungeMask`). Откат считается от КОНЦА
броска, а не от его начала.
Захват держит **ноги и только их**. `Squad::UpdateMovement` не двигает бойца с
`grabbedTimer > 0` — ни строй, ни решатель, ни расступание из чужой линии.
Стрельбу и клинок захват не читает вообще: правило 6.3 и сектор удара работают
как обычно, иначе единственным выходом из хватки был бы чужой выстрел. Сам
таймер гаснет, а держащая тварь продлевает его каждый шаг на `enemyGrabHold`:
убили хвата — продлевать некому, и боец свободен через одну шестую секунды.
Поэтому про захват не знают ни `bullet.cpp`, ни `melee.cpp`.
Общих ручек всего две, обе в [src/tuning.h](../src/tuning.h):
`enemyWindupScale` (длина всех телеграфов разом) и `enemyGrabHold`. Формы —
`swipeArcDeg`, `lungeDist`, `lungeTime`, `spitRange`, `slamRadius`, `grabTime`,
`grabTick`
свойства ВИДА и живут в `EnemyDef` рядом с остальными статами.
Эффекты получают отдельные события (`sim/events.h`): `ENEMY_WINDUP`,
`ENEMY_LUNGE`, `ENEMY_SLAM``owner` = индекс ТВАРИ; `ENEMY_HIT`, `ENEMY_GRAB`,
`ENEMY_RELEASE``owner` = индекс БОЙЦА. Индексы из разных массивов, путать их
нельзя.
**DUMMY по-прежнему инертна.** У мишени приёмки вид атаки выставлен формально, с
нулевыми уроном и дальностью, а `UpdateEnemies` до неё вообще не доходит:
`--accept` меряет ровно то же, что мерял.
Упавшего бойца твари не добивают — иначе отряд, потерявший одного, теряет всех
подряд, пока игрок ничего не может сделать.
Расстановка: `NUM_TARGETS` кучек по прежним точкам приёмки плюс
`NUM_EXTRA_ENEMIES` тварей по всей карте — без вторых зачистка первой кучки
заканчивала вылазку. Хваты стоят только в глубине: захват — ловушка для отряда,
который уже разошёлся по позициям, а не встречающий у порога.
## Разрушаемая обстановка
Ящики, бочки и шкафчики (`CRATE`, `BARREL`, `LOCKER`) живут **в том же пуле
`Target`**, что и твари. Заводить под них вторую сущность было незачем: пуля,
клинок, стена и правило 6.3 работают с ними одинаково — как с телом, у которого
есть позиция, радиус и HP. Ни одну из этих систем трогать не пришлось.
Отличий ровно четыре, и каждое проверяется через `Target::IsProp()`:
| Где | Что иначе |
|---|---|
| `UpdateEnemies` | не шевелится: ни скорости, ни агро, ни удара |
| `UpdateTargets` | не воскресает — разбитый хлам остаётся разбитым |
| выбор цели в `ai/` | **последний приоритет** (ниже) |
| события | `HIT_PROP` / `PROP_BREAK` вместо `HIT_TARGET` / `HIT_KILL` |
Отдельные виды событий нужны не для красоты: слой крови
([src/render/blood.cpp](../src/render/blood.cpp)) знает только позицию события,
и отличить попадание в бочку от попадания в тварь можно ТОЛЬКО видом события.
Иначе ящик кровоточит. Заодно разбитый хлам не идёт ни в `KILLS`, ни в
напряжение, ни в счётчик ударов клинком.
### Приоритет: обстановка — всегда последняя
Правило одно на оба класса оружия и живёт в одной функции —
`EnemyNearby` ([src/ai/firing_solver.h](../src/ai/firing_solver.h)):
> пока в радиусе `propYieldRadius` есть хоть одна живая тварь, ящик целью
> не становится **вообще**.
И стрелок (`EvaluateAgentLane`), и клинок (`EvaluateMeleeTarget`) делают два
прохода по целям: сначала твари, и только при полностью пустом первом проходе —
обстановка. Даже если линию до твари перекрыл свой, а до ящика она чиста, боец
переступает (решатель 6.5), а не разряжает магазин в мебель.
Радиус **свой**, а не дальность ствола. С дальностью правило читалось бы как
«не достаю — стреляю по ящику», и подошедшую тварь боец встречал бы на
перезарядке. Стены его не отменяют намеренно: тварь за углом в пяти шагах — всё
ещё повод не заниматься мебелью.
Линия к хламу никогда не `priorityLane`: приоритет запрещает решателю искать
позицию получше, а ради ящика боец переступать обязан.
Назначенная игроком цель (6.8) правило обходит — это осознанно: явный приказ
сильнее автоматики.
## Снаряжение
[src/sim/items.h](../src/sim/items.h). Предмет даёт бойцу ровно четыре числа:
броня, скорость, запас здоровья, дальность обзора. **Ни один предмет не может
ослабить правило «не бить своих»** — у него просто нет такой ручки, и это не
самоограничение, а устройство: `FireWeapons` и `UpdateMelee` про инвентарь не
знают вообще, они читают уже готовые статы агента.
Броня складывается, но упирается в `ARMOR_CAP = 0.72`: неуязвимых нет.
| Броня | Поглощение | Скорость | Прочее |
|---|---|---|---|
| JACKET | 10% | 100% | — |
| VEST | 28% | 95% | +10 HP |
| PLATE | 48% | 82% | +25 HP |
| SHROUD | 4% | 112% | обзор 92% |
В карман: LANTERN (обзор 135%), CHARM (+30 HP), SPURS (скорость 114%).
Расходники: BANDAGE, STIM.
Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (`Game::EquipKits`):
остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав
отряда читается прямо по строю.
## Экраны
![меню](menu.png)
Меню, пауза, экран отряда и поражение живут в [src/ui/menu.cpp](../src/ui/menu.cpp).
Это второе и последнее место в игре, где разрешены примитивы raylib (первое —
`debug/overlay`): здесь нужны слова, а собственный шрифт на 480x270 стоил бы
дороже всего остального UI вместе взятого. Мир по-прежнему рисуется только в
софтверный фреймбуфер, меню ложится поверх готового кадра.
![отряд](squad.png)
Экран отряда (`I` в бою) показывает те же числа, которые читает симуляция:
HP, броню, скорость, обзор. `ENTER` меняет местами оружие в руках и выбранную
ячейку сумки — отряд не носит воздух, у него всегда что-то в руках.
## HUD
[src/render/hud.cpp](../src/render/hud.cpp) рисуется в тот же софтверный
фреймбуфер, что и мир, — значит попадает под ту же сетку пикселей. Текста в нём
нет ни одной буквы:
- цвет полосы — номер бойца, тот же, что у его тела на карте;
- длина полосы — здоровье, тонкая полоска под ней — броня;
- квадратики — патроны, гаснут по одному; сплошная полоса — перезарядка;
- у клинка вместо патронов фаза удара: замах, удар, восстановление;
- уголок над карточкой — боец при цели;
- карточка перечёркнута — боец упал.
## Смерть бойца
Мёртвый агент остаётся в массиве: `NUM_AGENTS` — величина времени сборки, по
ней ходят все циклы, слоты строя, метрики и оверлей. Убирать его из пула значило
бы переиндексировать полмира на каждой смерти. Поэтому смерть — это флаг
`Agent::alive`, и его проверяют все системы: движение, расталкивание, реестр
линий, решатель, проверка «свой на линии», сектор клинка, пули.
Павший не перекрывает линию огня и не попадает под лезвие. Тело остаётся на
полу до конца вылазки.
## Сложность
Всё крутится на ходу через оверлей или `--set`:
```bash
squad_proto.exe --set enemyDamage=1.6 # во сколько раз больнее бьют
squad_proto.exe --set agentHP=60 # запас бойца до брони
squad_proto.exe --set enemyRespawn=8 # как быстро твари возвращаются
```
Числа видов правятся в `EnemyDefOf`, числа предметов — в `ItemDefOf`.
Баланс на 24 тварей и 5 бойцов подобран грубо и ждёт живых прогонов: отряд,
стоящий на месте, держится, но постепенно теряет здоровье.

109
docs/14-monsters.md Normal file
View File

@ -0,0 +1,109 @@
# 14. Твари: конвейер ассетов
Blender-first конвейер для пяти видов: `shambler`, `rusher`, `spitter`,
`brute`, `lurker`. Отдельный от агентского и намеренно.
## Зачем отдельный конвейер
У агента слои бумажной куклы обязаны совпасть **пиксель в пиксель**: тело,
броня и оружие рисуются на одном скелете, и любое расхождение видно сразу.
Отсюда в `tools/agent_assets.py` калибровка, растровый `ingest` и проверка
«экипировка не вылезла за силуэт тела».
У твари слоёв нет вовсе, склеивать нечего. Зато пять видов обязаны
**различаться силуэтом** с одного взгляда: игрок принимает решение по пятну,
а не по деталям. Поэтому здесь пять независимых скелетов, никакой общей
калибровки — и проверка ровно обратного смысла: `validate` ругается, если два
вида дали похожее пятно.
Общее у двух конвейеров ровно одно и намеренно: **неподвижная
ортографическая изометрическая камера** (`elevation_deg = atan(0.5)`, тот же
угол, под которым в игре лежит пол) и вырезка кадра постоянным
прямоугольником. Начало координат рига проецируется в центр кадра, поэтому
упаковщик режет все кадры одинаково — тварь не «дышит» при повороте.
## Файлы
| Файл | Что делает |
|---|---|
| `assets/monsters/catalog.yaml` | геометрия ячейки, камера, список видов и клипов — единственный источник чисел |
| `tools/blender/monster_rig.py` | анатомия и анимации; запускается ВНУТРИ Blender |
| `tools/monster_assets.py` | CLI: рендер, упаковка листов, манифест, проверки |
| `tools/build_monster_assets.ps1` | полный прогон: init → render-all → validate → sf validate |
| `assets/sources/monsters/*.png` | собранные листы (вход SpriteForge) |
| `assets/manifests/monsters.generated.yaml` | манифест, пишется `sync` |
| `generated/monster_sprites.h` | константы `MONSTER_ASSET_*` и `MonsterAsset(EnemyKind, AnimKind)` |
## Геометрия
Ячейка `48x56`, pivot `(24,47)`, восемь направлений в том же порядке, что у
агентов: юг, юго-запад, запад, ... Лист **direction-major**: сначала все кадры
юга, затем юго-запада.
`pivot_y = 47`, а не `55`: изометрический пол уходит от точки опоры **вниз** по
экрану, и всё, что у твари позади (хвост бегуна, задние лапы затаившегося),
проецируется ниже точки опоры. Девять строк под pivot — запас на глубину
подставки, а не воздух.
Клипы: `walk` 8, `attack` 6, `hit` 4, `death` 8 кадров на направление.
`fps_scale` в каталоге задаёт повадку: бегун частит, громила волочится при
одинаковом числе кадров.
## Морфологии
Не капсулы. Каждый вид — своя анатомия, а не перекрашенный гуманоид:
* **shambler** — вскрытая наружу грудная клетка, рёбра веером, правая рука
доросла до колена, в череп вбита пластина; хромает;
* **rusher** — четвероногий, горизонтальный хребет, пальцеходящие задние ноги,
косы вместо предплечий, вместо головы раскрытый цветок челюстей;
* **spitter** — волочащийся железистый мешок больше грудной клетки, вживлённый
в горло клапан, сопло вместо лица, атрофированные руки;
* **brute** — к плечу приварено второе, наполовину поглощённое тело; грудь
забрана болтами; правая рука — молот из сросшейся кости;
* **lurker** — плоский зашитый скобами панцирь подвешен под четырьмя длинными
выгнутыми лапами, безглазый клин с крюками.
Анимации описаны в **нормированном** времени (список опорных значений на 0..1)
и сэмплируются под число кадров из каталога: `frames` в каталоге можно менять,
не трогая `monster_rig.py`.
`death` — это **осадка в кучу**, а не падение во весь рост. Лежащая во весь
рост туша не помещается в клетку `48x56` при повороте «от камеры», и в игре это
выглядело бы как обрезанный труп.
## Порядок работы
```
python tools/monster_assets.py monster-init # собрать риг .blend
python tools/monster_assets.py monster-render shambler walk # один вид, один клип
python tools/monster_assets.py monster-render-all # всё
python tools/monster_assets.py validate --strict
python tools/monster_assets.py sync
python -m unittest discover -s tools/tests
```
`monster-render` и `monster-render-all` сами вызывают `sync`.
## Что проверяет validate
* холст листа равен `48 x (8 направлений x кадры)`, пустых ячеек нет;
* силуэт **не упирается в кромку** клетки — иначе в игре это отрубленная лапа;
* тварь стоит на общей линии пола (`death` освобождён: труп имеет право осесть);
* «роза» из восьми направлений центрирована на `pivot_x`;
* зеркальные пары (ЮЗ,ЮВ), (З,В), (СЗ,СВ) совпадают при отражении — ловит
перестановку направлений;
* **пять видов различаются пятном** (IoU силуэтов шага ниже 0.90);
* манифест не разошёлся с каталогом по pivot и размеру кадра, и ни один ID не
объявлен вторым манифестом.
## Запреты
* ID ассета — строго `<kind>_<animation>`: ровно это требует
`tools/asset_coverage.py` от группы `enemy`. Переименуешь — упадёт покрытие.
* Строковых ID в C++ нет: только `MONSTER_ASSET_*` из `generated/monster_sprites.h`.
* PNG и contact sheet не открывать: проверка только текстом — `validate`,
`sf list`, `sf describe`, `sf ascii`.
* `assets/manifests/enemies.yaml` — старые односторонние листы-заглушки. Пока
живы оба файла, ID пересекаются, и `validate` про это предупреждает: снимать
предупреждение — это отдельное решение на стороне рантайма, не конвейера.

View File

@ -0,0 +1,266 @@
# 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`.

212
docs/16-descent.md Normal file
View File

@ -0,0 +1,212 @@
# 16 — Спуск: глубина как первичное понятие
## Что за игра
Бесконечный спуск в подземелье. Чем глубже, тем страшнее окружение, злее твари,
богаче находки. Забег кончается, когда отряд полёг; сколько этажей он прошёл —
и есть результат.
Из этого следует главное решение всей вехи: **глубина не параметр уровня, а
первичное понятие движка**. От неё зависят планировка, население, лут, отделка
и свет. Поэтому она не размазана по генератору, а собрана в одну функцию.
## Кривая — одно место
[engine/progression.h](../engine/progression.h). `DescribeFloor(depth)`
возвращает `FloorSpec`: тема отделки, бюджет опасности, плотность, сколько и
какого лута, насколько сжат обзор, множитель опыта.
Коэффициенты с вехи M14 живут не в коде, а в манифесте проекта
([18-engine.md](18-engine.md)): `projects/descent/project.txt`. В коде осталась
ФОРМА кривой — проект гнёт её, но не может подменить линейный рост
экспонентой.
Кривая сложности — это то, что правят чаще всего и правят на ощупь. Размазанная
по генератору, каталогу и рендеру, она через месяц перестаёт поддаваться правке:
«сделать десятый этаж чуть злее» превращается в раскопки по трём файлам.
Чего в кривой **нет намеренно**:
- **множителей урона и здоровья тварей.** «Тот же бредущий, но со втрое большим
HP» — это не страшнее, это дольше. Глубина меняет, КТО выходит и сколько их;
- **потолка.** Функция определена для любого N: этаж 200 обязан собраться, даже
если каталог кончился на 12-м.
| Что | Как растёт | Почему так |
|---|---|---|
| опасность | линейно + квадратичный хвост | чистая линейка читается как «то же, только больше»; чистая экспонента обрывает забег там, где игрок начал понимать правила |
| плотность точек | до ×1.5 и упор | выше упора все точки срабатывают всегда, то есть этажи становятся одинаковыми |
| тир лута | +1 каждые 3 этажа | медленнее опасности: спуск обязан опережать снаряжение |
| обзор | до ×0.6 и упор | ниже — это уже не «страшно», а «не видно, куда идти» |
| опыт | +15% за этаж | медленнее опасности: спускаться выгодно, но не настолько, чтобы прыгать через этажи |
Полосы отделки: `STEEL` 13, `RUST` 47, `FLESH` 812, `FROST` 13+. Последняя
открытая: менять тему дальше не на что, а «случайная тема каждый этаж»
превратила бы спуск в чересполосицу.
## Бюджет опасности
Каждая сущность каталога несёт цену `danger` — не урон и не HP, а «сколько места
в голове игрока она занимает». Этаж получает бюджет и тратит его:
1. срабатывают точки спавна комнат — из их таблиц отбирается то, что разрешено
на этой глубине и по карману;
2. остаток **доливается** тварями вне комнатных точек.
Второй шаг обязателен. Без него глубина упирается в число точек, нарисованных в
комнатах: на двадцатом этаже все точки уже срабатывают и уже выдают самое
дорогое, и дальше этаж не становится страшнее вообще. Долив превращает бюджет из
потолка в норму, которую этаж обязан выбрать.
**Когда мест мало, а бюджета много, этаж становится злее, а не многолюднее.**
Тел в мире не может быть больше `MAX_TARGETS`, и на глубине бюджет неизбежно
перерастает это число. Поэтому на каждое оставшееся место считается, сколько
опасности обязано на него прийтись, и всё дешевле отбрасывается: хвост этажа
набирается самыми дорогими тварями.
> Потолок `MAX_TARGETS` пришлось поднять с 32 до 64. Это не «запас на будущее»:
> `tile2d_editor --check` показал, что примерно с шестнадцатого этажа бюджет
> перестаёт влезать в число тел, и глубже этажи переставали отличаться. Маска
> `MeleeWeapon::hitMask` стала 64-битной ровно поэтому.
## Каталог: глубина и цена
[projects/descent/catalog/entities.txt](../projects/descent/catalog/entities.txt) перешёл на
`ключ=значение`. Первая версия была позиционной, и глубина её сломала: каждое
новое свойство ломало бы все уже написанные строки.
```
entity shambler class=monster depth=1 danger=1.0 radius=0.34 color=116,52,52 sprite=shambler_walk name=SHAMBLER
entity brute class=monster depth=7 danger=4.5 radius=0.46 color=142,60,78 sprite=brute_walk name=BRUTE
entity plate class=loot tier=3 depth=9 radius=0.30 color=140,146,156 sprite=item_icon_plate name=PLATE
```
- `depth=N`с этажа N и глубже; `depth=N..M` — по M включительно;
- `danger` — цена в бюджете. У обстановки и лута она нулевая по определению:
ящик не делает этаж страшнее, он делает его обжитее;
- `tier` — для лута: этаж выдаёт свой тир и всё, что ниже. Бинт с глубиной не
исчезает, он просто перестаёт быть удачей.
Третий класс — `loot`. Мост в игру тот же: `sim/spawn_catalog.cpp` переводит id
в `EnemyKind` **и** в `ItemId` двумя таблицами.
Комнаты тоже получили глубину: `depth <min> [max]` в файле. Планировка — часть
погружения: широкая арена первых этажей и теснота, где не развернуть строй, —
это разные игры.
## Находки
[src/sim/loot.h](../src/sim/loot.h). Предмет лежит на полу отдельной сущностью
и подбирается **пассивно**: боец, прошедший по нему, кладёт его в свободную
ячейку сумки.
Почему не «выдать предмет при убийстве»: в игре про спуск находка — это МЕСТО,
до которого надо дойти. Лут, падающий в инвентарь сам, не заставляет никуда идти
и превращает «глубже — лучше лут» в строку в логе. Лежащий предмет плохо виден в
темноте, лежит там, куда отряд ещё не заходил, и за ним надо разомкнуть строй —
это и есть цена находки.
Отдельной кнопки подбора нет намеренно: игрок и так не нажимает «огонь»
(спек 5), и заводить ради лута первое ручное действие в игре значило бы менять
её жанр. Полная сумка — предмет остаётся лежать: «некуда положить» обязано быть
видно, а не молча съедено.
## Спуск
Точка спуска — **самый дальний достижимый тайл** от старта, найденный той же
заливкой, что считает достижимость. Не «дальняя комната»: комната может
оказаться отрезанной, а этот выбор недостижимым быть не может по построению.
Рисуется пульсирующим кольцом и видна даже в полной темноте — единственная цель
этажа обязана читаться с порога.
`F` на спуске уводит отряд вниз. Именно нажатие, а не заход на клетку: спуск —
решение игрока. Этаж не зачищен, лут не собран, уходить рано — это нормальный
выбор, и отряд может стоять на спуске сколько угодно.
Вниз переезжает **всё, что нажито**: здоровье, оружие в руках, содержимое сумок.
Павшие не воскресают — воскрешение на лестнице обесценило бы единственную
настоящую потерю в забеге. Стартовый комплект снаряжения выдаётся только тем, кто
пришёл ни с чем: раздавать его заново на каждом этаже значило бы стирать весь
смысл находок.
Симуляция сама этаж не меняет: `Game::Step` только отмечает `atExit`. Смена мира
посреди шага, между движением и стрельбой, оставила бы половину систем со
ссылками на снесённые сущности.
| Действие | Что делает |
|---|---|
| `NEW RAID` в меню | выбор чекпоинта, потом новый забег ([17-extraction.md](17-extraction.md)) |
| `F` на спуске | следующий этаж, отряд как есть |
| `TRY AGAIN` после поражения | тот же этаж заново |
| `R` в бою | тот же этаж заново |
## Опыт
Копится за убитых: цена `danger` умножается на множитель этажа. Обстановка опыта
не даёт — платить за ящик значило бы сделать разбивание хлама самым выгодным
занятием в игре.
Тратится он на улучшения отряда ([17-extraction.md](17-extraction.md)): запас
патронов, переноска, обзор, живучесть. Заглушкой это было три вехи — ровно до
тех пор, пока не нашлось, во что вкладываться так, чтобы не сломать жанр.
## Свет как главный признак глубины
Окружение становится страшнее не новыми спрайтами, а тем, что видно всё меньше.
`FloorSpec::sightScale` сжимает радиус обзора всем бойцам одинаково, поверх их
личных множителей: фонарь на двадцатом этаже по-прежнему лучше савана, просто
оба светят хуже.
Значение живёт в `Lighting::sightScale`, а не в `g_tune`: tuning общий на весь
запуск, и запись туда означала бы, что двенадцатый этаж подкрутил ручку, с
которой потом работает и первый этаж, и песочница приёмки.
## Цифры на экране
HUD прототипа был принципиально бессловесным (`render/hud.h`). Для спуска этого
перестало хватать ровно на одном: **номер этажа — это число**, и показать его
формой нельзя. Двадцать семь зарубок на краю экрана никто не считает.
Поэтому в движке появился растровый шрифт 3×5
([engine/microfont.h](../engine/microfont.h)). Не raylib-текст: правило
«ни одного примитива raylib в игровой сцене» остаётся в силе, потому что этот
шрифт пишет в тот же софтверный фреймбуфер и попадает под ту же сетку пикселей.
## Проверки
```bash
tile2d_editor.exe --check покрытие глубин и сборка на 1/3/6/10/16/24
squad_proto.exe --descend 8 забег на 8 этажей без окна
squad_proto.exe --rooms --depth 12 --seed 42 сразу двенадцатый этаж
```
`--check` отвечает на главный вопрос игры про бесконечный спуск: не «хороша ли
комната», а **есть ли чем застроить сотый этаж**. Набор, у которого на глубине
не остаётся ни одной подходящей комнаты, ни одной твари или ни одного предмета,
обрывает забег — и обрывает молча, если это не проверять.
Он же различает две разные вещи:
- **FAIL** — этаж собран неправильно: точки не встали, что-то отрезано от
старта, некуда спускаться, нет находок;
- **WARN** — этаж собран правильно, но набор комнат не успевает за кривой, и
глубина перестаёт ощущаться. Это повод дорисовать комнат, а не поломка.
`--descend` проверяет то, что не проверить ни скриншотом, ни `--check`: цепочку
«спуск → пересборка мира → перенос отряда». Ошибка здесь не падает и не
рисуется — она выглядит как отряд, приехавший вниз с пустыми руками.
```
DEPTH THEME MONSTER DANGER LOOT EXIT SQUAD
1 STEEL 3 3 2 63 живых 5, в сумках 8
8 FLESH 16 23 4 135 живых 5, в сумках 30
```
## Чего ещё нет
- **траты опыта** — см. выше, это отдельная веха с отдельными критериями;
- **боссов и особых этажей** — кривая пока гладкая, а спуск без вех в памяти
сливается в один длинный коридор;
- **сохранения забега** — вылет означает потерю прогресса;
- **уникального лута** — все находки берутся из общего каталога предметов, и
отличаются только тиром.

260
docs/17-extraction.md Normal file
View File

@ -0,0 +1,260 @@
# 17 — Экстракшен: спуск как ставка
## Что изменилось в игре
До этой вехи глубина была прогрессом: чем дальше, тем лучше. Теперь она
**ставка**. Отряд уходит вниз с тем, что нажил, и каждый этаж — заново заданный
вопрос: ещё ниже или назад, пока есть что выносить.
Три правила, из которых следует всё остальное:
1. **Унести можно только то, что на выживших.** Павший забирает своё с собой.
2. **Полный вайп не возвращает ничего.** Снаряжение сбрасывается к стартовому.
3. **Достигнутая глубина остаётся навсегда.** Она открывает чекпоинты — и это
единственное, чего смерть не отнимает.
Третье правило — ответ на «а если этажей десять тысяч». Проходить их заново
каждую вылазку не нужно; терять при этом можно только снаряжение.
## Два выхода с этажа
| | Где | Клавиша | Что делает |
|---|---|---|---|
| **спуск** | самая дальняя достижимая точка | `F` | следующий этаж, отряд как есть |
| **эвакуация** | там, где отряд вошёл на этаж | `E` | забег окончен, добыча в схроне |
Эвакуация совпадает с точкой входа не из экономии: это лестница наверх, и в этом
вся суть напряжения. Спуск лежит на дальнем конце этажа — решив уйти, отряд идёт
обратно через всё, что успел растревожить, раненый и с полными руками.
Кольца на полу разного цвета: зелёное — вниз, голубое — наверх. Оба видны даже
в полной темноте. Спутать их означает уйти не туда, и цена ошибки здесь — весь
забег, поэтому подсказки в HUD подкрашены теми же цветами.
Ни то ни другое не срабатывает от захода на клетку. Спуск и эвакуация — решения
игрока: этаж не зачищен, лут не собран, уходить рано — это нормальный выбор, и
отряд может стоять на выходе сколько угодно.
## Профиль
[src/sim/profile.h](../src/sim/profile.h) — всё, что переживает забег:
```
maxdepth 13
runs 6
extractions 3
agent 0 cleaver plate charm bandage stimpack - - - -
stash saber plate vest smg bow charm lantern spurs
```
- **снаряжение пяти бойцов** лежит здесь, а не собирается заново при старте.
То, в чём отряд ушёл вниз, — это то, в чём он вернулся с прошлой вылазки;
- **схрон** — общий запас сверх надетого, потолок `STASH_CAP`. Без потолка
экстракшен превращается в склад, и решение «что взять вниз» исчезает;
- **максимальная глубина** открывает чекпоинты.
Профиль — состояние ИГРОКА, а не мира: он не в `ecs::World` и не сбрасывается ни
`Reset`, ни `Descend`. Записывается он только в конце забега — авария посреди
вылазки означает потерю, и это часть контракта, а не недоделка.
Формат текстовый по той же причине, что у комнат: файл лежит рядом с игрой, его
правят руками при отладке, и он обязан читаться глазами.
## Чекпоинты
Каждые `CHECKPOINT_STEP` (5) этажей. Достигнутая глубина открывает все чекпоинты
не глубже неё: дошёл до 13-го — доступны D1, D6, D11.
![выбор глубины](ui-depth.png)
Рекорд глубины засчитывается **любым исходом**. Дошёл — значит дошёл; требовать
за чекпоинт ещё и удачную эвакуацию значило бы наказывать дважды за одну смерть.
Пять — это «достаточно редко, чтобы дойти до следующего было событием, и
достаточно часто, чтобы потеря забега не откатывала в самое начало».
## Сумка одна на отряд
Раньше каждый боец таскал свои шесть ячеек, и это порождало проблему, которую
нечем было решить: пачка патронов, поднятая бойцом с клинком, доставалась только
ему и лежала мёртвым грузом до конца забега. Перекладывать между бойцами игра не
умела.
Теперь сумка одна — шестнадцать ячеек на пятерых. Личным остаётся **надетое**:
руки, броня, карман. Их не разделить, они и есть «кто этот боец».
**Вес делится на живых.** Потерять бойца — значит взвалить его долю на
остальных: обратно наверх отряд пойдёт медленнее, чем шёл сюда. Это не побочный
эффект, а то, ради чего сумка общая.
Правило «унести можно только то, что на выживших» этим не ослаблено, оно стало
точнее: надетое павшего теряется по-прежнему, а общая ноша на нём не висела —
её тащил отряд. Вайп по-прежнему не выносит ничего: тогда выживших нет вовсе, и
сумку некому донести.
## Схрон и сборы
![экран отряда со схроном](ui-squad-stash.png)
Экран отряда (`I`) стал местом сборов: снаряжение бойца слева, общая сумка
справа, схрон двумя рядами внизу. Предметы перетаскиваются мышью, `Q`/`E`
листают схрон, правая кнопка по вещи выбрасывает её под ноги.
**Наведи — и увидишь, что это.** Подсказка под курсором показывает вес и все
эффекты предмета, а на строках характеристик объясняет, откуда берётся число.
До неё вещь была именем и цветным квадратиком: чем VEST отличается от JACKET,
знал только каталог в коде. С сумкой на шестнадцать мелких ячеек это перестало
быть терпимым — в ячейку не влезает даже имя.
Схрон полон — обмен не состоится целиком, и вещь останется в слоте. Молча терять
её нельзя: «некуда положить» обязано быть видно.
## Вес: что унести
Шесть ячеек в сумке отвечали на вопрос «сколько вещей», но не на вопрос «чего
они стоят»: шесть бинтов и шесть плит обходились одинаково. Теперь у каждой
вещи есть вес, а у бойца — два порога.
| | |
|---|---|
| до `CARRY_LIMIT` (7.5) | отряд идёт как шёл |
| дальше | тем медленнее, чем больше нагрёб, до 55% на пределе |
| выше `CARRY_MAX` (14) | не поднимет: вещь остаётся лежать |
Порогов ДВА намеренно. Один жёсткий предел означал бы «сумка кончилась» — то
же самое, что счёт ячеек, только считать труднее. Настоящий выбор возникает
там, где лишнее взять МОЖНО, но за это платят: а идти с этим — обратно через
весь этаж, который уже растревожен.
**Надетое не считается.** За броню уже заплачено множителем скорости (плита —
0.82), и считать её вес вторично значило бы наказывать за один выбор дважды.
Хуже того: плита съедала бы почти весь запас, и боец в тяжёлой броне не мог бы
поднять вообще ничего — вместо решения вышел бы запрет. А вот плита, НАЙДЕННАЯ
и лежащая в сумке, весит полной мерой: тащить её наверх — это и есть выбор.
Выбросить лишнее можно правой кнопкой по ячейке на экране отряда: вещь ложится
под ноги и подбирается обратно. Из рук бросать нельзя — безоружный боец не
бежит быстрее, он просто перестаёт быть бойцом.
## То, что кончается
Наверх гонит не таймер. Часы заставляли бы торопиться механически, а это
хоррор: давить должно **исчерпание**, а задача — уйти как можно глубже.
**Патроны конечны.** Запас (`startAmmo`, 120 на бойца) выдаётся на вылазку и
едет вниз вместе с отрядом. Перезарядка тянет из него; кончился — стрелок
молчит, и перезарядка даже не начинается: боец с пустым стволом не изображает
работу. Восполнить можно только находкой — пачкой на 40 патронов.
**Расходники применяются сами.** Ручных действий в игре нет (спек 5), поэтому
бинт и пачка — не кнопка, а поведение: боец перевязывается, когда ранен ниже
`healAtFrac` и рядом никого нет, и набивает подсумок, когда стрелять нечем.
Лечиться в схватке нельзя — перевязка посреди боя обесценила бы урон, который
тварь только что нанесла.
До этой вехи `heal` у бинта был мёртвым числом: расходники не применялись
нигде, и бинт был просто грузом. С весом (выше) он стал грузом буквально.
Патроны нужны тому, у кого **вообще** есть ствол, а не только тому, кто держит
его сейчас: иначе боец с клинком в руках и запасным карабином в сумке таскал бы
найденную пачку до конца забега. Это нашла проверка, а не глаз.
## Во что вкладывается опыт
![экран улучшений](ui-upgrades.png)
Опыт копился с вехи M12 и до сих пор был честной заглушкой: число, которое
ни на что не влияет. Теперь он тратится — на четыре улучшения отряда.
| | что даёт | почему это |
|---|---|---|
| AMMO RESERVE | +20 патронов на вылазку | |
| CARRY LIMIT | +1.5 до перегруза | |
| SIGHT RANGE | +6% обзора | |
| VIGOR | +12 здоровья | |
Все четыре — **ресурсные**, и ни одного силового. Игра про исчерпание, и
вкладываться в ней надо в то, что кончается: отряд, который бьёт вдвое сильнее,
перестаёт бояться, а страх тут единственная механика, которую нельзя
восстановить балансом.
Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре остаётся
снаряжение, и второе личное измерение превратило бы сборы в бухгалтерию на
пятерых.
Опыт зачисляется только за **успешную эвакуацию** (`FinishRun`): вайп не
приносит ничего. Это та же ставка, что и с добычей.
Приёмки это не касается вообще: улучшения живут в профиле, а `--accept` его не
подключает — там нет ни снаряжения, ни улучшений, и критерии меряются на том
же, на чём мерялись всегда.
## Итог забега
![итог вылазки](ui-summary.png)
Две цифры, ради которых экран и существует: **сколько вынес** и **сколько
осталось внизу**. Плюс глубина, выжившие и открытый чекпоинт.
Забег закрывается ДО показа экрана — в момент вайпа или нажатия `E`. Симуляция
при этом ничего не решает: она только отмечает `atExit`/`atExtract`, а конец
забега объявляет UI. Смена мира посреди шага, между движением и стрельбой,
оставила бы половину систем со ссылками на снесённые сущности.
## Как играть
```bash
squad_proto.exe --rooms
```
`NEW RAID` → выбрать чекпоинт → вылазка. `I` перед выходом — разобрать схрон.
| Ключ | Что делает |
|---|---|
| `--profile <файл>` | другой профиль (полезно для проверок) |
| `--no-profile` | не читать и не писать профиль вообще |
Без `--rooms` игра работает как раньше: одна карта, никакого забега. Приёмка и
`--headless` профиль не подключают — критерии меряются на прежнем снаряжении.
## Проверки
```bash
squad_proto.exe --descend 7 --no-profile
```
Прогон проверяет всю цепочку: спуск по этажам, перенос сумок, эвакуацию,
**круговой прогон профиля через запись** — и правило переноски.
Переноска проверяется на ВЫДУМАННОМ инвентаре, а не по грузу в прогоне: в сумки
кладутся бинты, чтобы проверить перенос между этажами, и по ним вес выглядит
втрое меньше настоящего. Первые две попытки выставить пороги делались как раз
по этой цифре — и обе дали числа, которые не ограничивали ничего.
```
расходники: патроны набиты, лечение сработало (33 -> 58), из сумки ушли обе
перезарядка: запас 40 -> 16 (магазин 24)
переноска: налегке 6.9 (скорость 100%), с грузом 12.9 (скорость 63%), плит влезло 1
```
Расход патронов проверяется тут же и отдельно: спуск ни с кем не воюет, столбец
«патронов» в таблице стоит на месте — и без прямой проверки бесконечный
боезапас выглядел бы ровно так же.
```
7 RUST 13 19 3 149 живых 5, в сумках 30
эвакуация: вынесено 40, потеряно 0, выживших 5, чекпоинт D6
профиль: запись и чтение сходятся
```
Круговой прогон здесь не для полноты: запись профиля — единственный путь,
которым игра может стереть всё нажитое, и ошибка в нём выглядит как «схрон
куда-то делся», а не как падение.
## Чего ещё нет
- **Схватки за добычу.** В настоящем экстракшене выход можно не отдать —
здесь на этаже нет никого, кто охотится именно за вынесенным.
- **Страховки и торговли.** Схрон только копится; девать его некуда.
- **Траты опыта** — она по-прежнему ждёт своей вехи
([16-descent.md](16-descent.md)).

226
docs/18-engine.md Normal file
View File

@ -0,0 +1,226 @@
# 18 — Tile2D: движок и проекты
## Что изменилось
До этой вехи «движком» называлась библиотека `squad_engine`, которая лежала
внутри `src/`, включала `src/tuning.h` и сама искала `assets/rooms`. То есть она
не собиралась без игры и знала имя ровно одной игры. Такой движок работает с
одним проектом и притворяется общим.
Теперь их трое, и границы проведены не договорённостью, а сборкой:
| Что | Где | Что знает |
|---|---|---|
| **Tile2D** | `engine/` | окно и цикл, кадр, проекции 2D, тайлы, комнаты, генератор этажей, формат проекта |
| **редактор** | `editor/` | Tile2D и **любой** проект |
| **Descent** | `src/` + `projects/descent/` | правила боя, отряд, снаряжение — то есть игра |
| **пример** | `samples/walk/` | ничего: он и есть проверка, что движком можно пользоваться со стороны |
Игра стала **проектом внутри движка**. Проверяется это не обещанием, а вторым
проектом: [projects/hollow](../projects/hollow) собирается тем же движком, тем
же редактором и тем же исполняемым файлом, а отличается видом отображения,
кривой сложности, каталогом и комнатами.
```bash
squad_proto.exe --rooms # DESCENT, изометрия
squad_proto.exe --project hollow # HOLLOW, вид сверху
tile2d_editor.exe --check --project hollow # проверить чужой набор ЕГО кривой
```
## Что именно вынуто из игры
Три зависимости, которые делали движок несамостоятельным. Каждая нашлась не
рассуждением, а компилятором — после переезда в свой каталог движок просто
перестал собираться:
- **`src/tuning.h`.** Размеры кадра, тайла и карты уехали в
[engine/config.h](../engine/config.h); `tuning.h` теперь его включает и
оставляет прежние имена, поэтому код игры не изменился ни строкой.
- **`MAX_TARGETS`.** Генератор уровней кэпил население игровой константой —
шириной маски удара клинком. У движка теперь свой потолок
`MAX_LEVEL_SPAWNS`, а `src/game.h` проверяет `static_assert`-ом, что одно
влезает в другое. Раньше рассогласование дало бы молча обкусанный этаж.
- **`SQUAD_START_X/Y`.** Запасная точка старта этажа была позицией отряда из
игры. Стала центром карты: движок не знает, кто к нему приедет.
Ещё две — `FindRoomsDir()` и `FindCatalogPath()` — просто удалены. Путь к
содержимому называет проект.
## Проекция: шов под виды 2D
[engine/projection.h](../engine/projection.h) — единственное место, где движок
знает про изометрию. Проекция отвечает на четыре вопроса:
1. куда мировая точка попадает на экран и обратно;
2. чем сортируется список отрисовки;
3. какой формы плита пола;
4. насколько крыша стены поднята над своей плитой.
Всё остальное — тайлсет, свет, кровь, эффекты, курсор редактора — считается
**через** эти четыре ответа и потому переживает смену вида.
Режима сразу два, и это не запас на будущее: абстракция с одной реализацией
ничем не проверена и обычно оказывается неправильной. Вид сверху нашёл ровно
такие места — кольцо эвакуации рисовало свой ромб мимо проекции и висело поверх
квадратной плиты в другом ракурсе.
| | Изометрия | Вид сверху |
|---|---|---|
| плита | ромб 32×16, наклон 2:1 | квадрат 32×32 |
| стена | крыша + две вертикальные грани | только крыша |
| сортировка | `x + y` | `y` |
| свет на плите | точная билинейка по углам ромба | без косого члена (см. ниже) |
![вид сверху](view-topdown-hollow.png)
Свет вплавляется в блит спрайта патчем `a + b·px + c·py + e·px² + f·py²`
(`ShadeQuad`). В изометрии билинейка по четырём углам ромба раскладывается по
этим членам **точно**, поэтому на общем ребре соседних тайлов значений не
расходится и швов нет. У квадрата честным слагаемым был бы `u·v`, которого в
этой форме нет, — он отбрасывается. На тайле в 32 пикселя это доли ступени
рампы, и лишний член в горячем блите того не стоит.
Вид выбирается **один раз** при старте: тайлсет рисуется под форму плиты, и
смена вида на ходу означала бы перерисовку всех спрайтов посреди кадра.
Чего вид сверху пока не умеет: тайлы из SpriteForge. Библиотека нарисована в
изометрии, и положить её в другой ракурс нельзя — это не «неточно», это другой
ракурс. `LoadFromForge` в этом режиме честно отказывается, и набор остаётся
процедурным целиком.
## Проект
[engine/project.h](../engine/project.h). Построчный `ключ значение`, как у
комнат и каталога: файл лежит в git, правится руками и обязан читаться в diff.
```
name DESCENT
view iso # iso | topdown
rooms rooms
catalog catalog/entities.txt
startdepth 1
danger 3.0 2.2 0.10 # база, линейный рост, квадратичный хвост
density 0.05 1.5 # прирост за этаж, упор
loot 2 4 3 # база, этажей на +1 предмет, этажей на тир
sight 0.035 0.60 # сжатие за этаж, нижний упор
xp 0.15
themes 1 4 8 13 # с какого этажа начинается каждая тема
```
Неизвестный ключ **валит загрузку**, а не пропускается: опечатка в текстовом
формате — самая дорогая ошибка, строка просто не действует, и это выглядит как
«движок не слушается».
Кривая глубины ([16-descent.md](16-descent.md)) переехала из кода в манифест:
её правят на ощупь и часто, и «сделать десятый этаж чуть злее» не должно
означать пересборку движка. **Форма** кривой при этом осталась в коде —
`FloorCurve` гнёт её, но не может подменить линейный рост экспонентой. Проект,
которому нужна другая форма, — это повод завести ещё одно поле, а не язык
выражений в текстовом файле.
### Как проект находится
`--project` принимает **имя** (каталог в `projects/`) или путь. Без ключа берётся
единственный найденный проект. Поиск идёт от текущего каталога вверх — запуск из
корня репозитория и из `build/bin` одинаково рабочие.
Несколько проектов и ни одного названного — отказ со списком доступных. Молча
взятый «первый попавшийся» читался бы как «движок открыл не то».
## Второй проект
[projects/hollow](../projects/hollow) — вид сверху, четыре комнаты, свой
каталог, короткая и злая кривая: `danger 5.0 3.4 0.25` против `3.0 2.2 0.10`,
плоть уже на четвёртом этаже вместо восьмого.
![редактор с чужим проектом](editor-hollow-topdown.png)
Редактор показывает палитру HOLLOW (без мишени приёмки, которой в этом наборе
нет) и считает предпросмотр **его** кривой: `danger 4.3 / 5.0` на первом этаже,
где у DESCENT было бы `3.0`.
Общее у двух проектов ровно одно — **id сущностей**. Это граница ИГРЫ, а не
движка: `squad_proto` умеет превращать в тварей только те id, что перечислены в
[src/sim/spawn_catalog.cpp](../src/sim/spawn_catalog.cpp). Движку всё равно, как
они называются; проект с чужими id соберётся, но эта игра его не населит.
## Что осталось компилтаймовым
В [engine/config.h](../engine/config.h), и это честно записано там же:
- **размер кадра** 480×270 — по нему считаются раскладки HUD, меню и оверлея;
- **размер карты** 48×48 — тайлмап плоский массив;
- **размер тайла** — проекция берёт свой, но `TILE_MAX_*` задаёт верхнюю оценку
для отсечения по краю кадра.
Проект пока не может поменять ни одно из трёх. Сделать их данными — отдельная
веха: это означает динамические буферы там, где сейчас массивы, и трогает
раскладку всего интерфейса.
## Приложение: окно, кадр, ход времени
[engine/app.h](../engine/app.h). Раньше окно, безрамочный режим, фреймбуфер,
блиттер и fixed timestep жили двумя почти одинаковыми копиями — в игре и в
редакторе. Третьему приложению пришлось бы написать третью, и она бы уже
отличалась: где-то забытый `SetExitKey`, где-то другой потолок шагов.
Инверсии управления здесь нет: цикл остаётся у приложения, а `App` отвечает на
три вопроса — пора ли закрываться, сколько шагов симуляции прошло, куда лёг кадр
в окне. Колбэки спрятали бы порядок «шаг — рендер — интерфейс», а он у каждого
приложения свой, и в нём вся суть.
```cpp
while (app.NextFrame())
{
for (int i = 0, n = app.Advance(halted); i < n; ++i) game.Step(FIXED_DT);
RenderGame(game, view, app.fb, app.Alpha(), app.frameDt);
app.Present(); // кадр на экран
ui.Draw(game, app.fit); // поверх кадра
app.Finish();
}
```
## Третье приложение
[samples/walk](../samples/walk/main.cpp) — открыть проект, собрать этаж,
походить по нему камерой. Полторы сотни строк вместе с разбором аргументов.
![пример на движке](sample-walk.png)
Он существует не для демонстрации. Игра и редактор проверить самостоятельность
движка не могут: редактор мира не рисует вовсе, а игра — это ровно то место,
куда общий код и уезжает незаметно. Пример же ломается сразу, как только для
показа этажа понадобится что-нибудь из `src/`.
Первое, что он потребовал, — `DrawTilemap` ([engine/tilemap_draw.h](../engine/tilemap_draw.h)):
до него движок не умел нарисовать собственную карту. Игра свой проход по полу
оставила себе, потому что вплавляет в каждый блит свет; две реализации здесь не
дублирование, а разные задачи — «показать карту» и «нарисовать сцену со светом».
Общий у них порядок сортировки, и он один на всех, потому что живёт в проекции.
## Проверки
```bash
squad_proto.exe --accept приёмка игры (спек 11)
tile2d_editor.exe --check --project descent DESCENT: набор и покрытие глубин
tile2d_editor.exe --check --project hollow HOLLOW: то же, его кривой
squad_proto.exe --descend 6 забег DESCENT
squad_proto.exe --project hollow --descend 4 забег HOLLOW
tile2d_walk.exe --project hollow --depth 3 пример: движок без игры
```
`--check` на втором проекте сразу нашёл в нём то же, что когда-то в `storage`:
глухую камеру посередине комнаты `maw`. На сетке она выглядит нормально, но
точка спавна внутри недостижима — тварь не дойдёт до отряда и не будет убита, и
этаж тихо станет легче.
## Чего ещё нет
- **Пространства имён.** Типы движка (`Sprite`, `Tilemap`, `Room`) лежат в
глобальном пространстве. Пока потребитель один репозиторий — терпимо; как
библиотека для чужого кода — нет.
- **Своей палитры видов.** Вид сверху есть, гексов и косой проекции нет.
Добавлять их теперь дёшево: это одна запись в `MODES` и две ветки в
`Projection`.
- **Генерации спрайтов внутри движка.** Она снаружи, в SpriteForge
([12-assets.md](12-assets.md)), и с видом сверху пока не дружит.

134
docs/19-agent.md Normal file
View File

@ -0,0 +1,134 @@
# 19 — Движок и языковая модель
## Из чего это сделано
Три вещи, и порядок между ними важен:
| Что | Где | Зачем |
|---|---|---|
| **инструменты** | `tile2d_editor --tool ...` | обратная связь: во что превращается содержимое |
| **скиллы** | `.claude/commands/*.md` | правила этого движка, которые нельзя вывести из кода |
| **терминал** | панель снизу в редакторе, клавиша `` ` `` | место, где всё это встречается |
Инструменты — главное. Терминал без них был бы просто консолью в чужом окне.
## Почему именно инструменты, а не «API редактора»
Содержимое проекта уже текст: комнаты, каталог сущностей и манифест лежат в git
и правятся руками. Значит, править их умеет кто угодно, включая модель, —
открыл файл, изменил, сохранил. Никакого особого интерфейса для этого не нужно.
Чего у модели нет — **последствий**. Собралась ли комната в этаж, достижима ли
точка спавна от входа, выбрал ли этаж бюджет опасности, во что превращается
набор на двадцать четвёртом этаже: на эти вопросы нельзя ответить, глядя на
текст комнаты. Их знает только генератор.
Поэтому инструменты — это замкнутая петля: **правка делается файлами, а
последствия читаются командой**. И всё выводится текстом, включая карту
собранного этажа, — по той же причине, по которой ассеты проверяются через
`sf ascii`, а не глазами по PNG: картинку нельзя ни продиффать, ни процитировать
в отчёте, ни сравнить с такой же картинкой другого сида.
```
tile2d_editor --tool map 3 42 --project hollow
20 ###.o.......#######.........#########.###.######
21 ###..##.##..#######..##.##..####...........#####
22 ##....@....................................#####
23 ##.....#...............#.........###.......#####
# стена . пол @ старт > спуск < эвакуация m тварь o обстановка $ лут
```
Полный список — `--tool help`. Код возврата 1 означает «не выполнено или найдена
поломка», поэтому на инструменты можно вешать хуки и скрипты, а не читать вывод
глазами.
![инструменты в терминале редактора](editor-terminal-tools.png)
## Терминал
![терминал редактора](editor-terminal.png)
Клавиша `` ` `` открывает панель снизу. Команды выполняются в **корне
репозитория**: работают не только над содержимым — рядом код движка, документация
и git. Каталог с бинарниками добавлен в `PATH`, поэтому `tile2d_editor --tool
validate` пишется без путей.
Строка ввода работает в трёх режимах, Tab переключает:
| режим | что делает с введённой строкой |
|---|---|
| `shell$` | выполняет как команду |
| `claude>` | `claude -p "..."` |
| `codex>` | `codex exec "..."` |
Одно поле, а не три — потому что работа выглядит как чередование: спросил,
посмотрел, проверил инструментом, снова спросил.
Открытый в редакторе проект уходит детям переменной окружения `TILE2D_PROJECT`.
Поэтому `--tool validate` без ключей проверяет именно его — и то же самое видит
агент, запущенный отсюда. Дописывать `--project` в чужие команды строковой
подстановкой было бы способом однажды подставить его не туда.
### Чего терминал НЕ делает
Он не эмулятор терминала. Полный VT100 — альтернативный экран, адресация
курсора, рамки из псевдографики — нужен ровно одному сорту программ:
интерактивным TUI, и `claude` без ключей именно такой.
Строчный вывод здесь не упрощение, а выбор в пользу того, как с моделью работают
из редактора на самом деле: `claude -p` и `codex exec` печатают обычный текст,
инструменты печатают таблицы и карты, git и cmake — тоже текст. Эмулятор не нужен
ни одному из них.
Полный интерактивный CLI открывается кнопкой `EXTERNAL` (или `F12`) во внешнем
окне, в том же рабочем каталоге. Так у каждого способа остаётся то, в чём он
хорош, и ни один не притворяется другим.
Шрифт панели — Consolas с кириллицей, а не встроенный шрифт raylib. Правило
«тексты на экране латиницей» здесь не нарушается: оно про ИГРУ, где текст пишет
разработчик. В терминал текст приходит извне, и выбора языка нет.
## Скиллы
`.claude/commands/`: `/tile2d` — работа над проектом вообще, `/room` — сделать
или починить комнату, `/floor` — править кривую сложности.
В них записано то, чего нет в коде и что модель иначе узнаёт слишком поздно:
- **не отчитываться об успехе без прохода `--tool validate`.** Комната, которая
безупречно читается в диффе, может запечатать карман от собственной двери —
в этом репозитории так случилось дважды (`storage`, потом `maw`);
- **id сущностей — граница игры, а не движка.** Движок поставит любой id из
каталога проекта; `squad_proto` превращает в тварь только те, что перечислены
в `src/sim/spawn_catalog.cpp`;
- глубину нельзя делать труднее множителями HP — это не страшнее, это дольше;
- приёмка меряется в песочнице и проект не подключает.
## Проверки
```bash
tile2d_editor --tool validate --project descent # код 0
tile2d_editor --tool validate --project hollow # код 0
tile2d_editor --tool map 6 1 --project descent # карта этажа текстом
tile2d_editor --term "tile2d_editor --tool floor 8 42" # то же изнутри редактора
```
Ключ `--term "<команда>"` открывает редактор сразу с выполненной командой. Он
нужен не только для картинок в документации: так редактор открывают «с делом» —
открыл, и уже видно, что не сходится.
## Чего ещё нет
- **Инструментов на запись.** Всё, что здесь есть, только читает и считает;
правит модель сами файлы. Это осознанно: запись через инструмент означала бы
второй способ менять комнату, и рано или поздно они разошлись бы.
- **MCP-сервера.** Инструменты доступны как команды, а не как протокол. Для
`claude` и `codex` этого достаточно; для редакторов, которые умеют только MCP,
понадобится обёртка.
- **Своей истории диалога.** Каждый вызов `claude -p` независим; терминал хранит
историю КОМАНД, но не контекст разговора. Длинную работу ведут во внешнем
интерактивном CLI.
- **Правки текущей комнаты из терминала.** Модель работает с файлами на диске, а
редактор держит рабочую копию в памяти: после правки файла нажмите `RELOAD`.

266
docs/20-gdd.md Normal file
View File

@ -0,0 +1,266 @@
# 20 — Дизайн игры: от одного исследователя до отряда думслееров
Это мастер-документ. Формулы урона и стихий — [21-damage.md](21-damage.md),
предметы и зачарования — [22-loot.md](22-loot.md), бесконечный лейт, лидерборды
и ачивки — [23-endgame.md](23-endgame.md).
Всё, что здесь написано, обязано ужиться с уже построенным: спуск и бюджет
опасности ([16-descent.md](16-descent.md)), экстракшен ([17-extraction.md](17-extraction.md)),
вес, боезапас и расходники, движок Tile2D ([18-engine.md](18-engine.md)).
Где новое ломает старое — сказано прямо.
## 1. Обещание игры
Первый экран: **один** оперативник-исследователь. Тактический нож, слабый
фонарь, костюм радиозащиты. Полупустая лаборатория, две вялых твари, свет
работает. Не страшно — и это НАМЕРЕННО: игроку показывают дно шкалы, чтобы
через сто этажей он мог оглянуться.
Последний экран, которого нет: пятеро в силовой броне идут сквозь Бездну,
поджигают, замораживают и расщепляют всё в радиусе, и всё равно однажды не
возвращаются.
Между этими двумя точками — 200 часов, и ни одной минуты, где сила решала бы
всё сама.
## 2. Закон масштаба — главное правило всей математики
> **Угроза растёт квадратично. Сила отряда — линейно с затуханием.
> Разрыв закрывается ЗНАНИЕМ, а не числами.**
Угроза этажа — это бюджет опасности, он уже квадратичный:
`T(d) = 3 + 2.2·(d1) + 0.10·(d1)²`.
Сила отряда `P` растёт от улучшений, снаряжения и артефактов, но каждый источник
имеет затухание (§6). Поэтому `T(d) / P` неизбежно растёт, и на каждой глубине
есть **стена** — этаж, где отряд перестаёт справляться.
Сдвинуть стену можно тремя способами, и только третий не имеет потолка:
| Способ | Даёт | Потолок |
|---|---|---|
| улучшения и снаряжение | +58 этажей за полный тир | есть, жёсткий |
| артефакты и зачарования | +36 этажей за сборку | есть, мягкий |
| **знание** — стихии, реакции, порядок целей, маршрут | ×1.53 к эффективной силе | **нет** |
Отсюда следуют все дальнейшие решения. Если бы числа закрывали разрыв, игра
перестала бы быть хоррором на сороковом этаже: страшно не там, где мало урона,
а там, где ты не понимаешь, что тебя убивает.
**Что ломается, если нарушить.** Дайте силе расти быстрее квадрата — и лейтгейм
превратится в покос травы, а лидерборд — в тест на терпение. Дайте угрозе расти
быстрее силы без канала знания — и стена станет непроходимой стеной, а не
задачей.
## 3. Акты и глубины
Шесть актов авторского содержимого, дальше — бесконечность.
| Акт | Глубины | Тема | Что вводится | Отряд |
|---|---|---|---|---|
| I. Лаборатория | 18 | STEEL | базовый бой, вес, патроны | 1 |
| II. Технические уровни | 920 | RUST | стихии INC/CRY, первые аффиксы, 2-й оперативник | 2 |
| III. Био-крыло | 2135 | FLESH | TOX, on-death эффекты, зачарования | 3 |
| IV. Криокамеры | 3652 | FROST | ARC, реакции, специализации | 4 |
| V. Реактор | 5372 | EMBER | RAD, аномалии, артефакты | 5 |
| VI. Провал | 73100 | VOID | VOID, залом, сборки-ключи | 5 |
| VII. Бездна | 101+ | ABYSS | мутации, сезоны, лидерборды | 5 |
Тем становится семь (сейчас четыре: `THEME_COUNT = 4` в `engine/theme.h`).
Полосы задаются проектом (`themes` в `project.txt`), поэтому это правка данных,
а не движка.
Каждый акт — это **новая причина умирать**, а не новый цвет стен. Акт без
собственной механики смерти вырезается.
## 4. Игровой цикл
Три петли, вложенные друг в друга. Каждая обязана давать ответ на вопрос
«зачем ещё раз».
### Петля этажа (38 минут)
Спуститься → разведать → решить, где драка неизбежна → собрать находки →
уйти на спуск или наверх. Решение одно и постоянное: **ещё этаж или назад**.
Его задают ресурсы (патроны, бинты, вес) и здоровье, а не таймер.
### Петля вылазки (2545 минут)
Чекпоинт → 512 этажей → эвакуация или вайп. Ставка: снаряжение на выживших.
Награда: добыча, опыт, материалы, продвижение рекорда.
### Петля мета (десятки часов)
Опыт → узлы улучшений. Материалы → крафт и зачарования. Артефакты → сборки.
Рекорд глубины → новые чекпоинты → доступ к более глубоким находкам.
```
┌──────────── этаж ────────────┐
спуск│ разведка → бой → находки │уход
└───────────┬──────────────────┘
↓ эвакуация
┌──────── вылазка ─────────────┐
│ опыт · материалы · артефакты │
└───────────┬──────────────────┘
↓ база
┌────────── мета ──────────────┐
│ узлы · крафт · сборка отряда │
└───────────┬──────────────────┘
↓ глубже
```
## 5. Двести часов: откуда цифра
Не «на глазок», а из бюджета времени.
| Величина | Значение |
|---|---|
| среднее время этажа | 4.5 мин (акт I — 2.5, акт VI — 7) |
| этажей за вылазку | 5 → 12 (растёт с глубиной чекпоинтов) |
| время вылазки с подготовкой | 28 → 48 мин |
| вылазок на продвижение одного чекпоинта (5 этажей) | 1 → 12 |
Вылазок на акт: I — 6, II — 22, III — 38, IV — 56, V — 74, VI — 96.
**Итого ≈ 292 вылазки × 40 мин ≈ 195 часов.**
Кривая «вылазок на чекпоинт» растёт линейно: `R(c) = 1 + 0.55·c`, где `c`
номер чекпоинта. Линейно, а не экспоненциально: экспонента даёт стену, за
которой игрок уходит, а не гриндит.
**Правило против гринда:** каждая вылазка обязана давать прогресс, даже
провальная. Вайп сохраняет рекорд глубины и половину материалов, найденных на
этажах глубже предыдущего рекорда (см. §7). Ноль прогресса за 40 минут — это
причина закрыть игру, а не мотивация.
## 6. Рост отряда
### 6.1. Оперативники
Начинаем с одного. Каждый следующий — событие, а не покупка.
| № | Класс | Где | Условие |
|---|---|---|---|
| 1 | **Исследователь** | старт | — |
| 2 | **Штурмовик** | D912 | капсула стазиса + 2 500 опыта |
| 3 | **Техник** | D2126 | капсула + 18 000 опыта + 3 био-ядра |
| 4 | **Егерь** | D3642 | капсула + 90 000 опыта + 5 крио-ядер |
| 5 | **Инквизитор** | D5360 | капсула + 400 000 опыта + артефакт |
Капсула — редкая находка в своей полосе глубин (шанс на этаж 6%, пити на 20-м
этаже полосы). Найти её можно только В ЭТОЙ полосе: пропустить акт и прийти за
пятым оперативником на десятом этаже нельзя.
Классы различаются НЕ статами, а поведением в бою — игра без ручных действий
(спек 5), и класс это то, как оперативник ведёт себя сам:
| Класс | Поведение | Слабость |
|---|---|---|
| Исследователь | держит дистанцию, первым замечает аномалии, +обзор | ломается в ближнем бою |
| Штурмовик | идёт первым, держит удар, ломает строй тварей | жрёт патроны и бинты |
| Техник | ставит турель/мину на стоянках, чинит броню между этажами | почти не бьёт сам |
| Егерь | бьёт по одиночным целям с дистанции, добивает раненых | бесполезен в толпе |
| Инквизитор | накладывает стихии по площади, запускает реакции | хрупкий, ест материалы |
Пятеро — потолок жёсткий: `NUM_AGENTS = 5` уже в коде, и это же потолок
читаемости строя. Шестой в клине не виден.
### 6.2. Дерево улучшений
Пять ветвей, в каждой 8 узлов, у каждого 5 уровней = **200 уровней узлов**.
| Ветвь | О чём | Пример узлов |
|---|---|---|
| Снабжение | патроны, вес, расходники | запас, переноска, ёмкость сумки, авто-бинт |
| Живучесть | HP, броня, сопротивления | живучесть, латание, сопротивление RAD |
| Огневая мощь | урон, крит, скорострельность | пробитие, крит, перезарядка |
| Стихии | элементы и реакции | сила стихии, длительность, сила реакций |
| Разведка | обзор, находки, аномалии | обзор, шанс редкого, чтение аномалий |
Существующие четыре улучшения (`Upgrade::AMMO/CARRY/SIGHT/VIGOR` в
`sim/profile.h`) становятся первым кольцом ветвей Снабжения и Живучести.
**Затухание — обязательное.** Уровень `L` узла даёт долю от максимума узла:
```
gain(L) = maxGain · (1 1 / (1 + 0.55·L))
```
L=1 → 35% максимума, L=2 → 52%, L=3 → 62%, L=4 → 69%, L=5 → 73%.
Пятый уровень стоит вчетверо дороже первого и даёт вчетверо меньше — и это
честно написано в подсказке узла.
**Цена уровня:** `cost(node, L) = base(node) · 2.15^L`, где `base` — от 150
(первое кольцо) до 12 000 (внешнее). Полное дерево ≈ **41 миллион опыта**.
### 6.3. Сходится ли экономика опыта
Опыт за этаж: `XP(d) = T(d) · 10 · (1 + 0.15·(d1))` — это уже работающая
формула (`xpPerDanger`, `xpScale`).
| Глубина | XP за этаж |
|---|---|
| 5 | 205 |
| 20 | 3 460 |
| 50 | 29 300 |
| 80 | 116 000 |
| 100 | 190 000 |
За 292 вылазки по 8 этажей средней глубины акта набегает ≈ **44 миллиона**.
Дерево стоит 41 — сходится с запасом 7% на переигрывание. Это не совпадение:
`base` узлов подобраны от этой суммы обратным счётом.
**Опыт зачисляется только за эвакуацию** — правило уже в коде (`FinishRun`).
Вайп теряет весь опыт вылазки, и это главный источник напряжения на выходе.
## 7. Что теряется и что остаётся
Дополняет правила экстракшена ([17-extraction.md](17-extraction.md)).
| | Эвакуация | Вайп |
|---|---|---|
| надетое на выживших | остаётся | — |
| надетое на павших | теряется | теряется |
| общая сумка | в схрон целиком | теряется |
| опыт вылазки | зачисляется | теряется |
| материалы | все | **половина того, что найдено глубже рекорда** |
| рекорд глубины | растёт | **растёт** |
| узлы, артефакты в схроне | остаются | остаются |
Половина материалов при вайпе — единственная поблажка во всей системе, и она
куплена конкретной ценой: без неё «пошёл проверить, что там на пять этажей
глубже» стоит сорок минут и не даёт ничего. Прогресс обязан идти даже из
провала, иначе разведка глубины перестаёт быть занятием.
## 8. Что должно измениться в коде
Не «когда-нибудь», а список того, что эта математика требует прямо сейчас.
| Что | Где | Объём |
|---|---|---|
| отряд переменного размера 1→5 | `sim/squad.*`, HUD, строй | средний |
| стихии и сопротивления | `sim/loadout.h`, `sim/target.*`, каталог | крупный |
| шина триггеров on-hit/on-kill | поверх `sim/events.h` — журнал УЖЕ есть | средний |
| аффиксы предметов | `sim/items.h` → предмет с рулонами | крупный |
| материалы и крафт | `sim/profile.h`, новый экран | средний |
| дерево узлов | `sim/profile.h` + экран | средний |
| темы FLESH.. VOID | данные проекта | мелкий |
| мутации тварей | `engine/catalog.*` + `sim/enemy.*` | средний |
**Порядок работ.** Стихии → триггеры → аффиксы → материалы → дерево → классы →
мутации. Каждый шаг — отдельная веха со своими проверками, как всё в этом
репозитории. Стихии первыми, потому что от них зависят и аффиксы, и мутации, и
половина ачивок.
## 9. Как это проверяется
Дизайн, который нельзя измерить, — это пожелание. Для каждого утверждения выше
есть или должен появиться инструмент ([19-agent.md](19-agent.md)):
| Утверждение | Чем меряется |
|---|---|
| угроза растёт квадратично | `--tool curve 1 120` |
| стена сдвигается на 58 этажей за тир | `--tool wall <сборка>` (новый) |
| TTK держится в коридоре | `--tool ttk <глубина>` (новый) |
| экономика опыта сходится | `--tool economy` (новый): доход против цены дерева |
| 200 часов | `--tool time` (новый): бюджет по актам из таблицы §5 |
| набор комнат тянет глубину | `--tool validate` (есть) |
Числа в этих документах — **вход** для инструментов, а не украшение. Расхождение
между таблицей и выводом инструмента означает, что неправ документ.

217
docs/21-damage.md Normal file
View File

@ -0,0 +1,217 @@
# 21 — Урон: стихии, статусы, реакции, триггеры
Продолжение [20-gdd.md](20-gdd.md). Здесь вся боевая арифметика.
Главное ограничение, из которого всё следует: **в игре нет ручных действий**
(спек 5). Игрок водит диск и строит отряд; ни одна механика ниже не вводит
кнопку. Стихии накладываются оружием, реакции срабатывают сами, эффекты висят
на снаряжении. Мастерство — это ЧТО ты взял вниз и КУДА повёл отряд, а не как
быстро ты нажимаешь.
## 1. Семь стихий
| Код | Имя | Что делает | Против кого |
|---|---|---|---|
| KIN | Кинетика | оглушение, отбрасывание | универсально, режется бронёй |
| INC | Жар | горение, распространение при смерти | мягкие, скопления |
| CRY | Холод | замедление, заморозка | быстрые, рывковые |
| TOX | Кислота | снятие брони, урон от макс. HP | бронированные, жирные |
| ARC | Дуга | цепь по целям, наводка | толпы, слабые |
| RAD | Радиация | взрыв при смерти, заражение | стаи, respawn-твари |
| VOID | Пустота | срезание сопротивлений, притяжение | всё, что защищено |
Стихии вводятся по актам ([20-gdd.md](20-gdd.md) §3), а не разом: игрок должен
успеть понять каждую, прежде чем получит следующую.
### 1.1. Статусы
Каждая стихия кладёт **стаки** статуса. Стак живёт `dur`, сгорает по одному.
| Статус | Стаки | Эффект за стак | Потолок |
|---|---|---|---|
| Горение (INC) | 5 | 6% урона удара в секунду, 4 с | при смерти поджигает в радиусе 1.5 |
| Оледенение (CRY) | 10 | 4% скорости, 3 с | 10 стаков → Заморозка 1.4 с, +50% получаемого KIN |
| Разъедание (TOX) | 8 | 4% брони, 5 с | +0.8% макс. HP цели в секунду |
| Разряд (ARC) | 4 | +5% получаемого крита, 3 с | 4 стака → цепь на 2 цели по 40% |
| Заражение (RAD) | 6 | +3% получаемого урона, 6 с | при смерти взрыв 60% макс. HP, R=2.2 |
| Схлопывание (VOID) | 3 | 8% всех сопротивлений, 4 с | 3 стака → притяжение к центру |
Числа подобраны под один принцип: **один стак не значит ничего, полный набор
меняет бой**. Это заставляет выбирать цель и держать её, а не поливать всех.
## 2. Формула урона
Одна формула на всё — и на пулю, и на клинок, и на тик горения.
```
┌ базовый урон источника
dmg_raw = W · (1 + Σ add) · Π mult
dmg_elem = dmg_raw · share(e) для каждой стихии e удара
mitig(e) = 1 clamp(R(e) + R_aff PEN(e), 0.60, +0.85)
dmg_final = Σ_e dmg_elem · mitig(e) · crit · vuln
```
| Символ | Что | Диапазон |
|---|---|---|
| `W` | урон оружия (каталог `sim/weapon_defs.h`) | 8 → 900 по глубине |
| `Σ add` | аддитивные проценты (аффиксы «+X% урона») | 0 → 4.5 |
| `Π mult` | множители (специализации, реакции, метки) | 1.0 → 3.0 |
| `share(e)` | доля стихии в уроне, Σ = 1 | конверсия аффиксами |
| `R(e)` | сопротивление вида (каталог) | 0.5 → 0.9 |
| `PEN(e)` | пробитие сборки | 0 → 0.75 |
| `crit` | 1.0 или `critMult` | 1.5 → 4.0 |
| `vuln` | уязвимости от статусов | 1.0 → 2.2 |
**Почему сопротивление режется до 0.60, а не глубже.** Отрицательное
сопротивление — это уязвимость, и она обязана быть ощутимой, но не абсурдной:
при 2.0 одна верная стихия обесценивала бы всю остальную сборку.
**Почему потолок +0.85, а не 1.0.** Иммунитета нет ни у кого. Тварь, которую
физически нельзя ранить, — это не сложность, а тупик: игрок без нужной стихии
просто не может играть.
### 2.1. Броня — отдельно от сопротивлений
Броня режет только KIN и работает долей с жёстким потолком `ARMOR_CAP = 0.72`
(уже в коде). TOX снимает броню стаками — это единственный способ пробить
тяжёлую цель без пробития в сборке.
### 2.2. Крит
```
critChance = clamp(0.05 + Σ aff, 0, 0.75)
critMult = 1.5 + Σ aff (без потолка)
```
Шанс с потолком, множитель без — потому что «всегда крит» убивает разброс
результата, а «очень больно раз в четыре удара» его сохраняет.
## 3. Реакции
Две РАЗНЫЕ стихии на одной цели в пределах 3 секунд дают реакцию. Реакция
срабатывает сама и снимает оба статуса.
| Реакция | Пара | Эффект |
|---|---|---|
| **Растрескивание** | INC + CRY | всплеск 250% APow в точке, +80% к следующему KIN |
| **Возгорание** | INC + TOX | облако R=3.0, 180% APow в секунду, 3 с |
| **Сверхпроводник** | ARC + CRY | 30% всех сопротивлений цели и соседей, 6 с |
| **Электролиз** | ARC + TOX | все DoT на цели ×3 на 3 с |
| **Распад** | RAD + INC | зона R=2.5 на 8 с, 60% APow в секунду, кладёт RAD |
| **Пепел** | RAD + CRY | цель не воскресает и не респавнится |
| **Резонанс** | VOID + любая | реакция повторяется вторым срабатыванием на 60% |
| **Схлоп** | VOID + ARC | притяжение всех в R=4 к цели, оглушение 1 с |
| **Осколки** | KIN + CRY | замороженная цель при смерти бьёт осколками, 120% APow |
| **Прорыв** | KIN + TOX | 40% брони цели на 5 с, игнорируя потолок |
### 3.1. Сила реакции не зависит от оружия
```
APow(d) = 4 + 0.12·d (d — глубина этажа)
reaction = k · APow(d) · (1 + reactionBonus)
```
Это ключевое решение. Реакция считается **от глубины, а не от урона оружия**
иначе на сотом этаже реакции стали бы округлением, и вся система стихий
превратилась бы в украшение. Так же поступает Genshin с Elemental Mastery, и по
той же причине.
Следствие: **реакции — это канал «знания»** из закона масштаба
([20-gdd.md](20-gdd.md) §2). Они растут сами по глубине, но только у того, кто
умеет их запускать.
### 3.2. Внутренний откат
У каждой пары свой ICD: 1.2 с на цель. Без него цепь ARC по десяти тварям
взорвала бы этаж одним нажатием, которого к тому же нет.
## 4. Триггеры: on-hit, on-kill, on-death
Журнал боевых событий **уже существует**`sim/events.h`, кольцевой буфер, по
которому сейчас работают эффекты и свет. Триггеры вешаются на него, а не рядом:
второй канал событий разошёлся бы с первым в первый же месяц.
| Триггер | Когда | Пример эффекта |
|---|---|---|
| `on_attack` | начало выстрела/замаха | «каждый 5-й выстрел бесплатный» |
| `on_hit` | попадание | «10% наложить CRY» |
| `on_crit` | критическое попадание | «крит снимает 1 стак перезарядки» |
| `on_kill` | цель умерла от тебя | «+8% скорости на 3 с, стак до 5» |
| `on_death` | ЦЕЛЬ умерла (любая) | «труп взрывается RAD» |
| `on_hurt` | по тебе попали | «20%: наложить INC атакующему» |
| `on_down` | твой оперативник пал | «отряд получает +25% урона на 10 с» |
| `on_floor` | вход на этаж | «+1 бинт, если сумка не полна» |
| `on_extract` | эвакуация | «+15% материалов» |
### 4.1. Против взрыва комбинаций
Три ограничителя, все обязательные:
1. **ICD у каждого эффекта.** Минимум 0.5 с, у мощных — до 8 с.
2. **Бюджет строки.** Предмет несёт суммарный бюджет `B(ilvl) = 40 + 3·ilvl`;
каждый аффикс стоит по тиру ([22-loot.md](22-loot.md) §3). Шесть сильнейших
аффиксов не влезают в один предмет никогда.
3. **Запрет рекурсии.** Эффект, сработавший от триггера, не может запустить тот
же триггер: `on_kill` → взрыв → смерть → `on_kill` обрывается на первом шаге.
Без третьего пункта одна цепь ARC на скоплении из сорока тварей вешает игру —
и это не гипотеза, а то, как ломаются все игры с триггерами.
## 5. Твари: сопротивления и роли
Сопротивление — свойство ВИДА, из каталога проекта. Роль читается по профилю
сопротивлений, и это единственная подсказка, которую игра даёт напрямую.
| Вид | Акт | KIN | INC | CRY | TOX | ARC | RAD | Роль |
|---|---|---|---|---|---|---|---|---|
| Бредущий | I | .0 | .2 | .0 | .0 | .0 | .3 | мясо |
| Бегун | I | .0 | .0 | .4 | .0 | .2 | .0 | давление |
| Плевок | II | .1 | .0 | .0 | .5 | .3 | .2 | дистанция |
| Хват | III | .3 | .3 | .1 | .0 | .0 | .0 | контроль |
| Громила | III | .5 | .2 | .3 | .4 | .0 | .1 | танк |
| Рой | IV | .2 | .5 | .0 | .0 | .4 | .4 | толпа |
| Скорлупа | IV | .7 | .3 | .4 | .5 | .3 | .2 | стена |
| Плакальщик | V | .2 | .0 | .0 | .2 | .2 | .6 | воскрешает |
| Пустотник | VI | .3 | .3 | .3 | .3 | .3 | .3 | требует VOID |
Читается так: против Скорлупы бесполезно всё, кроме TOX; Плакальщика надо жечь
радиацией, иначе он поднимет остальных; Пустотник равномерно защищён, и его
берут только срезанием сопротивлений.
**Ни у одного вида нет множителя HP от глубины.** Глубже выходят ДРУГИЕ виды —
правило из [16-descent.md](16-descent.md), и оно не отменяется до Бездны
([23-endgame.md](23-endgame.md) §2, где отменяется осознанно и с объяснением).
## 6. Коридор TTK
Чтобы бой не превращался ни в тир, ни в губку, время убийства держится в
коридоре — это проверяемое обещание, а не пожелание.
| Цель | TTK одним оперативником, с верной стихией | с неверной |
|---|---|---|
| мясо своей полосы | 0.61.2 с | 24 с |
| давление | 1.02.0 с | 48 с |
| танк | 47 с | 1540 с |
| элита (мутант) | 815 с | не убивается до отката ресурсов |
Разрыв «верная/неверная» — от ×3 до ×6. Меньше — стихии перестают значить,
больше — игра требует энциклопедии перед каждым спуском.
Проверяется инструментом `--tool ttk <глубина>`: он печатает таблицу «вид ×
сборка → секунды» и валится с кодом 1, если хоть одна пара вышла из коридора.
## 7. Свет как ресурс
Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не
садится. Должен садиться.
```
заряд(t) = заряд₀ drain·t, drain = 1/сек
радиус = base · (0.35 + 0.65 · заряд/заряд₀)
```
Батареи — расходник (вес 0.3), заменяются автоматически при разряде, как бинты.
Кончились — отряд идёт в темноте: обзор 35%, твари замечают первыми.
Это второй после патронов ресурс, который гонит наверх, и он же — причина, по
которой ветвь Разведки в дереве узлов не «приятная», а обязательная.

167
docs/22-loot.md Normal file
View File

@ -0,0 +1,167 @@
# 22 — Добыча: редкости, аффиксы, артефакты, зачарования
Продолжение [20-gdd.md](20-gdd.md). Боевые формулы — [21-damage.md](21-damage.md).
Правило, из которого всё растёт: **находка должна быть решением, а не
прибавкой**. Предмет, который просто «лучше на 7%», не стоит того, чтобы за ним
идти в темноту, — а идти в темноту и есть игра.
## 1. Редкости
| Редкость | Аффиксов | Доля дропа D1 | D50 | D100 |
|---|---|---|---|---|
| Обычное | 0 | 78% | 34% | 12% |
| Меченое | 12 | 20% | 40% | 30% |
| Разработка | 34 | 2% | 20% | 34% |
| Реликт | 56 | — | 5.5% | 20% |
| **Артефакт** | уникальное правило + 2 | — | 0.5% | 4% |
Кривая редкости:
```
P(rare+) = 0.02 + 0.0075·d (до 0.80)
P(артефакт) = max(0, 0.004·(d 30)) (до 0.06)
```
**Пити на артефакт:** 60 этажей без артефакта → следующий гарантирован. Без
пити распределение даёт игроку без артефакта на восьмидесятом этаже, и это не
«не повезло», а сломанная сессия на двадцать часов.
## 2. Слоты и что в них лежит
| Слот | Что | Личное/общее |
|---|---|---|
| Руки | ствол или клинок | личное |
| Броня | костюм | личное |
| Карман | оберег/фонарь/модуль | личное |
| Сумка | всё остальное | **общее на отряд** |
| Гнёзда артефактов | 1 → 3 по глубине рекорда | общее на отряд |
Гнёзда артефактов открываются рекордом: первое сразу, второе на D40, третье на
D75. Артефакты общие намеренно — они правила, а не снаряжение, и делить их по
бойцам значило бы прятать главное решение сборки в подменю.
## 3. Аффиксы
Аффикс = `тег + тир + рулон`. Тег определяет, ЧТО он даёт; тир — насколько
сильный доступен; рулон — конкретное число в диапазоне тира.
```
доступный тир на глубине: T_max(d) = 1 + floor(d / 12) (до 9)
диапазон тира T: [lo·T^1.35, hi·T^1.35]
цена аффикса в бюджете: cost(T) = 6 + 4·T
бюджет предмета: B(ilvl) = 40 + 3·ilvl
```
Из этого следует потолок: на предмете с ilvl 100 бюджет 340 — это шесть аффиксов
девятого тира и ни одним больше. Бюджет не позволяет собрать «всё сразу», и это
единственный барьер, который не приходится подпирать частными запретами.
### 3.1. Группы аффиксов
| Группа | Примеры | На чём |
|---|---|---|
| Урон | +% урона, +% стихии, +крит, +критмножитель | оружие |
| Пробитие | +PEN стихии, −сопротивление цели | оружие, карман |
| Стихия | конверсия X% урона в стихию | оружие |
| Статус | +стаки, +длительность, +шанс наложения | оружие, карман |
| Реакция | +% силы реакций, ICD реакции | карман, артефакт |
| Защита | +HP, +броня, +сопротивление, +уклонение | броня |
| Снабжение | +патроны, −вес, +ёмкость, +заряд фонаря | броня, сумка |
| Триггер | on-hit / on-kill / on-hurt эффекты | любое, дорого |
**Правило одной группы.** На предмете не может быть двух аффиксов одной группы
с одинаковым тегом. Иначе «+урон» шесть раз становится единственной правильной
сборкой, и весь остальной список превращается в мусор.
### 3.2. Конверсия стихий
Конверсия — самый важный аффикс в игре, потому что он превращает оружие в
инструмент против конкретного врага:
```
share(KIN) = 1 Σ конверсий, Σ конверсий ≤ 0.85
```
Полностью убрать кинетику нельзя: оружие без физического урона перестало бы
работать по целям, у которых нет сопротивлений вовсе.
## 4. Артефакты
Артефакт не даёт процентов. Он даёт **правило**, которое меняет то, как отряд
играет. Их около 60 к моменту выхода акта VI; ниже — восемь, задающих диапазон.
| Артефакт | Правило | Цена правила |
|---|---|---|
| **Стеклянный кулак** | +140% урона | 60% макс. HP всему отряду |
| **Двойное сердце** | реакции срабатывают дважды | 35% силы реакции |
| **Пепельный ход** | убийство даёт +12% скорости, стак 8 | вне боя стаки сгорают за 2 с |
| **Долг** | павший оперативник встаёт через 20 с | каждое воскрешение 5 макс. HP навсегда |
| **Счётчик Гейгера** | RAD не тратит стаки на реакции | весь отряд получает 3 RAD/сек на этаже |
| **Тихий шаг** | твари замечают на 45% ближе | фонарь садится вдвое быстрее |
| **Заём** | +1 гнездо артефакта | 25% опыта за вылазку |
| **Пустая рука** | оперативник без брони: +90% урона и +50% скорости | без брони |
Каждый артефакт — **сделка**, и ни один не является чистым улучшением. Артефакт
без цены превращается в обязательный предмет: его надевают все и всегда, и одно
гнездо сборки выпадает из игры навсегда.
## 5. Материалы и зачарование
### 5.1. Материалы
| Материал | Откуда | Куда |
|---|---|---|
| Лом | разбитая обстановка, D1+ | базовый крафт, ремонт |
| Био-ядро | твари FLESH, D21+ | статусные аффиксы |
| Крио-ядро | твари FROST, D36+ | защитные аффиксы |
| Осколок реактора | аномалии, D53+ | стихийные и реакционные |
| Пустотный шлак | Бездна, D73+ | залом, перековка тира |
Материал добывается только в своей полосе — это и есть причина возвращаться на
средние глубины после того, как рекорд ушёл вниз. Без этого весь контент, кроме
последнего акта, умирает.
### 5.2. Операции
| Операция | Что делает | Цена | Риск |
|---|---|---|---|
| Перековка | заново бросает ВСЕ рулоны | 4 лома × тир | — |
| Прививка | добавляет аффикс, если есть бюджет | 2 ядра × тир | — |
| Отсечение | убирает выбранный аффикс | 1 ядро × тир | — |
| Возвышение | +1 тир случайному аффиксу | 6 ядер + осколок | — |
| **Залом** | +2 тира и 1 бюджет ко всему | 3 шлака | **12%: предмет рассыпается** |
Залом — единственная операция с риском, и он настоящий: предмет исчезает.
Именно поэтому лучшая вещь в игре всегда чуть хуже, чем могла бы быть, — почти
никто не заламывает то, что собирал двадцать часов. Это и задумано: разрыв между
«хорошей сборкой» и «идеальной» держит лейтгейм живым.
### 5.3. Стоимость полной сборки
Одна вещь тира 9 со всеми операциями ≈ 340 ломов, 90 ядер, 22 осколка, 6 шлаков.
За вылазку на D80 добывается ≈ 40 ломов, 12 ядер, 4 осколка, 1 шлак — то есть
**около 8 вылазок на один предмет**. Слотов девять, предметов в сборке — пять
ключевых. Экономика материалов рассчитана на 6080 часов лейтгейма, и это ровно
акт VI из бюджета времени ([20-gdd.md](20-gdd.md) §5).
## 6. Как это не превращается в кашу
Три правила, которые придётся защищать от самих себя при любом расширении:
1. **Ни одного чистого улучшения.** Каждый артефакт — сделка, каждый высокий
тир — вес или риск. Предмет без цены обязателен, а обязательный предмет — это
вырезанный слот.
2. **Бюджет вместо запретов.** Ограничивает арифметика, а не список исключений.
Список исключений разрастается и однажды противоречит сам себе.
3. **Находка — решение.** Если новая вещь не заставляет игрока хоть раз
задуматься «а что снять», её не должно быть в таблице дропа вообще.
## 7. Проверки
| Утверждение | Инструмент |
|---|---|
| кривая редкости даёт артефакт к D50 | `--tool drops 1 120` (новый): симуляция 10⁴ этажей |
| бюджет не пускает шесть высших аффиксов | `--tool affix <ilvl>` (новый) |
| материалов хватает на сборку за ~8 вылазок | `--tool economy` (новый) |
| ни один артефакт не является чистым плюсом | ревью-чеклист + тест «сборка без цены» |
| TTK не выходит из коридора с лучшими вещами | `--tool ttk <глубина> --best` |

182
docs/23-endgame.md Normal file
View File

@ -0,0 +1,182 @@
# 23 — Лейтгейм: Бездна, лидерборды, ачивки
Продолжение [20-gdd.md](20-gdd.md). Здесь то, что начинается после сотого этажа
и не кончается.
## 1. Что такое лейтгейм этой игры
Не «ещё контент». После акта VI у игрока собран отряд из пяти, три гнезда
артефактов и сборка, которую он строил восемьдесят часов. Дальше игра меняет
вопрос: не «пройду ли я», а **«как глубоко»**.
Это переход от прохождения к соревнованию, и он должен быть заметным: на D101
меняются правила, а не только цифры.
## 2. Бездна: честная беговая дорожка
Ниже сотого этажа авторского содержимого больше нет — и притворяться, что есть,
нельзя. Поэтому здесь включается то, что до сих пор было запрещено.
### 2.1. Отмена правила о множителях
В [16-descent.md](16-descent.md) записано: **глубина меняет, КТО выходит, а не
сколько у него HP**. Это правило держало весь авторский контент, и оно верное:
«тот же бредущий, но втрое толще» — не страшнее, а дольше.
С D101 оно снимается:
```
HP,урон тварей ×= 1 + 0.045·(d 100)
```
Почему это допустимо здесь и нигде больше: в Бездне игрок не проходит контент, а
**меряет сборку**. Он уже видел всё, что игра хотела показать; дальше ему нужна
не новизна, а линейка. Множитель — это и есть линейка, и он честно назван
беговой дорожкой, а не «новым уровнем сложности».
Умножение линейное, а не экспоненциальное: экспонента обрывает лестницу на
конкретном этаже одинаково для всех, и лидерборд превращается в проверку, у кого
чуть больше урона. Линейный рост против затухающей силы отряда даёт лестницу
длиной в сотни этажей, где каждый следующий шаг стоит заметно дороже
предыдущего.
### 2.2. Мутации — то, что делает Бездну не только дорожкой
Каждая тварь глубже D101 получает `floor((d 100) / 25) + 1` мутаций из пула.
Мутация меняет ПОВЕДЕНИЕ, а не число.
| Мутация | Что делает |
|---|---|
| Зеркальная | отражает 25% полученной стихии в атакующего |
| Роящаяся | при смерти делится на двух вдвое слабее (до 3 поколений) |
| Голодная | лечится на 30% нанесённого урона |
| Слепая | не видит свет, но слышит стрельбу вдвое дальше |
| Якорная | не отбрасывается, KIN по ней даёт 50% |
| Заражённая | на смерти оставляет облако RAD |
| Стеклянная | 60% HP, +150% урона |
| Тихая | не издаёт звука и не подсвечивается в тумане |
| Насыщенная | иммунна к первому наложенному статусу |
Мутации стоят места в бюджете опасности этажа: мутировавшая тварь стоит
`danger × (1 + 0.35 · мутаций)`. Поэтому глубокий этаж — это не «те же сорок
тварей, но злее», а **меньше тварей, каждая из которых задача**. Потолок тел
(`MAX_LEVEL_SPAWNS = 64`) остаётся нетронутым, и это не совпадение: именно бюджет
опасности переводит рост угрозы в качество, когда мест больше нет.
### 2.3. Пороги Бездны
Каждые 25 этажей — **Порог**: этаж-аномалия с фиксированными правилами.
| Порог | D | Правило этажа |
|---|---|---|
| Первый | 125 | света нет вообще; работает только фонарь |
| Второй | 150 | эвакуация закрыта до убийства всех элит |
| Третий | 175 | стихии инвертированы: сопротивления становятся уязвимостями |
| Четвёртый | 200 | отряд входит без сумки |
| Дальше | +25 | правила смешиваются по два |
Порог — единственное место, где игра прямо проверяет знание, а не сборку.
## 3. Лидерборды
### 3.1. Три доски, а не одна
| Доска | Что мерит | Зачем отдельно |
|---|---|---|
| **Глубина** | максимальный этаж за сезон | главная, ради неё всё |
| **Скорость** | время до D100 с нуля профиля | другой навык, другие сборки |
| **Голыми руками** | глубина без артефактов | иначе доска — это лотерея дропа |
### 3.2. Счёт
```
score = 1000·D + 40·E + 12·A 250·W
```
| Символ | Что |
|---|---|
| `D` | достигнутая глубина |
| `E` | число успешных эвакуаций в сезоне |
| `A` | убитые элиты на глубинах ниже рекорда |
| `W` | вайпы |
Штраф за вайп — четверть этажа. Достаточно, чтобы «просто прыгнуть вниз и
посмотреть» имело цену, и слишком мало, чтобы делать доску страховкой от риска.
### 3.3. Сезоны
Сезон — 8 недель, общий сид генерации на всех. Один и тот же набор этажей у
всех игроков: без этого доска сравнивает удачу, а не игру. Профиль в сезонном
режиме отдельный и стартует с нуля — иначе первое место занимает тот, кто начал
раньше.
По окончании: рекорды в вечную таблицу, сезонный профиль сливается в основной
(материалы — половина, артефакты — все, опыт — весь).
## 4. Ачивки: 262 штуки
Ачивки — это не список задач, а **карта того, что игра считает достойным**.
Поэтому они собраны по категориям, и почти каждая формулируется правилом, а не
вручную.
| Категория | Кол-во | Как строятся |
|---|---|---|
| Глубина | 24 | D5, D10, …, D100 (20) + D125/150/175/200 (4) |
| Экстракшен | 22 | вынести N предметов; уйти на 1 HP; эвакуация без выстрела; 10 подряд без потерь |
| Отряд | 26 | набрать 2/3/4/5; довести оперативника до D50 без смертей; вылазка соло на D40 |
| Бой | 34 | убийства по видам (9×2), убийство залпом, TTK-рекорды, бой без урона |
| Стихии | 30 | по 3 на каждую из 7 (наложить/добить/сборка) + 9 на реакции |
| Реакции | 20 | каждая из 10 по разу + каждая 1000 раз |
| Предметы | 28 | редкости, тиры, полный комплект тира, конверсия 85% |
| Артефакты | 24 | найти N, собрать 3 гнезда, пройти D80 с одним, с самым дорогим |
| Крафт | 18 | операции, залом успешный, залом неудачный (да, за провал тоже) |
| Экономика | 16 | материалы, полное дерево ветви, все 200 уровней узлов |
| Хоррор | 12 | пройти этаж без света; без единого выстрела; не потревожив никого |
| Мета | 8 | сезонные места, три доски, вечная таблица |
**Итого 262.**
### 4.1. Правила, по которым ачивки не превращаются в мусор
1. **Ни одной за время.** «Играйте 100 часов» — это не достижение, а счётчик.
2. **Ни одной невозможной без удачи.** Всё, что зависит от дропа, имеет пити.
3. **Каждая пятая — за необычный способ, а не за повторение.** «Пройти этаж, не
потревожив никого» стоит рядом с «убить 10 000 тварей», и вторая нужна только
как фон для первой.
4. **За провал тоже.** Неудачный залом, вайп на D1, потеря артефакта — это
истории, а истории и есть то, что люди рассказывают о такой игре.
### 4.2. Примеры именованных
| Имя | Условие |
|---|---|
| «Первый свет» | пройти D1 |
| «Тише воды» | этаж без единого выстрела на D20+ |
| «Экономный» | эвакуация с полной сумкой и полным боезапасом |
| «Пирокинетик» | 1000 Растрескиваний |
| «Не мой день» | залом рассыпал реликт тира 9 |
| «Он был хорошим» | потерять оперативника, дошедшего до D50 |
| «Голыми руками» | D60 без единого артефакта |
| «Кто там?» | пройти Порог 125 без фонаря |
| «Полный набор» | все 200 уровней узлов |
| «Ниже некуда» | D200 |
## 5. Почему это удержит игрока после сотого этажа
Три независимых причины возвращаться, и ни одна не завязана на новый контент:
1. **Число.** Рекорд глубины — это одна цифра, которую можно улучшить сегодня.
2. **Сборка.** Материалы и залом дают потолок, до которого почти никто не
доходит: идеальная вещь всегда на один рискованный шаг впереди.
3. **Сезон.** Раз в восемь недель поле выравнивается, и лучший результат снова
решается игрой, а не тем, кто дольше играл.
## 6. Проверки
| Утверждение | Чем меряется |
|---|---|
| множитель Бездны даёт лестницу, а не обрыв | `--tool wall 100 300` (новый): где стена при сборке N |
| мутации не разносят потолок тел | `--tool floor <d>``spawnsDropped` обязан быть 0 |
| счёт лидерборда не выигрывается вайп-фармом | симуляция стратегий в `--tool score` (новый) |
| ачивки достижимы | `--tool achievements` (новый): для каждой — глубина и условия |
| сезонный сид даёт всем один набор этажей | `--tool map <d> <seed>` на двух машинах |

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

BIN
docs/descent-d12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
docs/editor-level.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
docs/editor-room.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/editor-spawn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
docs/editor-terminal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
docs/level-rooms-seed42.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

BIN
docs/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
docs/raid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
docs/rusher-direct-gate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
docs/sample-walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
docs/squad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
docs/ui-depth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
docs/ui-squad-stash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
docs/ui-summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
docs/ui-upgrades.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB