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

20 KiB
Raw Permalink Blame History

11 — Ближний бой

Второй класс оружия. Файлы: src/sim/loadout.h (типы и профили), src/ai/melee.cpp (цель, подход, дисциплина сектора), Game::UpdateMelee в src/game.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, множители к базовым характеристикам клинка:

# Удар Сектор Урон Отброс Замах Пауза Смысл
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, равновесие поводка).

Урон идёт по пройденной полосе, а не по факту удара

Каждый шаг считаются два угла лезвия: где оно было в начале шага и где стало в конце. Цель получает урон, если полоса шириной 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, 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 в --accept09-acceptance.md.

Куда приедут пушки и апгрейды

src/sim/loadout.h. Сейчас профиль ровно один на класс и целиком копируется из tuning.h каждый шаг (SyncLoadoutFromTuning) — так работает runtime-подкрутка. Когда профилей станет несколько, здесь встанет таблица и модификаторы поверх неё, а весь остальной код продолжит читать Agent::weapon / Agent::melee и не заметит разницы.

Что уже вынесено в параметры и годится под модификаторы: досягаемость, ширина сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание, скорость и ускорение рывка, множители скорости на замахе и восстановлении, радиус своего круга.

Парирование

Клинок умеет отбивать. Шанс — отдельная характеристика оружия (BladeDef::parry), и чем оружие легче, тем он выше: нож 34%, сабля 20%, тесак 8%. Тесаком не защищаются, им бьют первым.

Характеристика именно отдельная, а не выведенная из веса: её двигают аффиксы (PARRY), и «лёгкий тесак» обязан быть возможен как находка, а не запрещён формулой.

Отбить можно, только если удары соприкоснулись. Боец должен сам быть в замахе или в ударе (MeleeWeapon::Committed()). Парирование «просто так» превратило бы клинок в щит, который работает, пока стоишь, — и ближний бой перестал бы быть разменом.

Удавшийся отбив:

урон бойцу не проходит
тварь получает удар бойца целиком, со стихиями и пробитием
боец бьёт ЗАНОВО с начала фазы удара
откат не сбрасывается

Последняя строка — то, ради чего всё и делалось. Сбрасывай отбив откат, это была бы бесплатная атака, и выгоднее стало бы подставляться под удары. Не сбрасывая, парирование даёт ровно один лишний удар в текущем размене.

Пуля тоже отбивается. Плевок при удачном парировании летит назад и несёт урон БОЙЦА, а не твари: это его удар, просто по летящей цели. Отражённый снаряд становится дружественным, поэтому дальше по коду работает как обычный выстрел и в своих не попадёт.

Проверяется в --descend — размен целиком, а не факт срабатывания:

парирование: нож 34%, тесак 8%; отбив снял урон, ответ прошёл,
             откат не сброшен, стоя столбом не отбивает

Последняя половина строки важна не меньше первой: без неё правило «удары должны соприкоснуться» существовало бы только в комментарии.