squad-proto/src/sim/agent.h
z.kirill 3ebd1f96fc Дерево улучшений вместо четырёх кнопок (M32)
Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены
целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни
на что не влиял, и через две вылазки экран превращался в кнопку
"продолжить".

Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это
пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии,
снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят
предыдущий в этой же ветви. Свободная сетка читалась бы как список;
цепочка читается как путь.

Ни один узел не заводит нового канала влияния: каждый кладёт своё число
туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый
же "особенный" узел со своей веткой в коде превратил бы 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>
2026-08-16 19:24:50 +03:00

228 lines
15 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
// -----------------------------------------------------------------------------
// Агент: состояние + слои движения (спек 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);