Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1105 lines
49 KiB
C++
1105 lines
49 KiB
C++
#include "game.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;
|
||
}
|
||
|
||
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) profile->ResetLoadouts();
|
||
FinishRun(RunOutcome::WIPED);
|
||
}
|
||
|
||
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;
|
||
profile->bankedXp += xp;
|
||
}
|
||
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;
|
||
|
||
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)
|
||
{
|
||
const ItemId id = squad.bag.slots[s];
|
||
if (id == ItemId::NONE) 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];
|
||
// Ствол в руках живёт ещё и отдельной сущностью-оружием: без этого
|
||
// боец выходит с карабином по умолчанию, что бы ни лежало в профиле.
|
||
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;
|
||
}
|
||
|
||
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, &unknownLoot);
|
||
levelUnknownSpawns += unknownLoot;
|
||
}
|
||
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)
|
||
{
|
||
squad.carryBonus = UPGRADE_CARRY_STEP * float(profile->UpgradeLevel(Upgrade::CARRY));
|
||
squad.sightBonus = UPGRADE_SIGHT_STEP * float(profile->UpgradeLevel(Upgrade::SIGHT));
|
||
squad.hpBonus = UPGRADE_VIGOR_STEP * float(profile->UpgradeLevel(Upgrade::VIGOR));
|
||
ammoBonus = UPGRADE_AMMO_STEP * float(profile->UpgradeLevel(Upgrade::AMMO));
|
||
}
|
||
|
||
// Запас патронов выдаётся вместе со снаряжением и только в БОЮ.
|
||
// В песочнице приёмки его нет вовсе (-1 = бесконечно): критерии меряют
|
||
// правила стрельбы, а не то, на сколько минут хватило патронов.
|
||
for (int i = 0; i < NUM_AGENTS; ++i)
|
||
squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1;
|
||
}
|
||
|
||
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;
|
||
|
||
// Расстановка — это НАЧАЛО сценария. Павший боец, переехавший в него
|
||
// трупом, испортил бы и замеры, и игру.
|
||
a.alive = true;
|
||
a.hp = a.maxHp;
|
||
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 = ↦
|
||
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;
|
||
|
||
// Патроны нужны тому, у кого ВООБЩЕ есть ствол, — не только тому, кто
|
||
// держит его прямо сейчас. Боец с клинком в руках и запасным карабином
|
||
// в сумке иначе таскал бы найденную пачку до конца забега мёртвым
|
||
// грузом, а это ровно то, от чего расходники и лечились.
|
||
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;
|
||
if (!healsNow && !loadsNow) continue;
|
||
|
||
if (healsNow) a.hp = std::min(a.maxHp, a.hp + d.heal);
|
||
if (loadsNow) a.ammoReserve += d.rounds;
|
||
|
||
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;
|
||
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);
|
||
|
||
// --- 1. якорь и состояние отряда -----------------------------------------
|
||
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
|
||
const Vec2 moveWorld = v2d::ScreenDirToWorld(in.moveScreen);
|
||
squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt);
|
||
|
||
// --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) -----
|
||
squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt);
|
||
|
||
// --- 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);
|
||
|
||
// --- 10. находки -----------------------------------------------------------
|
||
// После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге,
|
||
// обязан быть подбираемым сразу, а не через кадр.
|
||
lootTaken += UpdateLootPickup(world, squad.agents.data(), NUM_AGENTS, squad.bag,
|
||
squad.Carriers(), squad.carryBonus, &events, dt);
|
||
|
||
// --- 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() || t.IsDummy()) continue;
|
||
|
||
float danger = 1.0f;
|
||
if (catalog)
|
||
if (const EntityDesc* d = catalog->Find(IdOfEnemyKind(t.kind))) danger = d->danger;
|
||
|
||
xp += danger * scale;
|
||
}
|
||
}
|
||
|
||
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;
|
||
const bool nearOut = Distance(squad.anchor, level.extractPos) <= r;
|
||
|
||
// События пишутся на ФРОНТЕ: иначе журнал забивался бы одним и тем же
|
||
// сообщением каждый кадр, пока отряд стоит на месте.
|
||
if (nearExit && !atExit) events.Push(CombatEventKind::DESCEND, -1, level.exitPos, Vec2{});
|
||
if (nearOut && !atExtract) events.Push(CombatEventKind::EXTRACT, -1, level.extractPos, Vec2{});
|
||
|
||
atExit = nearExit;
|
||
atExtract = nearOut;
|
||
}
|
||
|
||
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);
|
||
}
|
||
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 -= m.curDamage;
|
||
tg.hurtTimer = g_tune.targetHurtTime;
|
||
KnockTarget(tg, dir, m.curKnock);
|
||
// Щепки вместо крови и мимо счётчика ударов по врагу:
|
||
// разрубленный ящик не должен красить пол и накручивать
|
||
// 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;
|
||
}
|