squad-proto/docs/11-melee.md
z.kirill e3294cffd4 Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами
через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке.
Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление
тварей и живёт полторы минуты без управления. Первая стена: одиночка D7,
трое D30, пятеро D30 - петля работает.

И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером;
после появления ростера одиночка выходил на этаж с четырьмя невидимыми
телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже,
100 -> 41" был боем впятером.

ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом
проигрывает двум бредущим: 210 здоровья против ножа это девять секунд
работы под ответным уроном. Первые этажи населены личинками (55 здоровья,
7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый.
Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре
меняет, кто выходит, а не числа у одного и того же.

ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она
выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её
двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка.

Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в
замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь.

Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает
повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть
размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары.

Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это
его удар, просто по летящей цели.

Проверка меряет размен целиком, включая обратную половину правила:
"стоя столбом не отбивает". Без неё условие про соприкосновение
существовало бы только в комментарии.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 05:04:35 +03:00

267 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` и не заметит разницы.
Что уже вынесено в параметры и годится под модификаторы: досягаемость, ширина
сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание,
скорость и ускорение рывка, множители скорости на замахе и восстановлении,
радиус своего круга.
## Парирование
Клинок умеет отбивать. Шанс — отдельная характеристика оружия (`BladeDef::parry`),
и **чем оружие легче, тем он выше**: нож 34%, сабля 20%, тесак 8%. Тесаком не
защищаются, им бьют первым.
Характеристика именно отдельная, а не выведенная из веса: её двигают аффиксы
(`PARRY`), и «лёгкий тесак» обязан быть возможен как находка, а не запрещён
формулой.
**Отбить можно, только если удары соприкоснулись.** Боец должен сам быть в замахе
или в ударе (`MeleeWeapon::Committed()`). Парирование «просто так» превратило бы
клинок в щит, который работает, пока стоишь, — и ближний бой перестал бы быть
разменом.
Удавшийся отбив:
| | |
|---|---|
| урон бойцу | не проходит |
| тварь | получает удар бойца целиком, со стихиями и пробитием |
| боец | бьёт ЗАНОВО с начала фазы удара |
| откат | **не сбрасывается** |
Последняя строка — то, ради чего всё и делалось. Сбрасывай отбив откат, это была
бы бесплатная атака, и выгоднее стало бы подставляться под удары. Не сбрасывая,
парирование даёт ровно один лишний удар в текущем размене.
**Пуля тоже отбивается.** Плевок при удачном парировании летит назад и несёт урон
БОЙЦА, а не твари: это его удар, просто по летящей цели. Отражённый снаряд
становится дружественным, поэтому дальше по коду работает как обычный выстрел и
в своих не попадёт.
Проверяется в `--descend` — размен целиком, а не факт срабатывания:
```
парирование: нож 34%, тесак 8%; отбив снял урон, ответ прошёл,
откат не сброшен, стоя столбом не отбивает
```
Последняя половина строки важна не меньше первой: без неё правило «удары должны
соприкоснуться» существовало бы только в комментарии.