18-engine.md границы Tile2D, проекции 2D, формат проекта, второй проект 19-agent.md инструменты --tool, скиллы, терминал в редакторе 20-gdd.md акты, петли, рост отряда, закон масштаба, бюджет 200 часов 21-damage.md семь стихий, статусы, десять реакций, формулы, триггеры 22-loot.md редкости, аффиксы, артефакты, зачарования, материалы 23-endgame.md Бездна, мутации, лидерборды, 262 ачивки Плюс правки 01/02/07/15/16/17 под переехавшие пути и новые правила забега. Дизайнерская часть держится на одном законе: угроза растёт квадратично, сила отряда — линейно с затуханием, разрыв закрывается ЗНАНИЕМ, а не числами. Из него выведены и экономика опыта (41 млн против 44 млн дохода), и то, почему реакции считаются от глубины, а не от урона оружия, и почему в Бездне множитель HP разрешён, а во всём авторском контенте запрещён. .claude/commands: скиллы /tile2d, /room, /floor — правила этого движка, которые нельзя вывести из кода. Главное записано первым: не отчитываться об успехе без прохода --tool validate. Числа в этих документах — вход для инструментов, а не украшение. Расхождение между таблицей и выводом инструмента означает, что неправ документ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
13. Бой: оружие, твари, снаряжение, экраны
Прототип из вех M0–M6 проверял ОТРЯД: строй, линии огня, правило «не бить своих». Противника там не было — были мишени, которые стоят и не отвечают. Эта веха достраивает вторую половину: твари, которые идут и бьют, каталог оружия, снаряжение и экраны вне боя.
Два режима, и это важно
| Режим | Что в мире | Кто им пользуется |
|---|---|---|
| боевой (по умолчанию) | твари 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_SLAM — owner = индекс ТВАРИ; ENEMY_HIT, ENEMY_GRAB,
ENEMY_RELEASE — owner = индекс БОЙЦА. Индексы из разных массивов, путать их
нельзя.
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.
Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (Game::EquipKits):
остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав
отряда читается прямо по строю.
Экраны
Меню, пауза, экран отряда и поражение живут в src/ui/menu.cpp.
Это второе и последнее место в игре, где разрешены примитивы raylib (первое —
debug/overlay): здесь нужны слова, а собственный шрифт на 480x270 стоил бы
дороже всего остального UI вместе взятого. Мир по-прежнему рисуется только в
софтверный фреймбуфер, меню ложится поверх готового кадра.
Экран отряда (I в бою) показывает те же числа, которые читает симуляция:
HP, броню, скорость, обзор. ENTER меняет местами оружие в руках и выбранную
ячейку сумки — отряд не носит воздух, у него всегда что-то в руках.
HUD
src/render/hud.cpp рисуется в тот же софтверный фреймбуфер, что и мир, — значит попадает под ту же сетку пикселей. Текста в нём нет ни одной буквы:
- цвет полосы — номер бойца, тот же, что у его тела на карте;
- длина полосы — здоровье, тонкая полоска под ней — броня;
- квадратики — патроны, гаснут по одному; сплошная полоса — перезарядка;
- у клинка вместо патронов фаза удара: замах, удар, восстановление;
- уголок над карточкой — боец при цели;
- карточка перечёркнута — боец упал.
Смерть бойца
Мёртвый агент остаётся в массиве: 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 бойцов подобран грубо и ждёт живых прогонов: отряд,
стоящий на месте, держится, но постепенно теряет здоровье.


