БАГ С ПИСТОЛЕТОМ - на самом деле ДВА бага. Первый: экран отряда правит ЖИВОЙ МИР, а вылазка собирает бойцов заново из профиля (NewRun пересоздаёт ecs::World и раздаёт снаряжение через ApplyProfileLoadout). Всё, переложенное в меню, стиралось на старте, а вещь, вынутая из схрона, исчезала вместе с миром - схрон её уже отдал. Теперь всякая правка снаряжения пишется в профиль сразу. Второй, рядом: перетаскивание мышью НЕ РАБОТАЛО ВООБЩЕ. Флаг dropOk нигде не выставлялся в true - только сбрасывался, - и сброс молча отменялся. Единственным рабочим способом переодеться оставался ENTER. Теперь флаг считается каждый кадр, и ячейка честно красится в "нельзя". Заодно: вне забега сумка не принимает вещей. Она принадлежит вылазке и очищается при сборке этажа; вещь, положенная в неё из меню, исчезла бы так же молча. Проверка ловит именно цепочку "поменял в меню -> начал вылазку -> вышел с этим": сам обмен работал и раньше, пропадал он на старте. сборы: в профиле после обмена PISTOL, на этаже PISTOL ТВАРИ НЕ ВОСКРЕСАЮТ. Убитая в этой вылазке убита насовсем: с возвратом любая расчищенная комната через полминуты снова была полна, и "пробиться и уйти" не отличалось от "молотить бесконечную очередь". Исключение одно - аура плакальщика, это его роль. ЭВАКУАЦИЯ С ЛЮБОГО ЭТАЖА И ИЗ ЛЮБОЙ ТОЧКИ. Раньше уйти можно было только стоя на кольце: отряд, решивший не рисковать, всё равно шёл через этаж и умирал ровно там, где игрок уже принял решение. Чекпоинты теперь значат только то, чем должны быть: откуда можно НАЧАТЬ следующую вылазку. ОТЗЫВЧИВОСТЬ СТРОЯ. Отряд ходил 1.75 при курсоре 3.3 - он всегда отставал, и управление ощущалось как "тяну за верёвку". Стало 2.6, поводок короче (2.5 против 3.0), доворот строя вдвое резче. Ориентация строя теперь ПОВОРАЧИВАЕТСЯ, а не щёлкает: раньше слоты клина перепрыгивали через весь отряд на каждом повороте, и бойцы бросали дорогу ради перестроения. В тесноте доворот почти мгновенный - там строй и так колонна, а колонна, смотрящая в прошлую сторону, упирается в стену. Автопилот поймал, что первая версия правок сделала хуже: при быстром наборе натяжения (5.2) отряд уходил в вечный спринт, а спринт растягивает строй и выключает огонь. Порог спринта поднят до 0.95, набор до 3.4 - приёмка 11.4 держится (0.13 с при пороге 0.30). КНОПКИ В HUD. Слева внизу четыре: SQUAD, TREE, WORK, MENU. Экраны открывались только клавишами, о которых игра нигде не говорила, - без кнопки мастерская и дерево не открываются никогда. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
19 KiB
C++
290 lines
19 KiB
C++
#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 <cstdint>
|
||
#include <vector>
|
||
|
||
#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<Target>& targets, float dt);
|
||
|
||
// Число живых мишеней (знаменатель в оценке покрытия, спек 6.5).
|
||
int CountAliveTargets(const std::vector<Target>& targets);
|