#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(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) { // Статусы тикают первыми: горение обязано успеть добить цель до того, как // её посчитают живой в этом шаге. 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& targets) { // Хлам здесь не считается: это знаменатель «по скольким целям отряд может // работать» (спек 6.5) и цифра в HUD. Нетронутый ящик в углу не делает // огневую позицию хуже и зачистку — незаконченной. int n = 0; for (const Target& t : targets) if (t.alive && !t.IsProp()) ++n; return n; }