squad-proto/src/sim/target.h
z.kirill 53ea43f2a4 Пистолет не пропадает, эвакуация откуда угодно, кнопки в HUD (M36)
БАГ С ПИСТОЛЕТОМ - на самом деле ДВА бага.

Первый: экран отряда правит ЖИВОЙ МИР, а вылазка собирает бойцов заново
из профиля (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>
2026-08-17 05:24:10 +03:00

290 lines
19 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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, // хват: подбирается и держит бойца на месте
// --- население глубины (акты IIVI, 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);