Десять вех одним состоянием: разделять их пофайлово нечем, потому что
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>
377 lines
16 KiB
C++
377 lines
16 KiB
C++
#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;
|
||
}
|