squad-proto/docs/16-descent.md
z.kirill e3294cffd4 Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами
через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке.
Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление
тварей и живёт полторы минуты без управления. Первая стена: одиночка D7,
трое D30, пятеро D30 - петля работает.

И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером;
после появления ростера одиночка выходил на этаж с четырьмя невидимыми
телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже,
100 -> 41" был боем впятером.

ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом
проигрывает двум бредущим: 210 здоровья против ножа это девять секунд
работы под ответным уроном. Первые этажи населены личинками (55 здоровья,
7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый.
Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре
меняет, кто выходит, а не числа у одного и того же.

ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она
выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её
двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка.

Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в
замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь.

Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает
повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть
размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары.

Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это
его удар, просто по летящей цели.

Проверка меряет размен целиком, включая обратную половину правила:
"стоя столбом не отбивает". Без неё условие про соприкосновение
существовало бы только в комментарии.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 05:04:35 +03:00

237 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 16 — Спуск: глубина как первичное понятие
## Что за игра
Бесконечный спуск в подземелье. Чем глубже, тем страшнее окружение, злее твари,
богаче находки. Забег кончается, когда отряд полёг; сколько этажей он прошёл —
и есть результат.
Из этого следует главное решение всей вехи: **глубина не параметр уровня, а
первичное понятие движка**. От неё зависят планировка, население, лут, отделка
и свет. Поэтому она не размазана по генератору, а собрана в одну функцию.
## Кривая — одно место
[engine/progression.h](../engine/progression.h). `DescribeFloor(depth)`
возвращает `FloorSpec`: тема отделки, бюджет опасности, плотность, сколько и
какого лута, насколько сжат обзор, множитель опыта.
Коэффициенты с вехи M14 живут не в коде, а в манифесте проекта
([18-engine.md](18-engine.md)): `projects/descent/project.txt`. В коде осталась
ФОРМА кривой — проект гнёт её, но не может подменить линейный рост
экспонентой.
Кривая сложности — это то, что правят чаще всего и правят на ощупь. Размазанная
по генератору, каталогу и рендеру, она через месяц перестаёт поддаваться правке:
«сделать десятый этаж чуть злее» превращается в раскопки по трём файлам.
Чего в кривой **нет намеренно**:
- **множителей урона и здоровья тварей.** «Тот же бредущий, но со втрое большим
HP» — это не страшнее, это дольше. Глубина меняет, КТО выходит и сколько их;
- **потолка.** Функция определена для любого N: этаж 200 обязан собраться, даже
если каталог кончился на 12-м.
| Что | Как растёт | Почему так |
|---|---|---|
| опасность | линейно + квадратичный хвост | чистая линейка читается как «то же, только больше»; чистая экспонента обрывает забег там, где игрок начал понимать правила |
| плотность точек | до ×1.5 и упор | выше упора все точки срабатывают всегда, то есть этажи становятся одинаковыми |
| тир лута | +1 каждые 3 этажа | медленнее опасности: спуск обязан опережать снаряжение |
| обзор | до ×0.6 и упор | ниже — это уже не «страшно», а «не видно, куда идти» |
| опыт | +15% за этаж | медленнее опасности: спускаться выгодно, но не настолько, чтобы прыгать через этажи |
Полосы отделки: `STEEL` 13, `RUST` 47, `FLESH` 812, `FROST` 13+. Последняя
открытая: менять тему дальше не на что, а «случайная тема каждый этаж»
превратила бы спуск в чересполосицу.
## Бюджет опасности
Каждая сущность каталога несёт цену `danger` — не урон и не HP, а «сколько места
в голове игрока она занимает». Этаж получает бюджет и тратит его:
1. срабатывают точки спавна комнат — из их таблиц отбирается то, что разрешено
на этой глубине и по карману;
2. остаток **доливается** тварями вне комнатных точек.
Второй шаг обязателен. Без него глубина упирается в число точек, нарисованных в
комнатах: на двадцатом этаже все точки уже срабатывают и уже выдают самое
дорогое, и дальше этаж не становится страшнее вообще. Долив превращает бюджет из
потолка в норму, которую этаж обязан выбрать.
**Когда мест мало, а бюджета много, этаж становится злее, а не многолюднее.**
Тел в мире не может быть больше `MAX_TARGETS`, и на глубине бюджет неизбежно
перерастает это число. Поэтому на каждое оставшееся место считается, сколько
опасности обязано на него прийтись, и всё дешевле отбрасывается: хвост этажа
набирается самыми дорогими тварями.
> Потолок `MAX_TARGETS` пришлось поднять с 32 до 64. Это не «запас на будущее»:
> `tile2d_editor --check` показал, что примерно с шестнадцатого этажа бюджет
> перестаёт влезать в число тел, и глубже этажи переставали отличаться. Маска
> `MeleeWeapon::hitMask` стала 64-битной ровно поэтому.
## Каталог: глубина и цена
[projects/descent/catalog/entities.txt](../projects/descent/catalog/entities.txt) перешёл на
`ключ=значение`. Первая версия была позиционной, и глубина её сломала: каждое
новое свойство ломало бы все уже написанные строки.
```
entity shambler class=monster depth=1 danger=1.0 radius=0.34 color=116,52,52 sprite=shambler_walk name=SHAMBLER
entity brute class=monster depth=7 danger=4.5 radius=0.46 color=142,60,78 sprite=brute_walk name=BRUTE
entity plate class=loot tier=3 depth=9 radius=0.30 color=140,146,156 sprite=item_icon_plate name=PLATE
```
- `depth=N`с этажа N и глубже; `depth=N..M` — по M включительно;
- `danger` — цена в бюджете. У обстановки и лута она нулевая по определению:
ящик не делает этаж страшнее, он делает его обжитее;
- `tier` — для лута: этаж выдаёт свой тир и всё, что ниже. Бинт с глубиной не
исчезает, он просто перестаёт быть удачей.
Третий класс — `loot`. Мост в игру тот же: `sim/spawn_catalog.cpp` переводит id
в `EnemyKind` **и** в `ItemId` двумя таблицами.
Комнаты тоже получили глубину: `depth <min> [max]` в файле. Планировка — часть
погружения: широкая арена первых этажей и теснота, где не развернуть строй, —
это разные игры.
## Находки
[src/sim/loot.h](../src/sim/loot.h). Предмет лежит на полу отдельной сущностью
и подбирается **пассивно**: боец, прошедший по нему, кладёт его в свободную
ячейку сумки.
Почему не «выдать предмет при убийстве»: в игре про спуск находка — это МЕСТО,
до которого надо дойти. Лут, падающий в инвентарь сам, не заставляет никуда идти
и превращает «глубже — лучше лут» в строку в логе. Лежащий предмет плохо виден в
темноте, лежит там, куда отряд ещё не заходил, и за ним надо разомкнуть строй —
это и есть цена находки.
Отдельной кнопки подбора нет намеренно: игрок и так не нажимает «огонь»
(спек 5), и заводить ради лута первое ручное действие в игре значило бы менять
её жанр. Полная сумка — предмет остаётся лежать: «некуда положить» обязано быть
видно, а не молча съедено.
## Спуск
Точка спуска — **самый дальний достижимый тайл** от старта, найденный той же
заливкой, что считает достижимость. Не «дальняя комната»: комната может
оказаться отрезанной, а этот выбор недостижимым быть не может по построению.
Рисуется пульсирующим кольцом и видна даже в полной темноте — единственная цель
этажа обязана читаться с порога.
`F` на спуске уводит отряд вниз. Именно нажатие, а не заход на клетку: спуск —
решение игрока. Этаж не зачищен, лут не собран, уходить рано — это нормальный
выбор, и отряд может стоять на спуске сколько угодно.
Вниз переезжает **всё, что нажито**: здоровье, оружие в руках, содержимое сумок.
Павшие не воскресают — воскрешение на лестнице обесценило бы единственную
настоящую потерю в забеге. Стартовый комплект снаряжения выдаётся только тем, кто
пришёл ни с чем: раздавать его заново на каждом этаже значило бы стирать весь
смысл находок.
Симуляция сама этаж не меняет: `Game::Step` только отмечает `atExit`. Смена мира
посреди шага, между движением и стрельбой, оставила бы половину систем со
ссылками на снесённые сущности.
| Действие | Что делает |
|---|---|
| `NEW RAID` в меню | выбор чекпоинта, потом новый забег ([17-extraction.md](17-extraction.md)) |
| `F` на спуске | следующий этаж, отряд как есть |
| `TRY AGAIN` после поражения | тот же этаж заново |
| `R` в бою | тот же этаж заново |
## Опыт
Копится за убитых: цена `danger` умножается на множитель этажа. Обстановка опыта
не даёт — платить за ящик значило бы сделать разбивание хлама самым выгодным
занятием в игре.
Тратится он на улучшения отряда ([17-extraction.md](17-extraction.md)): запас
патронов, переноска, обзор, живучесть. Заглушкой это было три вехи — ровно до
тех пор, пока не нашлось, во что вкладываться так, чтобы не сломать жанр.
## Первый этаж обязан быть скучным
Бюджет опасности начинается с 2.2, и на первых этажах его тратят **личинки**
55 здоровья, 7 урона, медленные. Бредущий (210 здоровья) выходит с третьего
этажа, бегун с четвёртого.
Личинка заведена не ради разнообразия. `--wall` показал, что одиночка с ножом
проигрывает ДВУМ бредущим, даже когда их всего двое: 210 здоровья против ножа —
это девять секунд работы под ответным уроном. Новый ВИД, а не ослабленный
бредущий с множителем: глубина в этой игре меняет, кто выходит, а не числа у
одного и того же. До этого было 3.0 и бегуны с первого: одинокий
оперативник с ножом встречал троих, из которых один быстрый. Это не «страшно»,
это «непроходимо».
Свет там тоже другой: `sightStart` 1.30 и `ambient` 0.42 против 0.60 и 0.13 на
дне. Наверху ещё работает освещение, и хоррор первого этажа — в том, что игрок
один, а не в том, что не видно стен. Темнота это инструмент ГЛУБИНЫ, и тратить
её на первый этаж значит не иметь запаса дальше.
## Свет как главный признак глубины
Окружение становится страшнее не новыми спрайтами, а тем, что видно всё меньше.
`FloorSpec::sightScale` сжимает радиус обзора всем бойцам одинаково, поверх их
личных множителей: фонарь на двадцатом этаже по-прежнему лучше савана, просто
оба светят хуже.
Значение живёт в `Lighting::sightScale`, а не в `g_tune`: tuning общий на весь
запуск, и запись туда означала бы, что двенадцатый этаж подкрутил ручку, с
которой потом работает и первый этаж, и песочница приёмки.
Глубина жмёт на свет ВТОРОЙ раз, уже через ресурс: `LightDrain(глубина)` делает
каждый шаг дороже по заряду фонаря, а этажи внизу длиннее. Один множитель —
сколько видно, второй — сколько ещё будет видно
([17-extraction.md](17-extraction.md)).
## Цифры на экране
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
```
## Чего ещё нет
- **траты опыта** — см. выше, это отдельная веха с отдельными критериями;
- **боссов и особых этажей** — кривая пока гладкая, а спуск без вех в памяти
сливается в один длинный коридор;
- **сохранения забега** — вылет означает потерю прогресса;
- **уникального лута** — все находки берутся из общего каталога предметов, и
отличаются только тиром.