squad-proto/docs/13-combat.md
z.kirill 0fb6e31c2b Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.

Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».

Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.

Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.

  свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
        глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
        обзор 1.00 -> 0.35 в темноте, батарея встала сама

Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.

--accept PASS без изменений: в песочнице света бесконечно, как и патронов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:12:05 +03:00

18 KiB
Raw Blame History

13. Бой: оружие, твари, снаряжение, экраны

Прототип из вех M0M6 проверял ОТРЯД: строй, линии огня, правило «не бить своих». Противника там не было — были мишени, которые стоят и не отвечают. Эта веха достраивает вторую половину: твари, которые идут и бьют, каталог оружия, снаряжение и экраны вне боя.

вылазка

Два режима, и это важно

Режим Что в мире Кто им пользуется
боевой (по умолчанию) твари 5 видов, на бойцах комплекты снаряжения игра
песочница (--sandbox) прежние мишени, отряд без снаряжения ручные проверки
приёмка (--accept, --headless) всегда песочница замеры критериев

Разделение не косметическое. Броня несёт множитель скорости, а на скорости движения меряются критерии 11.4 и 11.5; твари бьют, а от урона меняется состав живых. Поэтому Game::combatMode выключен во всех прогонах приёмки, мишень EnemyKind::DUMMY ведёт себя ровно как раньше, и --accept считает те же цифры, что считал до этой вехи (09-acceptance.md).

Оружие

Каталог — src/sim/weapon_defs.h. Карабин и сабля по-прежнему читают числа из tuning.h: это оружие по умолчанию, и все прежние ручки оверлея крутят именно их (08-decisions.md).

Ствол Дальность Интервал Урон Магазин Чем отличается
CARBINE 7.2 0.35 12 24 штатный: ровный во всём
PISTOL 4.6 0.22 8 7 ближе и слабее, зато быстрая перезарядка
SMG 5.4 0.09 6 40 шквал; увод копится быстрее, чем оседает
SHOTGUN 3.4 0.85 26 x6 5 залп дробин, в упор рвёт
MAGNUM 8.6 0.95 78 6 дальше всех и валит с ног, но раз в секунду
Клинок Досягаемость Дуга Урон Чем отличается
SABER 1.40 100° 130 штатный
KNIFE 1.00 55° 62 почти без замаха; узкий сектор чаще чист
CLEAVER 1.65 135° 240 тяжёлый; занести его в тесноте негде

Картечь и правило 6.3. Дробины уходят в ТОТ ЖЕ конус SpreadRad, который проверен на своих перед спуском. Правило не ослаблено ни на градус — просто у дробовика конус широкий, и выстрел отсекается заметно чаще. Это его цена, а не исключение из правила.

Твари

Каталог — src/sim/target.cpp, поведение — src/sim/enemy.cpp.

Вид HP Скорость Урон Замах Атака Роль
SHAMBLER 210 0.95 13 0.42 SWIPE основа толпы; страшен числом
RUSHER 110 3.3 14 0.28 LUNGE добегает до перезарядки
SPITTER 110 1.15 16 0.55 SPIT единственный, кто достаёт издалека
BRUTE 520 1.05 46 0.95 SLAM разбирать вдвоём
LURKER 150 1.90 9/тик 0.50 GRAB вырывает бойца из строя

Поведение намеренно простое: заметил ближайшего живого бойца — идёт — дошёл — замахивается — бьёт. Замах (windupTimer) виден всегда: тело подсвечивается и глаза вспыхивают ровным светом даже в темноте. Между замахом и ударом у игрока есть время увести бойца. Без телеграфа удар твари неотличим от случайной потери здоровья, и бой перестаёт быть боем.

Ось удара на замахе НЕ доводится: тварь бьёт туда, куда замахнулась. Ровно это и делает уклонение возможным.

Пять атак — пять разных задач игроку

Один удар на всех делал любую тварь одинаковой: подошла — сняла здоровье. Разная ГЕОМЕТРИЯ (AttackKind, src/sim/target.h) — то единственное, ради чего отряд приходится расставлять по-разному.

