squad-proto/src/sim/target.cpp
z.kirill 92a8864726 Крафт стал выбором: закалка, отпечаток, направленные операции (M33)
Прежние пять операций были СЛЕПЫМИ: перековка бросала всё заново,
прививка ставила случайный тег, отсечение снимало последний аффикс,
возвышение поднимало случайный. Игрок не принимал ни одного решения - жал
кнопку и смотрел, что выпало. Это не крафт, это второй бросок предмета за
материалы.

Теперь половина операций направленные: игрок показывает пальцем, какой
аффикс трогать, и платит за это вдвое - вот это уже решение
("перебросить всё дёшево или снять один дорого").

Два новых глагола:
  ЗАКАЛКА двигает рулон выбранного аффикса к максимуму его тира на треть
  остатка. Дёшево, понятно, повторяемо - то самое "доработать почти
  хорошую вещь". Тир не трогает: за ступень платят возвышением.

  ОТПЕЧАТОК переносит лучший аффикс с ДРУГОЙ вещи, сжигая её. Именно он
  превращает хлам из схрона в ресурс, а не в мусор, который лежит.
  Правило "один тег один раз" им не обходится, бюджет проверяется.

В мастерской: клик по вещи кладёт её на верстак, повторный клик по той же
назначает донором (помечается кровью - он сгорит, и видеть это надо ДО
нажатия). Строки аффиксов стали кнопками: по ним выбирается цель.

Проверка меряет НАПРАВЛЕННОСТЬ, а не факт срабатывания:

  направленность: отсечение по цели, возвышение по цели,
                  закалка двигает рулон, отпечаток перенёс и сжёг донора

Отсечение пробуется на СРЕДНЕМ аффиксе из трёх: операция, которая
"работает", снимая последний, прошла бы и слепую версию.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:37:07 +03:00

