squad-proto/docs/08-decisions.md
z.kirill 7880f275bd Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).

Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.

Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.

Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».

Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.

Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.

Документация — CLAUDE.md как оглавление, содержание в docs/.

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

328 lines
25 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.

# 08 — Решения и отклонения от спека
Спек в разделе 12 требует: раздел 6 реализуется как написано, а если считаешь,
что где-то ошибка — сначала скажи, потом делай. Здесь перечислено **всё**, что
отличается от буквы спека, с причиной и замерами.
Отклонения разделены на две группы: содержательные (меняют поведение) и
структурные (не меняют).
---
## A. Содержательные
### A1. Пуля не летит дальше проверенного отрезка
**Спек**: 6.9 — снаряд со скоростью 22 юнита/с.
**Сделано**: время жизни пули = длина отрезка «дуло → цель» / скорость,
а не `дальность оружия / скорость`.
**Почему**. `LaneClear` проверяет отрезок до цели. Если цель успевает умереть от
чужой пули, снаряд летит **дальше** — в пространство, которое никто не проверял.
Ровно так возникало последнее упорное попадание по своим: агент стоял внутри
кучки мишеней, пуля прошла место умершей мишени насквозь и нашла его.
**Замер**: до фикса — 1 попадание за 300 с, стабильно воспроизводимое
(`t=94.49, #1 -> #2 @ (30.6,21.7)`); после — 0 за 300 с.
### A2. Спуск учитывает время полёта пули (`AllyInterceptsShot`)
**Спек**: 6.3 проверяет линию в момент выстрела.
**Сделано**: дополнительно проверяется сближение пули и союзника как двух
движущихся точек на всём времени полёта, с радиусом безопасности, растущим со
временем (`shotPredictSlack` — запас на то, что союзник может и повернуть).
**Почему**. 6.3 даёт мгновенный снимок, а 6.9 делает пулю реальным снарядом:
до 0.4 с полёта, за которые союзник проходит больше метра. Союзник, честно
чистый в момент нажатия, спокойно входит в коридор уже после выстрела.
Реактивное расступание (6.6) этот случай не закрывает по определению — оно
срабатывает, только когда тело уже в линии.
Само правило 6.3 при этом **не ослаблено**: это дополнительное условие спуска,
а не замена. `LaneClear` продолжает использоваться и в целеуказании, и в оценке
кандидатов, и в реестре линий — в том виде, как написано в спеке.
**Замер**: журнал попаданий показывал, что жертва **всегда** была `ADVANCING`
то есть именно вбегала под летящую пулю.
### A3. В реестр линий попадают линии, перекрытые своим
**Спек**: 6.6 — «список активных линий огня — сегментов от **стреляющих** агентов».
**Сделано**: линия регистрируется и тогда, когда у агента есть цель в радиусе,
стена не мешает, а мешает **только свой** (`Agent::laneIntent`).
**Почему**. При буквальном прочтении механика самоотменяется: стоит кому-то
влезть в линию — стрелок теряет линию, перестаёт быть «стреляющим», отрезок
исчезает из реестра, и перекрывшего **ничто не выталкивает**. Сектор обстрела не
освобождается никогда, а обещание 6.6 («отряд сам расплывается, освобождая
сектора обстрела») и критерий 11.4 становятся недостижимыми.
**Замер**: `FIRE UPTIME` на маршруте вырос с 33 % до 49 %, худший uptime на
стоянке — с 38 % до 51 %.
### A4. Резерв линии шире порога блокировки (`laneReserveScale = 1.8`)
**Спек**: 6.6 — импульс, если `dist < bodyRadius + laneHalfWidth + SAFETY_MARGIN`.
**Сделано**: импульс расступания начинается на `laneReserveScale` шире этого
порога. Сам порог блокировки в 6.3 не тронут.
**Почему**. Раздел называется «**Резервирование** линий». Если отжимать агента
ровно с той границы, за которой он уже блокирует выстрел, — это не резерв, а
реакция постфактум: агент успевает войти в сектор, где летит пуля.
**Замер** (300 с, с A1 и A2):
| `laneReserve` | FRIENDLY_HITS | худший uptime на стоянке |
|---|---|---|
| 1.0 (буква спека) | 1 | 42.5 % |
| 1.4 | 0 | 50.0 % |
| **1.8** (принято) | 0 | 50.4 % |
### A5. Касательная составляющая обхода стен (`wallSlide`)
**Спек**: 6.7, слой 4 — «wallAvoidance + скольжение вдоль стен при коллизии».
**Сделано**: помимо скольжения при коллизии, `WallAvoidForce` добавляет
касательную силу в сторону цели, когда стена стоит между агентом и целью.
**Почему**. Чисто отталкивающее избегание даёт классический локальный минимум:
seek тянет вперёд, отталкивание держит — агент залипает в «тени» препятствия.
В прогонах это ловилось буквально: агент оставался в северной комнате
(`y=12`), когда отряд уже стоял на арене (`y=26`).
### A6. Агент без целей в радиусе возвращается в строй
**Спек**: случай не описан. 6.5 запускает решатель, когда линии нет, но не
говорит, что делать, если стрелять вообще не по чему.
**Сделано**: если решатель не нашёл ни одной цели в пределах
`weaponRange + ring3`, `postPos` = слот строя.
**Почему**. Иначе отставший агент стоит на месте вечно: в `HOLDING` он идёт
только к `postPos`, а `postPos` равен его же позиции.
### A7. Дуло считается в сторону цели, а не по `facing`
**Спек**: 6.3 — «`muzzle()` — точка в 0.3 юнита от центра агента по `facing`».
**Сделано**: для проверки линии и для спавна пули дуло считается по направлению
на цель; выстрел разрешён только когда ствол доведён до цели с точностью ±5°
(`aimToleranceDeg`).
**Почему**. `facing` доворачивается плавно и на шаг отстаёт от направления на
цель. Если проверять линию по одному направлению, а стрелять по другому, пуля
летит не по проверенному отрезку — то есть гарантия «не сквозь своих» перестаёт
быть гарантией. Сейчас проверенный отрезок и траектория пули — буквально один и
тот же отрезок.
### A8. Пуля не бьёт собственного стрелка
**Спек**: 6.9 — «коллизия со **всеми** капсулами, включая союзников».
**Сделано**: стрелок исключён из коллизий своей пули.
**Почему**. Дуло вынесено на 0.3 юнита при радиусе тела 0.28 — пуля стартует
почти вплотную к своему же телу. Самопопадание было бы вырожденным шумом в
`FRIENDLY_HITS`, главной метрике корректности. Все **остальные** капсулы,
включая союзников, проверяются как написано.
### A9. Северная кучка мишеней стоит напротив прохода
**Спек**: 4 — 12 мишеней кучками, конкретные места на усмотрение реализации.
**Сделано**: кучка из 3 мишеней в северной комнате поставлена напротив прохода
`x=30..31`, так что из коридора до неё есть чистая линия.
**Почему**. Спек называет узкий проход важным тест-кейсом, а критерий 11.6
требует, чтобы в проходе отряд вытягивался в колонну и стрелял только передний.
Без цели, простреливаемой сквозь проход, проверять нечего.
### A10. Вторичные цели в `HOLDING` при ручном прицеливании
**Спек**: 6.8 — флаг `freeFireSecondary` описан только для `ADVANCING`.
**Сделано**: буквально — в `HOLDING` агент без линии до назначенной цели молчит
и перестраивается решателем, по вторичным целям не стреляет.
**Почему**. Иначе агент нашёл бы вторичную цель, получил бы `hasLane`, и решатель
для него никогда бы не запустился — то есть требование «в `HOLDING`
перестраивается через решатель» не выполнялось бы.
### A11. Туман войны и освещение вопреки разделу 12
**Спек**: 12 — «не добавлять освещение, туман войны».
**Сделано**: добавлено и то, и другое.
**Почему**. Заказчик изменил требования: прототип разворачивается в хоррор, где
ограниченный обзор и темнота — не украшение, а основа подачи. Правила стрельбы
(раздел 6) при этом не тронуты ни одной строкой, критерии приёмки остались
зелёными. Устройство слоя — [10-atmosphere.md](10-atmosphere.md).
### A12. Конус зрения не влияет на ИИ
**Сделано**: у агента есть конус обзора для КАРТИНКИ, но выбор целей и стрельба
по-прежнему работают по LOS и дальности оружия.
**Почему**. Если завязать целеуказание на видимость, меняются все критерии
приёмки, а отряд начинает «слепнуть» от поворота ствола — при том что `LaneClear`
и так требует чистой линии, то есть отсутствия стены. Чтобы стрельба в темноту
не выглядела абсурдом, мишень показывается, если освещена **или** если по ней уже
работает кто-то из отряда.
### A13. Shift — рывок, а не медленный шаг
**Спек**: 5 — «Shift (удерж.) — медленный шаг».
**Сделано**: базовая скорость снижена с 3.2 до 2.0, а Shift даёт рывок 3.2.
**Почему**. Заказчик просил «менее казуальное управление, помедленнее». Медленный
шаг при уже медленной базе — мёртвая клавиша. Инверсия сохраняет обе скорости из
спека (2.0 и 3.2), но делает осторожность режимом по умолчанию, а спешку —
осознанным решением игрока. Побочный эффект: проверка спуска на время полёта пули
обязана считать по МАКСИМАЛЬНОЙ скорости (`MaxAgentSpeed()`), иначе рывок союзника
не учитывается и возвращаются попадания по своим.
### A14. Дальность оружия 9.0 -> 7.2
**Сделано**: минус 20 % по требованию заказчика; вместе с ней отмасштабированы
`minEngageDist` (2.0 → 1.6) и кольца решателя (0.8/1.6/2.4 → 0.7/1.4/2.1),
а старт отряда сдвинут в (26.5, 26.5) ближе к кучке мишеней.
**Замер**: без сдвига старта и масштабирования колец сокращение дальности само по
себе давало 1 попадание по своим за 300 с и просадку uptime — агенты сбивались в
кучу у цели. С пакетом целиком: 0 попаданий, uptime в стоячем бою 100 %.
### A15. `candMinAlly` 0.55 -> 0.60
Порог «кандидат слишком близко к союзнику» был **меньше диаметра тела**
(2 × 0.28 = 0.56), то есть допускал позиции, где агенты физически пересекаются.
### A16. Клинок союзникам урона не наносит вообще
**Спек**: ближнего боя не описывает.
**Сделано**: сектор удара повторяет правило 6.3 в угловой форме — свой в
секторе — не бить. Но урон применяется **только к мишеням**: даже влезший под
лезвие союзник не получает ничего, взмах просто обрывается.
**Почему**. Соблазн был сделать «сильную» версию: клинок бьёт всех, а от
попаданий по своим спасает дисциплина сектора. Но у клинка нет времени полёта,
за которое можно передумать: союзник входит в дугу за 1/60 с уже после того, как
взмах начат, и в этот момент отменять нечего. «Сильная» версия означала бы
`FRIENDLY_HITS > 0` в любой плотной свалке, то есть провал главного критерия
прототипа ради механики, которой в спеке нет.
Обрыв взмаха даёт то же самое ощущение (боец отдёргивает клинок, серая вспышка,
`MELEE_ABORT` в метриках) и при этом не может сломать критерий 11.2.
**Замер**: 81 взмах за 30 с в плотной свалке, `FRIENDLY_HITS = 0`,
16 взмахов оборвано.
### A17. Старт замаха смотрит на союзника вперёд
**Сделано**: `MeleeArcClear` проверяет союзника в двух точках — где он сейчас и
где будет через `windup + strike`. Ровно та же поправка, что A2 для пули.
**Почему**. Взмах живёт 0.30 с, за которые союзник проходит полметра. Проверять
только текущее положение — значит начинать удары, которые всё равно придётся
обрывать.
**Замер** (сценарий M7, 30 с): без поправки и без резерва дуги — 21 оборванный
взмах из 77; с поправкой и резервом — 16 из 81, попаданий 81 против 81, убийств
23 против 23. То есть взмахов стало больше при том же результате.
### A18. Резерв в реестре линий получил явную полуширину
**Сделано**: у `FireLane` появилось поле `reserve`. Линия огня по-прежнему берёт
ширину из правила 6.3, клинок задаёт свою — долю бокового размаха сектора
(`meleeReserveFrac` 0.55).
**Почему**. Клинок резервирует отрезок вдоль оси удара, а поражает сектор ±50°.
Союзник, отжатый строго перпендикулярно оси, попадал ровно во фланг дуги — то
есть механизм расступания работал против себя.
**Замер**: `meleeReserve` 0 → 0.55 дало 16 оборванных взмахов вместо 21 при
большем числе взмахов, а худший `FIRE UPTIME` на стоянке маршрута — 23.3 %
вместо 0 % (стрелки перестали стоять в дугах и молчать).
### A19. Жёсткая граница огневой позиции (`postLeash`)
**Спек**: 6.5 — цена за удаление от якоря.
**Сделано**: дополнительно кандидат дальше `cohesionRadius + postLeash` от якоря
отбраковывается совсем.
**Почему**. Цена — мягкая: линия огня стоит 100 очков, а уход на 1.5 юнита за
круг — 36. Решатель всегда выбирал линию, и отряд расползался.
**Замер**: максимум удаления стрелка от диска 3.65 → 2.81 юнита. При этом
`FIRE UPTIME` на 180-секундном маршруте не упал, а вырос: на стоянках у мишеней
82.9 / 93.2 / 90.7 / 86.0 % — плотный отряд перекрывает друг другу линии реже,
чем расползшийся.
### A20. Мишени — тела, и они получают отдачу
**Сделано**: слой движения `TargetPushForce` (мишень расталкивает агентов) и
импульс от удара с возвратом на исходное место (`Target::home`).
**Почему**. Без расталкивания боец стоит **внутри** того, кого рубит, и удар не
читается вообще. Без отдачи попадание в упор визуально не отличается от промаха.
Возврат на `home` обязателен: расстановка уровня захардкожена, и уползание мишеней
поломало бы все замеры разом. Респавн дополнительно ставит `pos = home`.
### A21. Критерий 11.6 считает только выстрелы вперёд
**Было**: тест требовал, чтобы в коридоре передний агент стрелял минимум вдвое
чаще хвостовых, и считал ВСЕ выстрелы внутри прохода.
**Стало**: считаются только выстрелы **по ходу движения**. Выстрелы назад
выводятся в отчёт отдельно.
**Почему**. Диагностика `DIAG` показала, что почти все «нарушения» — это
хвостовой агент, стреляющий назад, в оставленную позади комнату, по совершенно
другой цели. Впереди по такой линии никого из отряда нет по построению, и к
правилу «не стрелять сквозь своего» это отношения не имеет. Порог ловил не
поведение, а геометрию расстановки мишеней.
**Замер**: на прежней базе тюнинга новый счёт даёт 4/0 (было 12/4), на текущей —
5/0. То есть критерий стал строже, а не мягче: вперёд хвост не стреляет вообще.
---
## B. Структурные (поведение не меняют)
### B1. Дополнительные файлы
Спек перечисляет структуру проекта; добавлены четыре файла:
| Файл | Зачем |
|---|---|
| `game.h/.cpp` | склейка подсистем и порядок шага. Без него это переехало бы в `main.cpp`, а спек требует, чтобы там были только окно и цикл |
| `render/scene.h/.cpp` | проход по полу и список сортировки; отдельно от `iso.h`, который остаётся чистой математикой |
| `core/input.h` | `InputState` нужен и `main.cpp`, и симуляции, и headless-прогонам |
| `debug/harness.h/.cpp` | прогоны `--headless` / `--accept` |
| `sim/loadout.h` | класс оружия и его параметры; сюда приедут разные пушки и апгрейды |
| `ai/melee.h/.cpp` | ближний бой; зеркало `firing_solver` для угловой геометрии |
### B2. У `Sprite` есть точка привязки
Спек: `Sprite { int w, h; std::vector<uint32_t> px; }`. Добавлены поля `ax, ay`
точка внутри спрайта, совмещаемая с проекцией мировой позиции. Иначе смещения
пришлось бы размазывать по коду отрисовки для каждого типа спрайта.
### B3. Параметры оружия синхронизируются из `Tuning` каждый шаг
`Agent::weapon` существует как в спеке, но перед движением поля копируются из
`g_tune` (`SyncLoadoutFromTuning`). Без этого runtime-подкрутка (раздел 8 спека)
не влияла бы на уже созданных агентов. Состояние ближнего боя — фаза, таймеры,
цель — при этом не трогается: копируются только параметры.
### B4. Dev-инструменты
`--headless`, `--accept`, `--probe`, `--set`, `--shot`, тумблеры `--no-solver` /
`--no-laneavoid`. В спеке их нет; они не влияют на игровую сборку и нужны, чтобы
критерии приёмки из раздела 11 проверялись цифрами, а не на глаз.
---
## Что осталось честно неидеальным
- `FIRE UPTIME` считает «нет живой цели в радиусе» простоем. На маршруте между
комнатами это даёт низкие значения, которые к качеству ИИ отношения не имеют.
- `SETTLE TIME` по той же причине растёт, если отряд остановился там, где стрелять
не по чему; в общем прогоне попадаются значения до 5.7 с. В обстановке, к
которой относится критерий 11.1 (мишени в радиусе), — 0.02 с.
- Пути агентов чисто локальные, без поиска пути. Отряд проходит узкие коридоры,
но лабиринт с тупиками не осилит — это вне рамок прототипа.