Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил. Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец, оказавшийся за стеной от своего слота, seek'ом тянулся В СТЕНУ, обход стены толкал его обратно, и он замирал в углу навсегда. Теперь у каждого бойца своё поле путей (BFS от клетки его цели) с натягиванием верёвки: он берёт самую дальнюю точку пути, которую видит. Включается обход по двум условиям сразу — боец упёрся (хочет идти и не едет) И потерял строй. Счётчик упора сбрасывается по факту «цель видно», а не «поехал»: сброс по скорости давал дребезг, и боец полз шаг за две секунды. По дороге вскрылись две неверные МЕРКИ: * критерий 11.6 считал колонной «разброс вдоль больше, чем поперёк» — и потому награждал отряд, у которого двое навсегда остались по ту сторону стены. Теперь колонна меряется самим проёмом: разом в горловине не больше двух, и прошли ВСЕ. * зонд --wall читал «стены не нашлось» (ноль) как «стена на нулевом этаже» и объявлял пятёрку, не сломавшуюся нигде, худшей из трёх. Обе переписаны и проверены на сломанной сборке. Замеры: --play 6 5 на восьми сидах 47/48 (было 0/48), --accept PASS, шаг 6.1 мкс, --wall 30 одиночка D20 / трое D20 / пятеро D31 (не найдена), --descend 8/30, hollow --descend 6, --ui-check, оба проекта --check — без ошибок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
28 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. То есть критерий стал строже, а не мягче: вперёд хвост не стреляет вообще.
A22. Критерий 11.6: «колонна» меряется проёмом, а не разбросом
Было: колонной считалось «разброс вдоль движения больше, чем поперёк, в полтора раза» — по бойцам в створе прохода вместе с подходами.
Стало: колонна — это про сам проём. Одновременно в горловине не больше двух (проём шириной два тайла), и через неё прошли все живые бойцы.
Почему. Старая мерка награждала ровно ту поломку, ради которой писалась. Отряд, у которого двое навсегда остались по ту сторону стены (у них не было поиска пути и вернуться они не могли), растянут вдоль на шесть тайлов — и проверку ПРОХОДИЛ. Тот же отряд, когда отставшие научились обходить стену и собираться, стал компактным — и проверку ронял. Замер трасс показал это прямо: без обхода бойцы 3 и 4 все 26 секунд стоят на y≈16.3 и y≈16.9, южнее стены.
Замер: на исправленной сборке — прошли 5 из 5, в горловине разом до 2,
FRIENDLY_HITS=0, сквозь своего 0. На сборке без обхода (--set navStuckTime=99)
критерий падает: прошли 3 из 5. То есть мерка стала строже: раньше она
пропускала брошенных за стеной, теперь нет.
A23. Зонд --wall: «стены не нашлось» — это не «стена на нулевом этаже»
Было: глубина первой стены хранилась нулём как «ещё не найдена», и итог
сравнивал wall[пятеро] <= wall[одиночка] буквально.
Стало: не найденная стена читается как «глубже последней проверенной»
(maxDepth + 1) и печатается как D31 (не найдена).
Почему. Пятёрка перестала ломаться на всех проверяемых глубинах — то есть показала лучший возможный результат, — и зонд объявил это провалом: ноль меньше двадцати. Ошибка мерки, а не игры, того же рода, что и A22.
Замер: --wall 30 — одиночка D20, трое D20, пятеро D31 (не найдена),
код возврата 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 с.- Поиск пути включается ТОЛЬКО для отставшего, который упёрся (см. 05-squad-ai.md, «поле путей»). Боец в строю по-прежнему ходит локальными слоями, и это осознанно: строй должен вести себя как строй, а не как пять независимых искателей пути.