squad-proto/src/game.cpp
z.kirill 7d6266aaf3 Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
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>
2026-08-13 21:07:19 +03:00

1105 lines
49 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"
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. 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 = &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;
}