squad-proto/src/ai/firing_solver.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

377 lines
16 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 "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];
if (!ally.alive) continue; // упавший больше не перекрывает линию
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
// -----------------------------------------------------------------------------
// Приоритет цели: обстановка идёт последней
// -----------------------------------------------------------------------------
bool EnemyNearby(Vec2 from, const SolverContext& ctx)
{
const float r = g_tune.propYieldRadius;
for (const Target& t : *ctx.targets)
{
if (!t.alive || t.IsProp()) continue;
if (Distance(from, t.pos) <= r) return true;
}
return false;
}
// -----------------------------------------------------------------------------
// 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];
if (!ally.alive) continue;
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;
if (targets[i].IsProp()) 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;
}
// --- авто: ближайшая цель, до которой линия чиста ---
// Два прохода, и порядок между ними — это и есть приоритет. Сначала твари;
// обстановка рассматривается только когда противника в поле зрения нет
// вообще, иначе боец бросал бы врага ради ящика за его спиной.
auto nearestClear = [&](bool wantProps) {
int best = -1;
float bestD = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
if (!targets[i].alive) continue;
if (targets[i].IsProp() != wantProps) 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;
}
return best;
};
int best = nearestClear(false);
const bool onProp = (best < 0 && !EnemyNearby(a.pos, ctx));
if (onProp) best = nearestClear(true);
if (best >= 0)
{
a.currentTargetId = best;
a.hasLane = true;
// Линия к хламу приоритетной не бывает: приоритет запрещает решателю
// искать позицию получше, а ради ящика боец переступать обязан.
a.priorityLane = (ctx.assignedTargetId < 0) && !onProp;
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;
// Покрытие считается по ПРОТИВНИКАМ. Позиция, с которой видно три
// ящика, огневой не является, а «слишком близко» к бочке — не штраф.
if (targets[i].IsProp()) 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 (!ctx.agents[k].alive) 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;
}