Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены
целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни
на что не влиял, и через две вылазки экран превращался в кнопку
"продолжить".
Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это
пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии,
снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят
предыдущий в этой же ветви. Свободная сетка читалась бы как список;
цепочка читается как путь.
Ни один узел не заводит нового канала влияния: каждый кладёт своё число
туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый
же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в
свалку.
ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9%
одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с
фиксированными ценами выкупается целиком за половину глубокого прохода, и
выбор кончается к сороковому этажу навсегда.
Введено общее удорожание: каждая вложенная точка поднимает цену всех
следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное
дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около
шести с половиной полных проходов.
Старые покупки из профиля читаются, но не применяются: потраченный на них
опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя.
Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов:
дерево: куплено уровней 45, закрытый узел закрыт
урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22
парирование 0.34 -> 0.49, свет 420 -> 620
Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже -
там одиночка с ножом гиб на первом же этаже по той же причине, по которой
это было в основном проекте.
Формат профиля 7: строки tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
15 KiB
C++
228 lines
15 KiB
C++
#pragma once
|
||
// -----------------------------------------------------------------------------
|
||
// Агент: состояние + слои движения (спек 6.1, 6.7).
|
||
// Никакой изометрии, никакого рендера.
|
||
//
|
||
// Agent — компонент ECS. Крупный: правила боя читают состояние бойца целиком
|
||
// и в одном порядке, дробить его на Position+Velocity+Body значило бы разложить
|
||
// один связный алгоритм по пяти пулам без единой строчки выигрыша
|
||
// (см. docs/12-ecs.md). А вот ОРУЖИЕ вынесено в отдельную сущность: у него
|
||
// действительно своя жизнь, свои статы и своя подменяемость.
|
||
// -----------------------------------------------------------------------------
|
||
#include <vector>
|
||
|
||
#include "engine/math.h"
|
||
#include "ecs/ecs.h"
|
||
#include "sim/affix.h"
|
||
#include "sim/items.h"
|
||
#include "sim/loadout.h"
|
||
#include "sim/target.h"
|
||
#include "sim/merc.h"
|
||
#include "sim/tree.h"
|
||
#include "sim/trigger.h"
|
||
#include "tuning.h"
|
||
#include "engine/tilemap.h"
|
||
|
||
enum class AgentState : uint8_t
|
||
{
|
||
ADVANCING = 0, // есть ввод движения
|
||
HOLDING = 1, // ввода нет дольше holdDelay
|
||
};
|
||
|
||
struct Agent
|
||
{
|
||
Vec2 pos{};
|
||
Vec2 vel{};
|
||
float facing = 0.0f; // радианы, куда смотрит ствол
|
||
float bodyRadius = 0.28f;
|
||
int slotIndex = 0;
|
||
AgentState state = AgentState::HOLDING;
|
||
int currentTargetId = -1; // -1 = нет
|
||
Vec2 postPos{}; // выбранная огневая позиция
|
||
float commitTimer = 0.0f; // блокировка перерешивания
|
||
float solveTimer = 0.0f; // стаггер решателя
|
||
|
||
// Сущности ECS: сам боец и его оружие. Статы оружия копируются отсюда
|
||
// в gun/melee каждый шаг (SyncLoadout), поэтому ai/ про ECS не знает вообще.
|
||
ecs::Entity self = ecs::NO_ENTITY;
|
||
ecs::Entity weaponEntity = ecs::NO_ENTITY;
|
||
|
||
WeaponClass cls = WeaponClass::RANGED;
|
||
FirearmId gunId = FirearmId::RIFLE;
|
||
BladeId bladeId = BladeId::SABER;
|
||
// Что именно в руках. Рендеру нужен силуэт, HUD — имя; выводить их из
|
||
// cls+gunId+bladeId в двух местах значило бы держать правило в двух местах.
|
||
ItemId held = ItemId::RIFLE;
|
||
Firearm gun; // работает только у RANGED
|
||
MeleeWeapon melee; // работает только у MELEE
|
||
|
||
// --- живучесть ------------------------------------------------------------
|
||
// Мёртвый боец остаётся в массиве: NUM_AGENTS — величина времени сборки,
|
||
// и по ней ходят все циклы, слоты строя, метрики и оверлей. Убирать его
|
||
// из пула значило бы переиндексировать полмира на каждой смерти. Поэтому
|
||
// смерть — это флаг, а не удаление; все системы обязаны его проверять.
|
||
//
|
||
// Флагов ДВА, и путать их нельзя.
|
||
//
|
||
// present — слот вообще занят человеком. Отряд растёт с одного до пяти
|
||
// (sim/squad.h), и в незанятом слоте никого нет и не было.
|
||
// alive — этот человек ещё жив.
|
||
//
|
||
// Правила боя, движение и решатель читают только alive: для них пустой слот
|
||
// и павший неразличимы, и это верно. Разница видна там, где показывают
|
||
// ЧЕЛОВЕКА: у павшего есть тело на полу и перечёркнутая карточка в HUD, а у
|
||
// пустого слота нет ни того, ни другого — иначе игрок с одним оперативником
|
||
// видел бы вокруг него четыре чужих трупа.
|
||
bool present = true;
|
||
bool alive = true;
|
||
float hp = 0.0f;
|
||
float maxHp = 0.0f;
|
||
float hurtTimer = 0.0f; // недавно получил урон: для рендера и HUD
|
||
|
||
// Бойца держит тварь (EnemyKind::LURKER). Не флаг, а таймер, и держащий
|
||
// продлевает его каждый шаг: убили тварь — таймер гаснет сам, и никому,
|
||
// кроме sim/enemy.cpp, про захват знать не надо.
|
||
//
|
||
// Захват останавливает НОГИ и только их. Стрелять и рубить боец продолжает:
|
||
// правила огня (6.3) и сектор клинка захват не читают вообще — иначе
|
||
// единственным выходом из хватки был бы чужой выстрел.
|
||
float grabbedTimer = 0.0f;
|
||
|
||
// Итог снаряжения, пересчитывается в SyncLoadout. Правила боя эти числа
|
||
// не читают — только движение, урон и обзор.
|
||
float armor = 0.0f; // доля поглощённого урона, 0..ARMOR_CAP
|
||
float speedMul = 1.0f;
|
||
// Патроны СВЕРХ магазина. Перезарядка тянет отсюда, и это главный ресурс
|
||
// вылазки: чем глубже отряд, тем ближе тот момент, когда стрелять станет
|
||
// нечем, — давит не время, а исчерпание.
|
||
//
|
||
// -1 означает «бесконечно» и стоит в ПЕСОЧНИЦЕ приёмки: там на бойцах нет
|
||
// снаряжения вообще, а критерии меряют огневую работу за минуту, и запас
|
||
// патронов сделал бы их проверкой запаса, а не правил стрельбы.
|
||
int ammoReserve = -1;
|
||
// Сколько боец несёт. Считается из инвентаря каждый шаг (SyncLoadout) и
|
||
// лежит здесь, чтобы HUD и экран отряда не лезли в ECS за компонентом.
|
||
float load = 0.0f;
|
||
float sightMul = 1.0f;
|
||
|
||
// Сумма аффиксов надетого и пробитие сопротивлений, которое из неё вышло.
|
||
// Считаются в SyncLoadout один раз за шаг (sim/affix.h); правила боя читают
|
||
// ТОЛЬКО pen, а остальное уже размазано по статам выше.
|
||
AffixMods mods;
|
||
float pen = 0.0f;
|
||
|
||
// Откаты триггеров и набранные стаки рывка (sim/trigger.h). Живут на бойце,
|
||
// потому что триггер — его свойство: два бойца с одним и тем же оружием
|
||
// считают откаты каждый свой.
|
||
TriggerState trig;
|
||
|
||
// Личные числа найденного оперативника (sim/profile.h). У первого нули: он
|
||
// не найден, он и есть начало игры.
|
||
MercStats merc;
|
||
|
||
// --- служебное (рендер/отладка/метрики), на правила не влияет ---
|
||
Vec2 prevPos{}; // для интерполяции кадра
|
||
bool hasLane = false; // есть валидная линия огня прямо сейчас
|
||
bool priorityLane = false; // линия именно к приоритетной цели (6.8)
|
||
bool laneIntent = false; // цель есть, мешает ТОЛЬКО свой -> линия резервируется
|
||
bool targetBlockedByAlly = false; // цель есть, линию перекрыл свой
|
||
bool targetBlockedByWall = false; // цель есть, линию перекрыла стена
|
||
Vec2 laneEnd{}; // куда указывает текущая линия
|
||
bool fired = false; // выстрелил на этом шаге (для оверлея)
|
||
|
||
float Speed() const { return Length(vel); }
|
||
|
||
bool Grabbed() const { return grabbedTimer > 0.0f; }
|
||
|
||
// Полураствор конуса выстрела прямо сейчас. Стоя он почти нулевой, на ходу
|
||
// растёт. Ровно этот конус проверяется на своих перед спуском.
|
||
float SpreadRad() const { return gun.SpreadRad(Speed()); }
|
||
|
||
// Точка вылета пули: muzzleOffset юнита от центра в сторону towards.
|
||
Vec2 MuzzleTowards(Vec2 towards) const
|
||
{
|
||
const Vec2 d = Normalized(towards - pos, FromAngle(facing));
|
||
return pos + d * gun.def.muzzleOffset;
|
||
}
|
||
Vec2 MuzzleFacing() const { return pos + FromAngle(facing) * gun.def.muzzleOffset; }
|
||
|
||
bool IsMelee() const { return cls == WeaponClass::MELEE; }
|
||
|
||
// Множитель скорости от фазы удара: на замахе ноги вкопаны, после удара
|
||
// боец медленный. Рывок в STRIKE считается отдельно, здесь его нет.
|
||
float SpeedScale() const
|
||
{
|
||
if (cls != WeaponClass::MELEE) return 1.0f;
|
||
switch (melee.state)
|
||
{
|
||
case MeleeState::WINDUP: return g_tune.meleeWindupScale;
|
||
case MeleeState::RECOVER: return g_tune.meleeRecoverScale;
|
||
default: return 1.0f;
|
||
}
|
||
}
|
||
};
|
||
|
||
// --- слои движения (6.7) -----------------------------------------------------
|
||
|
||
// 1. seek с торможением у цели
|
||
Vec2 SeekForce(const Agent& a, Vec2 goal, float maxSpeed);
|
||
// 2. расталкивание от союзников
|
||
Vec2 SeparationForce(const Agent& a, const Agent* all, int count, float maxSpeed);
|
||
// 3'. расталкивание от мишеней: чужое тело — не пустое место. Без этого боец
|
||
// ближнего боя стоит ВНУТРИ того, кого рубит, и удар не читается.
|
||
Vec2 TargetPushForce(const Agent& a, const std::vector<Target>& targets, float maxSpeed);
|
||
// 4. отталкивание от стен + скольжение вдоль них.
|
||
// seekDir нужен, чтобы обходить препятствие, а не упираться в него лбом:
|
||
// без тангенциальной составляющей агент залипает в «тени» стены.
|
||
Vec2 WallAvoidForce(const Agent& a, const Tilemap& map, Vec2 seekDir, float maxSpeed);
|
||
|
||
// 5. клампинг, интеграция, разрешение коллизий с тайлами.
|
||
// accel = -1 означает «взять steerAccel из tuning»; рывку клинка нужен свой.
|
||
void IntegrateAgent(Agent& a, Vec2 desiredVel, float maxSpeed, float dt, const Tilemap& map,
|
||
float accel = -1.0f);
|
||
|
||
// Доворот ствола к точке (или по вектору движения, если цели нет).
|
||
void AimAt(Agent& a, Vec2 point, float dt);
|
||
void AimAlongMotion(Agent& a, float dt);
|
||
|
||
// Система: скопировать статы с сущности-оружия и снаряжения, общие размеры из
|
||
// tuning. Рабочее состояние (патроны, фаза удара, откат, увод) НЕ трогается.
|
||
// bagShare — доля общей сумки отряда, приходящаяся на этого бойца. Инвентарь
|
||
// про отряд не знает, поэтому долю считает вызывающий (Game::Step).
|
||
// Прибавки отряда (улучшения профиля) приходят снаружи по той же причине, что
|
||
// и доля сумки: агент про профиль не знает.
|
||
struct SquadBuffs
|
||
{
|
||
float sight = 0.0f; // прибавка к множителю обзора
|
||
float hp = 0.0f; // прибавка к запасу здоровья
|
||
float carry = 0.0f; // прибавка к норме переноски
|
||
// Ярость отряда за павшего (ON_DOWN). Отрядная, а не личная: эффект
|
||
// называется «отряд получает +25% урона», и считать его на каждом бойце
|
||
// отдельно значило бы, что павшего оплакивает только сосед.
|
||
float rage = 0.0f;
|
||
// Итог дерева улучшений (sim/tree.h). Указателем, а не копией: структура
|
||
// большая, а меняется раз в вылазку. nullptr — приёмка и dev-прогоны, где
|
||
// дерева нет вовсе.
|
||
const TreeBonus* tree = nullptr;
|
||
// Фонарь отряда сел: обзор режется до DARK_SIGHT_MUL, а сам фонарь в кармане
|
||
// перестаёт что-либо давать. По умолчанию false — песочница приёмки светит
|
||
// всегда (sim/squad.h).
|
||
bool lightOut = false;
|
||
};
|
||
|
||
void SyncLoadout(ecs::World& world, Agent& a, float bagShare = 0.0f,
|
||
const SquadBuffs& buffs = SquadBuffs{});
|
||
|
||
// Создать сущность оружия и вернуть её. Стволы и клинки живут в одном пуле
|
||
// WeaponItem: подменить одно другим — это одна запись компонента.
|
||
ecs::Entity SpawnWeapon(ecs::World& world, ItemId item);
|
||
|
||
// Взять предмет в руки: меняет и сущность-оружие, и класс бойца. Не оружие —
|
||
// отказ. Возвращает false, если ничего не изменилось.
|
||
bool EquipWeapon(ecs::World& world, Agent& a, ItemId item);
|
||
|
||
// Урон бойцу с учётом брони. Возвращает true, если этот удар его добил.
|
||
// Единственное место, где агент теряет здоровье: броня применяется здесь,
|
||
// иначе половина источников урона про неё забудет.
|
||
bool DamageAgent(Agent& a, float amount);
|