diff --git a/.claude/commands/agent-sprite.md b/.claude/commands/agent-sprite.md new file mode 100644 index 0000000..db836f7 --- /dev/null +++ b/.claude/commands/agent-sprite.md @@ -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 ` 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 `. + 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 --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 ` 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. diff --git a/.claude/commands/floor.md b/.claude/commands/floor.md new file mode 100644 index 0000000..3e11377 --- /dev/null +++ b/.claude/commands/floor.md @@ -0,0 +1,48 @@ +# Tune the difficulty curve + +Request: `$ARGUMENTS` + +The whole slope of the game lives in one place: `projects//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 1 # one floor: budget spent, population, loot +tile2d_editor --tool floor 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 --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. diff --git a/.claude/commands/room.md b/.claude/commands/room.md new file mode 100644 index 0000000..fa0703e --- /dev/null +++ b/.claude/commands/room.md @@ -0,0 +1,49 @@ +# Author or repair a room + +Request: `$ARGUMENTS` + +A room is a text file: `projects//rooms/.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 ` — read a working room of the same kind. +3. `tile2d_editor --tool catalog monster ` — 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 # doors, spawn tables, problems +tile2d_editor --tool ascii # the grid, with spawn points numbered +tile2d_editor --tool validate # the whole set, including depth coverage +tile2d_editor --tool map 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. diff --git a/.claude/commands/sprite.md b/.claude/commands/sprite.md new file mode 100644 index 0000000..8b47555 --- /dev/null +++ b/.claude/commands/sprite.md @@ -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`. diff --git a/.claude/commands/tile2d.md b/.claude/commands/tile2d.md new file mode 100644 index 0000000..7f94bc0 --- /dev/null +++ b/.claude/commands/tile2d.md @@ -0,0 +1,50 @@ +# Work on a Tile2D project + +Request: `$ARGUMENTS` + +Tile2D is the engine (`engine/`); a game is a project (`projects//`). 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 grid as text +tile2d_editor --tool floor [seed] # assemble a floor, report the numbers +tile2d_editor --tool map [seed] # that floor as an ASCII map +tile2d_editor --tool curve # what depth means, per floor +tile2d_editor --tool validate # whole set: exit code 1 means broken +``` + +Every tool takes `--project `; 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. diff --git a/CLAUDE.md b/CLAUDE.md index e49a9b8..c4dcb56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,14 +19,92 @@ | [docs/09-acceptance.md](docs/09-acceptance.md) | критерии приёмки (спек 11) и их текущие цифры | приёмка | | [docs/10-atmosphere.md](docs/10-atmosphere.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) — источник истины по правилам. -- Сборка: `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 по всем измеримым критериям. -- Все числа — в [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/` не знает про рендер, изометрию и ввод. Это правило не нарушать. - Правило «не бить своих» одно на все классы оружия: у ствола это отрезок, у клинка — сектор. Ослаблять его нельзя нигде. - `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 `_`; renaming them breaks +`tools/asset_coverage.py`. Full rules in [docs/14-monsters.md](docs/14-monsters.md). diff --git a/docs/00-overview.md b/docs/00-overview.md index ed913cb..4935e78 100644 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -6,7 +6,12 @@ > отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама > расставляется так, чтобы атаковать и не задевать своих. -Всё остальное — вне рамок и намеренно не сделано. +Это ядро — вехи M0–M6, и в его рамках всё остальное намеренно не сделано. + +Поверх ядра достроен игровой слой: твари, которые идут и бьют, каталог оружия, +снаряжение с инвентарём, главное меню и HUD ([13-combat.md](13-combat.md)). +Прежняя песочница с мишенями никуда не делась — она живёт под `--sandbox`, +и все критерии приёмки по-прежнему меряются на ней. ## Что есть diff --git a/docs/01-build-and-run.md b/docs/01-build-and-run.md index 49ebc08..aba3bd0 100644 --- a/docs/01-build-and-run.md +++ b/docs/01-build-and-run.md @@ -6,6 +6,10 @@ - Компилятор с C++17. Проверено на MSVC 14.44 (Visual Studio 2022, x64). - Интернет при **первой** конфигурации: raylib 6.0 качается через `FetchContent`. Если в системе уже стоит 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 ``` -Бинарник: `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, так что проверять правки стоит именно в ней: MSVC включает проверки итераторов и ловит @@ -63,6 +77,11 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 | `←` / `→` | в ручном режиме — поворот вектора прицела | | `Tab` | сменить назначенную цель (внутри конуса прицела) | | `1` / `2` / `3` | строй: клин / линия / кольцо | +| `F` | спуститься глубже (стоя на зелёном кольце) | +| `E` | эвакуироваться: забег окончен, добыча в схроне (голубое кольцо) | +| `Esc` | пауза (из меню — выход) | +| `I` | экран отряда: снаряжение и сумки | +| `F11` | окно / безрамочный на весь экран | | `F1` | оверлей вкл/выкл | | `F2` | линии огня | | `F3` | кандидаты решателя и их оценки | @@ -71,8 +90,11 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 | `F6` | показать диски обзора | | `[` / `]` | выбрать параметр тюнинга | | `-` / `=` | изменить выбранный параметр | -| `R` | сброс уровня | -| `Esc` | выход | +| `R` | пересобрать ТОТ ЖЕ этаж заново | + +По умолчанию окно разворачивается безрамочно на весь монитор, а кадр 480x270 +масштабируется ЦЕЛЫМ множителем и центрируется — остаток уходит в чёрные поля. +Дробное растяжение размывало бы пиксель: одни строки удваивались бы, соседние нет. Стрельба **всегда автоматическая**, клавиши огня нет ни в одном режиме. Ближний бой тоже: бойцы с клинком сами доходят до цели и бьют, пока она внутри @@ -96,6 +118,24 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 | `--set имя=значение` | подкрутить любой параметр из `TUNABLES` без пересборки | | `--shot [кадров] [файл]` | снять скриншот через N кадров и выйти | | `--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 ` | перебить вид отображения из манифеста проекта (отладочное) | +| `--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 --set safetyMargin=0.25 --set laneReserve=2.0 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), маршрут и сценарии описаны там же. Что означают цифры в выводе — [06-debug-and-tuning.md](06-debug-and-tuning.md). diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 42ab24e..145c57e 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -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/ main.cpp окно, ввод, главный цикл, fixed timestep. Игровой логики нет game.h/.cpp склейка: карта + отряд + мишени + пули + метрики, порядок шага - tuning.h ВСЕ числа прототипа + таблица runtime-подкрутки + tuning.h ВСЕ числа ИГРЫ + таблица runtime-подкрутки core/ - math.h Vec2, отрезки, DistancePointToSegment, углы. Header-only input.h InputState — ввод, влияющий на симуляцию render/ view.h/.cpp ВСЁ render-only состояние + RenderGame(const Game&, ...) - framebuffer.h/.cpp софтверный буфер RGBA8, блит (в т.ч. со светом), аддитивные примитивы sprites.h/.cpp процедурная генерация всех спрайтов при старте - iso.h проекция мир↔экран, камера, ключ сортировки по глубине + hud.h/.cpp полосы отряда поверх кадра: ни одной буквы текста scene.h/.cpp проход по полу + общий список сущностей и стен с сортировкой lighting.h/.cpp свет и туман войны (см. docs/10-atmosphere.md) fx.h/.cpp трассеры, вспышки дула, искры попаданий post.h/.cpp напряжение (Dread) и слитый пост-проход noise.h единственное место render-only псевдослучайности - world/ - tilemap.h/.cpp карта, генерация уровня, DDA-рейкаст, circle-vs-AABB sim/ loadout.h класс оружия, параметры ствола и клинка, фазы удара agent.h/.cpp агент, слои движения (seek/separation/targetPush/wallAvoid) @@ -29,12 +71,17 @@ src/ target.h/.cpp мишени, респавн, отдача от удара и возврат на место bullet.h/.cpp снаряды, коллизии с тайлами и капсулами events.h кольцевой журнал боевых событий: единственный канал к эффектам + items.h каталог предметов и инвентарь бойца (docs/13-combat.md) + enemy.h/.cpp поведение тварей: заметил -> идёт -> замах -> удар + spawn_catalog.h/.cpp единственный перевод id движка в EnemyKind игры ai/ firing_solver.h/.cpp LaneClear, выбор цели, решатель огневых позиций lane_registry.h/.cpp активные линии огня и секторы клинков, импульс расступания melee.h/.cpp цель ближнего боя, подход вплотную, чистота сектора + ui/ + menu.h/.cpp меню, пауза, экран отряда, поражение (второе место с raylib) debug/ - overlay.h/.cpp оверлей F1–F4 поверх кадра (единственное место с raylib-примитивами) + overlay.h/.cpp оверлей F1–F4 поверх кадра (raylib-примитивы) metrics.h/.cpp FRIENDLY_HITS, FIRE UPTIME, SETTLE TIME, журнал попаданий по своим harness.h/.cpp прогоны --headless и --accept ``` @@ -51,12 +98,28 @@ src/ тряска камеры физически не могут изменить симуляцию — это проверяет компилятор, а не внимательность. Всё render-only состояние живёт в `View`, которое headless-прогоны не создают вообще. -2. **`ai/` не знает ничего** про рендер, изометрию и ввод. Только `core/`, `sim/`, `world/`. -3. **`sim/` и `world/` работают в декартовом мире** (top-down), тайл = 1.0 юнита. - Изометрия существует только в `render/` и в оверлее. +2. **`ai/` не знает ничего** про рендер, проекцию и ввод. Только `core/`, `sim/` + и тайлмап движка. +3. **`sim/` и тайлмап работают в декартовом мире**, тайл = 1.0 юнита. Про то, + ромбами картинка или квадратами, знает одна `engine/projection.h`; всё + остальное считает через неё ([18-engine.md](18-engine.md)). 4. Ввод в экранных осях переводится в мировое направление ровно в одном месте — - `Game::Step` через `iso::ScreenDirToWorld`. Дальше по симуляции экранных осей нет. -5. `tuning.h` не зависит ни от чего; его включают все. + `Game::Step` через `v2d::ScreenDirToWorld`. Дальше по симуляции экранных осей нет. +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 шагов за кадр. - Ни одного вызова RNG в симуляции. Уровень и расстановка мишеней захардкожены. + Уровень, собранный из комнат (`--rooms`), этого не меняет: RNG крутится один + раз, в `engine/level_gen.cpp`, до первого `Game::Step`, и полностью определён + сидом. `--accept` и `--headless` набор комнат не подключают вообще. - Вырожденные случаи разрешаются по индексу слота, а не случайно (например, расталкивание двух агентов, стоящих в одной точке). - Камера обновляется в рендере по реальному времени кадра и на симуляцию не влияет. diff --git a/docs/03-rendering.md b/docs/03-rendering.md index 256e12f..262dbc9 100644 --- a/docs/03-rendering.md +++ b/docs/03-rendering.md @@ -2,7 +2,7 @@ ## Фреймбуфер -[src/render/framebuffer.h](../src/render/framebuffer.h) — `std::vector` 480×270. +[engine/framebuffer.h](../engine/framebuffer.h) — `std::vector` 480×270. Формат — RGBA8, в памяти байты идут `R,G,B,A`, значит little-endian `uint32` это `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 diff --git a/docs/04-world.md b/docs/04-world.md index c8315c5..f06810e 100644 --- a/docs/04-world.md +++ b/docs/04-world.md @@ -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`. **Вне карты считается стеной.** Это убирает граничные проверки из рейкаста и коллизий. diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 41b98c8..8c9c265 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -12,6 +12,23 @@ | **M5** | ручное прицеливание, конус ±20°, назначенная цель, `Tab` | сценарий `11.7` в `--accept`: 0 попаданий по своим при активной назначенной цели | | **M6** | оверлей F1–F4, метрики, журнал попаданий по своим, runtime-тюнинг `[ ] - =`, `--set` | скриншот боевого кадра: зелёные линии у всех пятерых, FRIENDLY_HITS 0, uptime 99 % | | **M7** | ближний бой: классы оружия, сектор удара, три фазы, свой больший круг, дуга; отзывчивый диск на поводке | `--accept`: M7.1–M7.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)) | ## Что находилось по ходу и чинилось diff --git a/docs/10-atmosphere.md b/docs/10-atmosphere.md index 6e638f7..c803247 100644 --- a/docs/10-atmosphere.md +++ b/docs/10-atmosphere.md @@ -25,7 +25,7 @@ Clear(чёрный) ступень 0 рампы == цве ## Почему свет вплавлен в блит, а не сделан пост-проходом Соблазн очевидный: пройти по 129600 пикселям, для каждого получить мировую точку -через `iso::ScreenToWorld` и умножить на освещённость. Так делать нельзя. +через `v2d::ScreenToWorld` и умножить на освещённость. Так делать нельзя. Спрайт стены имеет привязку `ay = TILE_HALF_H + TILE_H = 24`, то есть верхняя грань поднята на 16 px над ромбом своего тайла. Обратная проекция этого пикселя diff --git a/docs/12-assets.md b/docs/12-assets.md new file mode 100644 index 0000000..22039d0 --- /dev/null +++ b/docs/12-assets.md @@ -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`. Строка живёт ровно в двух +местах: в манифесте и в имени файла `.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`, по одному ассету `_walk` на +вид. **Один ассет = один клип.** Бэкенд `import` имён клипов не читает и всегда +кладёт единственную анимацию `default`, поэтому `idle` и `attack`, когда до них +дойдёт дело, станут отдельными ассетами `_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 ради одной игровой сущности: если не + хватает генератора — это отдельная задача в его репозитории. diff --git a/docs/13-combat.md b/docs/13-combat.md new file mode 100644 index 0000000..f0a7f13 --- /dev/null +++ b/docs/13-combat.md @@ -0,0 +1,243 @@ +# 13. Бой: оружие, твари, снаряжение, экраны + +Прототип из вех M0–M6 проверял ОТРЯД: строй, линии огня, правило «не бить +своих». Противника там не было — были мишени, которые стоят и не отвечают. +Эта веха достраивает вторую половину: твари, которые идут и бьют, каталог +оружия, снаряжение и экраны вне боя. + +![вылазка](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 бойцов подобран грубо и ждёт живых прогонов: отряд, +стоящий на месте, держится, но постепенно теряет здоровье. diff --git a/docs/14-monsters.md b/docs/14-monsters.md new file mode 100644 index 0000000..53cd2e8 --- /dev/null +++ b/docs/14-monsters.md @@ -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 ассета — строго `_`: ровно это требует + `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` про это предупреждает: снимать + предупреждение — это отдельное решение на стороне рантайма, не конвейера. diff --git a/docs/15-engine-and-editor.md b/docs/15-engine-and-editor.md new file mode 100644 index 0000000..d8b5bf6 --- /dev/null +++ b/docs/15-engine-and-editor.md @@ -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`. diff --git a/docs/16-descent.md b/docs/16-descent.md new file mode 100644 index 0000000..a5d87ec --- /dev/null +++ b/docs/16-descent.md @@ -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` 1–3, `RUST` 4–7, `FLESH` 8–12, `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 [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 +``` + +## Чего ещё нет + +- **траты опыта** — см. выше, это отдельная веха с отдельными критериями; +- **боссов и особых этажей** — кривая пока гладкая, а спуск без вех в памяти + сливается в один длинный коридор; +- **сохранения забега** — вылет означает потерю прогресса; +- **уникального лута** — все находки берутся из общего каталога предметов, и + отличаются только тиром. diff --git a/docs/17-extraction.md b/docs/17-extraction.md new file mode 100644 index 0000000..4e73092 --- /dev/null +++ b/docs/17-extraction.md @@ -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)). diff --git a/docs/18-engine.md b/docs/18-engine.md new file mode 100644 index 0000000..4d79f75 --- /dev/null +++ b/docs/18-engine.md @@ -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)), и с видом сверху пока не дружит. diff --git a/docs/19-agent.md b/docs/19-agent.md new file mode 100644 index 0000000..24bdc32 --- /dev/null +++ b/docs/19-agent.md @@ -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`. diff --git a/docs/20-gdd.md b/docs/20-gdd.md new file mode 100644 index 0000000..02e0904 --- /dev/null +++ b/docs/20-gdd.md @@ -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·(d−1) + 0.10·(d−1)²`. + +Сила отряда `P` растёт от улучшений, снаряжения и артефактов, но каждый источник +имеет затухание (§6). Поэтому `T(d) / P` неизбежно растёт, и на каждой глубине +есть **стена** — этаж, где отряд перестаёт справляться. + +Сдвинуть стену можно тремя способами, и только третий не имеет потолка: + +| Способ | Даёт | Потолок | +|---|---|---| +| улучшения и снаряжение | +5–8 этажей за полный тир | есть, жёсткий | +| артефакты и зачарования | +3–6 этажей за сборку | есть, мягкий | +| **знание** — стихии, реакции, порядок целей, маршрут | ×1.5–3 к эффективной силе | **нет** | + +Отсюда следуют все дальнейшие решения. Если бы числа закрывали разрыв, игра +перестала бы быть хоррором на сороковом этаже: страшно не там, где мало урона, +а там, где ты не понимаешь, что тебя убивает. + +**Что ломается, если нарушить.** Дайте силе расти быстрее квадрата — и лейтгейм +превратится в покос травы, а лидерборд — в тест на терпение. Дайте угрозе расти +быстрее силы без канала знания — и стена станет непроходимой стеной, а не +задачей. + +## 3. Акты и глубины + +Шесть актов авторского содержимого, дальше — бесконечность. + +| Акт | Глубины | Тема | Что вводится | Отряд | +|---|---|---|---|---| +| I. Лаборатория | 1–8 | STEEL | базовый бой, вес, патроны | 1 | +| II. Технические уровни | 9–20 | RUST | стихии INC/CRY, первые аффиксы, 2-й оперативник | 2 | +| III. Био-крыло | 21–35 | FLESH | TOX, on-death эффекты, зачарования | 3 | +| IV. Криокамеры | 36–52 | FROST | ARC, реакции, специализации | 4 | +| V. Реактор | 53–72 | EMBER | RAD, аномалии, артефакты | 5 | +| VI. Провал | 73–100 | VOID | VOID, залом, сборки-ключи | 5 | +| VII. Бездна | 101+ | ABYSS | мутации, сезоны, лидерборды | 5 | + +Тем становится семь (сейчас четыре: `THEME_COUNT = 4` в `engine/theme.h`). +Полосы задаются проектом (`themes` в `project.txt`), поэтому это правка данных, +а не движка. + +Каждый акт — это **новая причина умирать**, а не новый цвет стен. Акт без +собственной механики смерти вырезается. + +## 4. Игровой цикл + +Три петли, вложенные друг в друга. Каждая обязана давать ответ на вопрос +«зачем ещё раз». + +### Петля этажа (3–8 минут) +Спуститься → разведать → решить, где драка неизбежна → собрать находки → +уйти на спуск или наверх. Решение одно и постоянное: **ещё этаж или назад**. +Его задают ресурсы (патроны, бинты, вес) и здоровье, а не таймер. + +### Петля вылазки (25–45 минут) +Чекпоинт → 5–12 этажей → эвакуация или вайп. Ставка: снаряжение на выживших. +Награда: добыча, опыт, материалы, продвижение рекорда. + +### Петля мета (десятки часов) +Опыт → узлы улучшений. Материалы → крафт и зачарования. Артефакты → сборки. +Рекорд глубины → новые чекпоинты → доступ к более глубоким находкам. + +``` + ┌──────────── этаж ────────────┐ + спуск│ разведка → бой → находки │уход + └───────────┬──────────────────┘ + ↓ эвакуация + ┌──────── вылазка ─────────────┐ + │ опыт · материалы · артефакты │ + └───────────┬──────────────────┘ + ↓ база + ┌────────── мета ──────────────┐ + │ узлы · крафт · сборка отряда │ + └───────────┬──────────────────┘ + ↓ глубже +``` + +## 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 | **Штурмовик** | D9–12 | капсула стазиса + 2 500 опыта | +| 3 | **Техник** | D21–26 | капсула + 18 000 опыта + 3 био-ядра | +| 4 | **Егерь** | D36–42 | капсула + 90 000 опыта + 5 крио-ядер | +| 5 | **Инквизитор** | D53–60 | капсула + 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·(d−1))` — это уже работающая +формула (`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` | +| стена сдвигается на 5–8 этажей за тир | `--tool wall <сборка>` (новый) | +| TTK держится в коридоре | `--tool ttk <глубина>` (новый) | +| экономика опыта сходится | `--tool economy` (новый): доход против цены дерева | +| 200 часов | `--tool time` (новый): бюджет по актам из таблицы §5 | +| набор комнат тянет глубину | `--tool validate` (есть) | + +Числа в этих документах — **вход** для инструментов, а не украшение. Расхождение +между таблицей и выводом инструмента означает, что неправ документ. diff --git a/docs/21-damage.md b/docs/21-damage.md new file mode 100644 index 0000000..0c5a70d --- /dev/null +++ b/docs/21-damage.md @@ -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.6–1.2 с | 2–4 с | +| давление | 1.0–2.0 с | 4–8 с | +| танк | 4–7 с | 15–40 с | +| элита (мутант) | 8–15 с | не убивается до отката ресурсов | + +Разрыв «верная/неверная» — от ×3 до ×6. Меньше — стихии перестают значить, +больше — игра требует энциклопедии перед каждым спуском. + +Проверяется инструментом `--tool ttk <глубина>`: он печатает таблицу «вид × +сборка → секунды» и валится с кодом 1, если хоть одна пара вышла из коридора. + +## 7. Свет как ресурс + +Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не +садится. Должен садиться. + +``` +заряд(t) = заряд₀ − drain·t, drain = 1/сек +радиус = base · (0.35 + 0.65 · заряд/заряд₀) +``` + +Батареи — расходник (вес 0.3), заменяются автоматически при разряде, как бинты. +Кончились — отряд идёт в темноте: обзор 35%, твари замечают первыми. + +Это второй после патронов ресурс, который гонит наверх, и он же — причина, по +которой ветвь Разведки в дереве узлов не «приятная», а обязательная. diff --git a/docs/22-loot.md b/docs/22-loot.md new file mode 100644 index 0000000..08a409a --- /dev/null +++ b/docs/22-loot.md @@ -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% | +| Меченое | 1–2 | 20% | 40% | 30% | +| Разработка | 3–4 | 2% | 20% | 34% | +| Реликт | 5–6 | — | 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 вылазок на один предмет**. Слотов девять, предметов в сборке — пять +ключевых. Экономика материалов рассчитана на 60–80 часов лейтгейма, и это ровно +акт VI из бюджета времени ([20-gdd.md](20-gdd.md) §5). + +## 6. Как это не превращается в кашу + +Три правила, которые придётся защищать от самих себя при любом расширении: + +1. **Ни одного чистого улучшения.** Каждый артефакт — сделка, каждый высокий + тир — вес или риск. Предмет без цены обязателен, а обязательный предмет — это + вырезанный слот. +2. **Бюджет вместо запретов.** Ограничивает арифметика, а не список исключений. + Список исключений разрастается и однажды противоречит сам себе. +3. **Находка — решение.** Если новая вещь не заставляет игрока хоть раз + задуматься «а что снять», её не должно быть в таблице дропа вообще. + +## 7. Проверки + +| Утверждение | Инструмент | +|---|---| +| кривая редкости даёт артефакт к D50 | `--tool drops 1 120` (новый): симуляция 10⁴ этажей | +| бюджет не пускает шесть высших аффиксов | `--tool affix ` (новый) | +| материалов хватает на сборку за ~8 вылазок | `--tool economy` (новый) | +| ни один артефакт не является чистым плюсом | ревью-чеклист + тест «сборка без цены» | +| TTK не выходит из коридора с лучшими вещами | `--tool ttk <глубина> --best` | diff --git a/docs/23-endgame.md b/docs/23-endgame.md new file mode 100644 index 0000000..5c24361 --- /dev/null +++ b/docs/23-endgame.md @@ -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 ` — `spawnsDropped` обязан быть 0 | +| счёт лидерборда не выигрывается вайп-фармом | симуляция стратегий в `--tool score` (новый) | +| ачивки достижимы | `--tool achievements` (новый): для каждой — глубина и условия | +| сезонный сид даёт всем один набор этажей | `--tool map ` на двух машинах | diff --git a/docs/agent-v2-attack.preview.png b/docs/agent-v2-attack.preview.png new file mode 100644 index 0000000..e7f209c Binary files /dev/null and b/docs/agent-v2-attack.preview.png differ diff --git a/docs/agent-v2-rifle-attack.preview.png b/docs/agent-v2-rifle-attack.preview.png new file mode 100644 index 0000000..20a4987 Binary files /dev/null and b/docs/agent-v2-rifle-attack.preview.png differ diff --git a/docs/agent-v3-rifle-attack.preview.png b/docs/agent-v3-rifle-attack.preview.png new file mode 100644 index 0000000..fd23584 Binary files /dev/null and b/docs/agent-v3-rifle-attack.preview.png differ diff --git a/docs/agent-v4-rifle-attack.preview.png b/docs/agent-v4-rifle-attack.preview.png new file mode 100644 index 0000000..cb535cb Binary files /dev/null and b/docs/agent-v4-rifle-attack.preview.png differ diff --git a/docs/agent-v5-rifle-attack.preview.png b/docs/agent-v5-rifle-attack.preview.png new file mode 100644 index 0000000..a8f1cf6 Binary files /dev/null and b/docs/agent-v5-rifle-attack.preview.png differ diff --git a/docs/agent-v6-rifle-attack.preview.png b/docs/agent-v6-rifle-attack.preview.png new file mode 100644 index 0000000..67969e1 Binary files /dev/null and b/docs/agent-v6-rifle-attack.preview.png differ diff --git a/docs/agent_attack.preview.png b/docs/agent_attack.preview.png new file mode 100644 index 0000000..e343255 Binary files /dev/null and b/docs/agent_attack.preview.png differ diff --git a/docs/art-target-alien-shooter.png b/docs/art-target-alien-shooter.png new file mode 100644 index 0000000..e28153c Binary files /dev/null and b/docs/art-target-alien-shooter.png differ diff --git a/docs/brute_attack.preview.png b/docs/brute_attack.preview.png new file mode 100644 index 0000000..7ad5104 Binary files /dev/null and b/docs/brute_attack.preview.png differ diff --git a/docs/descent-d12.png b/docs/descent-d12.png new file mode 100644 index 0000000..d69b357 Binary files /dev/null and b/docs/descent-d12.png differ diff --git a/docs/editor-hollow-topdown.png b/docs/editor-hollow-topdown.png new file mode 100644 index 0000000..156c322 Binary files /dev/null and b/docs/editor-hollow-topdown.png differ diff --git a/docs/editor-level.png b/docs/editor-level.png new file mode 100644 index 0000000..93ef2ae Binary files /dev/null and b/docs/editor-level.png differ diff --git a/docs/editor-room.png b/docs/editor-room.png new file mode 100644 index 0000000..e86c7e0 Binary files /dev/null and b/docs/editor-room.png differ diff --git a/docs/editor-spawn.png b/docs/editor-spawn.png new file mode 100644 index 0000000..f24202d Binary files /dev/null and b/docs/editor-spawn.png differ diff --git a/docs/editor-terminal-tools.png b/docs/editor-terminal-tools.png new file mode 100644 index 0000000..e3247cd Binary files /dev/null and b/docs/editor-terminal-tools.png differ diff --git a/docs/editor-terminal.png b/docs/editor-terminal.png new file mode 100644 index 0000000..8cb2f27 Binary files /dev/null and b/docs/editor-terminal.png differ diff --git a/docs/level-rooms-seed42.png b/docs/level-rooms-seed42.png new file mode 100644 index 0000000..49bb684 Binary files /dev/null and b/docs/level-rooms-seed42.png differ diff --git a/docs/lod-fitted-operative-idle-v2.preview.png b/docs/lod-fitted-operative-idle-v2.preview.png new file mode 100644 index 0000000..846e2cd Binary files /dev/null and b/docs/lod-fitted-operative-idle-v2.preview.png differ diff --git a/docs/lod-fitted-operative-idle.preview.png b/docs/lod-fitted-operative-idle.preview.png new file mode 100644 index 0000000..e129637 Binary files /dev/null and b/docs/lod-fitted-operative-idle.preview.png differ diff --git a/docs/lod-quaternius-body-walk.preview.png b/docs/lod-quaternius-body-walk.preview.png new file mode 100644 index 0000000..154c7fc Binary files /dev/null and b/docs/lod-quaternius-body-walk.preview.png differ diff --git a/docs/lod-source-body-idle-v3.preview.png b/docs/lod-source-body-idle-v3.preview.png new file mode 100644 index 0000000..7a1e4df Binary files /dev/null and b/docs/lod-source-body-idle-v3.preview.png differ diff --git a/docs/lod-source-body-walk-v2.preview.png b/docs/lod-source-body-walk-v2.preview.png new file mode 100644 index 0000000..63daba6 Binary files /dev/null and b/docs/lod-source-body-walk-v2.preview.png differ diff --git a/docs/lod-source-body-walk.preview.png b/docs/lod-source-body-walk.preview.png new file mode 100644 index 0000000..1e7ce4d Binary files /dev/null and b/docs/lod-source-body-walk.preview.png differ diff --git a/docs/menu.png b/docs/menu.png new file mode 100644 index 0000000..360702c Binary files /dev/null and b/docs/menu.png differ diff --git a/docs/raid.png b/docs/raid.png new file mode 100644 index 0000000..18f5af6 Binary files /dev/null and b/docs/raid.png differ diff --git a/docs/runtime-alien-shooter-clean.png b/docs/runtime-alien-shooter-clean.png new file mode 100644 index 0000000..e8e0105 Binary files /dev/null and b/docs/runtime-alien-shooter-clean.png differ diff --git a/docs/runtime-alien-shooter-pass.png b/docs/runtime-alien-shooter-pass.png new file mode 100644 index 0000000..26d9795 Binary files /dev/null and b/docs/runtime-alien-shooter-pass.png differ diff --git a/docs/runtime-spriteforge-blender-agent.png b/docs/runtime-spriteforge-blender-agent.png new file mode 100644 index 0000000..1013d12 Binary files /dev/null and b/docs/runtime-spriteforge-blender-agent.png differ diff --git a/docs/runtime-spriteforge-scale-fixed.png b/docs/runtime-spriteforge-scale-fixed.png new file mode 100644 index 0000000..3b429d5 Binary files /dev/null and b/docs/runtime-spriteforge-scale-fixed.png differ diff --git a/docs/runtime-spriteforge.png b/docs/runtime-spriteforge.png new file mode 100644 index 0000000..a88eb9d Binary files /dev/null and b/docs/runtime-spriteforge.png differ diff --git a/docs/rusher-direct-gate-v2.png b/docs/rusher-direct-gate-v2.png new file mode 100644 index 0000000..6f48a6e Binary files /dev/null and b/docs/rusher-direct-gate-v2.png differ diff --git a/docs/rusher-direct-gate.png b/docs/rusher-direct-gate.png new file mode 100644 index 0000000..5223256 Binary files /dev/null and b/docs/rusher-direct-gate.png differ diff --git a/docs/sample-walk-topdown.png b/docs/sample-walk-topdown.png new file mode 100644 index 0000000..2be9985 Binary files /dev/null and b/docs/sample-walk-topdown.png differ diff --git a/docs/sample-walk.png b/docs/sample-walk.png new file mode 100644 index 0000000..9f4c909 Binary files /dev/null and b/docs/sample-walk.png differ diff --git a/docs/spriteforge-contact-sheet.png b/docs/spriteforge-contact-sheet.png new file mode 100644 index 0000000..321ce03 Binary files /dev/null and b/docs/spriteforge-contact-sheet.png differ diff --git a/docs/squad.png b/docs/squad.png new file mode 100644 index 0000000..97adbc9 Binary files /dev/null and b/docs/squad.png differ diff --git a/docs/ui-depth.png b/docs/ui-depth.png new file mode 100644 index 0000000..4417112 Binary files /dev/null and b/docs/ui-depth.png differ diff --git a/docs/ui-squad-stash.png b/docs/ui-squad-stash.png new file mode 100644 index 0000000..3e8a86c Binary files /dev/null and b/docs/ui-squad-stash.png differ diff --git a/docs/ui-summary.png b/docs/ui-summary.png new file mode 100644 index 0000000..3678b81 Binary files /dev/null and b/docs/ui-summary.png differ diff --git a/docs/ui-upgrades.png b/docs/ui-upgrades.png new file mode 100644 index 0000000..80deb20 Binary files /dev/null and b/docs/ui-upgrades.png differ diff --git a/docs/view-topdown-hollow.png b/docs/view-topdown-hollow.png new file mode 100644 index 0000000..448f1f4 Binary files /dev/null and b/docs/view-topdown-hollow.png differ