squad-proto/docs/11-melee.md

223 lines
18 KiB
Markdown
Raw Normal View History

Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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
# 11 — Ближний бой
Второй класс оружия. Файлы: [src/sim/loadout.h](../src/sim/loadout.h) (типы и
профили), [src/ai/melee.cpp](../src/ai/melee.cpp) (цель, подход, дисциплина
сектора), `Game::UpdateMelee` в [src/game.cpp](../src/game.cpp) (машина
состояний и урон), [src/render/fx.cpp](../src/render/fx.cpp) (дуга).
## Главная мысль
Ближний бой — это **то же правило, что и стрельба, в угловой форме**. Ничего
нового в этику отряда он не приносит:
| | стрелок | боец ближнего боя |
|---|---|---|
| зона поражения | отрезок «дуло → цель» | сектор `reach` × `arcDeg` вокруг оси удара |
| правило безопасности | свой в отрезке → не стрелять | свой в секторе → не бить |
| проверка вперёд | `AllyInterceptsShot` (успеет ли влезть за время полёта) | экстраполяция союзника на `windup + strike` |
| срыв в последний момент | пуля уже летит, срывать нечего | взмах **обрывается** |
| резерв в реестре линий (6.6) | отрезок до цели | ось удара с явной полушириной |
| свой круг вокруг диска | `cohesionRadius` | `meleeCohesionRadius`, заметно больший |
| выбор позиции | решатель огневых позиций | подход вплотную к цели |
Из этого следует главное свойство: **клинок физически не может поднять
`FRIENDLY_HITS`**. Союзникам он урона не наносит вообще, а правило сектора
существует не ради урона, а ради того, чтобы отряд выглядел как отряд.
## Состав отряда
`Squad::meleeCount` (по умолчанию `DEFAULT_MELEE_COUNT` = 2) — сколько **первых
слотов строя** держат клинок. В клине это остриё и левое крыло, то есть те, кто
и так упирается в противника первым. Ставится до `Squad::Init`; из командной
строки — `--melee N`.
## Три фазы удара
```
IDLE ──цель в reach, корпус довёрнут, сектор чист──> WINDUP ──> STRIKE ──> RECOVER ──> IDLE
0.17 с 0.13 с 0.24 с
интервал между НАЧАЛАМИ замахов: 0.60 с
```
- **WINDUP** — замах. Ось удара уже зафиксирована и больше не доворачивается:
доворот на этой фазе — это самонаведение, только клинком. Скорость ×0.20,
ноги вкопаны. Игрок обязан успевать читать замах, иначе удар выглядит не
ударом, а случайным списанием HP.
- **STRIKE** — активное окно. Лезвие метёт сектор от одного края к другому,
сторона захода чередуется от взмаха к взмаху. Здесь же **рывок**: seek,
separation и расступание молчат, боец едет по оси удара со своей скоростью
и своим ускорением. Стены — единственное, что его останавливает.
- **RECOVER** — боец открыт и медленный (×0.55).
## Связка ударов
Чередование «влево-вправо» — это не связка, а метроном: два удара, между которыми
нет разницы ни на глаз, ни по последствиям. Связка обязана иметь **форму**.
Таблица — `MELEE_COMBO` в [src/tuning.h](../src/tuning.h), множители к базовым
характеристикам клинка:
| # | Удар | Сектор | Урон | Отброс | Замах | Пауза | Смысл |
|---|---|---|---|---|---|---|---|
| 1 | `slash` | ×1.00 | ×1.00 | ×1.00 | ×1.00 | ×1.00 | косой заход, с него всё начинается |
| 2 | `backhand` | ×0.85 | ×1.00 | ×0.85 | ×0.55 | ×0.50 | возврат с обратной стороны, быстро и почти без паузы |
| 3 | `spin` | ×1.70 | ×1.75 | ×2.30 | ×1.50 | ×1.35 | раскрутка: широкая, тяжёлая, с долгой расплатой |
Серия держится `meleeComboWindow` (1.1 с) после удара. Не продолжил вовремя —
начинаешь с первого. Итог по времени: три удара за 1.71 с вместо трёх за 1.80 с,
но суммарный урон выше в 1.25 раза — связка выгоднее ровного метронома, если её
успевают довести.
Раскрутку в тесноте **просто не дадут начать**: её сектор проверяется той же
дисциплиной, что и обычный удар, а он шире почти вдвое. Это не ограничение, а
цена: широкий замах требует места. В замере связки: `slash 21 / backhand 19 /
spin 15` — финиш доходит реже, и это видно в цифрах, а не на словах.
Параметры взмаха **запекаются на старте замаха** (`MeleeWeapon::BakeSwing`).
Причина техническая и важная: базовые поля переписываются со снаряжения каждый
шаг (`SyncLoadout`), и без запекания удар менял бы ширину и урон прямо в полёте.
## Удар на ходу
Бой на ходу — нормальный режим, а не исключение. Ограничение ровно одно: в
**спринте** боец не гоняется за целями (`Squad::GoalFor` возвращает слот строя),
но если цель сама оказалась под рукой — бьёт. Отдельного запрета «на бегу не
драться» нет.
При этом на текущих числах поводка удержание направления спринт и не включает:
отряд идёт быстрым шагом и работает (см. [05-squad-ai.md](05-squad-ai.md),
равновесие поводка).
## Урон идёт по пройденной полосе, а не по факту удара
Каждый шаг считаются два угла лезвия: где оно было в начале шага и где стало в
конце. Цель получает урон, если полоса шириной `meleeBladeDeg` прошла через неё
**за этот шаг** (`BladeSweepHits`). Проверяется и «стоит на цели прямо сейчас»,
и «проскочило насквозь за 1/60 с» — за шаг дуга проходит заметный угол.
### Почему это точная геометрия, а не угловой допуск
Лезвие — **отрезок** от бойца длиной `reach`, повернувшийся за шаг. Проверка
работает с ним напрямую: попало ли направление на цель в заметённую полосу, а
если нет — не задел ли цель край отрезка в крайних положениях.
Раньше здесь стоял угловой допуск `asin(r / dist)`. Он корректно описывает
угловой размер тела, но как **допуск к сектору** вырождается: при `dist -> r`
`asin` даёт 90°, сектор разрастается в полуплоскость, и боец рубит за спину.
В фазе рывка он как раз проезжает вплотную — отсюда и брались удары «на 360
градусов». Отрезок такого не допускает: для цели позади ближайшая точка лезвия —
рукоять, а до неё ровно `dist`, что больше радиуса цели.
Инвариант закреплён критерием **M7.4**: замеряется, на сколько градусов
попадание вышло за разрешённый сектор ЭТОГО взмаха. Норма — ноль.
Смысл: то, что видно на экране, и то, что получило урон, — одна и та же дуга.
Разовый расчёт «в начале взмаха по всему сектору» дал бы попадания раньше, чем
лезвие туда доедет, и это чувствуется.
Одна мишень получает урон **один раз за взмах** (`hitMask`), но один взмах может
задеть несколько — мишени стоят кучками, и это ровно то, ради чего клинок нужен.
Сквозь стену не рубит: перед уроном идёт `RaycastBlocked`.
## Дисциплина сектора
Проверяется дважды, как и у ствола:
1. **на старте замаха** — сектор `reach` × `arcDeg` обязан быть чист. Союзник
проверяется в двух точках: где он сейчас и где будет через `windup + strike`
(`meleeStartPredict`). Без этого треть взмахов начиналась впустую;
2. **каждый шаг активного окна** — если свой всё-таки влез под лезвие, взмах
**обрывается**: серая вспышка, `MELEE_ABORT`, сразу в `RECOVER`.
Оборванные взмахи считаются метрикой (`meleeAborts`) и видны в оверлее. Это не
баг, а видимая цена скученности: примерно каждый пятый взмах в плотной свалке.
Угловой размер тела союзника считается честно (`asin(r / dist)`), поэтому
вплотную союзник перекрывает почти весь сектор — так и должно быть.
## Свой, больший круг
Стрелок держится в `cohesionRadius` (2.1) от диска, и решатель **жёстко**
отбраковывает огневые позиции дальше `cohesionRadius + postLeash`. Боец ближнего
боя берёт цель, только если она внутри `meleeCohesionRadius` (5.6) от диска и не
дальше `meleeSeekRadius` от него самого. Нет такой цели — возвращается в слот
строя.
Поэтому классы живут в двух разных кругах вокруг одного диска: стрелки кучкой,
клинки — на вылазке, но на поводке. Замеряется критерием M7.3.
Двое с клинками не вцепляются в одну мишень: цена цели растёт на
`meleeClaimPenalty` за каждого своего, уже её выбравшего. Ближняя цель всё ещё
выигрывает, но не любой ценой — вдвоём на одну это оборванные взмахи друг об
друга и вдвое меньше урона (замер: 37 взмахов / 31 попадание против 74 / 75).
## Мишени получают отдачу
Удар отбрасывает мишень (`meleeKnockback`), импульс гасится и мишень возвращается
на своё место (`home`). Без отдачи попадание в упор ничем не отличается от
промаха. Расстановка уровня захардкожена, поэтому «уползание» мишеней исключено
конструктивно: возврат идёт всегда, а респавн ставит `pos = home`.
Отдельно появился слой движения `TargetPushForce`: мишень — тело, а не пустое
место. Без него боец стоял **внутри** того, кого рубит.
## Взаимодействие со стрельбой
- Боец ближнего боя — обычный союзник для `LaneClearFrom`. Стрелки сквозь него
не стреляют, и решатель разводит их так, чтобы работать мимо него.
- Пока идёт замах или удар, боец **не расступается** из чужих линий. Смысла нет:
стрелок всё равно не выстрелит (правило 6.3 запрещает), а удар сорвётся.
Расступается он до и после.
- Рывок учтён в `AllyInterceptsShot` отдельной экстраполяцией: на замахе боец
почти неподвижен и через мгновение выстреливает по уже известной оси, о чём
его текущая скорость ничего не говорит. Проверяются обе точки — где он есть и
куда прыгнет. Именно поэтому `MaxAgentSpeed()` намеренно **не** включает
скорость рывка: она бы раздула запас для всех выстрелов сразу.
## Как это выглядит
[src/render/fx.cpp](../src/render/fx.cpp), `DrawMeleeArcs`. Дуга читается прямо
из состояния агентов, а не из журнала событий: фаза живёт десятые доли секунды,
её видно в каждом кадре, и привязка к живой позиции важнее — боец в момент удара
едет вперёд.
- **WINDUP** — холодная стальная дуга уводится назад за край сектора и
разгорается. Это телеграф.
- **STRIKE** — серп из четырёх концентрических дуг: внешний край раскалён,
внутренние тускнеют к рукояти. Одна линия на 480×270 читается палкой, четыре —
взмахом. Хвост — доля пройденной дуги (`bladeTrailFrac`), а не вся: серп, а не
кольцо. На голове — раскалённая радиальная черта и точка.
- **RECOVER** — серп стоит на месте и быстро гаснет.
В изометрии круг — эллипс, поэтому дуга рисуется сэмплами мировых точек, а не
экранной окружностью.
События `MELEE_SWING` / `MELEE_HIT` / `MELEE_ABORT` идут по тому же каналу
`sim/events.h`, что и выстрелы: искры, вспышки света и тряска камеры (`meleeShake`
заметно сильнее, чем на выстрел — удар всегда в упор).
Класс читается силуэтом: у бойца капсула шире и со стальным клинком. Цвет занят
номером агента, поэтому классом он быть не может.
## Метрики
`FIRE UPTIME` усредняется **только по стрелкам**: линии огня у клинка не бывает
по определению, и его ноль занижал бы метрику стрельбы. Отдельно считается
`MELEE reach` — доля времени в зоне удара. Для `SETTLE TIME` «в бою» = стрелок
при линии **или** клинок при цели.
Критерии M7.1M7.3 в `--accept` — [09-acceptance.md](09-acceptance.md).
## Куда приедут пушки и апгрейды
[src/sim/loadout.h](../src/sim/loadout.h). Сейчас профиль ровно один на класс и
целиком копируется из `tuning.h` каждый шаг (`SyncLoadoutFromTuning`) — так
работает runtime-подкрутка. Когда профилей станет несколько, здесь встанет
таблица и модификаторы поверх неё, а весь остальной код продолжит читать
`Agent::weapon` / `Agent::melee` и не заметит разницы.
Что уже вынесено в параметры и годится под модификаторы: досягаемость, ширина
сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание,
скорость и ускорение рывка, множители скорости на замахе и восстановлении,
радиус своего круга.