squad-proto/docs/13-combat.md
z.kirill b29358ad12 Инвентарь: SHIFT+клик, сортировка схрона, сравнение с надетым (M38)
Перетаскивание мышью в кадре 480x270 — это попадание в ячейку 74x18 с зажатой
кнопкой, и делать так тридцать раз после каждой вылазки нельзя.

* SHIFT + клик отправляет вещь ТУДА, ГДЕ ЕЙ МЕСТО: ствол в руки, плита в броню,
  брелок в карман, расходник в сумку; из слота — обратно в схрон. Обмен идёт
  через тот же ApplyDrop, что и перетаскивание: своя копия правил означала бы
  ещё одно место, где вещь может потеряться.
* Кнопка SORT раскладывает схрон: класс -> редкость -> аффиксы -> имя.
* Подсказка вещи из схрона дописывает блок NOW: что стоит в этом слоте сейчас
  и чем новое отличается.

--ui-check меряет сохранность, а не факт вызова: вещей было 5 — стало 5, ствол
в руках, прежний в схроне, профиль о переодевании знает. Проверка сперва
воспроизведена на сломанной сборке: без SaveLoadout печатает «профиль НЕ знает».

Замеры: --ui-check поломок 0, --accept PASS, --descend 8 ошибок 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:42:28 +03:00

287 lines
21 KiB
Markdown
Raw Permalink 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.

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