#include "sim/target.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) { // Бредущий: основа толпы. Медленный настолько, что от него можно уйти // шагом, — и ровно поэтому страшен только гурьбой. 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; // --- разрушаемая обстановка ------------------------------------------- // Всё боевое по нулям: ни скорости, ни агро, ни урона. Даже если бы // 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(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& targets, float dt) { // Отдача гасится и мишень возвращается на своё место: расстановка уровня // захардкожена, и «уползание» мишеней ломало бы все замеры разом. const float damp = std::exp(-g_tune.targetKnockDamp * dt); const float pull = 1.0f - std::exp(-g_tune.targetReturnRate * dt); 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; t.respawnTimer -= dt; 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& targets) { // Хлам здесь не считается: это знаменатель «по скольким целям отряд может // работать» (спек 6.5) и цифра в HUD. Нетронутый ящик в углу не делает // огневую позицию хуже и зачистку — незаконченной. int n = 0; for (const Target& t : targets) if (t.alive && !t.IsProp()) ++n; return n; }