Документация: границы движка, работа через модель, дизайн игры до лейтгейма
18-engine.md границы Tile2D, проекции 2D, формат проекта, второй проект 19-agent.md инструменты --tool, скиллы, терминал в редакторе 20-gdd.md акты, петли, рост отряда, закон масштаба, бюджет 200 часов 21-damage.md семь стихий, статусы, десять реакций, формулы, триггеры 22-loot.md редкости, аффиксы, артефакты, зачарования, материалы 23-endgame.md Бездна, мутации, лидерборды, 262 ачивки Плюс правки 01/02/07/15/16/17 под переехавшие пути и новые правила забега. Дизайнерская часть держится на одном законе: угроза растёт квадратично, сила отряда — линейно с затуханием, разрыв закрывается ЗНАНИЕМ, а не числами. Из него выведены и экономика опыта (41 млн против 44 млн дохода), и то, почему реакции считаются от глубины, а не от урона оружия, и почему в Бездне множитель HP разрешён, а во всём авторском контенте запрещён. .claude/commands: скиллы /tile2d, /room, /floor — правила этого движка, которые нельзя вывести из кода. Главное записано первым: не отчитываться об успехе без прохода --tool validate. Числа в этих документах — вход для инструментов, а не украшение. Расхождение между таблицей и выводом инструмента означает, что неправ документ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
21
.claude/commands/agent-sprite.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Generate a modular agent sprite
|
||||
|
||||
Argument: `$ARGUMENTS` is `component animation` (for example `armor_vest idle`).
|
||||
|
||||
1. Read `assets/agents/catalog.yaml`. Do not invent dimensions or direction order.
|
||||
2. Run `python tools/agent_assets.py prompt <component> <animation>` and use that exact art brief.
|
||||
3. Prefer the local Blender pipeline. Create the shared rig once if missing:
|
||||
`python tools/agent_assets.py blender-init`. Render the requested aligned layer with:
|
||||
`python tools/agent_assets.py blender-render <component> <animation>`.
|
||||
Blender is at `C:\Program Files\Blender Foundation\Blender 5.2\blender.exe` and the
|
||||
CLI finds it automatically (or accepts `--blender`). Never create a separate scene,
|
||||
camera, scale or rig for equipment.
|
||||
For a whole requested loadout or release set use
|
||||
`python tools/agent_assets.py blender-render-all <components...> --animations idle walk attack`;
|
||||
existing sheets are cached unless `--force` is explicitly requested.
|
||||
4. If a raster image generator is available, request one 4x2 magenta-key board per animation frame, then run `python tools/agent_assets.py ingest <component> <animation> <boards...>` in frame order. Never hand-crop cells.
|
||||
5. Run `python tools/agent_assets.py validate --strict`, then
|
||||
`powershell -ExecutionPolicy Bypass -File tools/build_agent_assets.ps1`.
|
||||
6. Inspect only with SpriteForge `validate` and `ascii`; do not open every generated frame. A human may request a contact sheet.
|
||||
|
||||
Visual lock: lean realistic covert operator, fitted dark sci-fi gear, Splinter Cell / Metal Gear / STALKER silhouette. Never bulky power armor, astronaut, space marine, chibi or Among Us. Canvas is 70x90 per direction, Diablo-style isometric orthographic view.
|
||||
48
.claude/commands/floor.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Tune the difficulty curve
|
||||
|
||||
Request: `$ARGUMENTS`
|
||||
|
||||
The whole slope of the game lives in one place: `projects/<project>/project.txt`. The shape
|
||||
of the curve is code (`engine/progression.cpp`), its coefficients are data. Read
|
||||
`docs/16-descent.md` for what each number means and why it is shaped that way.
|
||||
|
||||
## Measure before changing
|
||||
|
||||
```bash
|
||||
tile2d_editor --tool curve 1 20 # what depth means today, floor by floor
|
||||
tile2d_editor --tool floor <depth> 1 # one floor: budget spent, population, loot
|
||||
tile2d_editor --tool floor <depth> 42 # same depth, another seed — variance matters
|
||||
```
|
||||
|
||||
Change one line of `project.txt`, run the same commands, and compare. A tuning change with
|
||||
no before/after numbers is an opinion.
|
||||
|
||||
## What the numbers are for
|
||||
|
||||
| key | meaning | the trap |
|
||||
|---|---|---|
|
||||
| `danger` | base, linear growth, quadratic tail | pure linear reads as "same, but more"; pure exponential ends the run where the player just started understanding the rules |
|
||||
| `density` | how often spawn points fire, and the cap | above the cap every point always fires, so floors stop differing |
|
||||
| `loot` | drops and how fast tier climbs | tier must lag danger, or the squad is dressed for the rest of the game by floor ten |
|
||||
| `sight` | how fast vision shrinks, and the floor | below the floor it stops being frightening and becomes "can't see where to go" |
|
||||
| `xp` | experience multiplier per floor | must grow slower than danger, or skipping floors becomes the only sane play |
|
||||
| `themes` | where each finish band starts | must ascend; the last band is open-ended on purpose |
|
||||
|
||||
Do **not** add creature HP or damage multipliers to make depth harder. "The same shambler
|
||||
with triple HP" is not scarier, it is longer. Depth changes *who* comes out and how many.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
tile2d_editor --tool validate # set still covers the depths you moved
|
||||
squad_proto --project <name> --descend 8 # the curve survives an actual run
|
||||
```
|
||||
|
||||
Watch for `ЗАМЕЧАНИЕ` in validate: the floor assembled correctly but the room set no longer
|
||||
keeps up with the curve. That means "draw more rooms", not "lower the curve" — unless the
|
||||
curve is what you were asked to change.
|
||||
|
||||
## Report
|
||||
|
||||
The exact lines you changed, and the `curve` table before and after for the depths that
|
||||
matter. Name the floors where behaviour changed most.
|
||||
49
.claude/commands/room.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Author or repair a room
|
||||
|
||||
Request: `$ARGUMENTS`
|
||||
|
||||
A room is a text file: `projects/<project>/rooms/<name>.room`. Edit it directly — there is
|
||||
no binary step. Format and reasoning: `docs/15-engine-and-editor.md`.
|
||||
|
||||
## Before writing
|
||||
|
||||
1. `tile2d_editor --tool rooms` — what the set already has, and at which depths.
|
||||
2. `tile2d_editor --tool ascii <similar-room>` — read a working room of the same kind.
|
||||
3. `tile2d_editor --tool catalog monster <depth>` — what may be placed at that depth, with
|
||||
its cost in the danger budget.
|
||||
|
||||
A new room must answer "what does this one do that the others don't": a wide arena, a
|
||||
corridor where the formation cannot turn, a pocket that forces the squad to split. Another
|
||||
rectangle with spawn points is not a room, it is filler.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- size between 5×5 and 14×14 (`ROOM_SLOT` is 16, the room must leave rock around it);
|
||||
- doors only on the border, and at least one — a room without doors is never connected;
|
||||
- **every floor tile must be reachable from the doors.** A sealed inner chamber looks
|
||||
perfectly normal in the grid and silently makes the floor easier: whatever spawns inside
|
||||
can neither reach the squad nor be killed. This has already happened twice in this repo
|
||||
(`storage`, then `maw`);
|
||||
- spawn points stand on floor, never in a wall, and their table is never empty;
|
||||
- ids in the table must exist in that project's catalog.
|
||||
|
||||
Spawn tables hold **weights and a chance**, not a creature: `spawn 6 4 0.65 rusher:3 shambler:2`
|
||||
means "this point fires 65% of the time, and then it is a rusher three times out of five".
|
||||
Depth decides who is allowed and what the floor can afford; the room only says where.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
tile2d_editor --tool room <name> # doors, spawn tables, problems
|
||||
tile2d_editor --tool ascii <name> # the grid, with spawn points numbered
|
||||
tile2d_editor --tool validate # the whole set, including depth coverage
|
||||
tile2d_editor --tool map <depth> 42 # the room inside an assembled floor
|
||||
```
|
||||
|
||||
`validate` returning 1 means the set is broken — fix it before reporting. `map` is how you
|
||||
see whether the room actually connects: look for its shape and check that the corridors
|
||||
reach it.
|
||||
|
||||
## Report
|
||||
|
||||
The room's name, size, what it is for, and the numbers from `validate` and one `map` run.
|
||||
25
.claude/commands/sprite.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Produce a game sprite
|
||||
|
||||
Request: `$ARGUMENTS`
|
||||
|
||||
Treat this as an asset-production task, not a request to draw a one-off shape in C++.
|
||||
|
||||
1. Read only the relevant catalog and documentation:
|
||||
- agent/body/armor/weapon: `assets/agents/catalog.yaml`, `.claude/commands/agent-sprite.md`;
|
||||
- monster: `assets/monsters/catalog.yaml`, `docs/14-monsters.md`;
|
||||
- environment/item: `assets/content_coverage.yaml`, `tools/generate_static_assets.py`.
|
||||
2. Preserve the visual lock: grounded near-future sci-fi horror and body horror. Agents are
|
||||
lean covert operators, never bulky space marines. Camera is Diablo-style orthographic
|
||||
isometry. Agent paper-doll layers are exactly 70x90 and share the existing rig/pivot.
|
||||
3. Extend a catalog/generator/rig; never commit a single hard-coded runtime Sprite as the
|
||||
final asset. New agent equipment is one layer per animation, not every loadout combination.
|
||||
4. Render only the requested/new component. Reuse the existing `.blend`; do not rebuild all
|
||||
rigs unless their geometry changed.
|
||||
5. Run `powershell -ExecutionPolicy Bypass -File tools/build_assets.ps1` and fix every strict
|
||||
validation or coverage failure before reporting success.
|
||||
6. Verify through text (`sf validate`, `sf ascii`, coverage). Do not open generated PNG/SFA.
|
||||
If the human asks to inspect it, create a grid using `tools/sprite_preview.py` and give the
|
||||
path; the human may open that preview.
|
||||
|
||||
Report the generated asset IDs, supported animations/directions, build status, and exact
|
||||
command needed to run the game with `--sf`.
|
||||
50
.claude/commands/tile2d.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Work on a Tile2D project
|
||||
|
||||
Request: `$ARGUMENTS`
|
||||
|
||||
Tile2D is the engine (`engine/`); a game is a project (`projects/<name>/`). The editor
|
||||
(`tile2d_editor`) belongs to the engine and opens any project. Read `docs/18-engine.md`
|
||||
before touching engine code, `docs/15-engine-and-editor.md` for the editor and room format.
|
||||
|
||||
## The loop
|
||||
|
||||
Content is plain text in git — rooms, entity catalog, project manifest. Edit the files
|
||||
directly. What you cannot get from reading files is **consequences**: whether the room
|
||||
assembles into a floor, whether a spawn point is reachable, whether the floor spends its
|
||||
danger budget. That is what the tools are for.
|
||||
|
||||
```bash
|
||||
tile2d_editor --tool help # every tool, with arguments
|
||||
tile2d_editor --tool project # manifest, paths, difficulty curve
|
||||
tile2d_editor --tool rooms # the set, with problems flagged
|
||||
tile2d_editor --tool ascii <room> # room grid as text
|
||||
tile2d_editor --tool floor <depth> [seed] # assemble a floor, report the numbers
|
||||
tile2d_editor --tool map <depth> [seed] # that floor as an ASCII map
|
||||
tile2d_editor --tool curve <from> <to> # what depth means, per floor
|
||||
tile2d_editor --tool validate # whole set: exit code 1 means broken
|
||||
```
|
||||
|
||||
Every tool takes `--project <name>`; inside the editor terminal `TILE2D_PROJECT` is already
|
||||
set, so the bare command targets the open project.
|
||||
|
||||
## Rules that are easy to break here
|
||||
|
||||
1. **Never report success without `--tool validate` passing.** Exit code 1 means broken.
|
||||
A room that looks right in a diff can still seal a pocket off from its own door.
|
||||
2. **Verify through text, never by opening generated PNG/SFA** (see CLAUDE.md). Floor
|
||||
layout is `--tool map`; a screenshot is `squad_proto --clean --shot`.
|
||||
3. **Entity ids are the game's border, not the engine's.** The engine places any id from
|
||||
the project catalog; `squad_proto` only turns into a creature the ids listed in
|
||||
`src/sim/spawn_catalog.cpp`. A new id needs a row there too, or the floor spawns nothing.
|
||||
4. **On-screen text is Latin.** raylib's built-in font has no Cyrillic and draws it as
|
||||
question marks. Console output and comments stay Russian.
|
||||
5. **Acceptance is measured in the sandbox.** `--accept` and `--headless` never load a
|
||||
project. Do not wire content into them.
|
||||
6. **The editor links only against the engine.** Anything in `editor/` reaching into
|
||||
`sim/`, `ai/` or `ui/` is a build error, and that is deliberate.
|
||||
|
||||
## Report
|
||||
|
||||
State what you changed, paste the numbers that prove it (`validate`, `floor`, `curve`), and
|
||||
name anything you left broken. If a check fails and you could not fix it, say so — do not
|
||||
describe the intended state as if it were the measured one.
|
||||
82
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 `<kind>_<animation>`; renaming them breaks
|
||||
`tools/asset_coverage.py`. Full rules in [docs/14-monsters.md](docs/14-monsters.md).
|
||||
|
||||
@ -6,7 +6,12 @@
|
||||
> отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама
|
||||
> расставляется так, чтобы атаковать и не задевать своих.
|
||||
|
||||
Всё остальное — вне рамок и намеренно не сделано.
|
||||
Это ядро — вехи M0–M6, и в его рамках всё остальное намеренно не сделано.
|
||||
|
||||
Поверх ядра достроен игровой слой: твари, которые идут и бьют, каталог оружия,
|
||||
снаряжение с инвентарём, главное меню и HUD ([13-combat.md](13-combat.md)).
|
||||
Прежняя песочница с мишенями никуда не делась — она живёт под `--sandbox`,
|
||||
и все критерии приёмки по-прежнему меряются на ней.
|
||||
|
||||
## Что есть
|
||||
|
||||
|
||||
@ -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 <iso\|topdown>` | перебить вид отображения из манифеста проекта (отладочное) |
|
||||
| `--seed N` | сид сборки уровня (по умолчанию 1) |
|
||||
| `--depth N` | начать не с первого этажа (для проверок) |
|
||||
| `--descend N` | прогон забега на N этажей без окна |
|
||||
| `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты |
|
||||
| `--no-profile` | не читать и не писать профиль |
|
||||
|
||||
`--rooms` на приёмку не влияет: `--accept` и `--headless` проект не подключают
|
||||
вообще и меряют на прежней карте ([15-engine-and-editor.md](15-engine-and-editor.md)).
|
||||
|
||||
Примеры:
|
||||
|
||||
@ -106,7 +146,43 @@ squad_proto.exe --headless 300 --no-solver --no-laneavoid # каким про
|
||||
squad_proto.exe --headless 300 --melee 0 # чистый отряд стрелков
|
||||
squad_proto.exe --headless 300 --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).
|
||||
|
||||
@ -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` набор комнат не подключают вообще.
|
||||
- Вырожденные случаи разрешаются по индексу слота, а не случайно
|
||||
(например, расталкивание двух агентов, стоящих в одной точке).
|
||||
- Камера обновляется в рендере по реальному времени кадра и на симуляцию не влияет.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## Фреймбуфер
|
||||
|
||||
[src/render/framebuffer.h](../src/render/framebuffer.h) — `std::vector<uint32_t>` 480×270.
|
||||
[engine/framebuffer.h](../engine/framebuffer.h) — `std::vector<uint32_t>` 480×270.
|
||||
|
||||
Формат — RGBA8, в памяти байты идут `R,G,B,A`, значит little-endian `uint32` это
|
||||
`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
|
||||
|
||||
@ -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`.
|
||||
|
||||
**Вне карты считается стеной.** Это убирает граничные проверки из рейкаста и коллизий.
|
||||
|
||||
@ -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)) |
|
||||
|
||||
## Что находилось по ходу и чинилось
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ Clear(чёрный) ступень 0 рампы == цве
|
||||
## Почему свет вплавлен в блит, а не сделан пост-проходом
|
||||
|
||||
Соблазн очевидный: пройти по 129600 пикселям, для каждого получить мировую точку
|
||||
через `iso::ScreenToWorld` и умножить на освещённость. Так делать нельзя.
|
||||
через `v2d::ScreenToWorld` и умножить на освещённость. Так делать нельзя.
|
||||
|
||||
Спрайт стены имеет привязку `ay = TILE_HALF_H + TILE_H = 24`, то есть верхняя
|
||||
грань поднята на 16 px над ромбом своего тайла. Обратная проекция этого пикселя
|
||||
|
||||
211
docs/12-assets.md
Normal file
@ -0,0 +1,211 @@
|
||||
# 12. Ассеты: конвейер SpriteForge
|
||||
|
||||
## Что где лежит
|
||||
|
||||
SpriteForge — внешний инструмент, отдельный репозиторий:
|
||||
`C:\Users\uuu\Documents\spriteforge`. Игра его исходники не трогает и ничего
|
||||
под конкретную игровую сущность в нём не правит.
|
||||
|
||||
Игра хранит только:
|
||||
|
||||
| Путь | Что это | В git |
|
||||
|---|---|---|
|
||||
| `assets/manifests/*.yaml` | описания ассетов — единственный источник истины | да |
|
||||
| `assets/sources/` | исходные PNG и риги для бэкендов `import` / `blender` | да |
|
||||
| `generated/spriteforge_assets.h` | числовые ID ассетов, вывод `sf codegen` | да |
|
||||
| `build/sprites/` | собранные `.sfa`, `palettes.sfp`, `index.json` | нет |
|
||||
| `.sfcache/` | контентный кеш сборки, одноразовый | нет |
|
||||
|
||||
Собранная библиотека живёт внутри `build/`, поэтому полная очистка каталога
|
||||
сборки сносит и её. Восстановление — одна команда `sf build`; кеш `.sfcache/`
|
||||
лежит отдельно и переживает такую чистку, так что пересборка почти мгновенная.
|
||||
|
||||
## Установка
|
||||
|
||||
```powershell
|
||||
cd C:\Users\uuu\Documents\spriteforge
|
||||
python -m pip install -e .
|
||||
sf version
|
||||
```
|
||||
|
||||
Установка editable: правки в SpriteForge видны игре без переустановки.
|
||||
Если `sf` не находится, каталог скриптов Python не в PATH — либо добавить
|
||||
`%APPDATA%\Python\Python313\Scripts`, либо звать `python -m spriteforge.cli`.
|
||||
|
||||
## Добавить ассет
|
||||
|
||||
Порядок ровно такой, шаги не пропускать:
|
||||
|
||||
1. Поправить манифест в `assets/manifests/`.
|
||||
2. План сборки, без записи файлов:
|
||||
```powershell
|
||||
sf build assets\manifests\environment.yaml --asset stone_altar --dry-run
|
||||
```
|
||||
3. Собрать только этот ассет:
|
||||
```powershell
|
||||
sf build assets\manifests\environment.yaml --asset stone_altar `
|
||||
--output build\sprites --cache-dir .sfcache
|
||||
```
|
||||
4. Проверить текстом:
|
||||
```powershell
|
||||
sf validate assets\manifests\environment.yaml --build-dir build\sprites
|
||||
sf ascii stone_altar --build-dir build\sprites
|
||||
```
|
||||
5. Обновить числовые ID:
|
||||
```powershell
|
||||
sf codegen --build-dir build\sprites --output generated\spriteforge_assets.h
|
||||
```
|
||||
|
||||
Сборка всей библиотеки:
|
||||
|
||||
```powershell
|
||||
sf build assets\manifests\environment.yaml --output build\sprites `
|
||||
--cache-dir .sfcache --jobs 8
|
||||
sf validate assets\manifests\environment.yaml --build-dir build\sprites
|
||||
sf codegen --build-dir build\sprites --output generated\spriteforge_assets.h
|
||||
```
|
||||
|
||||
`--rebuild-palette` пересчитывает общую палитру библиотеки и допустим только
|
||||
при полной сборке без фильтров: частичная пересборка палитры обесценила бы
|
||||
индексы в незатронутых ассетах.
|
||||
|
||||
## Контроль результата
|
||||
|
||||
Агент проверяет сборку **только текстом**: `sf list`, `sf describe`, `sf ascii`,
|
||||
`sf validate`, `sf stats`. `sf contact-sheet` делает PNG для человека — агент
|
||||
его не открывает.
|
||||
|
||||
Со стороны C++ есть своя проверка: `squad_proto.exe --sprites` печатает, что
|
||||
загрузчик реально видит в библиотеке.
|
||||
|
||||
```
|
||||
build/sprites: 3 ассет(ов)
|
||||
ID HASH FRAMES SIZE PIVOT
|
||||
floor_slab 78D3F27E 1 30x16 15,8
|
||||
floor_slab_dim 30CD3FBB 1 30x16 15,8
|
||||
wall_block 892CB17B 1 30x32 15,31
|
||||
```
|
||||
|
||||
## Загрузчик
|
||||
|
||||
`engine/sfa_library.h` — единственное место игры, знающее про формат `.sfa`.
|
||||
Заголовок загрузчика `sfa.h` берётся из репозитория SpriteForge, копии в игре
|
||||
нет; путь задаётся кешируемой переменной CMake:
|
||||
|
||||
```powershell
|
||||
cmake -S . -B build -DSPRITEFORGE_DIR=<путь к репозиторию spriteforge>
|
||||
```
|
||||
|
||||
По умолчанию `SPRITEFORGE_DIR` = `../spriteforge` рядом с игрой.
|
||||
|
||||
Слой ровно один: кадр `.sfa` (индексы палитры + RLE по строкам) превращается в
|
||||
обычный игровой `Sprite` с альфа-тестом. Дальше по коду разницы между
|
||||
процедурным спрайтом и собранным нет: тот же `Blit`, тот же свет, та же
|
||||
сортировка.
|
||||
|
||||
**Строковых ID ассетов в коде игры нет.** Обращение идёт по константам
|
||||
`SF_ASSET_*` из `generated/spriteforge_assets.h`. Строка живёт ровно в двух
|
||||
местах: в манифесте и в имени файла `<asset_id>.sfa`, по которому загрузчик и
|
||||
собирает свой индекс.
|
||||
|
||||
### Холст и точка привязки
|
||||
|
||||
SpriteForge обрезает кадр по силуэту, поэтому размер кадра плывёт вместе с
|
||||
содержимым: ромб 32x16 приходит как 30x16 с pivot (15,8). Для декора это
|
||||
безразлично, для тайлов — нет: `ShadeQuad` из `Lighting::QuadFor` считается в
|
||||
координатах спрайта и ждёт ромб на своём месте (docs/10-atmosphere.md).
|
||||
Съехавший на пиксель холст увёл бы свет пола относительно света стен.
|
||||
|
||||
Поэтому `SpriteLibrary::Read` принимает `FramePlacement`: холст нужного размера
|
||||
и точка, в которую на нём ложится pivot кадра. Тайлы просят канонический
|
||||
`TILE_W x TILE_H`, остальное берётся в натуральную величину.
|
||||
|
||||
## Тайлы из библиотеки и hot reload
|
||||
|
||||
По умолчанию весь набор спрайтов процедурный и ни от одного файла не зависит —
|
||||
`--accept` и `--headless` считают то же, что считали раньше. Пол и стены можно
|
||||
подменить собранными:
|
||||
|
||||
```powershell
|
||||
build\bin\squad_proto.exe --sf-tiles
|
||||
```
|
||||
|
||||
Осечка на любом шаге (нет библиотеки, нет ассета, битый файл) оставляет
|
||||
процедурный набор целиком: полусобранный хуже прежнего.
|
||||
|
||||
Бойцы и клинки остаются процедурными сознательно: там цвет занят номером агента
|
||||
и раздаётся из кода, а не из манифеста.
|
||||
|
||||
## Анимированные твари
|
||||
|
||||
```powershell
|
||||
build\bin\squad_proto.exe --sf-enemies # только твари
|
||||
build\bin\squad_proto.exe --sf # тайлы и твари разом
|
||||
```
|
||||
|
||||
Манифест — `assets/manifests/enemies.yaml`, по одному ассету `<kind>_walk` на
|
||||
вид. **Один ассет = один клип.** Бэкенд `import` имён клипов не читает и всегда
|
||||
кладёт единственную анимацию `default`, поэтому `idle` и `attack`, когда до них
|
||||
дойдёт дело, станут отдельными ассетами `<kind>_idle`, а не второй анимацией
|
||||
внутри существующего. Правку SpriteForge ради этого заводить не нужно.
|
||||
|
||||
### Откуда берутся листы
|
||||
|
||||
`assets/sources/make_enemy_sheets.py` — детерминированный генератор:
|
||||
|
||||
```powershell
|
||||
python assets\sources\make_enemy_sheets.py
|
||||
```
|
||||
|
||||
Он рисует **ту же геометрию**, что процедурный запасной вариант в
|
||||
`src/render/sprites.cpp` (объединение эллипсов, свет по верхне-левой кромке,
|
||||
обводка по силуэту), и лишь сдвигает конечности по фазе шага. Это не удобство,
|
||||
а требование: разъедься силуэты — и `--sf-enemies` менял бы не плавность
|
||||
движения, а саму тварь, которую игрок узнаёт по пятну. Цвета в скрипте — копия
|
||||
`EnemyDef` из `src/sim/target.cpp`, и разойтись им нельзя по той же причине.
|
||||
|
||||
Ни одного случайного числа: перегенерация даёт побитово тот же PNG.
|
||||
|
||||
### Кто и что хранит
|
||||
|
||||
Кадры лежат в `SpriteLibrary` в одном экземпляре на ассет. `SpriteSet` держит
|
||||
только ID (`enemyWalk[kind]`, 0 — «рисуй процедурный»), а фазу клипа — `View`:
|
||||
|
||||
- `View::enemyAnim[]` — `Animator` на каждую цель;
|
||||
- `View::enemyPrevPos[]` — позиция прошлого кадра.
|
||||
|
||||
Фаза шага живёт в `View`, а не в `Target`, ровно по правилу
|
||||
[02-architecture.md](02-architecture.md): попав в симуляцию, она поехала бы в
|
||||
цифрах приёмки. Шаг проигрывается **только когда тварь реально идёт** —
|
||||
скорости у `Target` нет (`vel` — это отдача от удара), поэтому движение
|
||||
определяется сравнением позиций. Иначе цикл крутился бы у стоящего плевка,
|
||||
который по замыслу держит дистанцию, и читался бы как ошибка.
|
||||
|
||||
`LoadEnemies` — всё или ничего: одна анимированная тварь среди четырёх
|
||||
статичных выглядит хуже пяти статичных.
|
||||
|
||||
Watch-режим в отдельном терминале:
|
||||
|
||||
```powershell
|
||||
sf watch assets\manifests\environment.yaml --output build\sprites `
|
||||
--cache-dir .sfcache --jobs 8
|
||||
```
|
||||
|
||||
После каждой успешной пересборки SpriteForge атомарно переписывает
|
||||
`build/sprites/reload.json`. Игра опрашивает его раз в кадр и перечитывает
|
||||
библиотеку целиком. Новая библиотека собирается рядом и подменяет старую только
|
||||
если собралась целиком — недописанная пересборка не гасит картинку.
|
||||
|
||||
Кровь при перезагрузке сбрасывается по той же причине, по которой она не
|
||||
переживает смену уровня: она запечена в спрайты пола, а пол уже другой
|
||||
(docs/10-atmosphere.md).
|
||||
|
||||
## Чего не делать
|
||||
|
||||
- не открывать `build/sprites/*.sfa`, `.sfcache/`, сгенерированные PNG и
|
||||
contact sheet — для проверки есть текстовые команды;
|
||||
- не редактировать `generated/spriteforge_assets.h` руками: это вывод
|
||||
`sf codegen`;
|
||||
- не заводить строковые ID ассетов в C++;
|
||||
- не править репозиторий SpriteForge ради одной игровой сущности: если не
|
||||
хватает генератора — это отдельная задача в его репозитории.
|
||||
243
docs/13-combat.md
Normal file
@ -0,0 +1,243 @@
|
||||
# 13. Бой: оружие, твари, снаряжение, экраны
|
||||
|
||||
Прототип из вех M0–M6 проверял ОТРЯД: строй, линии огня, правило «не бить
|
||||
своих». Противника там не было — были мишени, которые стоят и не отвечают.
|
||||
Эта веха достраивает вторую половину: твари, которые идут и бьют, каталог
|
||||
оружия, снаряжение и экраны вне боя.
|
||||
|
||||

|
||||
|
||||
## Два режима, и это важно
|
||||
|
||||
| Режим | Что в мире | Кто им пользуется |
|
||||
|---|---|---|
|
||||
| боевой (по умолчанию) | твари 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`):
|
||||
остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав
|
||||
отряда читается прямо по строю.
|
||||
|
||||
## Экраны
|
||||
|
||||

|
||||
|
||||
Меню, пауза, экран отряда и поражение живут в [src/ui/menu.cpp](../src/ui/menu.cpp).
|
||||
Это второе и последнее место в игре, где разрешены примитивы raylib (первое —
|
||||
`debug/overlay`): здесь нужны слова, а собственный шрифт на 480x270 стоил бы
|
||||
дороже всего остального UI вместе взятого. Мир по-прежнему рисуется только в
|
||||
софтверный фреймбуфер, меню ложится поверх готового кадра.
|
||||
|
||||

|
||||
|
||||
Экран отряда (`I` в бою) показывает те же числа, которые читает симуляция:
|
||||
HP, броню, скорость, обзор. `ENTER` меняет местами оружие в руках и выбранную
|
||||
ячейку сумки — отряд не носит воздух, у него всегда что-то в руках.
|
||||
|
||||
## HUD
|
||||
|
||||
[src/render/hud.cpp](../src/render/hud.cpp) рисуется в тот же софтверный
|
||||
фреймбуфер, что и мир, — значит попадает под ту же сетку пикселей. Текста в нём
|
||||
нет ни одной буквы:
|
||||
|
||||
- цвет полосы — номер бойца, тот же, что у его тела на карте;
|
||||
- длина полосы — здоровье, тонкая полоска под ней — броня;
|
||||
- квадратики — патроны, гаснут по одному; сплошная полоса — перезарядка;
|
||||
- у клинка вместо патронов фаза удара: замах, удар, восстановление;
|
||||
- уголок над карточкой — боец при цели;
|
||||
- карточка перечёркнута — боец упал.
|
||||
|
||||
## Смерть бойца
|
||||
|
||||
Мёртвый агент остаётся в массиве: `NUM_AGENTS` — величина времени сборки, по
|
||||
ней ходят все циклы, слоты строя, метрики и оверлей. Убирать его из пула значило
|
||||
бы переиндексировать полмира на каждой смерти. Поэтому смерть — это флаг
|
||||
`Agent::alive`, и его проверяют все системы: движение, расталкивание, реестр
|
||||
линий, решатель, проверка «свой на линии», сектор клинка, пули.
|
||||
|
||||
Павший не перекрывает линию огня и не попадает под лезвие. Тело остаётся на
|
||||
полу до конца вылазки.
|
||||
|
||||
## Сложность
|
||||
|
||||
Всё крутится на ходу через оверлей или `--set`:
|
||||
|
||||
```bash
|
||||
squad_proto.exe --set enemyDamage=1.6 # во сколько раз больнее бьют
|
||||
squad_proto.exe --set agentHP=60 # запас бойца до брони
|
||||
squad_proto.exe --set enemyRespawn=8 # как быстро твари возвращаются
|
||||
```
|
||||
|
||||
Числа видов правятся в `EnemyDefOf`, числа предметов — в `ItemDefOf`.
|
||||
Баланс на 24 тварей и 5 бойцов подобран грубо и ждёт живых прогонов: отряд,
|
||||
стоящий на месте, держится, но постепенно теряет здоровье.
|
||||
109
docs/14-monsters.md
Normal file
@ -0,0 +1,109 @@
|
||||
# 14. Твари: конвейер ассетов
|
||||
|
||||
Blender-first конвейер для пяти видов: `shambler`, `rusher`, `spitter`,
|
||||
`brute`, `lurker`. Отдельный от агентского и намеренно.
|
||||
|
||||
## Зачем отдельный конвейер
|
||||
|
||||
У агента слои бумажной куклы обязаны совпасть **пиксель в пиксель**: тело,
|
||||
броня и оружие рисуются на одном скелете, и любое расхождение видно сразу.
|
||||
Отсюда в `tools/agent_assets.py` калибровка, растровый `ingest` и проверка
|
||||
«экипировка не вылезла за силуэт тела».
|
||||
|
||||
У твари слоёв нет вовсе, склеивать нечего. Зато пять видов обязаны
|
||||
**различаться силуэтом** с одного взгляда: игрок принимает решение по пятну,
|
||||
а не по деталям. Поэтому здесь пять независимых скелетов, никакой общей
|
||||
калибровки — и проверка ровно обратного смысла: `validate` ругается, если два
|
||||
вида дали похожее пятно.
|
||||
|
||||
Общее у двух конвейеров ровно одно и намеренно: **неподвижная
|
||||
ортографическая изометрическая камера** (`elevation_deg = atan(0.5)`, тот же
|
||||
угол, под которым в игре лежит пол) и вырезка кадра постоянным
|
||||
прямоугольником. Начало координат рига проецируется в центр кадра, поэтому
|
||||
упаковщик режет все кадры одинаково — тварь не «дышит» при повороте.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что делает |
|
||||
|---|---|
|
||||
| `assets/monsters/catalog.yaml` | геометрия ячейки, камера, список видов и клипов — единственный источник чисел |
|
||||
| `tools/blender/monster_rig.py` | анатомия и анимации; запускается ВНУТРИ Blender |
|
||||
| `tools/monster_assets.py` | CLI: рендер, упаковка листов, манифест, проверки |
|
||||
| `tools/build_monster_assets.ps1` | полный прогон: init → render-all → validate → sf validate |
|
||||
| `assets/sources/monsters/*.png` | собранные листы (вход SpriteForge) |
|
||||
| `assets/manifests/monsters.generated.yaml` | манифест, пишется `sync` |
|
||||
| `generated/monster_sprites.h` | константы `MONSTER_ASSET_*` и `MonsterAsset(EnemyKind, AnimKind)` |
|
||||
|
||||
## Геометрия
|
||||
|
||||
Ячейка `48x56`, pivot `(24,47)`, восемь направлений в том же порядке, что у
|
||||
агентов: юг, юго-запад, запад, ... Лист **direction-major**: сначала все кадры
|
||||
юга, затем юго-запада.
|
||||
|
||||
`pivot_y = 47`, а не `55`: изометрический пол уходит от точки опоры **вниз** по
|
||||
экрану, и всё, что у твари позади (хвост бегуна, задние лапы затаившегося),
|
||||
проецируется ниже точки опоры. Девять строк под pivot — запас на глубину
|
||||
подставки, а не воздух.
|
||||
|
||||
Клипы: `walk` 8, `attack` 6, `hit` 4, `death` 8 кадров на направление.
|
||||
`fps_scale` в каталоге задаёт повадку: бегун частит, громила волочится при
|
||||
одинаковом числе кадров.
|
||||
|
||||
## Морфологии
|
||||
|
||||
Не капсулы. Каждый вид — своя анатомия, а не перекрашенный гуманоид:
|
||||
|
||||
* **shambler** — вскрытая наружу грудная клетка, рёбра веером, правая рука
|
||||
доросла до колена, в череп вбита пластина; хромает;
|
||||
* **rusher** — четвероногий, горизонтальный хребет, пальцеходящие задние ноги,
|
||||
косы вместо предплечий, вместо головы раскрытый цветок челюстей;
|
||||
* **spitter** — волочащийся железистый мешок больше грудной клетки, вживлённый
|
||||
в горло клапан, сопло вместо лица, атрофированные руки;
|
||||
* **brute** — к плечу приварено второе, наполовину поглощённое тело; грудь
|
||||
забрана болтами; правая рука — молот из сросшейся кости;
|
||||
* **lurker** — плоский зашитый скобами панцирь подвешен под четырьмя длинными
|
||||
выгнутыми лапами, безглазый клин с крюками.
|
||||
|
||||
Анимации описаны в **нормированном** времени (список опорных значений на 0..1)
|
||||
и сэмплируются под число кадров из каталога: `frames` в каталоге можно менять,
|
||||
не трогая `monster_rig.py`.
|
||||
|
||||
`death` — это **осадка в кучу**, а не падение во весь рост. Лежащая во весь
|
||||
рост туша не помещается в клетку `48x56` при повороте «от камеры», и в игре это
|
||||
выглядело бы как обрезанный труп.
|
||||
|
||||
## Порядок работы
|
||||
|
||||
```
|
||||
python tools/monster_assets.py monster-init # собрать риг .blend
|
||||
python tools/monster_assets.py monster-render shambler walk # один вид, один клип
|
||||
python tools/monster_assets.py monster-render-all # всё
|
||||
python tools/monster_assets.py validate --strict
|
||||
python tools/monster_assets.py sync
|
||||
python -m unittest discover -s tools/tests
|
||||
```
|
||||
|
||||
`monster-render` и `monster-render-all` сами вызывают `sync`.
|
||||
|
||||
## Что проверяет validate
|
||||
|
||||
* холст листа равен `48 x (8 направлений x кадры)`, пустых ячеек нет;
|
||||
* силуэт **не упирается в кромку** клетки — иначе в игре это отрубленная лапа;
|
||||
* тварь стоит на общей линии пола (`death` освобождён: труп имеет право осесть);
|
||||
* «роза» из восьми направлений центрирована на `pivot_x`;
|
||||
* зеркальные пары (ЮЗ,ЮВ), (З,В), (СЗ,СВ) совпадают при отражении — ловит
|
||||
перестановку направлений;
|
||||
* **пять видов различаются пятном** (IoU силуэтов шага ниже 0.90);
|
||||
* манифест не разошёлся с каталогом по pivot и размеру кадра, и ни один ID не
|
||||
объявлен вторым манифестом.
|
||||
|
||||
## Запреты
|
||||
|
||||
* ID ассета — строго `<kind>_<animation>`: ровно это требует
|
||||
`tools/asset_coverage.py` от группы `enemy`. Переименуешь — упадёт покрытие.
|
||||
* Строковых ID в C++ нет: только `MONSTER_ASSET_*` из `generated/monster_sprites.h`.
|
||||
* PNG и contact sheet не открывать: проверка только текстом — `validate`,
|
||||
`sf list`, `sf describe`, `sf ascii`.
|
||||
* `assets/manifests/enemies.yaml` — старые односторонние листы-заглушки. Пока
|
||||
живы оба файла, ID пересекаются, и `validate` про это предупреждает: снимать
|
||||
предупреждение — это отдельное решение на стороне рантайма, не конвейера.
|
||||
266
docs/15-engine-and-editor.md
Normal file
@ -0,0 +1,266 @@
|
||||
# 15 — Движок и редактор комнат
|
||||
|
||||
## Зачем это отдельно
|
||||
|
||||
До этой вехи проект был одним исполняемым файлом. Теперь их три, и граница
|
||||
между ними проведена **линковкой**, а не договорённостью:
|
||||
|
||||
```
|
||||
tile2d (STATIC) engine/ — не знает про правила боя и ни про одну игру по имени
|
||||
├── squad_proto.exe игра Descent: src/ + projects/descent/
|
||||
└── tile2d_editor.exe редактор комнат: editor/
|
||||
```
|
||||
|
||||
`tile2d_editor` линкуется **только** с движком. Поэтому «редактор полез в правила
|
||||
игры» — это ошибка сборки, а не то, что надо заметить на ревью. Это единственная
|
||||
причина, по которой редактор сделан отдельным приложением, а не режимом игры:
|
||||
режим внутри игры видит `EnemyKind`, `Agent` и `g_tune` целиком, и удержать его
|
||||
от этого нечем.
|
||||
|
||||
### Что лежит в движке
|
||||
|
||||
Всё, что лежит в `engine/`. Раньше состав задавался списком в
|
||||
[CMakeLists.txt](../CMakeLists.txt), потому что каталоги `src/render` и
|
||||
`src/world` делили обе цели; теперь у движка свой каталог, и решение «это
|
||||
движок» принимается тем, куда положен файл ([18-engine.md](18-engine.md)).
|
||||
|
||||
| Файл | О чём |
|
||||
|---|---|
|
||||
| `engine/config.h` | размеры кадра, тайла и карты: то, что нельзя менять без пересборки |
|
||||
| `engine/framebuffer` | софтверный буфер RGBA8, световая рампа, блит на экран |
|
||||
| `engine/sfa_library` | чтение библиотеки SpriteForge |
|
||||
| `engine/projection` | ЕДИНСТВЕННОЕ место, где движок знает про изометрию |
|
||||
| `engine/view2d.h` | камера, список отрисовки, сортировка по глубине |
|
||||
| `engine/project` | манифест проекта: пути, вид отображения, кривая глубины |
|
||||
| `engine/tilemap` | карта, DDA-рейкаст, circle-vs-AABB |
|
||||
| `engine/pixel` | ромб, обводка силуэта, `ShadeColor` |
|
||||
| `engine/tileset` | пол и стены по темам отделки |
|
||||
| `engine/theme` | сколько тем и как они называются |
|
||||
| `engine/catalog` | каталог размещаемых сущностей |
|
||||
| `engine/room` | формат комнаты-шаблона и набор комнат |
|
||||
| `engine/level_gen` | сборка этажа из комнат по сиду |
|
||||
| `engine/progression` | кривая глубины: что означает этаж N ([16-descent.md](16-descent.md)) |
|
||||
| `engine/microfont` | растровый шрифт 3×5 для софтверного буфера |
|
||||
| `engine/text_parse.h` | разбор строчных текстовых форматов |
|
||||
|
||||
Чего в движке НЕТ и быть не может: путей к содержимому. Каталог комнат и
|
||||
каталог сущностей называет проект, поэтому один и тот же `tile2d_editor`
|
||||
открывает и `projects/descent`, и `projects/hollow`.
|
||||
|
||||
## Каталог сущностей — мост между движком и игрой
|
||||
|
||||
[projects/descent/catalog/entities.txt](../projects/descent/catalog/entities.txt):
|
||||
|
||||
```
|
||||
entity shambler class=monster depth=1 danger=1.0 color=116,52,52 sprite=shambler_walk name=SHAMBLER
|
||||
entity crate class=prop depth=1 color=104,76,46 sprite=prop_crate name=CRATE
|
||||
entity plate class=loot tier=3 depth=9 color=140,146,156 sprite=item_icon_plate name=PLATE
|
||||
```
|
||||
|
||||
Редактор знает про сущность ровно это: строковый id, класс, цвет, радиус, имя
|
||||
ассета SpriteForge и на какой глубине она встречается. Что такое `SHAMBLER` и
|
||||
чем он бьёт — знание игры, и живёт оно в `sim/target.cpp`. Связывает одно с
|
||||
другим единственная таблица —
|
||||
[src/sim/spawn_catalog.cpp](../src/sim/spawn_catalog.cpp).
|
||||
|
||||
**Добавить тварь** = строка в каталоге + запись в `EnemyDefOf` + строка в
|
||||
таблице перевода. Формат комнат и редактор при этом не меняются вообще.
|
||||
|
||||
Формат `ключ=значение`, а не позиционный: свойства сущности прирастают (глубина,
|
||||
цена, тир), и каждое новое ломало бы все уже написанные строки. Смысл ключей —
|
||||
в [16-descent.md](16-descent.md).
|
||||
|
||||
Цвет и радиус в каталоге не для красоты: ими редактор рисует сущность, пока для
|
||||
неё не собран спрайт. Пустая палитра не должна мешать планировать уровень.
|
||||
|
||||
## Комната — это шаблон, а не кусок карты
|
||||
|
||||
[projects/descent/rooms/arena.room](../projects/descent/rooms/arena.room):
|
||||
|
||||
```
|
||||
name arena
|
||||
size 14 12
|
||||
weight 3 вес при выборе генератором: «как часто встречается»
|
||||
theme 0 набор тайлов отделки
|
||||
start 0 годится ли как стартовая комната отряда
|
||||
depth 1 0 с какого этажа и по какой (0 — до бесконечности)
|
||||
tiles
|
||||
####++####++## '.' пол '#' стена '+' дверь
|
||||
#............#
|
||||
...
|
||||
spawn 4 4 0.90 shambler:4 rusher:3 spitter:2
|
||||
spawn 6 1 0.40 crate:2 barrel:1
|
||||
```
|
||||
|
||||
Ключевое в формате — **точка спавна не хранит, кто в ней стоит**. Она хранит
|
||||
шанс срабатывания и таблицу весов по id каталога. Одна и та же комната,
|
||||
поставленная дважды, населена по-разному; уровень из восьми комнат читается как
|
||||
уровень, а не как одна кишка, повторённая девять раз.
|
||||
|
||||
Ограничения формата и почему они такие:
|
||||
|
||||
- размер от 5×5 до 14×14 — генератор ставит комнаты в слоты по 16 тайлов, и
|
||||
комната размером в слот не оставила бы коридорам места;
|
||||
- двери только на рамке и не в углах — коридор тянется НАРУЖУ, а из угла
|
||||
непонятно, в какую сторону;
|
||||
- точка спавна только на полу; точка с пустой таблицей в файл не пишется.
|
||||
|
||||
Текстовый построчный формат выбран потому, что комнаты лежат в git и правка
|
||||
планировки обязана читаться в diff.
|
||||
|
||||
## Сборка уровня
|
||||
|
||||
[engine/level_gen.h](../engine/level_gen.h). Карта 48×48 делится на
|
||||
сетку 3×3 слотов по 16 тайлов.
|
||||
|
||||
1. в каждый слот выбирается комната по весу;
|
||||
2. если ни одной стартовой не выпало — одна ставится принудительно;
|
||||
3. вся карта заливается камнем, комнаты отпечатываются по центрам слотов;
|
||||
4. по слотам строится **остовное дерево** (обход в глубину со случайным
|
||||
порядком соседей) — оно гарантирует, что из стартовой достижима каждая;
|
||||
5. поверх дерева добавляются лишние связи с шансом 1/3: без них уровень —
|
||||
дерево-кишка, с шансом 1 — решётка без тупиков;
|
||||
6. связи режутся коридорами шириной 2 от двери к двери, тремя отрезками по
|
||||
осям (нет двери на нужной стороне — стена пробивается посередине);
|
||||
7. разыгрываются точки спавна;
|
||||
8. заливкой от точки старта считается достижимость, недостижимые точки
|
||||
снимаются и **попадают в отчёт**.
|
||||
|
||||
Шаг 8 — не перестраховка: он поймал замурованную внутреннюю камеру у `storage`
|
||||
в тот же день, когда её нарисовали. Тварь в отрезанном кармане не дойдёт до
|
||||
отряда, отряд не дойдёт до неё, а счётчик зачистки будет вечно показывать
|
||||
недобитого противника.
|
||||
|
||||
### Про детерминизм
|
||||
|
||||
RNG крутится **только здесь**, один раз, при сборке уровня. К моменту первого
|
||||
`Game::Step` всё уже разложено, и правило «в симуляции ни одного вызова RNG»
|
||||
([02-architecture.md](02-architecture.md)) не нарушено. Один и тот же набор
|
||||
комнат плюс один и тот же сид дают побайтово ту же карту и то же население.
|
||||
|
||||
Броски по каждой точке спавна делаются **всегда**, даже когда точка не
|
||||
сработает: иначе последовательность уводило бы вбок и сид перестал бы значить
|
||||
одно и то же.
|
||||
|
||||
`--accept` и `--headless` набор комнат не подключают никогда — критерии
|
||||
меряются на прежней захардкоженной карте, иначе цифры не с чем сравнивать.
|
||||
|
||||
## Темы отделки
|
||||
|
||||
Слой `Tilemap::theme[]` — отдельный от `tiles[]` намеренно: проходимость и
|
||||
внешний вид это два разных вопроса, и смешав их, мы получили бы отдельный тип
|
||||
тайла на каждое сочетание. Симуляция этот слой не читает вообще.
|
||||
|
||||
Тем четыре: `STEEL`, `RUST`, `FLESH`, `FROST`. Пока они получаются перекраской
|
||||
базового набора по каналам — это честная заглушка, а не временное решение: она
|
||||
даёт различимые материалы, не требуя ни одного собранного ассета. Когда для темы
|
||||
соберут свой тайлсет, поменяется `engine/tileset.cpp`, а формат комнат и уже
|
||||
нарисованные комнаты — нет.
|
||||
|
||||
## Редактор
|
||||
|
||||
```
|
||||
build/bin/tile2d_editor.exe
|
||||
```
|
||||
|
||||

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

|
||||
|
||||
| Действие | Мышь | Клавиши |
|
||||
|---|---|---|
|
||||
| рисовать / стирать | ЛКМ / ПКМ | — |
|
||||
| кисть | панель справа | `1`–`5` |
|
||||
| сущность палитры | вкладки `MOB`/`PROP`/`LOOT`, колесо | `Q` / `E` |
|
||||
| тема отделки | кнопка темы | `T` |
|
||||
| диапазон глубин комнаты | счётчики `D` | — |
|
||||
| соседняя комната набора | `<` `>` | `[` `]` |
|
||||
| новая комната | `NEW` | `N` |
|
||||
| сохранить / перечитать | `SAVE` / `RELOAD` | `Ctrl+S` / `Ctrl+R` |
|
||||
| сборка этажа / новый сид | `LEVEL` / `SEED` | `F5` / `F6` |
|
||||
| этаж предпросмотра | `D-` / `D+` | `PgUp` / `PgDn` |
|
||||
| проверить в бою | `PLAY` | — |
|
||||
| сдвиг холста | — | стрелки, `Home` |
|
||||
|
||||
Точка спавна ставится одним нажатием, а не мазком: кисть, тянущаяся по сетке,
|
||||
засеяла бы полкомнаты.
|
||||
|
||||
`PLAY` запускает `squad_proto --rooms --seed N` **отдельным процессом**. Не
|
||||
встроенным режимом: иначе редактору пришлось бы знать правила игры, и вся
|
||||
граница между целями сборки была бы декоративной.
|
||||
|
||||
### Предпросмотр сборки
|
||||
|
||||

|
||||
|
||||
`F5` показывает этаж целиком видом **сверху**: 48×48 тайлов в изометрии — это
|
||||
полторы тысячи пикселей по ширине, и на 480×270 они не помещаются даже близко.
|
||||
Предпросмотр отвечает на вопрос «какая вышла планировка», а не «как это
|
||||
выглядит», и для этого вид сверху честнее.
|
||||
|
||||
В строке над картой — сид, число комнат, коридоров и сущностей. Всё, из-за чего
|
||||
уровень собрался не так, как задумано (срезано потолком, недостижимо от старта),
|
||||
печатается отдельной строкой и только когда оно не ноль: постоянная строка
|
||||
«0 ошибок» перестаёт читаться через минуту работы.
|
||||
|
||||
### Самопроверка набора
|
||||
|
||||
```
|
||||
tile2d_editor.exe --check
|
||||
```
|
||||
|
||||
Аналог `squad_proto --accept`, только для содержимого: набор комнат — такой же
|
||||
исходник, как код, и ломаться он обязан на проверке.
|
||||
|
||||
```
|
||||
ROOM SIZE DOORS SPAWNS THEME STATE
|
||||
arena 14x12 12 6 STEEL OK
|
||||
...
|
||||
[PASS] сид 42 комнат 9, коридоров 9, сущностей 31, достижимо 859, ...
|
||||
```
|
||||
|
||||
Проверяются: разбор каталога, разбор каждой комнаты, `RoomProblem` (двери, пол,
|
||||
точки), **круговой прогон через запись** и сборка уровня на пяти сидах.
|
||||
|
||||
Круговой прогон здесь не для полноты: записывающий путь — единственный, которым
|
||||
редактор может уничтожить работу. Читающий проверяется сам собой при каждом
|
||||
запуске, пишущий — только тут.
|
||||
|
||||
## Запуск игры на собранном уровне
|
||||
|
||||
```
|
||||
squad_proto.exe --rooms [каталог] собрать уровень из комнат
|
||||
squad_proto.exe --seed N сид сборки; без него 1
|
||||
```
|
||||
|
||||
Без `--rooms` игра работает ровно как раньше, на захардкоженной карте. Набор не
|
||||
нашёлся или не собрался — откат на неё же: прототип обязан запускаться на голом
|
||||
репозитории.
|
||||
|
||||
Собранный уровень населяет себя сам: захардкоженная расстановка мишеней и
|
||||
тварей относится к другой карте, и мешать их — верный способ получить тварь в
|
||||
стене на пустом месте.
|
||||
|
||||
Кто и когда меняет сид:
|
||||
|
||||
| Действие | Сид | Почему |
|
||||
|---|---|---|
|
||||
| `NEW RAID` в меню | следующий | новая вылазка — новый уровень |
|
||||
| `TRY AGAIN` после поражения | тот же | это переигрывание, а не новый заход |
|
||||
| `R` в бою | тот же | на карте, которая меняется под руками, нечего отлаживать |
|
||||
|
||||
«Следующий» считается тем же линейным конгруэнтным шагом, что и кнопка `SEED`
|
||||
в редакторе: сид, подобранный в редакторе, обязан повторяться в игре.
|
||||
|
||||
## Что сюда ещё не входит
|
||||
|
||||
- **Предметы на полу** появились отдельной вехой и раскладываются генератором
|
||||
по тиру этажа, а не ставятся в комнате руками ([16-descent.md](16-descent.md)).
|
||||
- **Поворот комнат при сборке.** Комната ставится как нарисована. Повороты
|
||||
удвоили бы разнообразие, но автор комнаты перестал бы понимать, что увидит.
|
||||
- **Свои тайлсеты на тему.** Пока перекраска базового набора; место для сборки
|
||||
из SpriteForge уже есть — `TileSet::LoadFromForge`.
|
||||
212
docs/16-descent.md
Normal file
@ -0,0 +1,212 @@
|
||||
# 16 — Спуск: глубина как первичное понятие
|
||||
|
||||
## Что за игра
|
||||
|
||||
Бесконечный спуск в подземелье. Чем глубже, тем страшнее окружение, злее твари,
|
||||
богаче находки. Забег кончается, когда отряд полёг; сколько этажей он прошёл —
|
||||
и есть результат.
|
||||
|
||||
Из этого следует главное решение всей вехи: **глубина не параметр уровня, а
|
||||
первичное понятие движка**. От неё зависят планировка, население, лут, отделка
|
||||
и свет. Поэтому она не размазана по генератору, а собрана в одну функцию.
|
||||
|
||||
## Кривая — одно место
|
||||
|
||||
[engine/progression.h](../engine/progression.h). `DescribeFloor(depth)`
|
||||
возвращает `FloorSpec`: тема отделки, бюджет опасности, плотность, сколько и
|
||||
какого лута, насколько сжат обзор, множитель опыта.
|
||||
|
||||
Коэффициенты с вехи M14 живут не в коде, а в манифесте проекта
|
||||
([18-engine.md](18-engine.md)): `projects/descent/project.txt`. В коде осталась
|
||||
ФОРМА кривой — проект гнёт её, но не может подменить линейный рост
|
||||
экспонентой.
|
||||
|
||||
Кривая сложности — это то, что правят чаще всего и правят на ощупь. Размазанная
|
||||
по генератору, каталогу и рендеру, она через месяц перестаёт поддаваться правке:
|
||||
«сделать десятый этаж чуть злее» превращается в раскопки по трём файлам.
|
||||
|
||||
Чего в кривой **нет намеренно**:
|
||||
|
||||
- **множителей урона и здоровья тварей.** «Тот же бредущий, но со втрое большим
|
||||
HP» — это не страшнее, это дольше. Глубина меняет, КТО выходит и сколько их;
|
||||
- **потолка.** Функция определена для любого N: этаж 200 обязан собраться, даже
|
||||
если каталог кончился на 12-м.
|
||||
|
||||
| Что | Как растёт | Почему так |
|
||||
|---|---|---|
|
||||
| опасность | линейно + квадратичный хвост | чистая линейка читается как «то же, только больше»; чистая экспонента обрывает забег там, где игрок начал понимать правила |
|
||||
| плотность точек | до ×1.5 и упор | выше упора все точки срабатывают всегда, то есть этажи становятся одинаковыми |
|
||||
| тир лута | +1 каждые 3 этажа | медленнее опасности: спуск обязан опережать снаряжение |
|
||||
| обзор | до ×0.6 и упор | ниже — это уже не «страшно», а «не видно, куда идти» |
|
||||
| опыт | +15% за этаж | медленнее опасности: спускаться выгодно, но не настолько, чтобы прыгать через этажи |
|
||||
|
||||
Полосы отделки: `STEEL` 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 <min> [max]` в файле. Планировка — часть
|
||||
погружения: широкая арена первых этажей и теснота, где не развернуть строй, —
|
||||
это разные игры.
|
||||
|
||||
## Находки
|
||||
|
||||
[src/sim/loot.h](../src/sim/loot.h). Предмет лежит на полу отдельной сущностью
|
||||
и подбирается **пассивно**: боец, прошедший по нему, кладёт его в свободную
|
||||
ячейку сумки.
|
||||
|
||||
Почему не «выдать предмет при убийстве»: в игре про спуск находка — это МЕСТО,
|
||||
до которого надо дойти. Лут, падающий в инвентарь сам, не заставляет никуда идти
|
||||
и превращает «глубже — лучше лут» в строку в логе. Лежащий предмет плохо виден в
|
||||
темноте, лежит там, куда отряд ещё не заходил, и за ним надо разомкнуть строй —
|
||||
это и есть цена находки.
|
||||
|
||||
Отдельной кнопки подбора нет намеренно: игрок и так не нажимает «огонь»
|
||||
(спек 5), и заводить ради лута первое ручное действие в игре значило бы менять
|
||||
её жанр. Полная сумка — предмет остаётся лежать: «некуда положить» обязано быть
|
||||
видно, а не молча съедено.
|
||||
|
||||
## Спуск
|
||||
|
||||
Точка спуска — **самый дальний достижимый тайл** от старта, найденный той же
|
||||
заливкой, что считает достижимость. Не «дальняя комната»: комната может
|
||||
оказаться отрезанной, а этот выбор недостижимым быть не может по построению.
|
||||
Рисуется пульсирующим кольцом и видна даже в полной темноте — единственная цель
|
||||
этажа обязана читаться с порога.
|
||||
|
||||
`F` на спуске уводит отряд вниз. Именно нажатие, а не заход на клетку: спуск —
|
||||
решение игрока. Этаж не зачищен, лут не собран, уходить рано — это нормальный
|
||||
выбор, и отряд может стоять на спуске сколько угодно.
|
||||
|
||||
Вниз переезжает **всё, что нажито**: здоровье, оружие в руках, содержимое сумок.
|
||||
Павшие не воскресают — воскрешение на лестнице обесценило бы единственную
|
||||
настоящую потерю в забеге. Стартовый комплект снаряжения выдаётся только тем, кто
|
||||
пришёл ни с чем: раздавать его заново на каждом этаже значило бы стирать весь
|
||||
смысл находок.
|
||||
|
||||
Симуляция сама этаж не меняет: `Game::Step` только отмечает `atExit`. Смена мира
|
||||
посреди шага, между движением и стрельбой, оставила бы половину систем со
|
||||
ссылками на снесённые сущности.
|
||||
|
||||
| Действие | Что делает |
|
||||
|---|---|
|
||||
| `NEW RAID` в меню | выбор чекпоинта, потом новый забег ([17-extraction.md](17-extraction.md)) |
|
||||
| `F` на спуске | следующий этаж, отряд как есть |
|
||||
| `TRY AGAIN` после поражения | тот же этаж заново |
|
||||
| `R` в бою | тот же этаж заново |
|
||||
|
||||
## Опыт
|
||||
|
||||
Копится за убитых: цена `danger` умножается на множитель этажа. Обстановка опыта
|
||||
не даёт — платить за ящик значило бы сделать разбивание хлама самым выгодным
|
||||
занятием в игре.
|
||||
|
||||
Тратится он на улучшения отряда ([17-extraction.md](17-extraction.md)): запас
|
||||
патронов, переноска, обзор, живучесть. Заглушкой это было три вехи — ровно до
|
||||
тех пор, пока не нашлось, во что вкладываться так, чтобы не сломать жанр.
|
||||
|
||||
## Свет как главный признак глубины
|
||||
|
||||
Окружение становится страшнее не новыми спрайтами, а тем, что видно всё меньше.
|
||||
`FloorSpec::sightScale` сжимает радиус обзора всем бойцам одинаково, поверх их
|
||||
личных множителей: фонарь на двадцатом этаже по-прежнему лучше савана, просто
|
||||
оба светят хуже.
|
||||
|
||||
Значение живёт в `Lighting::sightScale`, а не в `g_tune`: tuning общий на весь
|
||||
запуск, и запись туда означала бы, что двенадцатый этаж подкрутил ручку, с
|
||||
которой потом работает и первый этаж, и песочница приёмки.
|
||||
|
||||
## Цифры на экране
|
||||
|
||||
HUD прототипа был принципиально бессловесным (`render/hud.h`). Для спуска этого
|
||||
перестало хватать ровно на одном: **номер этажа — это число**, и показать его
|
||||
формой нельзя. Двадцать семь зарубок на краю экрана никто не считает.
|
||||
|
||||
Поэтому в движке появился растровый шрифт 3×5
|
||||
([engine/microfont.h](../engine/microfont.h)). Не raylib-текст: правило
|
||||
«ни одного примитива raylib в игровой сцене» остаётся в силе, потому что этот
|
||||
шрифт пишет в тот же софтверный фреймбуфер и попадает под ту же сетку пикселей.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
tile2d_editor.exe --check покрытие глубин и сборка на 1/3/6/10/16/24
|
||||
squad_proto.exe --descend 8 забег на 8 этажей без окна
|
||||
squad_proto.exe --rooms --depth 12 --seed 42 сразу двенадцатый этаж
|
||||
```
|
||||
|
||||
`--check` отвечает на главный вопрос игры про бесконечный спуск: не «хороша ли
|
||||
комната», а **есть ли чем застроить сотый этаж**. Набор, у которого на глубине
|
||||
не остаётся ни одной подходящей комнаты, ни одной твари или ни одного предмета,
|
||||
обрывает забег — и обрывает молча, если это не проверять.
|
||||
|
||||
Он же различает две разные вещи:
|
||||
|
||||
- **FAIL** — этаж собран неправильно: точки не встали, что-то отрезано от
|
||||
старта, некуда спускаться, нет находок;
|
||||
- **WARN** — этаж собран правильно, но набор комнат не успевает за кривой, и
|
||||
глубина перестаёт ощущаться. Это повод дорисовать комнат, а не поломка.
|
||||
|
||||
`--descend` проверяет то, что не проверить ни скриншотом, ни `--check`: цепочку
|
||||
«спуск → пересборка мира → перенос отряда». Ошибка здесь не падает и не
|
||||
рисуется — она выглядит как отряд, приехавший вниз с пустыми руками.
|
||||
|
||||
```
|
||||
DEPTH THEME MONSTER DANGER LOOT EXIT SQUAD
|
||||
1 STEEL 3 3 2 63 живых 5, в сумках 8
|
||||
8 FLESH 16 23 4 135 живых 5, в сумках 30
|
||||
```
|
||||
|
||||
## Чего ещё нет
|
||||
|
||||
- **траты опыта** — см. выше, это отдельная веха с отдельными критериями;
|
||||
- **боссов и особых этажей** — кривая пока гладкая, а спуск без вех в памяти
|
||||
сливается в один длинный коридор;
|
||||
- **сохранения забега** — вылет означает потерю прогресса;
|
||||
- **уникального лута** — все находки берутся из общего каталога предметов, и
|
||||
отличаются только тиром.
|
||||
260
docs/17-extraction.md
Normal file
@ -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.
|
||||
|
||||

|
||||
|
||||
Рекорд глубины засчитывается **любым исходом**. Дошёл — значит дошёл; требовать
|
||||
за чекпоинт ещё и удачную эвакуацию значило бы наказывать дважды за одну смерть.
|
||||
|
||||
Пять — это «достаточно редко, чтобы дойти до следующего было событием, и
|
||||
достаточно часто, чтобы потеря забега не откатывала в самое начало».
|
||||
|
||||
## Сумка одна на отряд
|
||||
|
||||
Раньше каждый боец таскал свои шесть ячеек, и это порождало проблему, которую
|
||||
нечем было решить: пачка патронов, поднятая бойцом с клинком, доставалась только
|
||||
ему и лежала мёртвым грузом до конца забега. Перекладывать между бойцами игра не
|
||||
умела.
|
||||
|
||||
Теперь сумка одна — шестнадцать ячеек на пятерых. Личным остаётся **надетое**:
|
||||
руки, броня, карман. Их не разделить, они и есть «кто этот боец».
|
||||
|
||||
**Вес делится на живых.** Потерять бойца — значит взвалить его долю на
|
||||
остальных: обратно наверх отряд пойдёт медленнее, чем шёл сюда. Это не побочный
|
||||
эффект, а то, ради чего сумка общая.
|
||||
|
||||
Правило «унести можно только то, что на выживших» этим не ослаблено, оно стало
|
||||
точнее: надетое павшего теряется по-прежнему, а общая ноша на нём не висела —
|
||||
её тащил отряд. Вайп по-прежнему не выносит ничего: тогда выживших нет вовсе, и
|
||||
сумку некому донести.
|
||||
|
||||
## Схрон и сборы
|
||||
|
||||

|
||||
|
||||
Экран отряда (`I`) стал местом сборов: снаряжение бойца слева, общая сумка
|
||||
справа, схрон двумя рядами внизу. Предметы перетаскиваются мышью, `Q`/`E`
|
||||
листают схрон, правая кнопка по вещи выбрасывает её под ноги.
|
||||
|
||||
**Наведи — и увидишь, что это.** Подсказка под курсором показывает вес и все
|
||||
эффекты предмета, а на строках характеристик объясняет, откуда берётся число.
|
||||
До неё вещь была именем и цветным квадратиком: чем VEST отличается от JACKET,
|
||||
знал только каталог в коде. С сумкой на шестнадцать мелких ячеек это перестало
|
||||
быть терпимым — в ячейку не влезает даже имя.
|
||||
|
||||
Схрон полон — обмен не состоится целиком, и вещь останется в слоте. Молча терять
|
||||
её нельзя: «некуда положить» обязано быть видно.
|
||||
|
||||
## Вес: что унести
|
||||
|
||||
Шесть ячеек в сумке отвечали на вопрос «сколько вещей», но не на вопрос «чего
|
||||
они стоят»: шесть бинтов и шесть плит обходились одинаково. Теперь у каждой
|
||||
вещи есть вес, а у бойца — два порога.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| до `CARRY_LIMIT` (7.5) | отряд идёт как шёл |
|
||||
| дальше | тем медленнее, чем больше нагрёб, до 55% на пределе |
|
||||
| выше `CARRY_MAX` (14) | не поднимет: вещь остаётся лежать |
|
||||
|
||||
Порогов ДВА намеренно. Один жёсткий предел означал бы «сумка кончилась» — то
|
||||
же самое, что счёт ячеек, только считать труднее. Настоящий выбор возникает
|
||||
там, где лишнее взять МОЖНО, но за это платят: а идти с этим — обратно через
|
||||
весь этаж, который уже растревожен.
|
||||
|
||||
**Надетое не считается.** За броню уже заплачено множителем скорости (плита —
|
||||
0.82), и считать её вес вторично значило бы наказывать за один выбор дважды.
|
||||
Хуже того: плита съедала бы почти весь запас, и боец в тяжёлой броне не мог бы
|
||||
поднять вообще ничего — вместо решения вышел бы запрет. А вот плита, НАЙДЕННАЯ
|
||||
и лежащая в сумке, весит полной мерой: тащить её наверх — это и есть выбор.
|
||||
|
||||
Выбросить лишнее можно правой кнопкой по ячейке на экране отряда: вещь ложится
|
||||
под ноги и подбирается обратно. Из рук бросать нельзя — безоружный боец не
|
||||
бежит быстрее, он просто перестаёт быть бойцом.
|
||||
|
||||
## То, что кончается
|
||||
|
||||
Наверх гонит не таймер. Часы заставляли бы торопиться механически, а это
|
||||
хоррор: давить должно **исчерпание**, а задача — уйти как можно глубже.
|
||||
|
||||
**Патроны конечны.** Запас (`startAmmo`, 120 на бойца) выдаётся на вылазку и
|
||||
едет вниз вместе с отрядом. Перезарядка тянет из него; кончился — стрелок
|
||||
молчит, и перезарядка даже не начинается: боец с пустым стволом не изображает
|
||||
работу. Восполнить можно только находкой — пачкой на 40 патронов.
|
||||
|
||||
**Расходники применяются сами.** Ручных действий в игре нет (спек 5), поэтому
|
||||
бинт и пачка — не кнопка, а поведение: боец перевязывается, когда ранен ниже
|
||||
`healAtFrac` и рядом никого нет, и набивает подсумок, когда стрелять нечем.
|
||||
Лечиться в схватке нельзя — перевязка посреди боя обесценила бы урон, который
|
||||
тварь только что нанесла.
|
||||
|
||||
До этой вехи `heal` у бинта был мёртвым числом: расходники не применялись
|
||||
нигде, и бинт был просто грузом. С весом (выше) он стал грузом буквально.
|
||||
|
||||
Патроны нужны тому, у кого **вообще** есть ствол, а не только тому, кто держит
|
||||
его сейчас: иначе боец с клинком в руках и запасным карабином в сумке таскал бы
|
||||
найденную пачку до конца забега. Это нашла проверка, а не глаз.
|
||||
|
||||
## Во что вкладывается опыт
|
||||
|
||||

|
||||
|
||||
Опыт копился с вехи M12 и до сих пор был честной заглушкой: число, которое
|
||||
ни на что не влияет. Теперь он тратится — на четыре улучшения отряда.
|
||||
|
||||
| | что даёт | почему это |
|
||||
|---|---|---|
|
||||
| AMMO RESERVE | +20 патронов на вылазку | |
|
||||
| CARRY LIMIT | +1.5 до перегруза | |
|
||||
| SIGHT RANGE | +6% обзора | |
|
||||
| VIGOR | +12 здоровья | |
|
||||
|
||||
Все четыре — **ресурсные**, и ни одного силового. Игра про исчерпание, и
|
||||
вкладываться в ней надо в то, что кончается: отряд, который бьёт вдвое сильнее,
|
||||
перестаёт бояться, а страх тут единственная механика, которую нельзя
|
||||
восстановить балансом.
|
||||
|
||||
Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре остаётся
|
||||
снаряжение, и второе личное измерение превратило бы сборы в бухгалтерию на
|
||||
пятерых.
|
||||
|
||||
Опыт зачисляется только за **успешную эвакуацию** (`FinishRun`): вайп не
|
||||
приносит ничего. Это та же ставка, что и с добычей.
|
||||
|
||||
Приёмки это не касается вообще: улучшения живут в профиле, а `--accept` его не
|
||||
подключает — там нет ни снаряжения, ни улучшений, и критерии меряются на том
|
||||
же, на чём мерялись всегда.
|
||||
|
||||
## Итог забега
|
||||
|
||||

|
||||
|
||||
Две цифры, ради которых экран и существует: **сколько вынес** и **сколько
|
||||
осталось внизу**. Плюс глубина, выжившие и открытый чекпоинт.
|
||||
|
||||
Забег закрывается ДО показа экрана — в момент вайпа или нажатия `E`. Симуляция
|
||||
при этом ничего не решает: она только отмечает `atExit`/`atExtract`, а конец
|
||||
забега объявляет UI. Смена мира посреди шага, между движением и стрельбой,
|
||||
оставила бы половину систем со ссылками на снесённые сущности.
|
||||
|
||||
## Как играть
|
||||
|
||||
```bash
|
||||
squad_proto.exe --rooms
|
||||
```
|
||||
|
||||
`NEW RAID` → выбрать чекпоинт → вылазка. `I` перед выходом — разобрать схрон.
|
||||
|
||||
| Ключ | Что делает |
|
||||
|---|---|
|
||||
| `--profile <файл>` | другой профиль (полезно для проверок) |
|
||||
| `--no-profile` | не читать и не писать профиль вообще |
|
||||
|
||||
Без `--rooms` игра работает как раньше: одна карта, никакого забега. Приёмка и
|
||||
`--headless` профиль не подключают — критерии меряются на прежнем снаряжении.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
squad_proto.exe --descend 7 --no-profile
|
||||
```
|
||||
|
||||
Прогон проверяет всю цепочку: спуск по этажам, перенос сумок, эвакуацию,
|
||||
**круговой прогон профиля через запись** — и правило переноски.
|
||||
|
||||
Переноска проверяется на ВЫДУМАННОМ инвентаре, а не по грузу в прогоне: в сумки
|
||||
кладутся бинты, чтобы проверить перенос между этажами, и по ним вес выглядит
|
||||
втрое меньше настоящего. Первые две попытки выставить пороги делались как раз
|
||||
по этой цифре — и обе дали числа, которые не ограничивали ничего.
|
||||
|
||||
```
|
||||
расходники: патроны набиты, лечение сработало (33 -> 58), из сумки ушли обе
|
||||
перезарядка: запас 40 -> 16 (магазин 24)
|
||||
переноска: налегке 6.9 (скорость 100%), с грузом 12.9 (скорость 63%), плит влезло 1
|
||||
```
|
||||
|
||||
Расход патронов проверяется тут же и отдельно: спуск ни с кем не воюет, столбец
|
||||
«патронов» в таблице стоит на месте — и без прямой проверки бесконечный
|
||||
боезапас выглядел бы ровно так же.
|
||||
|
||||
```
|
||||
7 RUST 13 19 3 149 живых 5, в сумках 30
|
||||
эвакуация: вынесено 40, потеряно 0, выживших 5, чекпоинт D6
|
||||
профиль: запись и чтение сходятся
|
||||
```
|
||||
|
||||
Круговой прогон здесь не для полноты: запись профиля — единственный путь,
|
||||
которым игра может стереть всё нажитое, и ошибка в нём выглядит как «схрон
|
||||
куда-то делся», а не как падение.
|
||||
|
||||
## Чего ещё нет
|
||||
|
||||
- **Схватки за добычу.** В настоящем экстракшене выход можно не отдать —
|
||||
здесь на этаже нет никого, кто охотится именно за вынесенным.
|
||||
- **Страховки и торговли.** Схрон только копится; девать его некуда.
|
||||
- **Траты опыта** — она по-прежнему ждёт своей вехи
|
||||
([16-descent.md](16-descent.md)).
|
||||
226
docs/18-engine.md
Normal file
@ -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` |
|
||||
| свет на плите | точная билинейка по углам ромба | без косого члена (см. ниже) |
|
||||
|
||||

|
||||
|
||||
Свет вплавляется в блит спрайта патчем `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`,
|
||||
плоть уже на четвёртом этаже вместо восьмого.
|
||||
|
||||

|
||||
|
||||
Редактор показывает палитру 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) — открыть проект, собрать этаж,
|
||||
походить по нему камерой. Полторы сотни строк вместе с разбором аргументов.
|
||||
|
||||

|
||||
|
||||
Он существует не для демонстрации. Игра и редактор проверить самостоятельность
|
||||
движка не могут: редактор мира не рисует вовсе, а игра — это ровно то место,
|
||||
куда общий код и уезжает незаметно. Пример же ломается сразу, как только для
|
||||
показа этажа понадобится что-нибудь из `src/`.
|
||||
|
||||
Первое, что он потребовал, — `DrawTilemap` ([engine/tilemap_draw.h](../engine/tilemap_draw.h)):
|
||||
до него движок не умел нарисовать собственную карту. Игра свой проход по полу
|
||||
оставила себе, потому что вплавляет в каждый блит свет; две реализации здесь не
|
||||
дублирование, а разные задачи — «показать карту» и «нарисовать сцену со светом».
|
||||
Общий у них порядок сортировки, и он один на всех, потому что живёт в проекции.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
squad_proto.exe --accept приёмка игры (спек 11)
|
||||
tile2d_editor.exe --check --project descent DESCENT: набор и покрытие глубин
|
||||
tile2d_editor.exe --check --project hollow HOLLOW: то же, его кривой
|
||||
squad_proto.exe --descend 6 забег DESCENT
|
||||
squad_proto.exe --project hollow --descend 4 забег HOLLOW
|
||||
tile2d_walk.exe --project hollow --depth 3 пример: движок без игры
|
||||
```
|
||||
|
||||
`--check` на втором проекте сразу нашёл в нём то же, что когда-то в `storage`:
|
||||
глухую камеру посередине комнаты `maw`. На сетке она выглядит нормально, но
|
||||
точка спавна внутри недостижима — тварь не дойдёт до отряда и не будет убита, и
|
||||
этаж тихо станет легче.
|
||||
|
||||
## Чего ещё нет
|
||||
|
||||
- **Пространства имён.** Типы движка (`Sprite`, `Tilemap`, `Room`) лежат в
|
||||
глобальном пространстве. Пока потребитель один репозиторий — терпимо; как
|
||||
библиотека для чужого кода — нет.
|
||||
- **Своей палитры видов.** Вид сверху есть, гексов и косой проекции нет.
|
||||
Добавлять их теперь дёшево: это одна запись в `MODES` и две ветки в
|
||||
`Projection`.
|
||||
- **Генерации спрайтов внутри движка.** Она снаружи, в SpriteForge
|
||||
([12-assets.md](12-assets.md)), и с видом сверху пока не дружит.
|
||||
134
docs/19-agent.md
Normal file
@ -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 означает «не выполнено или найдена
|
||||
поломка», поэтому на инструменты можно вешать хуки и скрипты, а не читать вывод
|
||||
глазами.
|
||||
|
||||

|
||||
|
||||
## Терминал
|
||||
|
||||

|
||||
|
||||
Клавиша `` ` `` открывает панель снизу. Команды выполняются в **корне
|
||||
репозитория**: работают не только над содержимым — рядом код движка, документация
|
||||
и git. Каталог с бинарниками добавлен в `PATH`, поэтому `tile2d_editor --tool
|
||||
validate` пишется без путей.
|
||||
|
||||
Строка ввода работает в трёх режимах, Tab переключает:
|
||||
|
||||
| режим | что делает с введённой строкой |
|
||||
|---|---|
|
||||
| `shell$` | выполняет как команду |
|
||||
| `claude>` | `claude -p "..."` |
|
||||
| `codex>` | `codex exec "..."` |
|
||||
|
||||
Одно поле, а не три — потому что работа выглядит как чередование: спросил,
|
||||
посмотрел, проверил инструментом, снова спросил.
|
||||
|
||||
Открытый в редакторе проект уходит детям переменной окружения `TILE2D_PROJECT`.
|
||||
Поэтому `--tool validate` без ключей проверяет именно его — и то же самое видит
|
||||
агент, запущенный отсюда. Дописывать `--project` в чужие команды строковой
|
||||
подстановкой было бы способом однажды подставить его не туда.
|
||||
|
||||
### Чего терминал НЕ делает
|
||||
|
||||
Он не эмулятор терминала. Полный VT100 — альтернативный экран, адресация
|
||||
курсора, рамки из псевдографики — нужен ровно одному сорту программ:
|
||||
интерактивным TUI, и `claude` без ключей именно такой.
|
||||
|
||||
Строчный вывод здесь не упрощение, а выбор в пользу того, как с моделью работают
|
||||
из редактора на самом деле: `claude -p` и `codex exec` печатают обычный текст,
|
||||
инструменты печатают таблицы и карты, git и cmake — тоже текст. Эмулятор не нужен
|
||||
ни одному из них.
|
||||
|
||||
Полный интерактивный CLI открывается кнопкой `EXTERNAL` (или `F12`) во внешнем
|
||||
окне, в том же рабочем каталоге. Так у каждого способа остаётся то, в чём он
|
||||
хорош, и ни один не притворяется другим.
|
||||
|
||||
Шрифт панели — Consolas с кириллицей, а не встроенный шрифт raylib. Правило
|
||||
«тексты на экране латиницей» здесь не нарушается: оно про ИГРУ, где текст пишет
|
||||
разработчик. В терминал текст приходит извне, и выбора языка нет.
|
||||
|
||||
## Скиллы
|
||||
|
||||
`.claude/commands/`: `/tile2d` — работа над проектом вообще, `/room` — сделать
|
||||
или починить комнату, `/floor` — править кривую сложности.
|
||||
|
||||
В них записано то, чего нет в коде и что модель иначе узнаёт слишком поздно:
|
||||
|
||||
- **не отчитываться об успехе без прохода `--tool validate`.** Комната, которая
|
||||
безупречно читается в диффе, может запечатать карман от собственной двери —
|
||||
в этом репозитории так случилось дважды (`storage`, потом `maw`);
|
||||
- **id сущностей — граница игры, а не движка.** Движок поставит любой id из
|
||||
каталога проекта; `squad_proto` превращает в тварь только те, что перечислены
|
||||
в `src/sim/spawn_catalog.cpp`;
|
||||
- глубину нельзя делать труднее множителями HP — это не страшнее, это дольше;
|
||||
- приёмка меряется в песочнице и проект не подключает.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
tile2d_editor --tool validate --project descent # код 0
|
||||
tile2d_editor --tool validate --project hollow # код 0
|
||||
tile2d_editor --tool map 6 1 --project descent # карта этажа текстом
|
||||
tile2d_editor --term "tile2d_editor --tool floor 8 42" # то же изнутри редактора
|
||||
```
|
||||
|
||||
Ключ `--term "<команда>"` открывает редактор сразу с выполненной командой. Он
|
||||
нужен не только для картинок в документации: так редактор открывают «с делом» —
|
||||
открыл, и уже видно, что не сходится.
|
||||
|
||||
## Чего ещё нет
|
||||
|
||||
- **Инструментов на запись.** Всё, что здесь есть, только читает и считает;
|
||||
правит модель сами файлы. Это осознанно: запись через инструмент означала бы
|
||||
второй способ менять комнату, и рано или поздно они разошлись бы.
|
||||
- **MCP-сервера.** Инструменты доступны как команды, а не как протокол. Для
|
||||
`claude` и `codex` этого достаточно; для редакторов, которые умеют только MCP,
|
||||
понадобится обёртка.
|
||||
- **Своей истории диалога.** Каждый вызов `claude -p` независим; терминал хранит
|
||||
историю КОМАНД, но не контекст разговора. Длинную работу ведут во внешнем
|
||||
интерактивном CLI.
|
||||
- **Правки текущей комнаты из терминала.** Модель работает с файлами на диске, а
|
||||
редактор держит рабочую копию в памяти: после правки файла нажмите `RELOAD`.
|
||||
266
docs/20-gdd.md
Normal file
@ -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` (есть) |
|
||||
|
||||
Числа в этих документах — **вход** для инструментов, а не украшение. Расхождение
|
||||
между таблицей и выводом инструмента означает, что неправ документ.
|
||||
217
docs/21-damage.md
Normal file
@ -0,0 +1,217 @@
|
||||
# 21 — Урон: стихии, статусы, реакции, триггеры
|
||||
|
||||
Продолжение [20-gdd.md](20-gdd.md). Здесь вся боевая арифметика.
|
||||
|
||||
Главное ограничение, из которого всё следует: **в игре нет ручных действий**
|
||||
(спек 5). Игрок водит диск и строит отряд; ни одна механика ниже не вводит
|
||||
кнопку. Стихии накладываются оружием, реакции срабатывают сами, эффекты висят
|
||||
на снаряжении. Мастерство — это ЧТО ты взял вниз и КУДА повёл отряд, а не как
|
||||
быстро ты нажимаешь.
|
||||
|
||||
## 1. Семь стихий
|
||||
|
||||
| Код | Имя | Что делает | Против кого |
|
||||
|---|---|---|---|
|
||||
| KIN | Кинетика | оглушение, отбрасывание | универсально, режется бронёй |
|
||||
| INC | Жар | горение, распространение при смерти | мягкие, скопления |
|
||||
| CRY | Холод | замедление, заморозка | быстрые, рывковые |
|
||||
| TOX | Кислота | снятие брони, урон от макс. HP | бронированные, жирные |
|
||||
| ARC | Дуга | цепь по целям, наводка | толпы, слабые |
|
||||
| RAD | Радиация | взрыв при смерти, заражение | стаи, respawn-твари |
|
||||
| VOID | Пустота | срезание сопротивлений, притяжение | всё, что защищено |
|
||||
|
||||
Стихии вводятся по актам ([20-gdd.md](20-gdd.md) §3), а не разом: игрок должен
|
||||
успеть понять каждую, прежде чем получит следующую.
|
||||
|
||||
### 1.1. Статусы
|
||||
|
||||
Каждая стихия кладёт **стаки** статуса. Стак живёт `dur`, сгорает по одному.
|
||||
|
||||
| Статус | Стаки | Эффект за стак | Потолок |
|
||||
|---|---|---|---|
|
||||
| Горение (INC) | 5 | 6% урона удара в секунду, 4 с | при смерти поджигает в радиусе 1.5 |
|
||||
| Оледенение (CRY) | 10 | −4% скорости, 3 с | 10 стаков → Заморозка 1.4 с, +50% получаемого KIN |
|
||||
| Разъедание (TOX) | 8 | −4% брони, 5 с | +0.8% макс. HP цели в секунду |
|
||||
| Разряд (ARC) | 4 | +5% получаемого крита, 3 с | 4 стака → цепь на 2 цели по 40% |
|
||||
| Заражение (RAD) | 6 | +3% получаемого урона, 6 с | при смерти взрыв 60% макс. HP, R=2.2 |
|
||||
| Схлопывание (VOID) | 3 | −8% всех сопротивлений, 4 с | 3 стака → притяжение к центру |
|
||||
|
||||
Числа подобраны под один принцип: **один стак не значит ничего, полный набор
|
||||
меняет бой**. Это заставляет выбирать цель и держать её, а не поливать всех.
|
||||
|
||||
## 2. Формула урона
|
||||
|
||||
Одна формула на всё — и на пулю, и на клинок, и на тик горения.
|
||||
|
||||
```
|
||||
┌ базовый урон источника
|
||||
dmg_raw = W · (1 + Σ add) · Π mult
|
||||
dmg_elem = dmg_raw · share(e) для каждой стихии e удара
|
||||
mitig(e) = 1 − clamp(R(e) + R_aff − PEN(e), −0.60, +0.85)
|
||||
dmg_final = Σ_e dmg_elem · mitig(e) · crit · vuln
|
||||
```
|
||||
|
||||
| Символ | Что | Диапазон |
|
||||
|---|---|---|
|
||||
| `W` | урон оружия (каталог `sim/weapon_defs.h`) | 8 → 900 по глубине |
|
||||
| `Σ add` | аддитивные проценты (аффиксы «+X% урона») | 0 → 4.5 |
|
||||
| `Π mult` | множители (специализации, реакции, метки) | 1.0 → 3.0 |
|
||||
| `share(e)` | доля стихии в уроне, Σ = 1 | конверсия аффиксами |
|
||||
| `R(e)` | сопротивление вида (каталог) | −0.5 → 0.9 |
|
||||
| `PEN(e)` | пробитие сборки | 0 → 0.75 |
|
||||
| `crit` | 1.0 или `critMult` | 1.5 → 4.0 |
|
||||
| `vuln` | уязвимости от статусов | 1.0 → 2.2 |
|
||||
|
||||
**Почему сопротивление режется до −0.60, а не глубже.** Отрицательное
|
||||
сопротивление — это уязвимость, и она обязана быть ощутимой, но не абсурдной:
|
||||
при −2.0 одна верная стихия обесценивала бы всю остальную сборку.
|
||||
|
||||
**Почему потолок +0.85, а не 1.0.** Иммунитета нет ни у кого. Тварь, которую
|
||||
физически нельзя ранить, — это не сложность, а тупик: игрок без нужной стихии
|
||||
просто не может играть.
|
||||
|
||||
### 2.1. Броня — отдельно от сопротивлений
|
||||
|
||||
Броня режет только KIN и работает долей с жёстким потолком `ARMOR_CAP = 0.72`
|
||||
(уже в коде). TOX снимает броню стаками — это единственный способ пробить
|
||||
тяжёлую цель без пробития в сборке.
|
||||
|
||||
### 2.2. Крит
|
||||
|
||||
```
|
||||
critChance = clamp(0.05 + Σ aff, 0, 0.75)
|
||||
critMult = 1.5 + Σ aff (без потолка)
|
||||
```
|
||||
|
||||
Шанс с потолком, множитель без — потому что «всегда крит» убивает разброс
|
||||
результата, а «очень больно раз в четыре удара» его сохраняет.
|
||||
|
||||
## 3. Реакции
|
||||
|
||||
Две РАЗНЫЕ стихии на одной цели в пределах 3 секунд дают реакцию. Реакция
|
||||
срабатывает сама и снимает оба статуса.
|
||||
|
||||
| Реакция | Пара | Эффект |
|
||||
|---|---|---|
|
||||
| **Растрескивание** | INC + CRY | всплеск 250% APow в точке, +80% к следующему KIN |
|
||||
| **Возгорание** | INC + TOX | облако R=3.0, 180% APow в секунду, 3 с |
|
||||
| **Сверхпроводник** | ARC + CRY | −30% всех сопротивлений цели и соседей, 6 с |
|
||||
| **Электролиз** | ARC + TOX | все DoT на цели ×3 на 3 с |
|
||||
| **Распад** | RAD + INC | зона R=2.5 на 8 с, 60% APow в секунду, кладёт RAD |
|
||||
| **Пепел** | RAD + CRY | цель не воскресает и не респавнится |
|
||||
| **Резонанс** | VOID + любая | реакция повторяется вторым срабатыванием на 60% |
|
||||
| **Схлоп** | VOID + ARC | притяжение всех в R=4 к цели, оглушение 1 с |
|
||||
| **Осколки** | KIN + CRY | замороженная цель при смерти бьёт осколками, 120% APow |
|
||||
| **Прорыв** | KIN + TOX | −40% брони цели на 5 с, игнорируя потолок |
|
||||
|
||||
### 3.1. Сила реакции не зависит от оружия
|
||||
|
||||
```
|
||||
APow(d) = 4 + 0.12·d (d — глубина этажа)
|
||||
reaction = k · APow(d) · (1 + reactionBonus)
|
||||
```
|
||||
|
||||
Это ключевое решение. Реакция считается **от глубины, а не от урона оружия** —
|
||||
иначе на сотом этаже реакции стали бы округлением, и вся система стихий
|
||||
превратилась бы в украшение. Так же поступает Genshin с Elemental Mastery, и по
|
||||
той же причине.
|
||||
|
||||
Следствие: **реакции — это канал «знания»** из закона масштаба
|
||||
([20-gdd.md](20-gdd.md) §2). Они растут сами по глубине, но только у того, кто
|
||||
умеет их запускать.
|
||||
|
||||
### 3.2. Внутренний откат
|
||||
|
||||
У каждой пары свой ICD: 1.2 с на цель. Без него цепь ARC по десяти тварям
|
||||
взорвала бы этаж одним нажатием, которого к тому же нет.
|
||||
|
||||
## 4. Триггеры: on-hit, on-kill, on-death
|
||||
|
||||
Журнал боевых событий **уже существует** — `sim/events.h`, кольцевой буфер, по
|
||||
которому сейчас работают эффекты и свет. Триггеры вешаются на него, а не рядом:
|
||||
второй канал событий разошёлся бы с первым в первый же месяц.
|
||||
|
||||
| Триггер | Когда | Пример эффекта |
|
||||
|---|---|---|
|
||||
| `on_attack` | начало выстрела/замаха | «каждый 5-й выстрел бесплатный» |
|
||||
| `on_hit` | попадание | «10% наложить CRY» |
|
||||
| `on_crit` | критическое попадание | «крит снимает 1 стак перезарядки» |
|
||||
| `on_kill` | цель умерла от тебя | «+8% скорости на 3 с, стак до 5» |
|
||||
| `on_death` | ЦЕЛЬ умерла (любая) | «труп взрывается RAD» |
|
||||
| `on_hurt` | по тебе попали | «20%: наложить INC атакующему» |
|
||||
| `on_down` | твой оперативник пал | «отряд получает +25% урона на 10 с» |
|
||||
| `on_floor` | вход на этаж | «+1 бинт, если сумка не полна» |
|
||||
| `on_extract` | эвакуация | «+15% материалов» |
|
||||
|
||||
### 4.1. Против взрыва комбинаций
|
||||
|
||||
Три ограничителя, все обязательные:
|
||||
|
||||
1. **ICD у каждого эффекта.** Минимум 0.5 с, у мощных — до 8 с.
|
||||
2. **Бюджет строки.** Предмет несёт суммарный бюджет `B(ilvl) = 40 + 3·ilvl`;
|
||||
каждый аффикс стоит по тиру ([22-loot.md](22-loot.md) §3). Шесть сильнейших
|
||||
аффиксов не влезают в один предмет никогда.
|
||||
3. **Запрет рекурсии.** Эффект, сработавший от триггера, не может запустить тот
|
||||
же триггер: `on_kill` → взрыв → смерть → `on_kill` обрывается на первом шаге.
|
||||
|
||||
Без третьего пункта одна цепь ARC на скоплении из сорока тварей вешает игру —
|
||||
и это не гипотеза, а то, как ломаются все игры с триггерами.
|
||||
|
||||
## 5. Твари: сопротивления и роли
|
||||
|
||||
Сопротивление — свойство ВИДА, из каталога проекта. Роль читается по профилю
|
||||
сопротивлений, и это единственная подсказка, которую игра даёт напрямую.
|
||||
|
||||
| Вид | Акт | KIN | INC | CRY | TOX | ARC | RAD | Роль |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Бредущий | I | .0 | −.2 | .0 | .0 | .0 | .3 | мясо |
|
||||
| Бегун | I | .0 | .0 | −.4 | .0 | .2 | .0 | давление |
|
||||
| Плевок | II | .1 | .0 | .0 | .5 | −.3 | .2 | дистанция |
|
||||
| Хват | III | .3 | −.3 | .1 | .0 | .0 | .0 | контроль |
|
||||
| Громила | III | .5 | .2 | .3 | −.4 | .0 | .1 | танк |
|
||||
| Рой | IV | −.2 | −.5 | .0 | .0 | −.4 | .4 | толпа |
|
||||
| Скорлупа | IV | .7 | .3 | .4 | −.5 | .3 | .2 | стена |
|
||||
| Плакальщик | V | .2 | .0 | .0 | .2 | .2 | −.6 | воскрешает |
|
||||
| Пустотник | VI | .3 | .3 | .3 | .3 | .3 | .3 | требует VOID |
|
||||
|
||||
Читается так: против Скорлупы бесполезно всё, кроме TOX; Плакальщика надо жечь
|
||||
радиацией, иначе он поднимет остальных; Пустотник равномерно защищён, и его
|
||||
берут только срезанием сопротивлений.
|
||||
|
||||
**Ни у одного вида нет множителя HP от глубины.** Глубже выходят ДРУГИЕ виды —
|
||||
правило из [16-descent.md](16-descent.md), и оно не отменяется до Бездны
|
||||
([23-endgame.md](23-endgame.md) §2, где отменяется осознанно и с объяснением).
|
||||
|
||||
## 6. Коридор TTK
|
||||
|
||||
Чтобы бой не превращался ни в тир, ни в губку, время убийства держится в
|
||||
коридоре — это проверяемое обещание, а не пожелание.
|
||||
|
||||
| Цель | TTK одним оперативником, с верной стихией | с неверной |
|
||||
|---|---|---|
|
||||
| мясо своей полосы | 0.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%, твари замечают первыми.
|
||||
|
||||
Это второй после патронов ресурс, который гонит наверх, и он же — причина, по
|
||||
которой ветвь Разведки в дереве узлов не «приятная», а обязательная.
|
||||
167
docs/22-loot.md
Normal file
@ -0,0 +1,167 @@
|
||||
# 22 — Добыча: редкости, аффиксы, артефакты, зачарования
|
||||
|
||||
Продолжение [20-gdd.md](20-gdd.md). Боевые формулы — [21-damage.md](21-damage.md).
|
||||
|
||||
Правило, из которого всё растёт: **находка должна быть решением, а не
|
||||
прибавкой**. Предмет, который просто «лучше на 7%», не стоит того, чтобы за ним
|
||||
идти в темноту, — а идти в темноту и есть игра.
|
||||
|
||||
## 1. Редкости
|
||||
|
||||
| Редкость | Аффиксов | Доля дропа D1 | D50 | D100 |
|
||||
|---|---|---|---|---|
|
||||
| Обычное | 0 | 78% | 34% | 12% |
|
||||
| Меченое | 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 <ilvl>` (новый) |
|
||||
| материалов хватает на сборку за ~8 вылазок | `--tool economy` (новый) |
|
||||
| ни один артефакт не является чистым плюсом | ревью-чеклист + тест «сборка без цены» |
|
||||
| TTK не выходит из коридора с лучшими вещами | `--tool ttk <глубина> --best` |
|
||||
182
docs/23-endgame.md
Normal file
@ -0,0 +1,182 @@
|
||||
# 23 — Лейтгейм: Бездна, лидерборды, ачивки
|
||||
|
||||
Продолжение [20-gdd.md](20-gdd.md). Здесь то, что начинается после сотого этажа
|
||||
и не кончается.
|
||||
|
||||
## 1. Что такое лейтгейм этой игры
|
||||
|
||||
Не «ещё контент». После акта VI у игрока собран отряд из пяти, три гнезда
|
||||
артефактов и сборка, которую он строил восемьдесят часов. Дальше игра меняет
|
||||
вопрос: не «пройду ли я», а **«как глубоко»**.
|
||||
|
||||
Это переход от прохождения к соревнованию, и он должен быть заметным: на D101
|
||||
меняются правила, а не только цифры.
|
||||
|
||||
## 2. Бездна: честная беговая дорожка
|
||||
|
||||
Ниже сотого этажа авторского содержимого больше нет — и притворяться, что есть,
|
||||
нельзя. Поэтому здесь включается то, что до сих пор было запрещено.
|
||||
|
||||
### 2.1. Отмена правила о множителях
|
||||
|
||||
В [16-descent.md](16-descent.md) записано: **глубина меняет, КТО выходит, а не
|
||||
сколько у него HP**. Это правило держало весь авторский контент, и оно верное:
|
||||
«тот же бредущий, но втрое толще» — не страшнее, а дольше.
|
||||
|
||||
С D101 оно снимается:
|
||||
|
||||
```
|
||||
HP,урон тварей ×= 1 + 0.045·(d − 100)
|
||||
```
|
||||
|
||||
Почему это допустимо здесь и нигде больше: в Бездне игрок не проходит контент, а
|
||||
**меряет сборку**. Он уже видел всё, что игра хотела показать; дальше ему нужна
|
||||
не новизна, а линейка. Множитель — это и есть линейка, и он честно назван
|
||||
беговой дорожкой, а не «новым уровнем сложности».
|
||||
|
||||
Умножение линейное, а не экспоненциальное: экспонента обрывает лестницу на
|
||||
конкретном этаже одинаково для всех, и лидерборд превращается в проверку, у кого
|
||||
чуть больше урона. Линейный рост против затухающей силы отряда даёт лестницу
|
||||
длиной в сотни этажей, где каждый следующий шаг стоит заметно дороже
|
||||
предыдущего.
|
||||
|
||||
### 2.2. Мутации — то, что делает Бездну не только дорожкой
|
||||
|
||||
Каждая тварь глубже D101 получает `floor((d − 100) / 25) + 1` мутаций из пула.
|
||||
Мутация меняет ПОВЕДЕНИЕ, а не число.
|
||||
|
||||
| Мутация | Что делает |
|
||||
|---|---|
|
||||
| Зеркальная | отражает 25% полученной стихии в атакующего |
|
||||
| Роящаяся | при смерти делится на двух вдвое слабее (до 3 поколений) |
|
||||
| Голодная | лечится на 30% нанесённого урона |
|
||||
| Слепая | не видит свет, но слышит стрельбу вдвое дальше |
|
||||
| Якорная | не отбрасывается, KIN по ней даёт −50% |
|
||||
| Заражённая | на смерти оставляет облако RAD |
|
||||
| Стеклянная | −60% HP, +150% урона |
|
||||
| Тихая | не издаёт звука и не подсвечивается в тумане |
|
||||
| Насыщенная | иммунна к первому наложенному статусу |
|
||||
|
||||
Мутации стоят места в бюджете опасности этажа: мутировавшая тварь стоит
|
||||
`danger × (1 + 0.35 · мутаций)`. Поэтому глубокий этаж — это не «те же сорок
|
||||
тварей, но злее», а **меньше тварей, каждая из которых задача**. Потолок тел
|
||||
(`MAX_LEVEL_SPAWNS = 64`) остаётся нетронутым, и это не совпадение: именно бюджет
|
||||
опасности переводит рост угрозы в качество, когда мест больше нет.
|
||||
|
||||
### 2.3. Пороги Бездны
|
||||
|
||||
Каждые 25 этажей — **Порог**: этаж-аномалия с фиксированными правилами.
|
||||
|
||||
| Порог | D | Правило этажа |
|
||||
|---|---|---|
|
||||
| Первый | 125 | света нет вообще; работает только фонарь |
|
||||
| Второй | 150 | эвакуация закрыта до убийства всех элит |
|
||||
| Третий | 175 | стихии инвертированы: сопротивления становятся уязвимостями |
|
||||
| Четвёртый | 200 | отряд входит без сумки |
|
||||
| Дальше | +25 | правила смешиваются по два |
|
||||
|
||||
Порог — единственное место, где игра прямо проверяет знание, а не сборку.
|
||||
|
||||
## 3. Лидерборды
|
||||
|
||||
### 3.1. Три доски, а не одна
|
||||
|
||||
| Доска | Что мерит | Зачем отдельно |
|
||||
|---|---|---|
|
||||
| **Глубина** | максимальный этаж за сезон | главная, ради неё всё |
|
||||
| **Скорость** | время до D100 с нуля профиля | другой навык, другие сборки |
|
||||
| **Голыми руками** | глубина без артефактов | иначе доска — это лотерея дропа |
|
||||
|
||||
### 3.2. Счёт
|
||||
|
||||
```
|
||||
score = 1000·D + 40·E + 12·A − 250·W
|
||||
```
|
||||
|
||||
| Символ | Что |
|
||||
|---|---|
|
||||
| `D` | достигнутая глубина |
|
||||
| `E` | число успешных эвакуаций в сезоне |
|
||||
| `A` | убитые элиты на глубинах ниже рекорда |
|
||||
| `W` | вайпы |
|
||||
|
||||
Штраф за вайп — четверть этажа. Достаточно, чтобы «просто прыгнуть вниз и
|
||||
посмотреть» имело цену, и слишком мало, чтобы делать доску страховкой от риска.
|
||||
|
||||
### 3.3. Сезоны
|
||||
|
||||
Сезон — 8 недель, общий сид генерации на всех. Один и тот же набор этажей у
|
||||
всех игроков: без этого доска сравнивает удачу, а не игру. Профиль в сезонном
|
||||
режиме отдельный и стартует с нуля — иначе первое место занимает тот, кто начал
|
||||
раньше.
|
||||
|
||||
По окончании: рекорды в вечную таблицу, сезонный профиль сливается в основной
|
||||
(материалы — половина, артефакты — все, опыт — весь).
|
||||
|
||||
## 4. Ачивки: 262 штуки
|
||||
|
||||
Ачивки — это не список задач, а **карта того, что игра считает достойным**.
|
||||
Поэтому они собраны по категориям, и почти каждая формулируется правилом, а не
|
||||
вручную.
|
||||
|
||||
| Категория | Кол-во | Как строятся |
|
||||
|---|---|---|
|
||||
| Глубина | 24 | D5, D10, …, D100 (20) + D125/150/175/200 (4) |
|
||||
| Экстракшен | 22 | вынести N предметов; уйти на 1 HP; эвакуация без выстрела; 10 подряд без потерь |
|
||||
| Отряд | 26 | набрать 2/3/4/5; довести оперативника до D50 без смертей; вылазка соло на D40 |
|
||||
| Бой | 34 | убийства по видам (9×2), убийство залпом, TTK-рекорды, бой без урона |
|
||||
| Стихии | 30 | по 3 на каждую из 7 (наложить/добить/сборка) + 9 на реакции |
|
||||
| Реакции | 20 | каждая из 10 по разу + каждая 1000 раз |
|
||||
| Предметы | 28 | редкости, тиры, полный комплект тира, конверсия 85% |
|
||||
| Артефакты | 24 | найти N, собрать 3 гнезда, пройти D80 с одним, с самым дорогим |
|
||||
| Крафт | 18 | операции, залом успешный, залом неудачный (да, за провал тоже) |
|
||||
| Экономика | 16 | материалы, полное дерево ветви, все 200 уровней узлов |
|
||||
| Хоррор | 12 | пройти этаж без света; без единого выстрела; не потревожив никого |
|
||||
| Мета | 8 | сезонные места, три доски, вечная таблица |
|
||||
|
||||
**Итого 262.**
|
||||
|
||||
### 4.1. Правила, по которым ачивки не превращаются в мусор
|
||||
|
||||
1. **Ни одной за время.** «Играйте 100 часов» — это не достижение, а счётчик.
|
||||
2. **Ни одной невозможной без удачи.** Всё, что зависит от дропа, имеет пити.
|
||||
3. **Каждая пятая — за необычный способ, а не за повторение.** «Пройти этаж, не
|
||||
потревожив никого» стоит рядом с «убить 10 000 тварей», и вторая нужна только
|
||||
как фон для первой.
|
||||
4. **За провал тоже.** Неудачный залом, вайп на D1, потеря артефакта — это
|
||||
истории, а истории и есть то, что люди рассказывают о такой игре.
|
||||
|
||||
### 4.2. Примеры именованных
|
||||
|
||||
| Имя | Условие |
|
||||
|---|---|
|
||||
| «Первый свет» | пройти D1 |
|
||||
| «Тише воды» | этаж без единого выстрела на D20+ |
|
||||
| «Экономный» | эвакуация с полной сумкой и полным боезапасом |
|
||||
| «Пирокинетик» | 1000 Растрескиваний |
|
||||
| «Не мой день» | залом рассыпал реликт тира 9 |
|
||||
| «Он был хорошим» | потерять оперативника, дошедшего до D50 |
|
||||
| «Голыми руками» | D60 без единого артефакта |
|
||||
| «Кто там?» | пройти Порог 125 без фонаря |
|
||||
| «Полный набор» | все 200 уровней узлов |
|
||||
| «Ниже некуда» | D200 |
|
||||
|
||||
## 5. Почему это удержит игрока после сотого этажа
|
||||
|
||||
Три независимых причины возвращаться, и ни одна не завязана на новый контент:
|
||||
|
||||
1. **Число.** Рекорд глубины — это одна цифра, которую можно улучшить сегодня.
|
||||
2. **Сборка.** Материалы и залом дают потолок, до которого почти никто не
|
||||
доходит: идеальная вещь всегда на один рискованный шаг впереди.
|
||||
3. **Сезон.** Раз в восемь недель поле выравнивается, и лучший результат снова
|
||||
решается игрой, а не тем, кто дольше играл.
|
||||
|
||||
## 6. Проверки
|
||||
|
||||
| Утверждение | Чем меряется |
|
||||
|---|---|
|
||||
| множитель Бездны даёт лестницу, а не обрыв | `--tool wall 100 300` (новый): где стена при сборке N |
|
||||
| мутации не разносят потолок тел | `--tool floor <d>` — `spawnsDropped` обязан быть 0 |
|
||||
| счёт лидерборда не выигрывается вайп-фармом | симуляция стратегий в `--tool score` (новый) |
|
||||
| ачивки достижимы | `--tool achievements` (новый): для каждой — глубина и условия |
|
||||
| сезонный сид даёт всем один набор этажей | `--tool map <d> <seed>` на двух машинах |
|
||||
BIN
docs/agent-v2-attack.preview.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/agent-v2-rifle-attack.preview.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
docs/agent-v3-rifle-attack.preview.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/agent-v4-rifle-attack.preview.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
docs/agent-v5-rifle-attack.preview.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/agent-v6-rifle-attack.preview.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/agent_attack.preview.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
docs/art-target-alien-shooter.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
docs/brute_attack.preview.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
docs/descent-d12.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
docs/editor-hollow-topdown.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/editor-level.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/editor-room.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
docs/editor-spawn.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/editor-terminal-tools.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/editor-terminal.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
docs/level-rooms-seed42.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
docs/lod-fitted-operative-idle-v2.preview.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/lod-fitted-operative-idle.preview.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
docs/lod-quaternius-body-walk.preview.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
docs/lod-source-body-idle-v3.preview.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/lod-source-body-walk-v2.preview.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
docs/lod-source-body-walk.preview.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/menu.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
docs/raid.png
Normal file
|
After Width: | Height: | Size: 317 KiB |
BIN
docs/runtime-alien-shooter-clean.png
Normal file
|
After Width: | Height: | Size: 407 KiB |
BIN
docs/runtime-alien-shooter-pass.png
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
docs/runtime-spriteforge-blender-agent.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
docs/runtime-spriteforge-scale-fixed.png
Normal file
|
After Width: | Height: | Size: 383 KiB |
BIN
docs/runtime-spriteforge.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
docs/rusher-direct-gate-v2.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
docs/rusher-direct-gate.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
docs/sample-walk-topdown.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/sample-walk.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
docs/spriteforge-contact-sheet.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
docs/squad.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/ui-depth.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/ui-squad-stash.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/ui-summary.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/ui-upgrades.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
docs/view-topdown-hollow.png
Normal file
|
After Width: | Height: | Size: 358 KiB |