squad-proto/src/ai/firing_solver.cpp

343 lines
14 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 "ai/firing_solver.h"
namespace
{
constexpr float BIG_F = 1e18f;
// Дуло: muzzleOffset юнита от точки from в сторону цели.
inline Vec2 MuzzleAt(const Agent& shooter, Vec2 from, Vec2 targetPos)
{
return from + Normalized(targetPos - from) * shooter.gun.def.muzzleOffset;
}
// Полураствор конуса выстрела, выраженный как тангенс: боковое отклонение
// пули на расстоянии L от дула равно tan * L.
inline float SpreadTan(const Agent& shooter)
{
return std::tan(std::min(shooter.SpreadRad(), 1.2f));
}
// Свободен ли конус «дуло -> цель» от своих.
//
// Раньше проверялся отрезок: пуля летела ровно по нему, и этого хватало.
// Теперь у выстрела есть разброс, и проверять надо ровно ту область, куда
// пуля может уйти, — конус. Ослабления правила 6.3 тут нет, наоборот: зона
// проверки стала шире отрезка ровно настолько, насколько шире стал выстрел.
// Расширение растёт вдоль линии (у дула конус нулевой), поэтому союзник,
// стоящий вплотную к стрелку сбоку, лишнего запрета не создаёт.
bool ConeClearOfAllies(const Agent& shooter, Vec2 muzzle, int shooterIndex, Vec2 targetPos,
const SolverContext& ctx)
{
const float half = shooter.gun.def.laneHalfWidth + g_tune.safetyMargin;
const float segLen = Distance(muzzle, targetPos);
const float k = SpreadTan(shooter) * segLen;
for (int i = 0; i < ctx.agentCount; ++i)
{
if (i == shooterIndex) continue;
const Agent& ally = ctx.agents[i];
float t = 0.0f;
const float d = DistancePointToSegment(ally.pos, muzzle, targetPos, &t);
if (d < ally.bodyRadius + half + k * t) return false;
}
return true;
}
// Почему линии нет: свой или стена (для F2-оверлея).
void ClassifyBlock(Vec2 from, int shooterIndex, Vec2 targetPos, const SolverContext& ctx,
bool& byAlly, bool& byWall)
{
const Agent& shooter = ctx.agents[shooterIndex];
const Vec2 muzzle = MuzzleAt(shooter, from, targetPos);
byAlly = !ConeClearOfAllies(shooter, muzzle, shooterIndex, targetPos, ctx);
byWall = ctx.map->RaycastBlocked(muzzle, targetPos);
}
// Куда союзник МОЖЕТ уехать за время t. Обычно это линейная экстраполяция
// скорости, но боец ближнего боя на замахе стоит почти неподвижно и через
// мгновение выстреливает рывком по УЖЕ ИЗВЕСТНОЙ оси удара — скорость об этом
// ещё ничего не говорит. Поэтому для него проверяются обе точки: где он есть
// и куда он прыгнет. Иначе выстрел «мимо стоящего» находит его в полёте.
int PredictAllyPositions(const Agent& ally, float t, Vec2 out[2])
{
out[0] = ally.pos + ally.vel * t;
if (!ally.IsMelee() || !ally.melee.Committed()) return 1;
out[1] = ally.pos + FromAngle(ally.melee.dir) * (g_tune.meleeLungeSpeed * t);
return 2;
}
} // namespace
// -----------------------------------------------------------------------------
// 6.3 LaneClear
// -----------------------------------------------------------------------------
bool LaneClearFrom(Vec2 from, int shooterIndex, Vec2 targetPos, const SolverContext& ctx)
{
const Agent& shooter = ctx.agents[shooterIndex];
const Vec2 muzzle = MuzzleAt(shooter, from, targetPos);
// 1. свои — по конусу разброса, а не по осевому отрезку
if (!ConeClearOfAllies(shooter, muzzle, shooterIndex, targetPos, ctx)) return false;
// 2. геометрия
if (ctx.map->RaycastBlocked(muzzle, targetPos)) return false;
// 3. дистанция — своя у каждого ствола
if (Distance(muzzle, targetPos) > shooter.gun.def.range) return false;
return true;
}
bool AllyInterceptsShot(Vec2 muzzle, Vec2 targetPos, int shooterIndex, const SolverContext& ctx)
{
const Agent& shooter = ctx.agents[shooterIndex];
const Vec2 dir = Normalized(targetPos - muzzle);
const float speed = std::max(0.001f, shooter.gun.def.bulletSpeed);
const float tImpact = Distance(muzzle, targetPos) / speed;
// Пуля летит внутри конуса, поэтому её «толщина» растёт с пройденным путём.
const float spreadK = SpreadTan(shooter);
// Идём по времени полёта: где будет пуля и где будет союзник.
// Радиус безопасности растёт со временем — союзник может и повернуть,
// а линейная экстраполяция скорости это не ловит.
constexpr int SAMPLES = 6;
for (int k = 0; k <= SAMPLES; ++k)
{
const float t = tImpact * (float(k) / float(SAMPLES));
const Vec2 bulletPos = muzzle + dir * (speed * t);
for (int i = 0; i < ctx.agentCount; ++i)
{
if (i == shooterIndex) continue;
const Agent& ally = ctx.agents[i];
const float r = ally.bodyRadius + shooter.gun.def.bulletRadius + g_tune.safetyMargin +
g_tune.shotPredictSlack * MaxAgentSpeed() * t +
spreadK * speed * t;
Vec2 predicted[2];
const int n = PredictAllyPositions(ally, t, predicted);
for (int q = 0; q < n; ++q)
if (DistanceSq(bulletPos, predicted[q]) < r * r) return true;
}
}
return false;
}
int NearestAliveTarget(Vec2 from, const SolverContext& ctx, float searchRadius)
{
const std::vector<Target>& targets = *ctx.targets;
int best = -1;
float bestD = searchRadius;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
const float d = Distance(from, targets[i].pos);
if (d < bestD)
{
bestD = d;
best = i;
}
}
return best;
}
// -----------------------------------------------------------------------------
// Выбор цели: 6.4 (ближайшая с чистой линией) + 6.8 (приоритет назначенной).
// -----------------------------------------------------------------------------
void EvaluateAgentLane(Agent& a, int index, const SolverContext& ctx)
{
const std::vector<Target>& targets = *ctx.targets;
a.hasLane = false;
a.priorityLane = false;
a.laneIntent = false;
a.currentTargetId = -1;
a.targetBlockedByAlly = false;
a.targetBlockedByWall = false;
// --- ручной режим: сначала назначенная цель ---
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
targets[ctx.assignedTargetId].alive)
{
const Vec2 tp = targets[ctx.assignedTargetId].pos;
a.laneEnd = tp;
if (LaneClearFrom(a.pos, index, tp, ctx))
{
a.currentTargetId = ctx.assignedTargetId;
a.hasLane = true;
a.priorityLane = true;
return;
}
ClassifyBlock(a.pos, index, tp, ctx, a.targetBlockedByAlly, a.targetBlockedByWall);
a.laneIntent = a.targetBlockedByAlly && !a.targetBlockedByWall &&
Distance(a.pos, tp) <= a.gun.def.range + a.gun.def.muzzleOffset;
// Вторичные цели — только в движении и только при включённом флаге (6.8).
if (!(a.state == AgentState::ADVANCING && g_tune.freeFireSecondary)) return;
}
// --- авто: ближайшая цель, до которой линия чиста ---
int best = -1;
float bestD = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
const float d = Distance(a.pos, targets[i].pos);
if (d >= bestD || d > a.gun.def.range + a.gun.def.muzzleOffset) continue;
if (!LaneClearFrom(a.pos, index, targets[i].pos, ctx)) continue;
best = i;
bestD = d;
}
if (best >= 0)
{
a.currentTargetId = best;
a.hasLane = true;
a.priorityLane = (ctx.assignedTargetId < 0);
a.laneEnd = targets[best].pos;
return;
}
// Линии нет — для оверлея показываем ближайшую цель и причину.
if (ctx.assignedTargetId < 0)
{
const int nearest = NearestAliveTarget(a.pos, ctx, a.gun.def.range);
if (nearest >= 0)
{
a.laneEnd = targets[nearest].pos;
ClassifyBlock(a.pos, index, targets[nearest].pos, ctx,
a.targetBlockedByAlly, a.targetBlockedByWall);
// Линию перекрыл только свой -> намерение стрелять остаётся, и линия
// всё равно резервируется (6.6), иначе перекрывшего ничто не сдвинет.
a.laneIntent = a.targetBlockedByAlly && !a.targetBlockedByWall;
}
}
}
// -----------------------------------------------------------------------------
// 6.5 Оценка кандидата
// -----------------------------------------------------------------------------
float ScoreCandidate(Vec2 cand, const Agent& a, int index, const SolverContext& ctx,
int bestTargetId)
{
const std::vector<Target>& targets = *ctx.targets;
float score = 0.0f;
if (bestTargetId >= 0 && targets[bestTargetId].alive &&
LaneClearFrom(cand, index, targets[bestTargetId].pos, ctx))
score += g_tune.sLaneClear;
int shootable = 0;
int total = 0;
float nearestDist = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
++total;
nearestDist = std::min(nearestDist, Distance(cand, targets[i].pos));
if (LaneClearFrom(cand, index, targets[i].pos, ctx)) ++shootable;
}
if (total > 0) score += g_tune.sCoverage * float(shootable) / float(total);
if (ctx.lanes)
score -= g_tune.sBlockAlly * float(ctx.lanes->CountBlocked(index, cand, a.bodyRadius));
score -= g_tune.sDistCost * Distance(cand, a.pos);
score -= g_tune.sCohesionCost *
std::max(0.0f, Distance(cand, ctx.anchor) - g_tune.cohesionRadius);
if (nearestDist < g_tune.minEngageDist) score -= g_tune.sTooClose;
return score;
}
// -----------------------------------------------------------------------------
// 6.5 Решатель: 3 кольца x 12 направлений + текущая позиция, коммит с гистерезисом
// -----------------------------------------------------------------------------
bool SolveFiringPosition(Agent& a, int index, const SolverContext& ctx, SolveResult& out)
{
const std::vector<Target>& targets = *ctx.targets;
out.count = 0;
out.bestIndex = -1;
out.committed = false;
// Цель решателя: назначенная (ручной режим) либо ближайшая достижимая.
int bestTargetId = -1;
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
targets[ctx.assignedTargetId].alive)
bestTargetId = ctx.assignedTargetId;
else
bestTargetId = NearestAliveTarget(a.pos, ctx, a.gun.def.range + g_tune.ring3);
out.bestTargetId = bestTargetId;
if (bestTargetId < 0) return false; // стрелять не по чему — стоим
const float rings[SOLVER_RINGS] = {g_tune.ring1, g_tune.ring2, g_tune.ring3};
auto push = [&](Vec2 p, bool forceValid) {
if (out.count >= SOLVER_CANDIDATES) return;
const int i = out.count++;
out.candidates[i] = p;
out.valid[i] = true;
out.scores[i] = 0.0f;
if (!forceValid)
{
// Отбраковка: вне карты / в стене / за поводком от диска /
// слишком близко к другому агенту.
if (p.x < 1.0f || p.y < 1.0f || p.x > float(MAP_W) - 1.0f || p.y > float(MAP_H) - 1.0f)
out.valid[i] = false;
else if (Distance(p, ctx.anchor) > g_tune.cohesionRadius + g_tune.postLeash)
out.valid[i] = false;
else if (ctx.map->CircleHitsWall(p, a.bodyRadius))
out.valid[i] = false;
else
{
for (int k = 0; k < ctx.agentCount; ++k)
{
if (k == index) continue;
if (Distance(p, ctx.agents[k].pos) < g_tune.candMinAlly)
{
out.valid[i] = false;
break;
}
}
}
}
if (out.valid[i]) out.scores[i] = ScoreCandidate(p, a, index, ctx, bestTargetId);
};
push(a.pos, true); // индекс 0 — текущая позиция, для сравнения
out.currentScore = out.scores[0];
for (int r = 0; r < SOLVER_RINGS; ++r)
for (int k = 0; k < SOLVER_DIRS; ++k)
push(a.pos + FromAngle(float(k) * (TWO_PI_F / float(SOLVER_DIRS))) * rings[r], false);
out.bestScore = out.currentScore;
for (int i = 1; i < out.count; ++i)
{
if (!out.valid[i]) continue;
if (out.scores[i] > out.bestScore)
{
out.bestScore = out.scores[i];
out.bestIndex = i;
}
}
// Коммит с гистерезисом — иначе агент дёргается между двумя точками.
if (out.bestIndex > 0 && out.bestScore > out.currentScore + g_tune.hysteresis)
{
a.postPos = out.candidates[out.bestIndex];
a.commitTimer = g_tune.commitTime;
out.committed = true;
}
return true;
}