Атака Форма Что требует от игрока
SWIPE сектор swipeArcDeg (100° у бредущего) в пределах attackRange уходить вбок: назад от короткого удара не успеть
LUNGE рывок на lungeDist (3.4) за lungeTime (0.20 с); урон всем, кого задело по пути освобождать ось, а не точку: на прямой броска безопасных мест нет
SPIT снаряд SpawnHostileBullet, летит spitRange (8.4) — дальше, чем плевок «достаёт» единственная атака, которую перекрывает стена
SLAM круг slamRadius (2.15) вокруг твари не толпиться возле громилы: второй боец платит наравне с целью
GRAB захват на grabTime (2.6 с), урон тикает раз в grabTick (0.45 с) вытаскивать своего огнём

Рывок — это движение по времени, а не телепорт: позиция бегуна меняется каждый шаг и упирается в стены как обычно (map.ResolveCircle). Каждого бойца один рывок задевает не больше одного раза (lungeMask). Откат считается от КОНЦА броска, а не от его начала.

Захват держит ноги и только их. Squad::UpdateMovement не двигает бойца с grabbedTimer > 0 — ни строй, ни решатель, ни расступание из чужой линии. Стрельбу и клинок захват не читает вообще: правило 6.3 и сектор удара работают как обычно, иначе единственным выходом из хватки был бы чужой выстрел. Сам таймер гаснет, а держащая тварь продлевает его каждый шаг на enemyGrabHold: убили хвата — продлевать некому, и боец свободен через одну шестую секунды. Поэтому про захват не знают ни bullet.cpp, ни melee.cpp.

Общих ручек всего две, обе в src/tuning.h: enemyWindupScale (длина всех телеграфов разом) и enemyGrabHold. Формы — swipeArcDeg, lungeDist, lungeTime, spitRange, slamRadius, grabTime, grabTick — свойства ВИДА и живут в EnemyDef рядом с остальными статами.

Эффекты получают отдельные события (sim/events.h): ENEMY_WINDUP, ENEMY_LUNGE, ENEMY_SLAMowner = индекс ТВАРИ; ENEMY_HIT, ENEMY_GRAB, ENEMY_RELEASEowner = индекс БОЙЦА. Индексы из разных массивов, путать их нельзя.

DUMMY по-прежнему инертна. У мишени приёмки вид атаки выставлен формально, с нулевыми уроном и дальностью, а UpdateEnemies до неё вообще не доходит: --accept меряет ровно то же, что мерял.

Упавшего бойца твари не добивают — иначе отряд, потерявший одного, теряет всех подряд, пока игрок ничего не может сделать.

Расстановка: NUM_TARGETS кучек по прежним точкам приёмки плюс NUM_EXTRA_ENEMIES тварей по всей карте — без вторых зачистка первой кучки заканчивала вылазку. Хваты стоят только в глубине: захват — ловушка для отряда, который уже разошёлся по позициям, а не встречающий у порога.

Разрушаемая обстановка

Ящики, бочки и шкафчики (CRATE, BARREL, LOCKER) живут в том же пуле Target, что и твари. Заводить под них вторую сущность было незачем: пуля, клинок, стена и правило 6.3 работают с ними одинаково — как с телом, у которого есть позиция, радиус и HP. Ни одну из этих систем трогать не пришлось.

Отличий ровно четыре, и каждое проверяется через Target::IsProp():

Где Что иначе
UpdateEnemies не шевелится: ни скорости, ни агро, ни удара
UpdateTargets не воскресает — разбитый хлам остаётся разбитым
выбор цели в ai/ последний приоритет (ниже)
события HIT_PROP / PROP_BREAK вместо HIT_TARGET / HIT_KILL

Отдельные виды событий нужны не для красоты: слой крови (src/render/blood.cpp) знает только позицию события, и отличить попадание в бочку от попадания в тварь можно ТОЛЬКО видом события. Иначе ящик кровоточит. Заодно разбитый хлам не идёт ни в KILLS, ни в напряжение, ни в счётчик ударов клинком.

Приоритет: обстановка — всегда последняя

Правило одно на оба класса оружия и живёт в одной функции — EnemyNearby (src/ai/firing_solver.h):

пока в радиусе propYieldRadius есть хоть одна живая тварь, ящик целью не становится вообще.