635 lines
40 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "sim/target.h"
#include "sim/reactions.h"
namespace
{
// Кучки: 5 на арене (рядом со стартом), 3 в северной комнате,
// 3 в западной, 1 одиночная в восточной. Всего 12.
//
// Северная кучка стоит НАПРОТИВ прохода x=30..31: из коридора до неё есть
// чистая линия. Это тот самый тест-кейс из спека — в узком проходе правило
// «не стрелять сквозь своих» упирается в геометрию, и отряд обязан вытянуться
// в колонну: стреляет передний, задние молчат.
//
// Кучка обязана быть В ПРЕДЕЛАХ ДАЛЬНОСТИ от середины прохода (y≈14), иначе
// стрелять из коридора не по чему и тест-кейс проверяет пустоту. При weaponRange
// 7.2 ближняя мишень стоит в ~6.5 юнита: запас есть, но небольшой — если менять
// дальность, эти координаты надо пересчитывать вместе с ней.
const float kTargetSpots[NUM_TARGETS][2] = {
{30.5f, 20.5f}, {31.5f, 22.5f}, {29.5f, 23.5f}, {32.5f, 21.5f}, {30.5f, 24.5f},
{29.5f, 8.5f}, {31.5f, 7.5f}, {27.5f, 10.5f},
{ 7.5f, 40.5f}, { 9.5f, 42.5f}, { 6.5f, 43.5f},
{43.5f, 35.5f},
};
// Сопротивление отбросу по слотам. Не случайное: в каждой кучке есть кто-то
// тяжёлый, кого клинком не сдвинуть, и кто-то лёгкий, кто улетает от первого
// же удара. Иначе «отброс» и «сопротивление» — два числа, которых не видно.
const float kTargetKnockResist[NUM_TARGETS] = {
0.00f, 0.65f, 0.15f, 0.40f, 0.00f,
0.55f, 0.10f, 0.30f,
0.20f, 0.70f, 0.05f,
0.85f,
};
// Кто стоит в каждой точке в боевом режиме. Ближняя кучка — та, на которую
// отряд наткнётся первой: бегун, двое бредущих, плевок и ещё бегун. Громилы
// сидят в дальних комнатах: до них надо дойти.
const EnemyKind kTargetKinds[NUM_TARGETS] = {
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SHAMBLER,
EnemyKind::SPITTER, EnemyKind::RUSHER,
EnemyKind::SHAMBLER, EnemyKind::SPITTER, EnemyKind::BRUTE,
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SPITTER,
EnemyKind::BRUTE,
};
// Твари СВЕРХ мишеней: расставлены по всей карте, а не только у старта.
// Без них зачистка первой кучки заканчивала вылазку, и карта стояла пустой.
// В песочнице приёмки их нет вообще — та расстановка не менялась.
const float kExtraSpots[NUM_EXTRA_ENEMIES][2] = {
{14.5f, 12.5f}, {18.5f, 16.5f}, {12.5f, 24.5f},
{36.5f, 12.5f}, {40.5f, 18.5f}, {38.5f, 27.5f},
{21.5f, 36.5f}, {25.5f, 41.5f}, {33.5f, 39.5f},
{16.5f, 32.5f}, {30.5f, 33.5f}, {41.5f, 43.5f},
};
// Хваты стоят ПОДАЛЬШЕ от старта, в глубине карты: захват — это ловушка для
// отряда, который уже разошёлся по позициям, а не встречающий у порога.
const EnemyKind kExtraKinds[NUM_EXTRA_ENEMIES] = {
EnemyKind::SHAMBLER, EnemyKind::RUSHER, EnemyKind::SHAMBLER,
EnemyKind::SPITTER, EnemyKind::SHAMBLER, EnemyKind::RUSHER,
EnemyKind::LURKER, EnemyKind::BRUTE, EnemyKind::SPITTER,
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::LURKER,
};
// Разрушаемая обстановка. Стоит ПАРАМИ и по краям комнат, а не по центру:
// брошенный скарб жмётся к стенам, и ровно поэтому он читается как обстановка,
// а не как расставленные мишени. Две штуки — у самого старта: игрок обязан
// узнать, что хлам ломается, до того как в него полетит первая тварь.
const float kPropSpots[NUM_PROPS][2] = {
{24.5f, 24.5f}, {23.5f, 25.5f}, // у старта: пара ящиков под рукой
{33.5f, 19.5f}, // ближняя кучка: укрытие сбоку
{28.5f, 6.5f}, {26.5f, 6.5f}, // северная комната, вдоль стены
{ 5.5f, 41.5f}, // западная комната
{44.5f, 33.5f}, {42.5f, 44.5f}, // восточная и юго-восточный угол
};
const EnemyKind kPropKinds[NUM_PROPS] = {
EnemyKind::CRATE, EnemyKind::BARREL,
EnemyKind::CRATE,
EnemyKind::LOCKER, EnemyKind::BARREL,
EnemyKind::CRATE,
EnemyKind::BARREL, EnemyKind::LOCKER,
};
} // namespace
EnemyDef EnemyDefOf(EnemyKind kind)
{
EnemyDef d;
switch (kind)
{
// Бредущий: основа толпы. Медленный настолько, что от него можно уйти
// шагом, — и ровно поэтому страшен только гурьбой.
// Личинка — то, чем первый этаж обязан быть населён: она почти не
// опасна и умирает от ножа за пару взмахов. Заведена НЕ ради
// разнообразия, а потому что «полупустая лаборатория» с бредущим на
// 210 здоровья непроходима для одиночки с ножом — это показал --wall.
//
// Именно новый ВИД, а не ослабленный бредущий с множителем: глубина в
// этой игре меняет, кто выходит, а не числа у одного и того же
// (docs/16-descent.md).
case EnemyKind::LARVA:
d.name = "LARVA";
d.hp = 55.0f; d.radius = 0.28f; d.speed = 0.72f; d.knockResist = 0.0f;
d.aggroRadius = 7.5f; d.attackRange = 0.70f; d.damage = 7.0f;
d.interval = 1.6f; d.windup = 0.55f;
d.attack = AttackKind::SWIPE; d.swipeArcDeg = 80.0f;
d.lightPower = 0.03f;
d.r = 132; d.g = 120; d.b = 78;
// мягкая и сырая: жар берёт её сразу, дуга проходит плохо
d.resist = Resists(0.0f, -0.40f, 0.0f, 0.0f, 0.20f, 0.0f, 0.0f);
break;
case EnemyKind::SHAMBLER:
d.name = "SHAMBLER";
d.hp = 210.0f; d.radius = 0.34f; d.speed = 0.95f; d.knockResist = 0.25f;
d.aggroRadius = 9.5f; d.attackRange = 0.85f; d.damage = 13.0f;
d.windup = 0.42f; d.interval = 1.25f;
// Взмах лапой: широкий сектор перед собой. Отходить от него назад
// бесполезно — он и так короткий; уходить надо вбок.
d.attack = AttackKind::SWIPE; d.swipeArcDeg = 100.0f;
d.lightPower = 0.05f;
d.r = 116; d.g = 52; d.b = 52;
// мясо: горит как солома, дугу проводит плохо, радиация его и подняла
d.resist = Resists(0.0f, -0.60f, 0.0f, 0.0f, 0.60f, 0.30f, 0.0f);
break;
// Бегун: тонкий и быстрый. Успевает добежать до перезарядки — и тогда
// стрелок остаётся с ним один на один.
case EnemyKind::RUSHER:
d.name = "RUSHER";
d.hp = 110.0f; d.radius = 0.27f; d.speed = 3.3f; d.knockResist = 0.0f;
d.aggroRadius = 12.0f; d.attackRange = 0.75f; d.damage = 14.0f;
d.windup = 0.28f; d.interval = 1.10f;
// Рывок: разрыв в три метра он закрывает за пятую долю секунды и
// сносит ВСЕХ по прямой. Замах длиннее прежнего удара намеренно —
// это единственное окно, в котором отряд успевает разойтись с оси.
d.attack = AttackKind::LUNGE; d.lungeDist = 3.4f; d.lungeTime = 0.20f;
d.lightPower = 0.07f;
d.r = 158; d.g = 74; d.b = 44;
// быстрый и тонкий: горит и стынет, к кислоте привычен
d.resist = Resists(0.0f, -0.50f, -0.40f, 0.55f, 0.0f, 0.0f, 0.0f);
break;
// Плевок: единственный, кто достаёт издалека. Держит дистанцию и бьёт
// по площадям — из-за него отряд не может просто встать в линию.
case EnemyKind::SPITTER:
d.name = "SPITTER";
d.hp = 110.0f; d.radius = 0.31f; d.speed = 1.15f; d.knockResist = 0.1f;
d.aggroRadius = 10.5f; d.attackRange = 6.2f; d.damage = 16.0f;
d.windup = 0.55f; d.interval = 1.60f;
d.ranged = true; d.projectileSpeed = 8.5f;
// Снаряд летит дальше, чем плевок «достаёт»: отойти от него на шаг
// мало, надо разрывать дистанцию или прятаться за стену.
d.attack = AttackKind::SPIT; d.spitRange = 8.4f;
d.lightPower = 0.10f;
d.r = 96; d.g = 132; d.b = 72;
// сам кислотный: TOX по нему бесполезен, дуга — наоборот
d.resist = Resists(0.10f, 0.0f, 0.0f, 0.70f, -0.45f, 0.20f, 0.0f);
break;
// Громила: медленный, толстый, с одного удара снимает половину бойца.
// Клинком его надо разбирать вдвоём, и это правильная цена.
case EnemyKind::BRUTE:
d.name = "BRUTE";
d.hp = 520.0f; d.radius = 0.46f; d.speed = 1.05f; d.knockResist = 0.85f;
d.aggroRadius = 8.5f; d.attackRange = 1.25f; d.damage = 46.0f;
d.windup = 0.95f; d.interval = 2.60f;
// Удар оземь: круг вокруг себя, а не выпад в жертву. Разбирать
// громилу вдвоём теперь стоит денег — оба стоят в круге.
d.attack = AttackKind::SLAM; d.slamRadius = 2.15f;
d.lightPower = 0.12f;
d.r = 142; d.g = 60; d.b = 78;
// танк: пуля вязнет в туше, кислота разъедает
d.resist = Resists(0.60f, 0.20f, 0.30f, -0.50f, 0.0f, 0.10f, 0.0f);
break;
// Хват: сам по себе слаб, но держит бойца на месте, пока его не убьют.
// Опасен не уроном, а тем, что вырывает одного из строя: линия огня
// рушится, отряд разваливается, и остальные твари доедают.
case EnemyKind::LURKER:
d.name = "LURKER";
d.hp = 150.0f; d.radius = 0.30f; d.speed = 1.90f; d.knockResist = 0.15f;
d.aggroRadius = 11.0f; d.attackRange = 0.90f; d.damage = 9.0f;
d.windup = 0.50f; d.interval = 2.40f;
// Урон тикает, пока держит: 9 за тик каждые 0.45 с — само по себе
// терпимо, смертельна именно неподвижность.
d.attack = AttackKind::GRAB; d.grabTime = 2.60f; d.grabTick = 0.45f;
d.lightPower = 0.09f;
d.r = 88; d.g = 62; d.b = 118;
// тварь тени: жгут, а не расстреливают; пустота ей почти родня
d.resist = Resists(0.30f, -0.60f, 0.10f, 0.0f, 0.0f, 0.0f, 0.60f);
break;
// ---------------------------------------------------------------------
// НАСЕЛЕНИЕ ГЛУБИНЫ (акты IIVI).
//
// Шесть видов выше держат первые семь этажей и кончаются там же. Дальше
// спуск на сотню этажей упирался в одно и то же: те же твари, только их
// больше. Множителя HP от глубины в этой игре нет и не будет
// (docs/16-descent.md) — значит, глубина обязана приносить ДРУГИХ.
//
// Каждая ниже проходит один и тот же отбор:
// 1. своя ЗАДАЧА игроку, которой не решает ни одна из уже готовых;
// 2. роль читается по профилю сопротивлений и по виду атаки;
// 3. ни одного иммунитета: потолок 0.85, уязвимость не ниже 0.60
// (sim/element.h). Тварь, которую нечем ранить, — тупик, а не
// сложность: игрок без нужной стихии просто перестаёт играть.
//
// Числа здоровья подобраны под НЫНЕШНИЙ каталог оружия (магнум 78 за
// выстрел), а не под обещанные девятьсот на сотом этаже: тварь, которую
// не берут сегодняшние стволы, ничем не отличается от иммунной.
//
// Профили взяты из спроектированной таблицы docs/21-damage.md §5. Где
// они от неё отходят — отмечено в комментарии вида и объяснено: в
// таблице расписаны семь стихий, а носят их сегодня ПЯТЬ стволов
// (кинетика, жар, дуга, кислота, пустота), и вид, у которого и
// уязвимость, и сопротивление легли мимо этих пяти, для игрока
// неотличим от вида вообще без профиля (docs/21-damage.md §6.1).
// ---------------------------------------------------------------------
// Головня. Тварь, вокруг которой акт II учит стихиям: она ГОРИТ, и
// дробовик — лучший ствол против всего остального — против неё почти
// бесполезен (0.80 по жару против 0.55 по холоду). Задача игроку —
// не «убей», а «принеси второй ствол»: до неё огонь всегда был просто
// лучше, и это первый этаж, где такой ответ не работает.
//
// Бьёт не выпадом, а вспышкой вокруг себя: подходить к ней в упор
// нельзя даже вдвоём, зато круг маленький и уйти из него успевает
// любой. Светится сильнее всех — единственная тварь, которую видно в
// темноте раньше, чем она увидит отряд, и это честная плата за то,
// что взять её нечем, кроме кинетики.
case EnemyKind::CINDER:
d.name = "CINDER";
d.hp = 190.0f; d.radius = 0.32f; d.speed = 1.55f; d.knockResist = 0.20f;
d.aggroRadius = 10.0f; d.attackRange = 1.00f; d.damage = 22.0f;
d.windup = 0.60f; d.interval = 2.00f;
d.attack = AttackKind::SLAM; d.slamRadius = 1.60f;
d.lightPower = 0.30f;
d.r = 178; d.g = 92; d.b = 40;
// жар в неё не идёт вовсе, холод разбирает; пуля работает как всегда
d.resist = Resists(0.10f, 0.80f, -0.55f, 0.0f, 0.0f, 0.30f, 0.10f);
break;
// Жалун. Плевок стреляет редко и больно, этот — часто и почти не
// больно, зато с девяти юнитов и не переставая. Задача другая: он не
// убивает, он не даёт СТОЯТЬ. Решатель огневых позиций держит отряд на
// месте, пока цель под огнём, — жалун обнуляет это, и до него
// приходится идти, ломая строй.
//
// Живучести у него нет намеренно: дошёл — снял одним магазином. Цена
// не в его здоровье, а в том, куда отряд ради него ушёл.
case EnemyKind::STINGER:
d.name = "STINGER";
d.hp = 85.0f; d.radius = 0.26f; d.speed = 1.90f; d.knockResist = 0.0f;
d.aggroRadius = 12.5f; d.attackRange = 7.00f; d.damage = 6.0f;
d.windup = 0.30f; d.interval = 0.55f;
d.ranged = true; d.projectileSpeed = 11.0f;
d.attack = AttackKind::SPIT; d.spitRange = 9.00f;
d.lightPower = 0.08f;
d.r = 104; d.g = 120; d.b = 132;
// хитин держит удар и кислоту, но трескается от жара и стыни
d.resist = Resists(0.35f, -0.35f, -0.25f, 0.50f, 0.15f, 0.0f, 0.20f);
break;
// Рой. Одно тело, внутри которого много: отсюда и широчайший сектор
// удара (обойти его вбок нельзя, из него можно только ВЫЙТИ), и частые
// слабые укусы вместо одного удара. Задача игроку — площадь: жар и дуга
// снимают его мгновенно, а прицельная стрельба по одному телу работает
// ровно так же плохо, как стрельба по стае.
//
// Дёшев и быстр: рой не «сильнее бредущего», он проверяет, взяли ли
// вниз хоть что-то, кроме кинетики.
//
// Отход от таблицы: кислота там нулевая, здесь 0.55. Иначе у роя не
// остаётся НИ ОДНОГО сопротивления среди стихий, которые носят стволы
// (жар, дуга и радиация у него уже расписаны, а радиацию не носит
// никто), и разброс схлопывается в 1.3 — то есть «состав удара по нему
// не значит ничего». Смысл тот же: кислота травит одно тело, а их тут
// много.
case EnemyKind::SWARM:
d.name = "SWARM";
d.hp = 40.0f; d.radius = 0.30f; d.speed = 2.20f; d.knockResist = 0.0f;
d.aggroRadius = 13.0f; d.attackRange = 0.65f; d.damage = 5.0f;
d.windup = 0.22f; d.interval = 0.75f;
d.attack = AttackKind::SWIPE; d.swipeArcDeg = 160.0f;
d.lightPower = 0.04f;
d.r = 138; d.g = 132; d.b = 56;
// горит и проводит дугу как солома; кислоте достаётся одно тело из многих
d.resist = Resists(-0.20f, -0.60f, 0.0f, 0.55f, -0.55f, 0.40f, 0.30f);
break;
// Скорлупа. Стена: медленная, тупая, с узким сектором удара — уйти от
// неё вбок проще, чем от бредущего. Опасна не ударом, а тем, что стоит
// и не умирает: шестьсот с лишним здоровья под сопротивлением 0.70 по
// кинетике — это семьдесят секунд работы карабином, то есть весь
// боезапас отряда за одну тварь.
//
// Ответ ровно один и написан в профиле: 0.50 по кислоте. Лук снимает
// её за пятнадцать секунд. Задача игроку — не «убей», а «пойми, что
// прежний ствол больше не ствол», и это первая тварь, ради которой
// сборку меняют целиком.
//
// Отброса нет вообще (0.90): клинком её не сдвинуть, коридор она
// перекрывает намертво.
case EnemyKind::HUSK:
d.name = "HUSK";
d.hp = 620.0f; d.radius = 0.44f; d.speed = 0.80f; d.knockResist = 0.90f;
d.aggroRadius = 8.00f; d.attackRange = 1.00f; d.damage = 26.0f;
d.windup = 0.70f; d.interval = 1.80f;
d.attack = AttackKind::SWIPE; d.swipeArcDeg = 55.0f;
d.lightPower = 0.06f;
d.r = 128; d.g = 122; d.b = 104;
// панцирь держит всё, кроме кислоты: она его и растворяет
d.resist = Resists(0.70f, 0.30f, 0.40f, -0.50f, 0.30f, 0.20f, 0.15f);
break;
// Спящий. Радиус агро 3.4 против 813 у всех остальных: он почти слеп,
// и мимо него МОЖНО пройти. Единственная тварь в игре, с которой боя
// может не быть вовсе, — и ровно поэтому она бьёт так, что бой лучше
// не начинать: круг радиусом три юнита и шестьдесят два урона снимают
// половину отряда, если тот шёл кучей.
//
// Задача игроку — маршрут, а не стрельба. Замах длинный (1.15 с),
// потому что уйти из такого круга обязано быть возможно; глаза почти
// не горят (0.02) — именно поэтому её и принимают за камень.
//
// Профиль в таблице §5 её не описывает: вид заведён здесь. Холод
// (0.50) — потому что разбудить её должно быть дешевле, чем ломать
// панцирь; пустота (0.30) — потому что спит она не совсем в этом мире.
case EnemyKind::SLEEPER:
d.name = "SLEEPER";
d.hp = 780.0f; d.radius = 0.50f; d.speed = 0.85f; d.knockResist = 0.95f;
d.aggroRadius = 3.40f; d.attackRange = 1.40f; d.damage = 62.0f;
d.windup = 1.15f; d.interval = 3.00f;
d.attack = AttackKind::SLAM; d.slamRadius = 3.00f;
d.lightPower = 0.02f;
d.r = 96; d.g = 88; d.b = 74;
// камень: пуля вязнет, жар не берёт, а стынь и пустота будят и рвут
d.resist = Resists(0.55f, 0.30f, -0.50f, 0.10f, 0.10f, 0.20f, -0.30f);
break;
// Плакальщик. Единственная тварь, которая меняет не бой, а ПОРЯДОК
// целей: пока он жив, лежащие рядом встают в шесть раз быстрее, и
// зачистка комнаты не заканчивается никогда. Сам он почти безобиден —
// одиннадцать урона плевком раз в две секунды.
//
// Задача игроку — увидеть, кто здесь главный, и добраться до него
// первым. Он держится позади (плюётся с пяти юнитов), светится ярче
// соседей (0.14) и ходит медленно: игра показывает его честно, а
// ошибается тот, кто стреляет в ближайшего.
//
// По таблице §5 ответ на него — радиация, и она здесь на 0.60. Но
// радиацию сегодня не носит ни один ствол, поэтому у него есть и
// вторая брешь — пустота (0.35): она обрывает связь, которой он тянет
// мёртвых, и её носит магнум. Кислота (0.55) — сопротивление, без
// которого разброс стихий по нему схлопывается ниже коридора.
case EnemyKind::WAILER:
d.name = "WAILER";
d.hp = 240.0f; d.radius = 0.33f; d.speed = 1.00f; d.knockResist = 0.20f;
d.aggroRadius = 12.0f; d.attackRange = 5.00f; d.damage = 11.0f;
d.windup = 0.70f; d.interval = 2.20f;
d.ranged = true; d.projectileSpeed = 7.0f;
d.attack = AttackKind::SPIT; d.spitRange = 7.00f;
// Аура, а не атака: он никого ею не бьёт и не целится. Радиус
// меньше дальности его же плевка — тела, которые он поднимает,
// лежат вокруг НЕГО, и подойти к ним отряду придётся.
d.reviveRadius = 4.20f; d.reviveRate = 6.00f;
d.lightPower = 0.14f;
d.r = 158; d.g = 150; d.b = 176;
// радиация выжигает то, чем он тянет мёртвых; пустота рвёт саму связь
d.resist = Resists(0.20f, 0.0f, 0.0f, 0.55f, 0.25f, -0.60f, -0.35f);
break;
// Пустотник. Ровные 0.45 по шести стихиям: против него нет верного
// ствола, потому что все стволы одинаково плохи. Это последний урок
// акта VI и проверка на то, что игрок понял систему целиком: брешь
// одна — пустота (0.60), и берётся он не подбором стихии, а СРЕЗАНИЕМ
// сопротивлений (магнум, схлопывание, сверхпроводник).
//
// Ходит быстро и рвёт на шесть с половиной юнитов — вдвое дальше
// бегуна: дистанции для него нет, и держать его на расстоянии
// бессмысленно. Задача игроку — сборка, а не позиция.
//
// Таблица §5 даёт ему ровные 0.30. Здесь 0.45: при 0.30 разброс стихий
// по нему выходит 1.48 — то есть «пустота почти ничего не меняет», а
// весь смысл вида в обратном. Иммунитета всё равно нет нигде: даже
// карабин его убивает, просто вчетверо дольше магнума.
case EnemyKind::VOIDLING:
d.name = "VOIDLING";
d.hp = 300.0f; d.radius = 0.32f; d.speed = 2.00f; d.knockResist = 0.45f;
d.aggroRadius = 14.0f; d.attackRange = 0.85f; d.damage = 24.0f;
d.windup = 0.40f; d.interval = 1.50f;
d.attack = AttackKind::LUNGE; d.lungeDist = 6.40f; d.lungeTime = 0.26f;
d.lightPower = 0.16f;
d.r = 52; d.g = 48; d.b = 86;
// защищён ровно всюду и нигде не непробиваем; пустота — единственная брешь
d.resist = Resists(0.45f, 0.45f, 0.45f, 0.45f, 0.45f, 0.45f, -0.60f);
break;
// --- разрушаемая обстановка -------------------------------------------
// Всё боевое по нулям: ни скорости, ни агро, ни урона. Даже если бы
// UpdateEnemies до них дошёл (он не доходит — см. IsProp), ящику нечем
// ни идти, ни бить. Единственное, что у них есть, — HP и радиус.
//
// Сопротивление отбросу единица: ящик от попадания не ездит по полу.
// Живучесть подобрана от ОДНОГО магазина: труху сносит очередью на ходу,
// шкафчик требует остановиться — и в этом вся разница между ними.
case EnemyKind::CRATE:
d.name = "CRATE";
d.hp = 60.0f; d.radius = 0.38f; d.knockResist = 1.0f;
d.r = 104; d.g = 76; d.b = 46;
break;
case EnemyKind::BARREL:
d.name = "BARREL";
d.hp = 95.0f; d.radius = 0.33f; d.knockResist = 1.0f;
d.r = 92; d.g = 84; d.b = 58;
break;
case EnemyKind::LOCKER:
d.name = "LOCKER";
d.hp = 140.0f; d.radius = 0.36f; d.knockResist = 1.0f;
d.r = 78; d.g = 88; d.b = 96;
break;
// Мишень приёмки: всё по нулям, поведения нет. Вид атаки ей выставлен
// формально — с нулевым уроном и нулевой дальностью он не сработает
// никогда, а UpdateEnemies до неё вообще не доходит.
default:
d.name = "TARGET";
d.hp = g_tune.targetHP;
d.radius = g_tune.targetRadius;
d.attack = AttackKind::SWIPE;
d.r = 120; d.g = 44; d.b = 44;
break;
}
return d;
}
const char* EnemyName(EnemyKind kind) { return EnemyDefOf(kind).name; }
void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist,
const Tilemap* map)
{
const EnemyDef def = EnemyDefOf(kind);
Target t;
t.self = world.Create();
t.kind = kind;
t.pos = pos;
t.home = t.pos;
t.vel = {};
t.hp = def.hp;
t.maxHp = def.hp;
t.radius = def.radius;
t.resist = def.resist;
t.respawnTimer = 0.0f;
t.alive = true;
// У мишени приёмки сопротивление отбросу расписано по слотам, у твари оно —
// свойство вида. Отрицательное значение означает «взять из каталога».
t.knockResist = (knockResist >= 0.0f) ? knockResist : def.knockResist;
t.speed = def.speed;
t.aggroRadius = def.aggroRadius;
t.attackRange = def.attackRange;
t.damage = def.damage;
t.windup = def.windup;
t.interval = def.interval;
t.ranged = def.ranged;
t.projectileSpeed = def.projectileSpeed;
t.attack = def.attack;
t.swipeArcDeg = def.swipeArcDeg;
t.lungeDist = def.lungeDist;
t.lungeTime = def.lungeTime;
t.spitRange = def.spitRange;
t.slamRadius = def.slamRadius;
t.grabTime = def.grabTime;
t.grabTick = def.grabTick;
// Точки расставлены руками, а планировка генерируется: тварь, поставленная
// в стену, никогда бы оттуда не вышла — она сдвигается только когда идёт
// за кем-то, а из стены ей никого не видно.
if (map)
{
map->ResolveCircle(t.pos, t.radius);
t.home = t.pos;
}
world.Add<Target>(t.self, t);
}
void SpawnTargets(ecs::World& world, bool hostile, const Tilemap* map)
{
// Три полосы подряд в одном пуле: мишени, твари сверх них, обстановка.
// В песочнице приёмки живёт только первая — ни одной лишней сущности,
// и все замеры остаются сравнимыми с прежними.
const int total = hostile ? (NUM_TARGETS + NUM_EXTRA_ENEMIES + NUM_PROPS) : NUM_TARGETS;
for (int i = 0; i < total; ++i)
{
const int extraIdx = i - NUM_TARGETS;
const int propIdx = extraIdx - NUM_EXTRA_ENEMIES;
const bool extra = (extraIdx >= 0 && propIdx < 0);
const bool prop = (propIdx >= 0);
const EnemyKind kind = !hostile ? EnemyKind::DUMMY
: prop ? kPropKinds[propIdx]
: extra ? kExtraKinds[extraIdx]
: kTargetKinds[i];
const Vec2 pos = prop ? Vec2{kPropSpots[propIdx][0], kPropSpots[propIdx][1]}
: extra ? Vec2{kExtraSpots[extraIdx][0], kExtraSpots[extraIdx][1]}
: Vec2{kTargetSpots[i][0], kTargetSpots[i][1]};
MakeTarget(world, kind, pos, hostile ? -1.0f : kTargetKnockResist[i], map);
}
}
void KnockTarget(Target& t, Vec2 dir, float speed)
{
// Обстановка не ездит по полу от попаданий. Домой её никто не тянет (это
// свойство мишени приёмки), так что сдвинутый ящик уехал бы навсегда.
if (t.IsProp()) return;
t.vel += dir * (speed * t.KnockScale());
}
void UpdateTargets(std::vector<Target>& targets, float dt)
{
// Статусы тикают первыми: горение обязано успеть добить цель до того, как
// её посчитают живой в этом шаге.
UpdateStatuses(targets, dt);
// Отдача гасится и мишень возвращается на своё место: расстановка уровня
// захардкожена, и «уползание» мишеней ломало бы все замеры разом.
const float damp = std::exp(-g_tune.targetKnockDamp * dt);
const float pull = 1.0f - std::exp(-g_tune.targetReturnRate * dt);
// --- кто тянет мёртвых обратно (плакальщик) ------------------------------
// Живёт ЗДЕСЬ, а не в sim/enemy.cpp, потому что срок покойника считает эта
// функция и только она: поднятие павшего — это ускоренный тот же таймер, а
// не второй механизм воскрешения рядом с первым.
//
// Список собирается один раз за шаг. EnemyDefOf — это switch, и спрашивать
// его на каждую пару «труп × тварь» значило бы четыре тысячи вызовов в кадр
// на полном этаже. Почти всегда список пуст, и тогда весь механизм не стоит
// ничего: в песочнице приёмки он пуст ВСЕГДА (там одни мишени), поэтому
// --accept меряет ровно то же, что мерял.
struct Reviver { Vec2 pos; float radius2; float rate; };
Reviver revivers[MAX_TARGETS];
int reviverCount = 0;
for (const Target& t : targets)
{
if (!t.alive || t.IsDummy() || t.IsProp()) continue;
if (reviverCount >= MAX_TARGETS) break;
const EnemyDef def = EnemyDefOf(t.kind);
if (def.reviveRate <= 1.0f || def.reviveRadius <= 0.0f) continue;
revivers[reviverCount++] =
Reviver{t.pos, def.reviveRadius * def.reviveRadius, def.reviveRate};
}
for (Target& t : targets)
{
t.hurtTimer = std::max(0.0f, t.hurtTimer - dt);
if (t.IsDummy())
{
// Мишень приёмки: ручки tuning продолжают крутить именно её.
t.radius = g_tune.targetRadius;
t.maxHp = g_tune.targetHP;
}
t.pos += t.vel * dt;
t.vel *= damp;
// Возврат на своё место — свойство МИШЕНИ, а не тела: тварь, которую
// тянет домой, не может ни догнать отряд, ни отойти от него.
if (t.IsDummy()) t.pos += (t.home - t.pos) * pull;
if (t.alive) continue;
// Разбитый хлам остаётся разбитым: RespawnDelay() возвращает ему
// отрицательный срок, и без этой проверки он воскресал бы в тот же кадр.
if (t.IsProp()) continue;
// Плакальщик рядом — срок идёт быстрее. Берётся НАИБОЛЬШИЙ множитель, а
// не сумма: двое плакальщиков не поднимают вдвое быстрее, иначе комната
// с тремя такими не зачищается в принципе, сколько по ней ни стреляй.
// Мишень приёмки под ауру не попадает: её срок — ручка tuning, на
// которой меряют, и трогать его нельзя ничем.
float reviveRate = 1.0f;
if (!t.IsDummy())
{
for (int i = 0; i < reviverCount; ++i)
if (DistanceSq(revivers[i].pos, t.pos) <= revivers[i].radius2)
reviveRate = std::max(reviveRate, revivers[i].rate);
}
t.respawnTimer -= dt * reviveRate;
if (t.respawnTimer <= 0.0f)
{
const EnemyDef def = EnemyDefOf(t.kind);
t.alive = true;
t.hp = t.IsDummy() ? g_tune.targetHP : def.hp;
t.pos = t.home;
t.vel = {};
t.victim = -1;
t.aggro = false;
t.attackTimer = 0.0f;
t.windupTimer = 0.0f;
// Недоведённый рывок и незакрытый захват пережили бы смерть и
// вернулись бы вместе с тварью. Захваченного бойца при этом никто
// не держит: его таймер гаснет сам, пока держать некому.
t.lungeTimer = 0.0f;
t.lungeMask = 0;
t.grabbed = -1;
t.grabTimer = 0.0f;
t.grabTickTimer = 0.0f;
}
}
}
int CountAliveTargets(const std::vector<Target>& targets)
{
// Хлам здесь не считается: это знаменатель «по скольким целям отряд может
// работать» (спек 6.5) и цифра в HUD. Нетронутый ящик в углу не делает
// огневую позицию хуже и зачистку — незаконченной.
int n = 0;
for (const Target& t : targets)
if (t.alive && !t.IsProp()) ++n;
return n;
}