squad-proto/src/game.cpp

558 lines
23 KiB
C++
Raw Normal View History

Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
#include "game.h"
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
#include "render/iso.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)
{
}
void Game::Init()
{
sprites.Generate();
Reset();
}
void Game::Reset()
{
map.Generate();
world.Clear(); // бойцы, оружие, мишени, пули — всё разом
squad.Init(world, {SQUAD_START_X, SQUAD_START_Y});
SpawnTargets(world);
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;
}
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::Step(float dt)
{
if (in.setFormation >= 0 && in.setFormation < int(Formation::COUNT))
squad.formation = Formation(in.setFormation);
// --- 0. статы снаряжения: с сущностей-оружия на бойцов ---------------------
// Одна система в начале шага. Всё остальное дальше читает уже готовые числа
// и не лазает в ECS: ai/ про мир сущностей не знает вообще.
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i]);
UpdateTargets(targets, dt);
// --- 1. якорь и состояние отряда -----------------------------------------
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
const Vec2 moveWorld = iso::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.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.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];
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.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. метрики -----------------------------------------------------------
metrics.Sample(squad, dt);
simTime += dt;
}
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[NUM_TARGETS];
int coneCount = 0;
for (int i = 0; i < int(targets.size()); ++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.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;
g.ammo = g.def.magazine;
events.Push(CombatEventKind::RELOAD_DONE, i, a.pos, FromAngle(a.facing));
}
continue;
}
if (g.Empty())
{
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;
// Отклонение внутри уже проверенного конуса.
const float spread = a.SpreadRad();
const float axis = AngleOf(Normalized(tp - muzzle, FromAngle(a.facing)));
const Vec2 dir = FromAngle(axis + g.ShotOffset(i, spread));
SpawnBullet(world, g.def, muzzle, dir, Distance(muzzle, tp), 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.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. мишени, по одной за взмах
for (int t = 0; t < int(targets.size()); ++t)
{
Target& tg = targets[t];
if (!tg.alive) continue;
if (m.hitMask & (1u << 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 |= (1u << t);
a.fired = true;
const Vec2 dir = Normalized(tg.pos - a.pos, FromAngle(m.dir));
const Vec2 contact = tg.pos - dir * tg.radius;
tg.hp -= m.curDamage;
KnockTarget(tg, dir, m.curKnock);
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 = g_tune.targetRespawn;
++metrics.targetsKilled;
events.Push(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;
}
}
}
}