Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил. Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец, оказавшийся за стеной от своего слота, 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>
21 KiB
05 — Ядро: поведение отряда
Главный файл проекта. Всё здесь — чистые функции над состоянием мира, без рендера и изометрии: src/ai/firing_solver.cpp, src/ai/lane_registry.cpp, src/sim/squad.cpp.
Этот файл описывает поведение стрелка. Боец ближнего боя живёт по тем же правилам в угловой форме — 11-melee.md.
Состояния отряда
ADVANCING— есть ввод движения.HOLDING— ввода нет дольшеholdDelay(0.15 с).
Состояние общее для отряда, копируется в каждого агента. При смене состояния
сбрасываются commitTimer, а при входе в HOLDING каждому агенту ставится
postPos = его текущая позиция: отряд встал там, где встал, дальше его
расставляет решатель.
1. Проверка линии огня — LaneClearFrom
Сердце всей механики. Порядок проверок ровно как в спеке 6.3:
muzzle = from + normalize(target - from) * muzzleOffset // 0.3 юнита
1. свои: для каждого союзника (кроме стрелка)
если DistancePointToSegment(ally.pos, muzzle, target)
< ally.bodyRadius + laneHalfWidth + safetyMargin -> нет линии
2. геометрия: если DDA-рейкаст muzzle->target упёрся в стену -> нет линии
3. дистанция: если |muzzle - target| > weaponRange -> нет линии
Порог по умолчанию: 0.28 + 0.10 + 0.15 = 0.53 юнита.
Функция принимает произвольную точку from, а не только позицию агента —
поэтому ей же оценивается «а если я встану вот сюда» в решателе.
Дуло считается в сторону цели, а не по текущему facing. Это важно: пуля
вылетает из той же точки и в том же направлении, что проверялось.
2. Выбор цели
EvaluateAgentLane — один проход на агента за шаг:
- Авто-режим: ближайшая живая мишень в радиусе, до которой линия чиста.
- Ручной режим (спек 6.8): сначала назначенная цель. Если до неё линии нет:
- в
HOLDINGагент молчит и перестраивается решателем (цель решателя = назначенная); - в
ADVANCINGстреляет по вторичным целям, только еслиfreeFireSecondary(по умолчанию да).
- в
Побочно заполняются флаги для оверлея: targetBlockedByAlly, targetBlockedByWall,
и laneIntent — «цель есть, мешает только свой» (см. пункт 4).
3. Решатель огневых позиций (HOLDING)
SolveFiringPosition. Запускается не каждый кадр: у каждого агента свой
solveTimer с интервалом 0.25 с, фазы разведены при старте так, что за кадр
решает максимум один агент.
Условия запуска: состояние HOLDING, commitTimer == 0, и у агента нет
линии к приоритетной цели.
Кандидаты: текущая позиция (индекс 0, для сравнения) + 3 кольца радиусами 0.8 / 1.6 / 2.4 × 12 направлений = 37 точек.
Отбраковка: вне карты, в стене, ближе 0.55 юнита к другому агенту. Текущая позиция не отбраковывается никогда — она база сравнения.
Оценка (ScoreCandidate, все веса в tuning.h):
+100 если из кандидата чиста линия до основной цели
+25 * (простреливаемых целей / всего живых целей)
-35 * (число союзников, чью текущую линию перекрывает кандидат)
- 9 * расстояние(кандидат, текущая позиция)
-24 * max(0, расстояние(кандидат, якорь) - cohesionRadius 2.1)
-60 если ближайшая цель ближе minEngageDist 1.6
Жёсткая граница: кандидат дальше cohesionRadius + postLeash (2.1 + 1.0) от
якоря отбраковывается совсем. Одной цены не хватает — линия огня стоит 100 очков,
и за неё решатель готов уйти сколько угодно далеко. Замер: без жёсткой границы
стрелок отходил от диска на 3.65 юнита, с ней — на 2.81, а FIRE UPTIME на
маршруте вырос, а не упал (отряд плотнее — линий больше).
Коммит с гистерезисом — без него агент дёргается:
если bestScore > currentScore + 15:
postPos = лучший кандидат
commitTimer = 0.6 // это время не перерешивать
Пока commitTimer > 0, агент просто идёт к postPos.
Если целей в досягаемости нет вообще, решателю не над чем работать — тогда
postPos = слот строя, чтобы отставший агент возвращался к отряду
(случай, которого нет в спеке, см. 08-decisions.md).
4. Резервирование линий и расступание
src/ai/lane_registry.cpp. Каждый шаг собирается список активных линий — отрезков «дуло → цель». В реестр попадают:
- агенты с чистой линией (реально стреляющие);
- агенты, чью линию перекрыл только свой (
laneIntent).
Второй пункт — важное уточнение против буквального текста спека. Если регистрировать только реально стреляющих, то стоит кому-то влезть в линию — у стрелка линия пропадает, отрезок исчезает из реестра, и перекрывшего ничто не выталкивает. Сектор обстрела не освобождается никогда. Подробно и с цифрами — 08-decisions.md.
Импульс: агент, попавший в чужую линию, получает силу перпендикулярно линии
в сторону ближайшего края; сила растёт при приближении к оси
((1 - d/threshold) * laneAvoidStrength, с насыщением до 1).
Порог резерва шире, чем порог «тело в линии» из 6.3, в laneReserveScale раз
(по умолчанию 1.8). Причина — там же в 08-decisions: резерв должен отжимать
агента до того, как он влез под уже летящую пулю.
Работает в обоих состояниях отряда. Это и даёт главное ощущение: отряд «расплывается» сам, без единой явной команды.
5. Движение агента — слои
Squad::UpdateMovement, порядок из спека 6.7:
| # | Слой | Вес | Файл |
|---|---|---|---|
| 1 | seek к цели (слот строя или postPos), с торможением у цели |
wSeek 1.0 |
agent.cpp |
| 2 | separation от союзников, радиус 0.7 |
wSeparation 1.0 |
agent.cpp |
| 3 | laneAvoidance — расступание из чужих линий и секторов |
wLaneAvoid 2.4 |
lane_registry.cpp |
| 3′ | targetPush — мишень тоже тело, а не пустое место |
wTargetPush 1.0 |
agent.cpp |
| 4 | wallAvoidance + касательное скольжение |
wWallAvoid 1.2 |
agent.cpp |
| 5 | клампинг скорости, интеграция, разрешение коллизий | — | agent.cpp |
Вес расступания намеренно выше веса separation — так требует спек.
Слой 3′ добавлен вместе с ближним боем: без него боец стоял внутри того, кого рубит. Стрелков он тоже касается — они перестали проходить сквозь мишени.
Боец ближнего боя в активном окне удара весь этот стек пропускает: он едет по оси удара со своей скоростью и своим ускорением, остаются только стены.
Слой 4 не только отталкивает от стены, но и добавляет касательную составляющую в сторону цели, когда стена стоит между агентом и целью. Без этого seek и отталкивание уравновешиваются, и агент залипает в «тени» препятствия — это реально ловилось в прогонах (агент оставался в комнате, когда отряд ушёл).
Скорость 2.6 юнита/с, 4.4 на пределе натяжения. Скорость подтягивается к
желаемой с ограничением steerAccel, чтобы резкие развороты не телепортировали
агента.
5b. Поле путей: как отставший возвращается в строй
Слои выше — локальные, и этого хватает ровно до первого угла. Боец, оказавшийся
за стеной от своего слота, seek'ом тянулся В СТЕНУ, слой 4 толкал его обратно, и
он замирал в углу навсегда. Снаружи это выглядело как «пятеро не проходят
там, где одиночка проходит»: --play 6 5 вставал на первом же этаже на всех
восьми сидах. Ширина коридоров тут ни при чём — отряд вставал и на проходах в
три, четыре и пять клеток.
Устройство (Squad::RebuildNav, Squad::NavWaypoint, sim/squad.cpp):
- BFS по проходимым клеткам от клетки цели, четыре соседа (по диагонали круглое тело в щель между углами не пролезает);
- боец спускается по градиенту и берёт самую дальнюю точку пути, которую ещё видит — натянутая верёвка; идёт он по ней как по прямой;
- поле у каждого своё. Одно общее поле до якоря сводило всех обходящих в одну точку, и отряд входил в проход лужей вместо колонны.
Включается обход по ДВУМ условиям сразу:
| Условие | Зачем |
|---|---|
stuckFor > navStuckTime (0.35 с) — хочет идти и не едет |
обход нужен тому, кто реально упёрся |
отстал от слота дальше straggleDist (2.8) |
боец на своём месте не потерялся, его держит бой |
Счётчик stuckFor сбрасывается не по факту «поехал», а по факту «цель
видно». Разница принципиальная: сброс по скорости давал дребезг — обход трогал
бойца с места, поле выключалось, боец упирался в ту же стену, и так по кругу со
средней скоростью шаг за две секунды.
Платим только когда обход реально включён: поле пересчитывается на смену клетки цели и только у того, кто сейчас идёт в обход. Средний шаг симуляции — 6.1 мкс при бюджете кадра 16666 мкс.
Замеры (--play 6 5, восемь сидов):
| Сборка | Этажей пройдено |
|---|---|
| до поля путей | 0 из 48 — отряд вставал на первом этаже |
| поле путей | 47 из 48 (единственная потеря — вайп в бою, не затык) |
5a. Диск и поводок
Якорь («диск», за которым идёт камера) движется быстрее бойцов: 3.3 против
1.75, на Shift 4.8 против 2.7. Это отвечает за отзывчивость: старт и разворот
читаются мгновенно, а не через полсекунды инерции пятерых.
Само по себе это ломало бы отряд — диск просто уезжал бы вперёд, слоты строя за ним, и агенты вечно бежали бы в хвосте. Поэтому есть поводок, и он упругий, а не жёсткий:
| Параметр | Смысл |
|---|---|
anchorLeash (2.4) |
мягкая граница: до неё диск идёт свободно |
anchorLeashMax (3.6) |
жёсткий предел: страховка, дальше не пускает вообще |
leashDrag (0.75) |
между границами диск тяжелеет; на упоре остаётся 25 % скорости |
Жёсткий поводок здесь не годился принципиально: он мгновенно упирается,
дистанция намертво встаёт в одно значение, и по ней уже нельзя отличить
«подправил направление» от «тяну отряд вперёд изо всех сил». Упругий поводок
делает дистанцию непрерывной величиной — а из неё считается натяжение
(urgency), которое разгоняет отряд, растягивает строй и на верхней трети шкалы
(sprintAt) прекращает огонь вовсе.
Итог: «быстро» — это отзывчивость на старте и на развороте, а средняя скорость
похода всё равно определяется ногами отряда. Порядок важен: сдвиг → сопротивление
→ жёсткий предел → ResolveCircle по стенам.
Равновесие поводка, или почему отряд идёт с боем
Диск быстрее отряда, поэтому при зажатом направлении дистанция растёт, пока сопротивление поводка не сравняет скорости. Это равновесие — рабочий режим игры, и считать его надо явно:
pull = (dist - anchorLeash*urgeStartFrac) / (anchorLeashMax - anchorLeash*urgeStartFrac)
speed = lerp(moveSpeed, moveSpeedFast, urgency) отряд
anchor = anchorSpeed * max(0.08, 1 - leashDrag*over) диск
На текущих числах равновесие садится на dist ≈ 3.4, то есть urgency ≈ 0.55
и скорость отряда ≈ 2.8 юнита/с — ниже порога sprintAt (0.85). Значит,
удерживая направление, игрок ведёт отряд быстрым шагом, и отряд при этом
продолжает работать по целям: и стрелки, и клинки.
Спринт остаётся отдельным решением: либо Shift (прямой приказ), либо
ситуация, когда курсор реально уехал. И только в спринте огонь прекращается.
Раньше порог стоял на 0.62, равновесие было выше него, и любое удержание клавиши означало вечный бег без стрельбы. Это и ощущалось как «милишники не работают».
6. Строй
Три пресета, локальные координаты (x = вперёд, y = вправо), множатся на
formationSpacing и поворачиваются по направлению движения отряда:
- клин: вершина впереди, двое по бокам, двое сзади шире;
- линия: пятеро в ряд поперёк движения;
- кольцо: пятеро по окружности радиуса 1.5 × spacing.
В HOLDING слоты не используются — агент идёт к postPos от решателя.
Первые meleeCount слотов держат клинок (по умолчанию 2). В клине это остриё и
левое крыло — те, кто и так упирается в противника первым. Боец ближнего боя
идёт к postPos в обоих состояниях отряда: иначе клинок был бы полезен
только на стоянке, а нужен он как раз в движении.
7. Огонь
Game::FireWeapons. Выстрел происходит, только если выполнено всё:
- есть цель и чистая линия (
hasLane); - истёк
cooldown(0.35 с); - ствол доведён до цели (±5°) — иначе выстрел выглядел бы враньём;
LaneClearFromподтверждает линию по позициям этого шага (повторно, «никаких исключений»);AllyInterceptsShot— за время полёта пули свой не окажется на её пути.
Пункт 5 — добавление к спеку, вынужденное тем, что 6.3 проверяет линию в момент выстрела, а 6.9 делает пулю настоящим снарядом с временем полёта до 0.4 с. Обоснование и замеры — 08-decisions.md.
8. Пули
friendlyFire = true всегда: пуля сталкивается со стенами, мишенями и со всеми
капсулами, включая союзников (кроме самого стрелка). За шаг ищется самое раннее
столкновение вдоль пройденного отрезка, чтобы стена перед союзником срабатывала первой.
Пуля живёт ровно столько, чтобы пройти проверенный отрезок (дуло → цель, плюс радиус мишени). Дальше она не летит: за целью пространство никто не проверял, и если цель успела умереть от чужой пули, снаряд ушёл бы в непроверенную зону.
Счётчик попаданий по своим и журнал событий (время, стрелок, жертва, место, состояние жертвы) — в 06-debug-and-tuning.md.
9. Ручное прицеливание
Space включает режим, ←/→ крутят вектор прицела от якоря, Tab циклит
цели в конусе ±20°, отсортированные по дальности от якоря. Все агенты
приоритезируют назначенную цель.
Правила 1–8 при этом не меняются вообще. Ручной режим никогда не позволяет
стрелять сквозь своих — тот же LaneClearFrom на том же спуске.