Прежние пять операций были СЛЕПЫМИ: перековка бросала всё заново,
прививка ставила случайный тег, отсечение снимало последний аффикс,
возвышение поднимало случайный. Игрок не принимал ни одного решения - жал
кнопку и смотрел, что выпало. Это не крафт, это второй бросок предмета за
материалы.
Теперь половина операций направленные: игрок показывает пальцем, какой
аффикс трогать, и платит за это вдвое - вот это уже решение
("перебросить всё дёшево или снять один дорого").
Два новых глагола:
ЗАКАЛКА двигает рулон выбранного аффикса к максимуму его тира на треть
остатка. Дёшево, понятно, повторяемо - то самое "доработать почти
хорошую вещь". Тир не трогает: за ступень платят возвышением.
ОТПЕЧАТОК переносит лучший аффикс с ДРУГОЙ вещи, сжигая её. Именно он
превращает хлам из схрона в ресурс, а не в мусор, который лежит.
Правило "один тег один раз" им не обходится, бюджет проверяется.
В мастерской: клик по вещи кладёт её на верстак, повторный клик по той же
назначает донором (помечается кровью - он сгорит, и видеть это надо ДО
нажатия). Строки аффиксов стали кнопками: по ним выбирается цель.
Проверка меряет НАПРАВЛЕННОСТЬ, а не факт срабатывания:
направленность: отсечение по цели, возвышение по цели,
закалка двигает рулон, отпечаток перенёс и сжёг донора
Отсечение пробуется на СРЕДНЕМ аффиксе из трёх: операция, которая
"работает", снимая последний, прошла бы и слепую версию.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
635 lines
40 KiB
C++
635 lines
40 KiB
C++
#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;
|
||
|
||
// ---------------------------------------------------------------------
|
||
// НАСЕЛЕНИЕ ГЛУБИНЫ (акты II–VI).
|
||
//
|
||
// Шесть видов выше держат первые семь этажей и кончаются там же. Дальше
|
||
// спуск на сотню этажей упирался в одно и то же: те же твари, только их
|
||
// больше. Множителя 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 против 8–13 у всех остальных: он почти слеп,
|
||
// и мимо него МОЖНО пройти. Единственная тварь в игре, с которой боя
|
||
// может не быть вовсе, — и ровно поэтому она бьёт так, что бой лучше
|
||
// не начинать: круг радиусом три юнита и шестьдесят два урона снимают
|
||
// половину отряда, если тот шёл кучей.
|
||
//
|
||
// Задача игроку — маршрут, а не стрельба. Замах длинный (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;
|
||
}
|