# 05 — Ядро: поведение отряда Главный файл проекта. Всё здесь — чистые функции над состоянием мира, без рендера и изометрии: [src/ai/firing_solver.cpp](../src/ai/firing_solver.cpp), [src/ai/lane_registry.cpp](../src/ai/lane_registry.cpp), [src/sim/squad.cpp](../src/sim/squad.cpp). Этот файл описывает поведение **стрелка**. Боец ближнего боя живёт по тем же правилам в угловой форме — [11-melee.md](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](08-decisions.md)). ## 4. Резервирование линий и расступание [src/ai/lane_registry.cpp](../src/ai/lane_registry.cpp). Каждый шаг собирается список активных линий — отрезков «дуло → цель». В реестр попадают: - агенты с чистой линией (реально стреляющие); - **агенты, чью линию перекрыл только свой** (`laneIntent`). Второй пункт — важное уточнение против буквального текста спека. Если регистрировать только реально стреляющих, то стоит кому-то влезть в линию — у стрелка линия пропадает, отрезок исчезает из реестра, и **перекрывшего ничто не выталкивает**. Сектор обстрела не освобождается никогда. Подробно и с цифрами — [08-decisions.md](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 мкс. **Отзывчивость** меряется отдельно (`--respond`), и требование к ней задано сравнением: вести пятерых должно быть сопоставимо с ведением одного. | Отряд | старт | разворот | хвост | |---|---|---|---| | один | 0.13 с | 0.60 с | 2.0 | | пятеро | 0.12 с | 0.58 с | 2.9 | Пятеро против одного: старт x0.87, разворот x0.97 при пределе x1.50. Сверх сравнения стоит абсолютный потолок (0.30 с и 1.20 с): без него зонд пропустил бы вязкость, одинаковую для одного и для пятерых — отношение осталось бы единицей, а управление стало бы киселём. Проверено: `--set steerAccel=3` роняет обе проверки. **Замеры** (`--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`. Выстрел происходит, только если выполнено **всё**: 1. есть цель и чистая линия (`hasLane`); 2. истёк `cooldown` (0.35 с); 3. ствол доведён до цели (±5°) — иначе выстрел выглядел бы враньём; 4. `LaneClearFrom` подтверждает линию по позициям этого шага (повторно, «никаких исключений»); 5. `AllyInterceptsShot` — за время полёта пули свой не окажется на её пути. Пункт 5 — добавление к спеку, вынужденное тем, что 6.3 проверяет линию в момент выстрела, а 6.9 делает пулю настоящим снарядом с временем полёта до 0.4 с. Обоснование и замеры — [08-decisions.md](08-decisions.md). ## 8. Пули `friendlyFire = true` всегда: пуля сталкивается со стенами, мишенями и **со всеми капсулами, включая союзников** (кроме самого стрелка). За шаг ищется самое раннее столкновение вдоль пройденного отрезка, чтобы стена перед союзником срабатывала первой. Пуля живёт ровно столько, чтобы пройти **проверенный** отрезок (дуло → цель, плюс радиус мишени). Дальше она не летит: за целью пространство никто не проверял, и если цель успела умереть от чужой пули, снаряд ушёл бы в непроверенную зону. Счётчик попаданий по своим и журнал событий (время, стрелок, жертва, место, состояние жертвы) — в [06-debug-and-tuning.md](06-debug-and-tuning.md). ## 9. Ручное прицеливание `Space` включает режим, `←`/`→` крутят вектор прицела от якоря, `Tab` циклит цели в конусе ±20°, отсортированные по дальности от якоря. Все агенты приоритезируют назначенную цель. Правила 1–8 при этом **не меняются вообще**. Ручной режим никогда не позволяет стрелять сквозь своих — тот же `LaneClearFrom` на том же спуске.