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