И стрелок (EvaluateAgentLane), и клинок (EvaluateMeleeTarget) делают два прохода по целям: сначала твари, и только при полностью пустом первом проходе — обстановка. Даже если линию до твари перекрыл свой, а до ящика она чиста, боец переступает (решатель 6.5), а не разряжает магазин в мебель.

Радиус свой, а не дальность ствола. С дальностью правило читалось бы как «не достаю — стреляю по ящику», и подошедшую тварь боец встречал бы на перезарядке. Стены его не отменяют намеренно: тварь за углом в пяти шагах — всё ещё повод не заниматься мебелью.

Линия к хламу никогда не priorityLane: приоритет запрещает решателю искать позицию получше, а ради ящика боец переступать обязан.

Назначенная игроком цель (6.8) правило обходит — это осознанно: явный приказ сильнее автоматики.

Снаряжение

src/sim/items.h. Предмет даёт бойцу ровно четыре числа: броня, скорость, запас здоровья, дальность обзора. Ни один предмет не может ослабить правило «не бить своих»у него просто нет такой ручки, и это не самоограничение, а устройство: FireWeapons и UpdateMelee про инвентарь не знают вообще, они читают уже готовые статы агента.

Броня складывается, но упирается в ARMOR_CAP = 0.72: неуязвимых нет.

Броня Поглощение Скорость Прочее
JACKET 10% 100%
VEST 28% 95% +10 HP
PLATE 48% 82% +25 HP
SHROUD 4% 112% обзор 92%

В карман: LANTERN (обзор 135%), CHARM (+30 HP), SPURS (скорость 114%). Расходники: BANDAGE, STIM, AMMO, BATTERY.

Фонарь — единственная вещь с ЗАРЯДОМ: он садится за пройденный путь, и погасший не даёт ничего. Заряд общий на отряд, батарея встаёт сама (17-extraction.md).

Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (Game::EquipKits): остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав отряда читается прямо по строю.

Экраны

меню

Меню, пауза, экран отряда и поражение живут в src/ui/menu.cpp. Это второе и последнее место в игре, где разрешены примитивы raylib (первое — debug/overlay): здесь нужны слова, а собственный шрифт на 480x270 стоил бы дороже всего остального UI вместе взятого. Мир по-прежнему рисуется только в софтверный фреймбуфер, меню ложится поверх готового кадра.

отряд

Экран отряда (I в бою) показывает те же числа, которые читает симуляция: HP, броню, скорость, обзор. ENTER меняет местами оружие в руках и выбранную ячейку сумки — отряд не носит воздух, у него всегда что-то в руках.

HUD

src/render/hud.cpp рисуется в тот же софтверный фреймбуфер, что и мир, — значит попадает под ту же сетку пикселей. Текста в нём нет ни одной буквы:

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

Исключение одно и на весь HUD — строка забега сверху: этаж, опыт, добыча и заряд фонаря. Номер этажа формой не покажешь. Погасший фонарь там показан СЛОВОМ DARK, а не полосой нулевой длины: темнота меняет правила, а «полоски не видно» читалось бы как «полоски тут и не было».

Смерть бойца

Мёртвый агент остаётся в массиве: NUM_AGENTS — величина времени сборки, по ней ходят все циклы, слоты строя, метрики и оверлей. Убирать его из пула значило бы переиндексировать полмира на каждой смерти. Поэтому смерть — это флаг Agent::alive, и его проверяют все системы: движение, расталкивание, реестр линий, решатель, проверка «свой на линии», сектор клинка, пули.

Павший не перекрывает линию огня и не попадает под лезвие. Тело остаётся на полу до конца вылазки.

Сложность

Всё крутится на ходу через оверлей или --set:

squad_proto.exe --set enemyDamage=1.6     # во сколько раз больнее бьют
squad_proto.exe --set agentHP=60          # запас бойца до брони
squad_proto.exe --set enemyRespawn=8      # как быстро твари возвращаются

Числа видов правятся в EnemyDefOf, числа предметов — в ItemDefOf. Баланс на 24 тварей и 5 бойцов подобран грубо и ждёт живых прогонов: отряд, стоящий на месте, держится, но постепенно теряет здоровье.