squad-proto/src/game.cpp
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

1412 lines
68 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.

#include "game.h"
#include "sim/craft.h"
#include "sim/trigger.h"
#include "sim/reactions.h"
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
#include "engine/view2d.h"
#include "sim/enemy.h"
#include "sim/spawn_catalog.h"
namespace
{
// Снять с мира все пули разом. Пуля — сущность, поэтому просто очистить вектор
// нельзя: остались бы живые идентификаторы без компонентов.
void ClearBullets(ecs::World& world)
{
std::vector<Bullet>& bullets = world.All<Bullet>();
while (!bullets.empty()) world.Destroy(bullets.back().self);
}
} // namespace
Game::Game()
: targets(world.Store<Target>().data),
bullets(world.Store<Bullet>().data),
floorItems(world.Store<FloorItem>().data)
{
}
void Game::Init()
{
sprites.Generate();
Reset();
}
void Game::Reset()
{
BuildFloor(nullptr);
xp = 0.0f;
floorsCleared = 0;
lootTaken = 0;
for (int m = 0; m < MATERIAL_COUNT; ++m) runMaterials[m] = 0;
}
void Game::NewRun(int startDepth)
{
// Новый забег — новый сид. Сид крутится тем же линейным конгруэнтным шагом,
// что и кнопка SEED в редакторе: «следующий» обязано быть одним и тем же
// понятием в обоих приложениях, иначе сид, подобранный в редакторе, в игре
// не повторить.
levelSeed = levelSeed * 1664525u + 1013904223u;
// Начать глубже открытого чекпоинта нельзя: иначе достигнутая глубина
// перестаёт что-либо значить, а вместе с ней и весь риск.
depth = std::max(1, startDepth);
if (profile) depth = std::min(depth, profile->DeepestCheckpoint());
report = RunReport{};
if (profile) ++profile->runs;
Reset();
ApplyProfileLoadout();
}
void Game::Extract()
{
// Унести можно только то, что на ВЫЖИВШИХ. Павший забирает своё с собой —
// в этом вся ставка: не «доживи», а «доживи, имея что вынести».
HarvestToProfile(true);
FinishRun(RunOutcome::EXTRACTED);
}
void Game::WipeRun()
{
// Отряд полёг — но выносится ПОЛОВИНА добычи, а не ноль.
//
// Ноль был честным правилом и плохой игрой: вылазка, кончившаяся вайпом на
// тридцатом этаже, не оставляла ни следа, и единственным разумным поведением
// становилось уходить наверх при первой царапине. Половина сохраняет ставку
// (терять по-прежнему больно) и оставляет смысл идти глубже.
//
// ЧТО ИМЕННО теряется — решает редкость: чем вещь лучше, тем охотнее она
// роняется. Иначе половина всегда состояла бы из бинтов, а реликты доезжали
// бы домой сами.
if (profile)
{
SalvageAfterWipe();
profile->ResetLoadouts();
}
FinishRun(RunOutcome::WIPED);
}
// Шанс УЦЕЛЕТЬ по редкости. В среднем половина, но обычное доезжает часто,
// а реликт — редко: 0.68 / 0.52 / 0.38 / 0.24.
static float SalvageChance(Rarity r)
{
switch (r)
{
case Rarity::MARKED: return 0.52f;
case Rarity::PROTOTYPE: return 0.38f;
case Rarity::RELIC: return 0.24f;
default: return 0.68f;
}
}
void Game::SalvageAfterWipe()
{
if (!profile) return;
// Сид от состояния забега, а не от времени: тот же прогон обязан давать тот
// же итог, иначе ни воспроизвести жалобу, ни проверить правило.
uint32_t rng = uint32_t(levelSeed) * 2654435761u + uint32_t(depth) * 9176u + 7u;
auto roll = [&rng]() {
rng = rng * 1664525u + 1013904223u;
return float((rng >> 8) & 0xFFFFu) / 65536.0f;
};
report.itemsBrought = 0;
report.itemsLost = 0;
auto trySave = [&](const ItemInstance& it) {
if (it.Empty()) return;
if (roll() < SalvageChance(it.rarity) + squad.tree.salvage && profile->StashPush(it))
++report.itemsBrought;
else ++report.itemsLost;
};
// Считается ВСЁ, что отряд нёс: и надетое павшими, и общая сумка. Разделять
// их тут незачем — выживших нет ни одного, и «на ком именно» была вещь,
// роли уже не играет.
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (!a.present) continue;
if (const Inventory* inv = world.Get<Inventory>(a.self))
{
trySave(inv->weapon);
trySave(inv->armor);
trySave(inv->trinket);
}
}
for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) trySave(squad.bag.slots[s]);
squad.bag.Clear();
// Материалы уходят целиком: они валюта, и делить их «по редкости» не по
// чему. Вайп обязан стоить чего-то безусловно.
}
void Game::FinishRun(RunOutcome outcome)
{
report.outcome = outcome;
report.depth = depth;
report.deepest = std::max(report.deepest, depth);
report.survivors = AliveAgents();
report.xp = xp;
if (!profile) { report.checkpoint = 1; return; }
if (outcome == RunOutcome::EXTRACTED)
{
++profile->extractions;
// Опыт здесь НЕ начисляется: он уже зачислен по убийствам (AwardKillXp).
// Материалы — по-прежнему ставка: донёс или потерял.
// Ветвь снабжения умножает добытое: узел «PROSPECTOR» обязан быть
// виден в цифре на выходе, а не только в описании (sim/tree.h).
const float matMul = 1.0f + squad.tree.materials;
for (int m = 0; m < MATERIAL_COUNT; ++m)
profile->material[m] += int(float(runMaterials[m]) * matMul + 0.5f);
}
else
{
++profile->wipes;
}
// Рекорд глубины засчитывается ЛЮБЫМ исходом. Дошёл — значит дошёл;
// требовать за чекпоинт ещё и удачную эвакуацию значило бы наказывать
// дважды за одну смерть.
report.newRecord = report.deepest > profile->maxDepth;
profile->maxDepth = std::max(profile->maxDepth, report.deepest);
report.checkpoint = profile->DeepestCheckpoint();
}
void Game::HarvestToProfile(bool onlySurvivors)
{
if (!profile) return;
report.itemsBrought = 0;
report.itemsLost = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
const Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
// Пустой слот ничего не выносит и ничего не теряет: там некому.
if (!a.present) continue;
auto countCarried = [&](const Inventory& v) {
int n = 0;
if (v.weapon != ItemId::NONE) ++n;
if (v.armor != ItemId::NONE) ++n;
if (v.trinket != ItemId::NONE) ++n;
return n;
};
if (onlySurvivors && !a.alive)
{
report.itemsLost += countCarried(*inv);
// Слот павшего получает стартовый набор: следующая вылазка идёт
// впятером, и пустые руки одного из них — это не сложность,
// а неиграбельность.
profile->loadout[i] = Inventory{};
continue;
}
profile->loadout[i] = *inv;
report.itemsBrought += countCarried(*inv);
}
// Общая сумка уходит в схрон целиком, если из забега вообще кто-то вышел.
//
// Правило «унести можно только то, что на выживших» этим не ослаблено, оно
// стало точнее: НАДЕТОЕ павшего теряется по-прежнему, а общая ноша на нём
// не висела — её тащил отряд. Вайп по-прежнему не выносит ничего: тогда
// выживших нет вовсе, и сумку некому донести.
const bool anySurvivor = AliveAgents() > 0;
for (int s = 0; s < SQUAD_BAG_SLOTS; ++s)
{
// ЭКЗЕМПЛЯР, а не вид: через ItemId вещь доехала бы до схрона без
// аффиксов, то есть игрок вынес бы реликт, а на базе нашёл обычную
// куртку. Неявная конверсия молчит, поэтому такие места пишутся явно.
const ItemInstance& id = squad.bag.slots[s];
if (id.Empty()) continue;
if (anySurvivor && profile->StashPush(id)) ++report.itemsBrought;
else ++report.itemsLost;
}
squad.bag.Clear();
// Пустой слот павшего добивается стартовым комплектом. Делается через
// ResetLoadouts только для тех, у кого ничего нет: иначе выжившие
// потеряли бы найденное. Ненанятые слоты набивать нечем и незачем — но и
// мешать они не мешают: их снаряжение никто не наденет.
Profile fresh;
fresh.ResetLoadouts();
for (int i = 0; i < NUM_AGENTS; ++i)
if (profile->loadout[i].weapon == ItemId::NONE) profile->loadout[i] = fresh.loadout[i];
}
void Game::ApplyProfileLoadout()
{
if (!profile) return;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
*inv = profile->loadout[i];
a.merc = profile->merc[i];
// Ствол в руках живёт ещё и отдельной сущностью-оружием: без этого
// боец выходит с карабином по умолчанию, что бы ни лежало в профиле.
EquipWeapon(world, a, inv->weapon);
// Запас здоровья зависит от надетого (броня, оберег), и посчитан он
// будет только на первом шаге. Без явной синхронизации боец выходит
// на этаж с прежними ста единицами при полутора сотнях максимума —
// то есть уже раненым, ничего не сделав.
SyncLoadout(world, a);
a.hp = a.maxHp;
}
}
void Game::Descend()
{
// Отряд переезжает КАК ЕСТЬ: здоровье, оружие и сумки. В этом весь забег —
// спуск обязан ощущаться как продолжение, а не как новый заход.
SquadCarry carry;
SnapshotSquad(carry);
++depth;
++floorsCleared;
report.deepest = std::max(report.deepest, depth);
BuildFloor(&carry);
}
// -----------------------------------------------------------------------------
// Что переезжает между этажами. Отдельная структура, а не «не трогать мир»:
// Reset пересоздаёт весь ecs::World, и по-другому сохранить бойцов нельзя без
// того, чтобы половина систем начала различать «новый мир» и «тот же мир».
// -----------------------------------------------------------------------------
void Game::SnapshotSquad(SquadCarry& out) const
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
out.alive[i] = a.alive;
out.hp[i] = a.hp;
out.ammo[i] = a.ammoReserve;
if (const Inventory* inv = world.Get<Inventory>(a.self)) out.inv[i] = *inv;
}
out.valid = true;
}
void Game::RestoreSquad(const SquadCarry& carry)
{
if (!carry.valid) return;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (Inventory* inv = world.Get<Inventory>(a.self))
{
*inv = carry.inv[i];
// Ствол в руках — часть инвентаря, но живёт ещё и отдельной
// сущностью-оружием. Без этого боец приезжает на новый этаж с
// карабином по умолчанию, что бы он ни нашёл этажом выше.
EquipWeapon(world, a, inv->weapon);
}
// Павший остаётся павшим: воскрешение на лестнице обесценило бы
// единственную настоящую потерю в забеге.
a.alive = carry.alive[i];
a.hp = a.alive ? std::max(1.0f, carry.hp[i]) : 0.0f;
a.ammoReserve = carry.ammo[i];
}
}
void Game::BuildFloor(const SquadCarry* carry)
{
// Этаж из комнат — только если набор подключён снаружи. Не собрался
// (пустой набор, битые комнаты) — откатываемся на захардкоженную карту:
// прототип обязан запускаться на голом репозитории.
levelGenerated = false;
levelUnknownSpawns = 0;
atExit = false;
Vec2 start{SQUAD_START_X, SQUAD_START_Y};
const FloorSpec spec = DescribeFloor(depth);
if (rooms && catalog && rooms->Count() > 0 &&
GenerateLevel(*rooms, *catalog, spec, levelSeed, map, level))
{
levelGenerated = true;
start = level.squadStart;
}
else
{
map.Generate();
level = GeneratedLevel{};
level.floor = spec;
}
// Сколько слотов занято. Из профиля, потому что размер отряда — это
// накопленный результат, а не состояние вылазки. Нет профиля (приёмка,
// dev-прогоны) — все пять, как было всегда: критерии меряются на пятерых.
squad.roster = profile ? std::max(1, std::min(NUM_AGENTS, profile->squadSize)) : NUM_AGENTS;
world.Clear(); // бойцы, оружие, мишени, пули — всё разом
squad.Init(world, start);
if (carry) RestoreSquad(*carry);
// Собранный этаж населяет СЕБЯ САМ: захардкоженная расстановка мишеней
// и тварей относится к другой карте, и мешать их — верный способ получить
// тварь в стене на пустом месте.
if (levelGenerated)
{
int unknownLoot = 0;
SpawnFromLevel(world, level.spawns, map, &levelUnknownSpawns);
SpawnLootFromLevel(world, level.loot, depth, levelSeed, &unknownLoot);
levelUnknownSpawns += unknownLoot;
MaybeSpawnCapsule();
}
else
{
SpawnTargets(world, combatMode, &map);
}
// Снаряжение раздаётся только тем, кто пришёл ни с чем: переезжающий отряд
// уже одет тем, что нашёл, и выдавать ему стартовый комплект заново
// означало бы стирать весь смысл находок.
//
// Есть профиль — одеваемся из него: это и есть «в чём вернулись с прошлой
// вылазки». Нет профиля (приёмка, dev-прогоны) — прежний фиксированный
// комплект, чтобы замеры не поехали.
if (!carry)
{
if (profile) ApplyProfileLoadout();
else if (combatMode) EquipKits();
// Улучшения профиля — сюда же: они часть того, с чем отряд выходит на
// вылазку. Нет профиля (приёмка, dev-прогоны) — нет и прибавок.
squad.carryBonus = 0.0f;
squad.sightBonus = 0.0f;
squad.hpBonus = 0.0f;
float ammoBonus = 0.0f;
if (profile)
{
// Дерево кладёт свои числа в те же каналы, что и снаряжение
// (sim/tree.h): своих у него нет и быть не должно.
const TreeBonus tb = profile->Tree();
squad.carryBonus = tb.carry;
squad.sightBonus = tb.sight;
squad.hpBonus = tb.health;
ammoBonus = tb.ammo;
squad.tree = tb;
}
// Запас патронов выдаётся вместе со снаряжением и только в БОЮ.
// В песочнице приёмки его нет вовсе (-1 = бесконечно): критерии меряют
// правила стрельбы, а не то, на сколько минут хватило патронов.
// Патроны выдаются ЛЮДЯМ. Пустому слоту запас не нужен, и выдай его
// туда — отряд из одного оперативника выходил бы с пятикратным
// боезапасом, разложенным по четырём никому.
//
// Аффикс ROUNDS прибавляется ЛИЧНО тому, у кого он выкатан: запас
// патронов личный, в отличие от света. Складывать его в общий котёл
// значило бы, что найденная кем-то одним разгрузка кормит весь отряд.
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
a.ammoReserve =
combatMode ? (a.present ? int(g_tune.startAmmo + ammoBonus + a.mods.ammo) : 0) : -1;
}
// Аффикс LIGHT — наоборот, ОБЩИЙ: фонарь у отряда один (sim/items.h),
// и «мой фонарь держит дольше» тут не бывает.
float lightBonus = 0.0f;
for (int i = 0; i < NUM_AGENTS; ++i)
if (squad.agents[i].present) lightBonus += squad.agents[i].mods.light;
// Свежий фонарь выдаётся на ВЫЛАЗКУ, а не на этаж: заряд переезжает
// вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь
// смысл — на четвёртом этаже светить уже почти нечем.
// В песочнице приёмки света бесконечно (-1), как и патронов.
squad.lightCharge =
combatMode ? (LIGHT_FULL + lightBonus + squad.tree.light) : -1.0f;
}
lanes.Clear();
metrics.Reset();
events.Clear();
aim = AimState{};
for (bool& v : lastSolveValid) v = false;
simTime = 0.0f;
}
void Game::PlaceSquad(Vec2 at, bool clumped)
{
squad.anchor = at;
squad.anchorPrev = at;
squad.anchorVel = {};
squad.state = AgentState::HOLDING;
squad.idleTimer = g_tune.holdDelay;
squad.holdElapsed = 0.0f;
squad.urgency = 0.0f;
squad.sprinting = false;
squad.formSpacing = 1.0f;
squad.formStretch = 1.0f;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
a.pos = clumped ? at : squad.SlotWorld(i);
map.ResolveCircle(a.pos, a.bodyRadius);
a.prevPos = a.pos;
a.postPos = a.pos;
a.vel = {};
a.commitTimer = 0.0f;
a.state = AgentState::HOLDING;
a.currentTargetId = -1;
a.hasLane = false;
// Недорубленный взмах пережил бы расстановку и уехал в следующий
// сценарий приёмки. Параметры при этом не трогаем — они из tuning.
a.melee.state = MeleeState::IDLE;
a.melee.phase = 0.0f;
a.melee.cooldown = 0.0f;
a.melee.targetId = -1;
a.melee.inReach = false;
a.melee.arcBlockedByAlly = false;
a.melee.hitMask = 0;
a.melee.comboStep = 0; // связка тоже не должна переезжать в новый сценарий
a.melee.comboTimer = 0.0f;
// Магазин тоже относится к расстановке: недозаряженный ствол переехал бы
// в следующий сценарий приёмки вместе с бойцом.
a.gun.ammo = a.gun.def.magazine;
a.gun.reloadTimer = 0.0f;
a.gun.cooldown = 0.0f;
a.gun.recoilDeg = 0.0f;
a.gun.shotSeq = 0;
// Расстановка — это НАЧАЛО сценария. Павший боец, переехавший в него
// трупом, испортил бы и замеры, и игру.
//
// Но ТОЛЬКО занятые слоты: пустой воскрешать нечего и некого. Строка
// писалась, когда отряд всегда был впятером, и после появления
// ростера (M24) оживляла четверых несуществующих. Поймано `--wall`:
// одиночка выходил на этаж впятером.
a.alive = a.present;
a.hp = a.present ? a.maxHp : 0.0f;
a.hurtTimer = 0.0f;
// Захват — тоже состояние сценария: боец, переехавший в новый сценарий
// в лапах хвата, просто не сдвинулся бы с места.
a.grabbedTimer = 0.0f;
}
ClearBullets(world);
lanes.Clear();
metrics.Reset();
events.Clear();
simTime = 0.0f;
}
SolverContext Game::MakeContext() const
{
SolverContext ctx;
ctx.map = &map;
ctx.agents = squad.agents.data();
ctx.agentCount = NUM_AGENTS;
ctx.targets = &targets;
ctx.lanes = &lanes;
ctx.anchor = squad.anchor;
ctx.assignedTargetId = aim.manual ? aim.assignedTargetId : -1;
return ctx;
}
// -----------------------------------------------------------------------------
// Расходники. Одно место на все: бинт, стим и пачка патронов — это «предмет,
// который тратится, когда стало нужно», и разводить их по разным системам
// значило бы трижды написать одно правило.
//
// Лечиться можно только ВНЕ схватки: перевязка посреди боя обесценила бы урон,
// который тварь только что нанесла. Патроны — наоборот, набиваются когда угодно:
// боец с пустым стволом и пачкой в сумке, стоящий столбом, выглядел бы поломкой.
// Батарея ставится ровно в тот шаг, когда фонарь сел: держать её в сумке при
// погасшем свете отряду незачем, а темнота ради темноты — не решение игрока.
// -----------------------------------------------------------------------------
void Game::UseConsumables(float dt)
{
(void)dt;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive) continue;
const Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
// Насколько близко ближайшая живая тварь: лечиться при ней нельзя.
float nearest = 1e9f;
for (const Target& t : targets)
{
if (!t.alive || t.IsProp()) continue;
nearest = std::min(nearest, Distance(t.pos, a.pos));
}
const bool hurt = a.hp < a.maxHp * g_tune.healAtFrac;
const bool safe = nearest > g_tune.healSafeDist;
// Свет — ресурс ОТРЯДА, поэтому условие пересчитывается на каждом бойце:
// поставил батарею первый — второй уже видит заряженный фонарь и свою
// не тратит.
const bool dark = squad.LightOut();
// Патроны нужны тому, у кого ВООБЩЕ есть ствол, — не только тому, кто
// держит его прямо сейчас. Боец с клинком в руках и запасным карабином
// в сумке иначе таскал бы найденную пачку до конца забега мёртвым
// грузом, а это ровно то, от чего расходники и лечились.
bool ownsGun = !a.IsMelee();
for (int s = 0; s < SQUAD_BAG_SLOTS && !ownsGun; ++s)
if (ItemDefOf(squad.bag.slots[s]).kind == ItemKind::FIREARM) ownsGun = true;
const bool dry = (a.ammoReserve == 0) && ownsGun;
for (int s = 0; s < SQUAD_BAG_SLOTS; ++s)
{
const ItemDef d = ItemDefOf(squad.bag.slots[s]);
if (d.kind != ItemKind::CONSUMABLE) continue;
const bool healsNow = (d.heal > 0.0f) && hurt && safe;
const bool loadsNow = (d.rounds > 0) && dry;
const bool lightsNow = (d.charge > 0.0f) && dark;
if (!healsNow && !loadsNow && !lightsNow) continue;
if (healsNow) a.hp = std::min(a.maxHp, a.hp + d.heal);
if (loadsNow) a.ammoReserve += d.rounds;
if (lightsNow) squad.lightCharge = d.charge;
squad.bag.slots[s] = ItemId::NONE;
events.Push(CombatEventKind::ITEM_USED, i, a.pos, Vec2{});
++metrics.itemsUsed;
break; // не больше одного расходника за шаг: тратить пачку разом незачем
}
}
}
void Game::Step(float dt)
{
if (in.setFormation >= 0 && in.setFormation < int(Formation::COUNT))
squad.formation = Formation(in.setFormation);
// --- 0. статы снаряжения: с сущностей-оружия на бойцов ---------------------
// Одна система в начале шага. Всё остальное дальше читает уже готовые числа
// и не лазает в ECS: ai/ про мир сущностей не знает вообще.
// Общая ноша делится поровну на живых. Потерять бойца — значит взвалить
// его долю на остальных: обратно наверх отряд пойдёт медленнее, чем шёл
// сюда, и это не побочный эффект, а то, ради чего сумка общая.
const float bagShare = squad.bag.Load() / float(squad.Carriers());
SquadBuffs buffs;
buffs.sight = squad.sightBonus;
buffs.hp = squad.hpBonus;
buffs.carry = squad.carryBonus;
buffs.lightOut = squad.LightOut();
buffs.tree = &squad.tree;
// Ярость живёт таймером и гаснет сама. Множитель урона от неё уезжает в
// SyncLoadout вместе с аффиксами: своего канала у триггера нет.
squad.rageTimer = std::max(0.0f, squad.rageTimer - dt);
buffs.rage = (squad.rageTimer > 0.0f) ? SQUAD_RAGE_DAMAGE : 0.0f;
// Откаты триггеров и стаки рывка тикают у КАЖДОГО бойца, включая павших:
// иначе набранное на прошлом этаже держалось бы вечно.
for (int i = 0; i < NUM_AGENTS; ++i) squad.agents[i].trig.Tick(dt);
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs);
for (int i = 0; i < NUM_AGENTS; ++i)
squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt);
// --- 0.5 расходники ---------------------------------------------------------
// Ручных действий в игре нет (спек 5), поэтому расходники боец применяет
// САМ. Иначе бинт остаётся числом в каталоге и мёртвым грузом в сумке —
// ровно тем, чем он и был до этой вехи.
UseConsumables(dt);
UpdateTargets(targets, dt);
// Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует
// на то, что видит сейчас, а не на прошлый кадр.
UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events,
buffs.lightOut ? DARK_AGGRO_MUL : 1.0f, &squad.rageTimer);
// --- 1. якорь и состояние отряда -----------------------------------------
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
const Vec2 moveWorld = v2d::ScreenDirToWorld(in.moveScreen);
squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt);
// --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) -----
const Vec2 lightFrom = squad.Centroid();
squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt);
// --- 2.5 свет -------------------------------------------------------------
// Фонарь садится за ПРОЙДЕННЫЙ ПУТЬ, и путь меряется по центру отряда, а не
// по якорю: якорь — это курсор, он ходит и тогда, когда отряд упёрся в
// стену, и светил бы фонарь на месте.
//
// Считается ТОЛЬКО в ADVANCING, то есть когда игрок ведёт отряд. И это не
// мелочь: в строю бойцы постоянно подтягиваются к слотам и расталкиваются,
// и центр отряда за две секунды «стоянки» проходит около 0.8 тайла. Списывай
// мы и это — заряд таял бы, пока игрок думает над картой, то есть вернулся
// бы ровно тот таймер, ради отсутствия которого расход и считается за путь
// (docs/17-extraction.md). Замер поймал это на трёх глубинах сразу.
//
// Перестановки в бою при этом бесплатны — и правильно: свет тратит РАЗВЕДКА,
// а не драка.
if (squad.lightCharge > 0.0f && squad.state == AgentState::ADVANCING)
{
const float walked = Distance(squad.Centroid(), lightFrom);
squad.lightCharge = std::max(0.0f, squad.lightCharge - walked * LightDrain(depth));
}
// --- 3. ручное прицеливание (6.8) ----------------------------------------
UpdateAim(dt);
// --- 4. цели по НОВЫМ позициям: линии огня у стрелков, сектор у клинков ---
SolverContext ctx = MakeContext();
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive) continue;
if (a.IsMelee()) EvaluateMeleeTarget(a, i, ctx);
else EvaluateAgentLane(a, i, ctx);
}
// --- 5. реестр активных линий (6.6) --------------------------------------
lanes.Clear();
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (!a.alive) continue; // мёртвый ничего не резервирует
if (a.IsMelee())
{
// Траектория клинка резервируется тем же механизмом, что и линия
// огня: свои расступаются с дороги замаха, а не выясняют это телом.
// Резерв задаётся явно и шире отрезка — по боковому размаху сектора,
// иначе союзника отжимает ровно во фланг дуги.
if (a.melee.targetId >= 0 && (a.melee.inReach || a.melee.Committed()))
{
const float flank = a.melee.reach * std::sin(a.melee.HalfArc());
lanes.Add(i, a.pos, MeleeStrikeEnd(a, ctx), flank * g_tune.meleeReserveFrac);
}
continue;
}
if (a.hasLane && a.currentTargetId >= 0)
{
lanes.Add(i, a.MuzzleTowards(targets[a.currentTargetId].pos),
targets[a.currentTargetId].pos);
}
else if (a.laneIntent)
{
// Линия перекрыта своим: резервируем её, чтобы перекрывшего отжало
// в сторону. Без этого сектор обстрела никогда не освободится.
lanes.Add(i, a.MuzzleTowards(a.laneEnd), a.laneEnd);
}
}
// --- 6. решатель огневых позиций (6.5) / подход клинка --------------------
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive) { lastSolveValid[i] = false; continue; }
a.commitTimer = std::max(0.0f, a.commitTimer - dt);
if (a.IsMelee())
{
// Решателю здесь делать нечего: у клинка одна огневая позиция —
// вплотную к цели. Нет цели в своём круге — назад в строй.
a.postPos = MeleeGoal(a, ctx, squad.SlotWorld(i));
lastSolveValid[i] = false;
continue;
}
a.solveTimer -= dt;
if (a.solveTimer > 0.0f) continue;
a.solveTimer += g_tune.solveInterval;
if (!enableSolver) continue;
if (a.state != AgentState::HOLDING) continue;
if (a.commitTimer > 0.0f) continue;
if (a.priorityLane) continue; // линия к приоритетной цели уже есть
lastSolveValid[i] = SolveFiringPosition(a, i, ctx, lastSolve[i]);
// Решателю не по чему работать (ни одной цели в досягаемости) — случай,
// который спек не описывает. Возвращаемся в свой слот строя, иначе агент,
// отставший от отряда, остаётся стоять на месте навсегда.
if (!lastSolveValid[i]) a.postPos = squad.SlotWorld(i);
}
// --- 7. доворот ствола / клинка -------------------------------------------
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive) continue;
// На замахе и ударе ось зафиксирована: доворачивать её задним числом —
// это стрельба с самонаведением, только клинком.
if (a.IsMelee() && a.melee.Committed())
{
a.facing = a.melee.dir;
continue;
}
if (a.currentTargetId >= 0) AimAt(a, targets[a.currentTargetId].pos, dt);
else AimAlongMotion(a, dt);
}
// --- 8. атака: огонь и клинок ---------------------------------------------
FireWeapons(ctx, dt);
UpdateMelee(ctx, dt);
// --- 9. пули ---------------------------------------------------------------
UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events,
depth, squad.tree.reaction);
// --- 10. находки -----------------------------------------------------------
// После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге,
// обязан быть подбираемым сразу, а не через кадр.
int rescuedBy = -1;
lootTaken += UpdateLootPickup(world, squad.agents.data(), NUM_AGENTS, squad.bag,
squad.Carriers(), squad.carryBonus, &events, dt, &rescuedBy);
// Найденный оперативник встаёт в строй ТУТ ЖЕ, посреди этажа: капсула —
// это место, до которого дошли, а не покупка (sim/profile.h).
if (rescuedBy >= 0) RescueOperative(rescuedBy);
// --- 11. опыт за убитых ----------------------------------------------------
AwardKillXp();
// --- 12. спуск -------------------------------------------------------------
// Симуляция только ОТМЕЧАЕТ, что отряд дошёл. Сам этаж она не меняет: смена
// мира посреди шага, между движением и стрельбой, оставила бы половину
// систем со ссылками на снесённые сущности.
UpdateExit();
// --- 13. метрики -----------------------------------------------------------
metrics.Sample(squad, dt);
simTime += dt;
}
// -----------------------------------------------------------------------------
// Опыт. Цена убитого — его danger из каталога движка, помноженная на множитель
// этажа: и то и другое живёт в данных, а не здесь.
//
// Обстановка опыта не даёт по определению: ящик — это обстановка, и платить за
// него значило бы сделать разбивание хлама самым выгодным занятием в игре.
// -----------------------------------------------------------------------------
void Game::AwardKillXp()
{
const float scale = g_tune.xpPerDanger * level.floor.xpScale;
for (Target& t : targets)
{
if (t.alive) { t.countedDead = false; continue; }
if (t.countedDead) continue;
t.countedDead = true;
// Материалы падают и с ОБСТАНОВКИ: лом — это разбитый ящик, и других
// источников у него нет. Опыт за неё по-прежнему не начисляется,
// иначе разбивание хлама стало бы самым выгодным занятием в игре.
if (t.IsProp())
{
const MaterialDrop d = PropDrop(depth);
for (int m = 0; m < MATERIAL_COUNT; ++m) runMaterials[m] += d.amount[m];
continue;
}
if (t.IsDummy()) continue;
float danger = 1.0f;
if (catalog)
if (const EntityDesc* d = catalog->Find(IdOfEnemyKind(t.kind))) danger = d->danger;
xp += danger * scale;
// И СРАЗУ в профиль. Опыт больше не надо выносить: он платится за
// работу, а не за удачный выход. Вынос — про ВЕЩИ, и ставка осталась
// на них; заставлять игрока терять ещё и понимание того, что он уже
// сделал, значит наказывать дважды за одну смерть.
if (profile) profile->bankedXp += danger * scale;
// Ядра падают только со «своих» этажей — тема и глубина решают, что
// именно (sim/craft.h). Это и есть причина возвращаться на средние
// глубины, когда рекорд ушёл вниз.
const MaterialDrop d = KillDrop(depth, int(level.floor.theme), danger);
for (int m = 0; m < MATERIAL_COUNT; ++m) runMaterials[m] += d.amount[m];
}
}
// -----------------------------------------------------------------------------
// Капсула вскрыта: свободный слот занимает человек.
//
// Он приходит СО СВОИМИ числами (Profile::merc) и со стартовым снаряжением:
// найденный в капсуле не таскает с собой плиту и магнум. Числа сохранятся в
// профиле только если он ДОЙДЁТ до выхода живым — как и всё остальное.
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Капсула на этаже. Редко и с двумя границами (sim/profile.h):
//
// * в среднем раз в RESCUE_EVERY этажей — иначе пятый оперативник перестаёт
// быть событием забега;
// * не чаще раза в RESCUE_COOLDOWN — иначе две подряд закрывают полотряда за
// один спуск, и вся редкость оказывается на бумаге.
//
// Кладётся В ТОЧКУ ЛУТА: там гарантированно достижимый пол. Своя раскладка тут
// не нужна — капсула и есть находка, просто вместо вещи в ней человек.
// -----------------------------------------------------------------------------
void Game::MaybeSpawnCapsule()
{
if (!profile || !combatMode || !profile->CanRescue()) return;
if (level.loot.empty()) return;
const int since = depth - profile->lastRescueDepth;
if (profile->lastRescueDepth > 0 && since < RESCUE_COOLDOWN) return;
// Бросок от сида уровня: тот же этаж обязан давать ту же встречу.
uint32_t rng = uint32_t(levelSeed) * 2246822519u + uint32_t(depth) * 3266489917u;
rng = rng * 1664525u + 1013904223u;
const float roll = float((rng >> 8) & 0xFFFFu) / 65536.0f;
if (roll >= 1.0f / float(RESCUE_EVERY)) return;
const size_t at = size_t(rng % uint32_t(level.loot.size()));
SpawnFloorItem(world, ItemInstance(ItemId::CAPSULE), level.loot[at].pos);
}
void Game::RescueOperative(int finder)
{
(void)finder;
if (!profile || !profile->CanRescue()) return;
// Первый незанятый слот. Слоты по порядку, а не «любой»: строй читается по
// номерам, и дыра в середине означала бы, что клин собран не по правилам.
int slot = -1;
for (int i = 0; i < NUM_AGENTS && slot < 0; ++i)
if (!squad.agents[i].present) slot = i;
if (slot < 0) return;
uint32_t rng = uint32_t(levelSeed) * 40503u + uint32_t(depth) * 7919u + uint32_t(slot);
profile->merc[slot] = RollMerc(rng);
++profile->squadSize;
profile->lastRescueDepth = depth;
Agent& a = squad.agents[slot];
a.present = true;
a.alive = true;
squad.roster = std::max(squad.roster, slot + 1);
// Снаряжение — стартовое: человек выходит из капсулы, а не из оружейной.
Profile fresh;
fresh.ResetLoadouts();
if (Inventory* inv = world.Get<Inventory>(a.self))
{
*inv = fresh.loadout[slot];
EquipWeapon(world, a, inv->weapon);
}
a.merc = profile->merc[slot];
SyncLoadout(world, a);
a.hp = a.maxHp;
a.ammoReserve = combatMode ? int(g_tune.startAmmo) : -1;
events.Push(CombatEventKind::LOOT_TAKEN, slot, a.pos, Vec2{});
}
void Game::UpdateExit()
{
if (!levelGenerated) { atExit = false; atExtract = false; return; }
const float r = g_tune.descendRadius;
const bool nearExit = Distance(squad.anchor, level.exitPos) <= r;
// ЭВАКУАЦИЯ ВОЗМОЖНА С ЛЮБОГО ЭТАЖА И ИЗ ЛЮБОЙ ТОЧКИ.
//
// Раньше уйти можно было, только стоя на кольце аномалии. Это выглядело как
// правило, а работало как ловушка: отряд, который решил уйти на середине
// этажа, всё равно должен был дойти до конкретной клетки — и умирал по
// дороге ровно там, где игрок уже принял решение не рисковать.
//
// Ставка при этом не ослабла ни на грамм: уносишь то, что на выживших,
// теряешь половину при вайпе. Решение «глубже или домой» осталось решением,
// просто перестало требовать пешей прогулки обратно.
//
// Чекпоинты после этого значат ровно то, чем и должны быть: не «где можно
// выйти», а «откуда можно начать следующую вылазку».
atExit = nearExit;
atExtract = true;
// Событие пишется на ФРОНТЕ входа в кольцо: журнал не должен забиваться
// одним и тем же сообщением, пока отряд стоит на месте.
if (nearExit && !atExit) events.Push(CombatEventKind::DESCEND, -1, level.exitPos, Vec2{});
}
void Game::UpdateAim(float dt)
{
if (in.toggleAimMode)
{
aim.manual = !aim.manual;
if (aim.manual)
{
// Стартуем от текущего направления отряда.
aim.dir = AngleOf(squad.forward);
aim.assignedTargetId = -1;
}
else
{
aim.assignedTargetId = -1;
}
}
if (!aim.manual)
{
aim.assignedTargetId = -1;
return;
}
aim.dir = WrapPi(aim.dir + in.aimTurn * g_tune.aimTurnRateDeg * DEG2RAD_F * dt);
// Назначенная цель — ближайшая мишень в конусе +-aimConeDeg от вектора прицела.
const float cone = g_tune.aimConeDeg * DEG2RAD_F;
// Собираем кандидатов в конусе, отсортированных по дальности от якоря.
int inCone[MAX_TARGETS];
int coneCount = 0;
for (int i = 0; i < int(targets.size()) && coneCount < MAX_TARGETS; ++i)
{
if (!targets[i].alive) continue;
const Vec2 d = targets[i].pos - squad.anchor;
if (std::fabs(AngleDiff(aim.dir, AngleOf(d))) > cone) continue;
inCone[coneCount++] = i;
}
for (int i = 1; i < coneCount; ++i) // вставками, список крошечный
{
const int v = inCone[i];
int j = i - 1;
while (j >= 0 &&
DistanceSq(targets[inCone[j]].pos, squad.anchor) > DistanceSq(targets[v].pos, squad.anchor))
{
inCone[j + 1] = inCone[j];
--j;
}
inCone[j + 1] = v;
}
if (coneCount == 0)
{
aim.assignedTargetId = -1;
return;
}
int current = -1;
for (int i = 0; i < coneCount; ++i)
if (inCone[i] == aim.assignedTargetId) current = i;
if (current < 0) aim.assignedTargetId = inCone[0];
else if (in.cycleTarget) aim.assignedTargetId = inCone[(current + 1) % coneCount];
}
// -----------------------------------------------------------------------------
// Огонь: перезарядка, увод ствола, спуск.
//
// Про разброс. Раньше пуля летела РОВНО по проверенному отрезку, и это было
// главным аргументом за «FRIENDLY_HITS = 0 без исключений». Теперь у выстрела
// есть конус, и аргумент остался тем же — просто проверяется не отрезок, а весь
// конус (ai/firing_solver.cpp, ConeClearOfAllies). Пуля физически не может уйти
// туда, где не проверяли: угол отклонения берётся из ТОЙ ЖЕ величины SpreadRad,
// по которой считалась ширина проверки.
//
// Разброс не случаен в смысле генератора: это чистый хеш от (стрелок, номер
// выстрела). Прогон воспроизводим, --headless по-прежнему годится как тест.
// -----------------------------------------------------------------------------
void Game::FireWeapons(const SolverContext& ctx, float dt)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
a.fired = false;
if (!a.alive || a.IsMelee()) continue;
Firearm& g = a.gun;
g.cooldown = std::max(0.0f, g.cooldown - dt);
// Увод ствола оседает сам: очередь «раскрывается», пауза её собирает.
g.recoilDeg = std::max(0.0f, g.recoilDeg - g.def.recoilDecay * dt);
// --- перезарядка ------------------------------------------------------
if (g.Reloading())
{
g.reloadTimer -= dt;
if (g.reloadTimer <= 0.0f)
{
g.reloadTimer = 0.0f;
// Магазин набивается ИЗ ЗАПАСА. Запас отрицательный — песочница
// приёмки, там патроны бесконечны (sim/agent.h).
if (a.ammoReserve < 0)
{
g.ammo = g.def.magazine;
}
else
{
const int take = std::min(g.def.magazine, a.ammoReserve);
g.ammo = take;
a.ammoReserve -= take;
}
events.Push(CombatEventKind::RELOAD_DONE, i, a.pos, FromAngle(a.facing));
}
continue;
}
if (g.Empty())
{
// Нечем набивать — перезарядка даже не начинается. Боец с пустым
// стволом не изображает работу: он молчит, и это видно.
if (a.ammoReserve == 0) continue;
g.reloadTimer = g.def.reloadTime;
events.Push(CombatEventKind::RELOAD_START, i, a.pos, FromAngle(a.facing));
++metrics.reloads;
continue;
}
// Бег с курсором на вытянутом поводке — это приказ идти, а не драться.
if (squad.sprinting) continue;
if (!a.hasLane || a.currentTargetId < 0) continue;
if (g.cooldown > 0.0f) continue;
const Vec2 tp = targets[a.currentTargetId].pos;
// Ствол должен быть доведён до цели — иначе выстрел выглядит враньём.
if (std::fabs(AngleDiff(a.facing, AngleOf(tp - a.pos))) >
g_tune.aimToleranceDeg * DEG2RAD_F)
continue;
// Повторная проверка конуса по позициям этого шага: «никаких исключений».
if (!LaneClearFrom(a.pos, i, tp, ctx)) continue;
const Vec2 muzzle = a.MuzzleTowards(tp);
// И проверка на время полёта пули — свой не должен успеть влезть.
if (AllyInterceptsShot(muzzle, tp, i, ctx)) continue;
// Отклонение внутри уже проверенного конуса.
//
// Картечь ничего не ослабляет: ВСЕ дробины уходят в тот же конус
// SpreadRad, который только что проверили на своих. Поэтому у дробовика
// конус широкий, и выстрел отсекается чаще, — это его цена, а не
// послабление правила 6.3.
const float spread = a.SpreadRad();
const float axis = AngleOf(Normalized(tp - muzzle, FromAngle(a.facing)));
const float travel = Distance(muzzle, tp);
const int pellets = std::max(1, g.def.pellets);
Vec2 dir{};
for (int p = 0; p < pellets; ++p)
{
dir = FromAngle(axis + g.ShotOffset(i, spread, p));
SpawnBullet(world, g.def, muzzle, dir, travel, i, a.pen);
}
events.Push(CombatEventKind::SHOT, i, muzzle, dir);
g.cooldown = g.def.fireInterval;
g.ammo = std::max(0, g.ammo - 1);
++g.shotSeq;
g.recoilDeg = std::min(g.def.spreadRecoilMax, g.recoilDeg + g.def.spreadRecoilDeg);
a.fired = true;
++metrics.shotsFired;
}
}
// -----------------------------------------------------------------------------
// Ближний бой: замах -> удар -> восстановление.
//
// Правило то же, что и у стрельбы, только в угловой форме: свой в секторе —
// не бьём. Оно проверяется дважды, как и у ствола:
// 1) на старте замаха — сектор reach x arcDeg обязан быть чист;
// 2) каждый шаг активного окна — если свой всё-таки влез под лезвие,
// взмах ОБРЫВАЕТСЯ. Урон союзнику не наносится ни при каком раскладе,
// поэтому FRIENDLY_HITS клинок физически поднять не может.
//
// Урон идёт не «по факту начала удара», а по реально пройденной за шаг полосе
// лезвия: то, что видно на экране, и то, что получило урон, — одна и та же дуга.
// -----------------------------------------------------------------------------
void Game::UpdateMelee(const SolverContext& ctx, float dt)
{
const float bladeHalf = g_tune.meleeBladeDeg * 0.5f * DEG2RAD_F;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
if (!a.alive || !a.IsMelee()) continue;
MeleeWeapon& m = a.melee;
m.cooldown = std::max(0.0f, m.cooldown - dt);
a.fired = false;
switch (m.state)
{
case MeleeState::IDLE:
{
// Связка живёт своим окном: не продолжил вовремя — начинаешь
// серию сначала.
m.comboTimer = std::max(0.0f, m.comboTimer - dt);
if (m.comboTimer <= 0.0f) m.comboStep = 0;
// На бегу боец не гоняется за целями (за него это решает
// Squad::GoalFor — он идёт в слот), но если цель сама оказалась
// под рукой, клинок работает. Бой на ходу — это нормально.
if (m.targetId < 0 || !m.inReach) break;
if (m.cooldown > 0.0f) break;
if (m.arcBlockedByAlly) break; // посчитано в EvaluateMeleeTarget
const Vec2 tp = targets[m.targetId].pos;
const float axis = AngleOf(tp - a.pos);
// Корпус должен быть довёрнут — иначе удар выглядит враньём
// ровно так же, как выстрел мимо ствола.
if (std::fabs(AngleDiff(a.facing, axis)) >
g_tune.meleeAimToleranceDeg * DEG2RAD_F)
break;
const MeleeComboStep& cs = m.NextComboStep();
m.BakeSwing(cs); // ширина, урон и тайминги — на весь взмах
m.state = MeleeState::WINDUP;
m.phase = m.curWindup;
m.dir = axis;
if (cs.flipSide) m.sweepSign = -m.sweepSign;
m.hitMask = 0;
// Интервал считается от НАЧАЛА замаха; внутри связки он короче.
m.cooldown = m.interval * cs.intervalMul;
break;
}
case MeleeState::WINDUP:
{
m.phase -= dt;
if (m.phase > 0.0f) break;
m.state = MeleeState::STRIKE;
m.phase = m.curStrike;
events.Push(CombatEventKind::MELEE_SWING, i, a.pos, FromAngle(m.dir));
++metrics.meleeSwings;
if (m.comboStep >= 0 && m.comboStep < MELEE_COMBO_STEPS)
++metrics.meleeComboSwings[m.comboStep];
break;
}
case MeleeState::STRIKE:
{
const float p0 = m.Progress();
m.phase -= dt;
const float p1 = m.Progress();
const float ang0 = m.BladeAngleAt(p0);
const float ang1 = m.BladeAngleAt(p1);
// 1. свой под лезвием -> взмах оборван
bool aborted = false;
for (int k = 0; k < ctx.agentCount; ++k)
{
if (k == i) continue;
const Agent& ally = ctx.agents[k];
if (!BladeSweepHits(a.pos, ang0, ang1, bladeHalf,
m.reach + g_tune.meleeAllyMargin, ally.pos,
ally.bodyRadius + g_tune.meleeAllyMargin))
continue;
aborted = true;
break;
}
if (aborted)
{
events.Push(CombatEventKind::MELEE_ABORT, i, a.pos, FromAngle(ang1));
++metrics.meleeAborts;
m.state = MeleeState::RECOVER;
m.phase = m.curRecover;
break;
}
// 2. мишени, по одной за взмах
// MAX_TARGETS — это ширина hitMask, а не «столько влезет»:
// за пределами маски удар прошёл бы по одной цели дважды.
for (int t = 0; t < int(targets.size()) && t < MAX_TARGETS; ++t)
{
Target& tg = targets[t];
if (!tg.alive) continue;
if (m.hitMask & (uint64_t(1) << t)) continue;
if (!BladeSweepHits(a.pos, ang0, ang1, bladeHalf, m.reach, tg.pos, tg.radius))
continue;
if (map.RaycastBlocked(a.pos, tg.pos)) continue; // сквозь стену не рубим
m.hitMask |= (uint64_t(1) << t);
a.fired = true;
const Vec2 dir = Normalized(tg.pos - a.pos, FromAngle(m.dir));
const Vec2 contact = tg.pos - dir * tg.radius;
const bool prop = tg.IsProp();
// Через ту же формулу, что и пуля: клинок отличается
// формой удара, а не правилами урона.
tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist,
ResistCutOf(tg.status) + a.pen) *
VulnerabilityOf(tg.status);
tg.hurtTimer = g_tune.targetHurtTime;
KnockTarget(tg, dir, m.curKnock);
ApplyElementalHit(targets, t, a.melee.elem, depth, &events, false,
squad.tree.reaction);
// Триггеры клинка — те же, что у ствола, и в том же месте:
// сразу за уроном (sim/trigger.h).
if (!prop)
{
const float onHit = a.mods.trigger[int(TriggerKind::ON_HIT)];
if (tg.alive && TriggerReady(a.trig, TriggerKind::ON_HIT, onHit) &&
a.trig.Chance(onHit))
{
ApplyElementalHit(targets, t, ElementMix::Of(Element::INC, 1.0f),
depth, &events, false, squad.tree.reaction);
TriggerFired(a.trig, TriggerKind::ON_HIT);
}
const float onKill = a.mods.trigger[int(TriggerKind::ON_KILL)];
if (tg.hp <= 0.0f && TriggerReady(a.trig, TriggerKind::ON_KILL, onKill) &&
a.trig.Chance(onKill))
{
a.trig.AddKillStack();
TriggerFired(a.trig, TriggerKind::ON_KILL);
}
}
// Щепки вместо крови и мимо счётчика ударов по врагу:
// разрубленный ящик не должен красить пол и накручивать
// MELEE HITS в метриках.
if (prop)
{
events.Push(CombatEventKind::HIT_PROP, i, contact, dir);
}
else
{
events.Push(CombatEventKind::MELEE_HIT, i, contact, dir);
++metrics.meleeHits;
}
// Инвариант сектора: попадание обязано лежать внутри дуги
// ЭТОГО взмаха. Замеряется превышение — оно должно быть ~0.
const float off = std::fabs(AngleDiff(m.dir, AngleOf(tg.pos - a.pos)));
const float allowed = m.curHalfArc + bladeHalf;
metrics.meleeMaxHitExcessDeg =
std::max(metrics.meleeMaxHitExcessDeg,
std::max(0.0f, off - allowed) / DEG2RAD_F);
if (tg.hp <= 0.0f)
{
tg.alive = false;
tg.respawnTimer = tg.RespawnDelay();
if (!prop) ++metrics.targetsKilled;
events.Push(prop ? CombatEventKind::PROP_BREAK
: CombatEventKind::HIT_KILL,
i, tg.pos, dir);
}
}
if (m.phase <= 0.0f)
{
m.state = MeleeState::RECOVER;
m.phase = m.curRecover;
}
break;
}
case MeleeState::RECOVER:
{
m.phase -= dt;
if (m.phase <= 0.0f)
{
m.state = MeleeState::IDLE;
m.phase = 0.0f;
// Удар доведён до конца — связка продвигается. Окно открыто:
// успел ударить снова, пока оно не истекло, — пойдёт
// следующий удар серии, не успел — начнёшь сначала.
m.comboStep = (m.comboStep + 1) % MELEE_COMBO_STEPS;
m.comboTimer = g_tune.meleeComboWindow;
}
break;
}
}
}
}
// -----------------------------------------------------------------------------
// Комплекты снаряжения.
//
// Раздаются ТОЛЬКО в боевом режиме и только здесь. Причина жёсткая: броня несёт
// множитель скорости, а на скорости движения меряются критерии 11.4 и 11.5.
// Отряд приёмки обязан оставаться голым — тогда цифры сравнимы с прежними.
// -----------------------------------------------------------------------------
void Game::EquipKits()
{
struct Kit
{
ItemId blade; // если слот держит клинок
ItemId gun; // если слот держит ствол
ItemId armor;
ItemId trinket;
ItemId bag[3]; // запасное оружие и расходники
};
// Комплект на СЛОТ СТРОЯ, а не на «персонажа»: остриё клина берёт плиту и
// тесак, хвост — лёгкую броню и дальний ствол. Состав читается по строю.
static const Kit KITS[NUM_AGENTS] = {
{ItemId::CLEAVER, ItemId::SHOTGUN, ItemId::PLATE, ItemId::CHARM,
{ItemId::BANDAGE, ItemId::NONE, ItemId::NONE}},
{ItemId::KNIFE, ItemId::SMG, ItemId::SHROUD, ItemId::SPURS,
{ItemId::PISTOL, ItemId::BANDAGE, ItemId::NONE}},
{ItemId::SABER, ItemId::RIFLE, ItemId::VEST, ItemId::LANTERN,
{ItemId::SHOTGUN, ItemId::NONE, ItemId::NONE}},
{ItemId::SABER, ItemId::SHOTGUN, ItemId::VEST, ItemId::CHARM,
{ItemId::RIFLE, ItemId::STIMPACK, ItemId::NONE}},
{ItemId::KNIFE, ItemId::MAGNUM, ItemId::JACKET, ItemId::SPURS,
{ItemId::SMG, ItemId::BANDAGE, ItemId::NONE}},
};
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = squad.agents[i];
Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
const Kit& kit = KITS[i];
const bool melee = (i < squad.meleeCount);
*inv = Inventory{};
inv->weapon = melee ? kit.blade : kit.gun;
inv->armor = kit.armor;
inv->trinket = kit.trinket;
// Запасное уходит в ОБЩУЮ сумку: она одна на отряд.
for (int s = 0; s < 3; ++s)
if (kit.bag[s] != ItemId::NONE) squad.bag.Take(kit.bag[s], NUM_AGENTS, 0.0f);
EquipWeapon(world, a, inv->weapon);
a.hp = a.maxHp;
}
}
int Game::AliveAgents() const
{
int n = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
if (squad.agents[i].alive) ++n;
return n;
}