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

25 KiB
Raw Blame History

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.

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