Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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>
18 KiB
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.
Дисциплина сектора
Проверяется дважды, как и у ствола:
- на старте замаха — сектор
reach×arcDegобязан быть чист. Союзник проверяется в двух точках: где он сейчас и где будет черезwindup + strike(meleeStartPredict). Без этого треть взмахов начиналась впустую; - каждый шаг активного окна — если свой всё-таки влез под лезвие, взмах
обрывается: серая вспышка,
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.1–M7.3 в --accept — 09-acceptance.md.
Куда приедут пушки и апгрейды
src/sim/loadout.h. Сейчас профиль ровно один на класс и
целиком копируется из tuning.h каждый шаг (SyncLoadoutFromTuning) — так
работает runtime-подкрутка. Когда профилей станет несколько, здесь встанет
таблица и модификаторы поверх неё, а весь остальной код продолжит читать
Agent::weapon / Agent::melee и не заметит разницы.
Что уже вынесено в параметры и годится под модификаторы: досягаемость, ширина сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание, скорость и ускорение рывка, множители скорости на замахе и восстановлении, радиус своего круга.