Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (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 17:00:49 +07:00
|
|
|
|
# 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 и
|
|
|
|
|
|
отталкивание уравновешиваются, и агент залипает в «тени» препятствия — это
|
|
|
|
|
|
реально ловилось в прогонах (агент оставался в комнате, когда отряд ушёл).
|
|
|
|
|
|
|
Поле путей: отставший возвращается в строй (M37)
Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил.
Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец,
оказавшийся за стеной от своего слота, 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>
2026-08-18 22:31:34 +07:00
|
|
|
|
Скорость 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 (M39)
Требование к строю задано сравнением — вести пятерых должно быть сопоставимо с
ведением одного, — и меряться должно так же. Зонд считает три числа в пустом
поле и без боя: старт (от нажатия до половины скорости хода), разворот и хвост
строя, для одиночки и для пятёрки.
Сейчас: один 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 роняет обе проверки.
Первая версия зонда меряла мусор: отряд гнали по карте приёмки, где он через
семь тайлов упирался в стену, а «поехал» считалось по ВСЕМ бойцам сразу, поэтому
разворот не засчитывался никогда. Меряем в пустом поле и по центру масс.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:47:34 +07:00
|
|
|
|
**Отзывчивость** меряется отдельно (`--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` роняет обе
|
|
|
|
|
|
проверки.
|
|
|
|
|
|
|
Поле путей: отставший возвращается в строй (M37)
Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил.
Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец,
оказавшийся за стеной от своего слота, 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>
2026-08-18 22:31:34 +07:00
|
|
|
|
**Замеры** (`--play 6 5`, восемь сидов):
|
|
|
|
|
|
|
|
|
|
|
|
| Сборка | Этажей пройдено |
|
|
|
|
|
|
|---|---|
|
|
|
|
|
|
| до поля путей | 0 из 48 — отряд вставал на первом этаже |
|
|
|
|
|
|
| поле путей | 47 из 48 (единственная потеря — вайп в бою, не затык) |
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (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 17:00:49 +07:00
|
|
|
|
|
|
|
|
|
|
## 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` на том же спуске.
|