# 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.1–M7.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%; отбив снял урон, ответ прошёл, откат не сброшен, стоя столбом не отбивает ``` Последняя половина строки важна не меньше первой: без неё правило «удары должны соприкоснуться» существовало бы только в комментарии.