#include "game.h" // Перевод экранных осей ввода в мировые — единственное место, // где симуляционный слой касается изометрии (см. docs/02-architecture.md). #include "render/iso.h" namespace { // Снять с мира все пули разом. Пуля — сущность, поэтому просто очистить вектор // нельзя: остались бы живые идентификаторы без компонентов. void ClearBullets(ecs::World& world) { std::vector& bullets = world.All(); while (!bullets.empty()) world.Destroy(bullets.back().self); } } // namespace Game::Game() : targets(world.Store().data), bullets(world.Store().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 = ↦ 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; } } } }