558 lines
23 KiB
C++
558 lines
23 KiB
C++
|
|
#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 = ↦
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|