#pragma once #include "sim/element.h" #include "sim/status.h" // ----------------------------------------------------------------------------- // Противник. Одна сущность на две роли, и это сделано намеренно. // // * EnemyKind::DUMMY — прежняя МИШЕНЬ: стоит на месте, не атакует, после // смерти возвращается. На ней меряются все критерии приёмки, поэтому её // поведение не изменилось ни на кадр: --accept обязан считать то же самое. // * остальные виды — живые твари: замечают отряд, идут, бьют, стреляют. // // Разводить их по двум компонентам не за чем: решатель огневых позиций, правило // 6.3, клинок и пули работают с ними одинаково — как с телом, у которого есть // позиция, радиус и здоровье. Разница живёт ровно в одном месте: UpdateEnemies // (sim/enemy.cpp) шевелит тех, у кого kind != DUMMY. // // Target — компонент ECS: сущности создаются в SpawnTargets, а вектор, по // которому ходят правила, — это плотный массив пула (см. ecs/ecs.h). // ----------------------------------------------------------------------------- #include #include #include "engine/math.h" #include "ecs/ecs.h" #include "tuning.h" #include "engine/tilemap.h" enum class EnemyKind : uint8_t { DUMMY = 0, // мишень приёмки: не ходит, не бьёт LARVA, // личинка: медленная, дохлая, почти не бьёт — первые этажи SHAMBLER, // бредущий: медленный, живучий, бьёт вплотную RUSHER, // бегун: тонкий, быстрый, налетает первым SPITTER, // плевок: держится поодаль и стреляет BRUTE, // громила: медленный, толстый, сносит одним ударом LURKER, // хват: подбирается и держит бойца на месте // --- население глубины (акты II–VI, docs/20-gdd.md §3) --------------------- // Каждая ниже поставлена ради СВОЕЙ задачи игроку, а не ради строки в // бестиарии. «Тот же бредущий, но толще» — это не новая тварь, а множитель // от глубины, которого в этой игре нет (docs/16-descent.md): вниз выходят // ДРУГИЕ виды, а числа у одного и того же не меняются никогда. // // Роль обязана читаться по профилю сопротивлений и по виду атаки — это // единственная подсказка, которую игра даёт напрямую (docs/21-damage.md §5). CINDER, // головня: горит сама — жаром её не взять, нужен второй ствол STINGER, // жалун: дальнее давление, вблизи дохлый SWARM, // рой: много слабых в одном теле, берётся площадью HUSK, // скорлупа: стена, которую вскрывает только кислота SLEEPER, // спящий: почти слеп, но разбуженный сносит круг WAILER, // плакальщик: поднимает павших рядом, пока сам жив VOIDLING, // пустотник: защищён ровно везде, брешь одна — пустота // --- разрушаемая обстановка ----------------------------------------------- // Ниже этой черты идут НЕ противники: ящик не ходит, не бьёт и не пугает. // Живут они в том же пуле Target намеренно — пуля, клинок, стена и правило // 6.3 работают с ними как с любым телом «позиция + радиус + HP», и ни одну // из этих систем трогать не пришлось. Разница ровно в двух местах: // приоритет цели (ai/) и то, что UpdateEnemies их не шевелит. CRATE, // ящик: труха и хлам, разлетается быстро BARREL, // бочка: ржавая, гулкая, держит дольше LOCKER, // шкафчик: жестяной, самый живучий из хлама COUNT, }; // Первый вид обстановки. Всё, что >= него, — хлам, а не тварь. constexpr EnemyKind FIRST_PROP_KIND = EnemyKind::CRATE; // Чем именно бьёт вид. Один удар на всех делал любую тварь одинаковой: подошла — // сняла здоровье. Разная ГЕОМЕТРИЯ атаки — единственное, что заставляет игрока // по-разному расставлять отряд против разных тварей. // // Общее для всех пяти: сначала замах (windupTimer), и он ВИДЕН. Без телеграфа // смерть бойца выглядит случайной, а не проигранной. enum class AttackKind : uint8_t { SWIPE = 0, // сектор перед собой: уводи бойца вбок, а не назад LUNGE, // рывок на lungeDist за lungeTime; урон всем, кого задел по пути SPIT, // снаряд: единственная атака, которую перекрывает стена SLAM, // круг радиусом slamRadius вокруг себя: опасен и соседям жертвы GRAB, // захват: держит бойца grabTime секунд и тикает уроном }; // Описание вида: одна запись каталога, как у оружия. struct EnemyDef { const char* name = "dummy"; float hp = 500.0f; float radius = 0.34f; float speed = 0.0f; // 0 — с места не сходит float knockResist = 0.0f; float aggroRadius = 0.0f; // с какой дистанции замечает отряд float attackRange = 0.0f; float damage = 0.0f; float windup = 0.0f; // телеграф: замах видно до удара float interval = 1.0f; // между атаками bool ranged = false; float projectileSpeed = 9.0f; // --- геометрия атаки ------------------------------------------------------ // Числа здесь, а не в коде поведения: форма удара — свойство ВИДА, и менять // её надо в каталоге, рядом с остальными статами. Общие множители (сложность, // длина всех телеграфов) живут в tuning.h. AttackKind attack = AttackKind::SWIPE; float swipeArcDeg = 90.0f; // SWIPE: полная ширина сектора float lungeDist = 0.0f; // LUNGE: длина рывка, она же дистанция начала float lungeTime = 0.18f; // LUNGE: за сколько он проходится // SPIT: сколько летит снаряд. Больше attackRange намеренно — иначе плевок // гаснет ровно там, откуда цель уже успела шагнуть, и стрелять смысла нет. float spitRange = 0.0f; float slamRadius = 0.0f; // SLAM: радиус круга поражения float grabTime = 0.0f; // GRAB: сколько держит float grabTick = 0.5f; // GRAB: как часто тикает урон, пока держит // --- поднятие павших (плакальщик) ----------------------------------------- // Не отдельный вид атаки: он никого не бьёт этим и ни в кого не целится. // Это АУРА — пока такая тварь жива, лежащие рядом тела встают быстрее в // reviveRate раз. Ноль у всех, кроме плакальщика, и ровно поэтому проверка // стоит по данным, а не по имени вида. // // Задача игроку: зачистка перестаёт быть последовательной. Пока аура // работает, отряд перебивает одних и тех же — цель приходится ВЫБИРАТЬ, // а не просто стрелять в ближайшего. float reviveRadius = 0.0f; // на каком расстоянии тянет мёртвых обратно float reviveRate = 0.0f; // во сколько раз быстрее идёт их срок // Профиль сопротивлений. Свойство ВИДА, а не глубины: глубже выходят другие // твари, а не те же с другими числами (docs/16-descent.md). Он же — // единственная подсказка, которую игра даёт про правильное оружие. Resists resist; float lightPower = 0.0f; // светятся ли глаза (render-only) // Цвет тела. Нейтральные байты: типов рендера в sim/ нет. uint8_t r = 120, g = 44, b = 44; }; EnemyDef EnemyDefOf(EnemyKind kind); const char* EnemyName(EnemyKind kind); // Сколько «живёт» труп твари. Практически вечность: убитая в вылазке тварь // не возвращается. Не бесконечность только потому, что плакальщик умеет // ускорять этот таймер своей аурой, а деление вечности ни на что не даёт // ничего. constexpr float ENEMY_STAYS_DEAD = 100000.0f; struct Target { ecs::Entity self = ecs::NO_ENTITY; EnemyKind kind = EnemyKind::DUMMY; Vec2 pos{}; // Своё место. Удар отбрасывает мишень, после чего она возвращается сюда: // без отдачи попадание в упор ничем не отличается от промаха. Vec2 home{}; Vec2 vel{}; float hp = 0.0f; float maxHp = 0.0f; float respawnTimer = 0.0f; float radius = 0.34f; bool alive = true; // Сопротивление отбросу, 0..1. 0 — мишень летит от удара как есть, // 1 — не сдвигается вообще. Тяжёлая цель гасит импульс клинка, и это // единственное, чем она отличается от лёгкой при равном HP. float knockResist = 0.0f; // Копия профиля вида: правила урона не лезут в каталог за каждой пулей, а // мутации однажды будут править сопротивления ЭКЗЕМПЛЯРА, а не вида. Resists resist; // Наложенные стихии. Живут на экземпляре: это состояние боя, а не описание // вида (sim/status.h). StatusState status; // Во сколько раз ослабляется импульс с учётом общего множителя из tuning. float KnockScale() const { const float r = Clampf(knockResist * g_tune.knockResistScale, 0.0f, 0.98f); return 1.0f - r; } // --- статы вида (копия записи каталога, чтобы ИИ не лазал в таблицу) ------ float speed = 0.0f; float aggroRadius = 0.0f; float attackRange = 0.0f; float damage = 0.0f; float windup = 0.0f; float interval = 1.0f; bool ranged = false; float projectileSpeed = 9.0f; // --- геометрия атаки (копия записи каталога) ------------------------------ AttackKind attack = AttackKind::SWIPE; float swipeArcDeg = 90.0f; float lungeDist = 0.0f; float lungeTime = 0.18f; float spitRange = 0.0f; float slamRadius = 0.0f; float grabTime = 0.0f; float grabTick = 0.5f; // --- состояние ИИ --------------------------------------------------------- int victim = -1; // индекс бойца, за которым идёт; -1 — никого float attackTimer = 0.0f; // до следующей атаки float windupTimer = 0.0f; // > 0 — замах уже виден игроку float facing = 0.0f; float hurtTimer = 0.0f; // недавно получил урон (render-only) bool aggro = false; // заметил отряд // Опыт за эту смерть уже начислен. Не «мёртв ли» — смерть длится много // шагов до респавна, и без отдельного флага опыт капал бы каждый кадр, // пока тело лежит. Респавн флаг снимает: вернувшаяся тварь — новая цель. bool countedDead = false; // --- рывок (LUNGE) -------------------------------------------------------- // Рывок — это ДВИЖЕНИЕ по времени, а не телепорт: игрок обязан видеть бросок // и успевать понять, кого именно им снесло. float lungeTimer = 0.0f; // > 0 — рывок идёт прямо сейчас Vec2 lungeDir{}; // ось зафиксирована на старте и не доводится uint32_t lungeMask = 0; // кого уже задел ЭТИМ рывком: бит = индекс бойца // --- захват (GRAB) -------------------------------------------------------- int grabbed = -1; // кого держит; -1 — никого float grabTimer = 0.0f; // сколько ещё продержит float grabTickTimer = 0.0f; // до следующего тика урона bool IsDummy() const { return kind == EnemyKind::DUMMY; } // Хлам, а не противник: ящик, бочка, шкафчик. Проверяется во всех местах, // где «цель» означала «кого-то, кто может убить»: приоритет ИИ, счётчик // напряжения, глаза в темноте, знаменатель покрытия в решателе. bool IsProp() const { return kind >= FIRST_PROP_KIND; } // Мишень приёмки встаёт быстро — на ней меряют, и без возврата мерить было // бы нечего: сценарии длятся дольше, чем живут мишени. // // ТВАРЬ НЕ ВСТАЁТ ВООБЩЕ. Убитая в этой вылазке — убита насовсем: зачистка // обязана что-то значить. С возвратом любая расчищенная комната через // полминуты снова была полна, и «пробиться и уйти» переставало отличаться // от «стоять и молотить бесконечную очередь». // // Единственное исключение — аура плакальщика (sim/target.cpp): он поднимает // ПАВШИХ рядом с собой, и это его роль, а не общее правило. Поэтому не // «мгновенно навсегда», а очень долгий срок, который аура умеет ускорить. // Разбитый ящик не встаёт никогда: обстановка, которая отрастает, — это // не обстановка, а мишень. float RespawnDelay() const { if (IsProp()) return -1.0f; return IsDummy() ? g_tune.targetRespawn : ENEMY_STAYS_DEAD; } // Замах виден: единственный признак, по которому игрок успевает среагировать. bool Telegraphing() const { return windupTimer > 0.0f; } // Атака уже пошла и назад не отыгрывается: тварь в рывке или держит бойца. // В этих фазах она не выбирает цель заново и не ходит по своим делам. bool Busy() const { return lungeTimer > 0.0f || grabbed >= 0; } }; // Импульс от удара (dir нормализован вызывающим). speed — импульс ДО того, // как мишень ему сопротивляется: сопротивление применяется здесь, в одном месте, // иначе половина источников урона про него забудет. void KnockTarget(Target& t, Vec2 dir, float speed); // Собрать сущность-цель заданного вида в точке и добавить её в мир. // knockResist < 0 — взять из каталога вида; иначе это расписанное по слоту // значение мишени приёмки. map (если передан) выталкивает тело из стены. // // Одна функция на обе расстановки — захардкоженную и собранную из комнат // (sim/spawn_catalog.h). Двадцать полей Target, скопированных в два места, // разошлись бы на первой же новой характеристике вида. void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist, const Tilemap* map); // hostile = false — прежняя расстановка мишеней приёмки (все DUMMY). // hostile = true — та же расстановка видами тварей ПЛЮС NUM_EXTRA_ENEMIES // по всей карте: игра. // map нужен, чтобы вытолкнуть тварь из стены, если точка попала в блок. void SpawnTargets(ecs::World& world, bool hostile = false, const Tilemap* map = nullptr); // Отдача, возврат мишени на место и респавн. ИИ здесь нет — он в sim/enemy.cpp. void UpdateTargets(std::vector& targets, float dt); // Число живых мишеней (знаменатель в оценке покрытия, спек 6.5). int CountAliveTargets(const std::vector& targets);