squad-proto/docs/05-squad-ai.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

18 KiB
Raw Blame History

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 и отталкивание уравновешиваются, и агент залипает в «тени» препятствия — это реально ловилось в прогонах (агент оставался в комнате, когда отряд ушёл).

Скорость 1.75 юнита/с, 2.7 на Shift. Скорость подтягивается к желаемой с ограничением steerAccel, чтобы резкие развороты не телепортировали агента.

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.

8. Пули

friendlyFire = true всегда: пуля сталкивается со стенами, мишенями и со всеми капсулами, включая союзников (кроме самого стрелка). За шаг ищется самое раннее столкновение вдоль пройденного отрезка, чтобы стена перед союзником срабатывала первой.

Пуля живёт ровно столько, чтобы пройти проверенный отрезок (дуло → цель, плюс радиус мишени). Дальше она не летит: за целью пространство никто не проверял, и если цель успела умереть от чужой пули, снаряд ушёл бы в непроверенную зону.

Счётчик попаданий по своим и журнал событий (время, стрелок, жертва, место, состояние жертвы) — в 06-debug-and-tuning.md.

9. Ручное прицеливание

Space включает режим, / крутят вектор прицела от якоря, Tab циклит цели в конусе ±20°, отсортированные по дальности от якоря. Все агенты приоритезируют назначенную цель.

Правила 18 при этом не меняются вообще. Ручной режим никогда не позволяет стрелять сквозь своих — тот же LaneClearFrom на том же спуске.