Десять вех одним состоянием: разделять их пофайлово нечем, потому что
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>
200 lines
9.3 KiB
C++
200 lines
9.3 KiB
C++
#include "ai/melee.h"
|
||
|
||
namespace
|
||
{
|
||
|
||
constexpr float BIG_F = 1e18f;
|
||
|
||
// Угловой полуразмер тела радиуса r, видимого с расстояния dist.
|
||
// Вплотную союзник перекрывает почти весь сектор — так и должно быть.
|
||
float AngularHalfSize(float dist, float r)
|
||
{
|
||
return std::asin(Clampf(r / std::max(dist, r), 0.0f, 1.0f));
|
||
}
|
||
|
||
// Годится ли мишень как цель ближнего боя: жива, внутри СВОЕГО круга бойца,
|
||
// не дальше meleeSeekRadius от него самого и видна (за стену не бегаем —
|
||
// путей в прототипе нет, а обход угла даёт слой wallAvoidance).
|
||
bool MeleeCandidate(const Agent& a, const SolverContext& ctx, int id)
|
||
{
|
||
const Target& t = (*ctx.targets)[id];
|
||
if (!t.alive) return false;
|
||
if (Distance(t.pos, ctx.anchor) > g_tune.meleeCohesionRadius) return false;
|
||
if (Distance(t.pos, a.pos) > g_tune.meleeSeekRadius) return false;
|
||
if (ctx.map->RaycastBlocked(a.pos, t.pos)) return false;
|
||
return true;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx)
|
||
{
|
||
const std::vector<Target>& targets = *ctx.targets;
|
||
|
||
// Линии огня у бойца нет и быть не может — обнуляем всю стрелковую часть,
|
||
// иначе он попадёт и в реестр линий, и в FIRE UPTIME.
|
||
a.hasLane = false;
|
||
a.priorityLane = false;
|
||
a.laneIntent = false;
|
||
a.targetBlockedByAlly = false;
|
||
a.targetBlockedByWall = false;
|
||
|
||
MeleeWeapon& m = a.melee;
|
||
|
||
// Пока идёт замах или удар, цель не переназначается: ось уже зафиксирована.
|
||
if (m.Committed() && m.targetId >= 0 && targets[m.targetId].alive)
|
||
{
|
||
a.currentTargetId = m.targetId;
|
||
a.laneEnd = targets[m.targetId].pos;
|
||
m.inReach = Distance(a.pos, targets[m.targetId].pos) <=
|
||
m.reach + targets[m.targetId].radius;
|
||
return;
|
||
}
|
||
|
||
m.targetId = -1;
|
||
m.inReach = false;
|
||
|
||
// Назначенная игроком цель приоритетна и здесь (6.8), но только если она
|
||
// проходит те же условия — иначе боец убежал бы через всю карту.
|
||
int best = -1;
|
||
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
|
||
MeleeCandidate(a, ctx, ctx.assignedTargetId))
|
||
{
|
||
best = ctx.assignedTargetId;
|
||
}
|
||
else
|
||
{
|
||
// Обстановка — только когда вокруг чисто. Предикат общий со стрелком
|
||
// (EnemyNearby): ящик за спиной не уводит с боя ни клинок, ни ствол.
|
||
const bool wantProps = !EnemyNearby(a.pos, ctx);
|
||
|
||
float bestCost = BIG_F;
|
||
for (int i = 0; i < int(targets.size()); ++i)
|
||
{
|
||
if (targets[i].IsProp() != wantProps) continue;
|
||
if (!MeleeCandidate(a, ctx, i)) continue;
|
||
|
||
// Цена = дистанция + штраф за каждого своего, уже вцепившегося
|
||
// в эту мишень. Ближняя цель всё ещё выигрывает, но не любой ценой:
|
||
// вдвоём на одну — это оборванные взмахи друг об друга и ноль урона.
|
||
float cost = Distance(a.pos, targets[i].pos);
|
||
for (int k = 0; k < ctx.agentCount; ++k)
|
||
{
|
||
if (k == index) continue;
|
||
if (!ctx.agents[k].alive) continue;
|
||
if (ctx.agents[k].IsMelee() && ctx.agents[k].melee.targetId == i)
|
||
cost += g_tune.meleeClaimPenalty;
|
||
}
|
||
if (cost >= bestCost) continue;
|
||
best = i;
|
||
bestCost = cost;
|
||
}
|
||
}
|
||
|
||
m.targetId = best;
|
||
a.currentTargetId = best;
|
||
if (best < 0) return;
|
||
|
||
a.laneEnd = targets[best].pos;
|
||
m.inReach = Distance(a.pos, targets[best].pos) <= m.reach + targets[best].radius;
|
||
m.arcBlockedByAlly = m.inReach && !MeleeArcClear(a, index, AngleOf(targets[best].pos - a.pos), ctx);
|
||
}
|
||
|
||
Vec2 MeleeGoal(const Agent& a, const SolverContext& ctx, Vec2 fallbackSlot)
|
||
{
|
||
const MeleeWeapon& m = a.melee;
|
||
if (m.targetId < 0) return fallbackSlot;
|
||
|
||
const Target& t = (*ctx.targets)[m.targetId];
|
||
|
||
// Встаём вплотную, но не внутрь: reach считается от центра тела, поэтому
|
||
// с такой дистанции удар достаёт с запасом.
|
||
const float stand = t.radius + a.bodyRadius + g_tune.meleeGap;
|
||
const Vec2 away = Normalized(a.pos - t.pos, FromAngle(a.facing + PI_F));
|
||
return t.pos + away * stand;
|
||
}
|
||
|
||
bool MeleeArcClear(const Agent& a, int index, float axisAngle, const SolverContext& ctx)
|
||
{
|
||
const MeleeWeapon& m = a.melee;
|
||
const float half = m.HalfArc();
|
||
const float reach = m.reach + g_tune.meleeAllyMargin;
|
||
|
||
// Взмах живёт windup + strike секунд, и всё это время союзник продолжает
|
||
// идти. Проверять только текущее положение — значит начинать удары, которые
|
||
// всё равно придётся обрывать. Это ровно та же поправка, что и
|
||
// AllyInterceptsShot для пули: правило не ослабляется, оно смотрит вперёд.
|
||
const float horizon = (m.windup + m.strike) * g_tune.meleeStartPredict;
|
||
|
||
for (int i = 0; i < ctx.agentCount; ++i)
|
||
{
|
||
if (i == index) continue;
|
||
const Agent& ally = ctx.agents[i];
|
||
if (!ally.alive) continue; // упавший под лезвие не попадает
|
||
|
||
const Vec2 probe[2] = {ally.pos, ally.pos + ally.vel * horizon};
|
||
for (const Vec2& q : probe)
|
||
{
|
||
const Vec2 d = q - a.pos;
|
||
const float dist = Length(d);
|
||
if (dist > reach + ally.bodyRadius) continue;
|
||
if (dist < 1e-4f) return false; // стоим в одной точке — бить нельзя
|
||
|
||
const float pad = AngularHalfSize(dist, ally.bodyRadius + g_tune.meleeAllyMargin);
|
||
if (std::fabs(AngleDiff(axisAngle, AngleOf(d))) <= half + pad) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool BladeSweepHits(Vec2 from, float angPrev, float angCur, float bladeHalf, float reach,
|
||
Vec2 p, float pRadius)
|
||
{
|
||
const Vec2 d = p - from;
|
||
const float dist = Length(d);
|
||
if (dist > reach + pRadius) return false;
|
||
|
||
// Тела перекрываются — попадает любой взмах, и это честно: цель обнимает
|
||
// рукоять. Случай редкий, отсечён отдельно, чтобы ниже не делить на ноль.
|
||
if (dist <= pRadius) return true;
|
||
|
||
// Дальше — ТОЧНАЯ геометрия: лезвие это ОТРЕЗОК от бойца длиной reach,
|
||
// повернувшийся за шаг с angPrev на angCur.
|
||
//
|
||
// Раньше здесь стоял угловой допуск asin(r/dist), и он вырождался: на
|
||
// контактной дистанции (dist -> pRadius) asin даёт 90 градусов, «сектор»
|
||
// разрастался в полуплоскость, и боец рубил за спину. В фазе рывка он как
|
||
// раз проезжает вплотную — отсюда и брались удары «на 360 градусов».
|
||
// Отрезок такого не допускает: для цели позади ближайшая точка лезвия —
|
||
// рукоять, а до неё ровно dist, что больше pRadius.
|
||
const float ang = AngleOf(d);
|
||
const float sweep = AngleDiff(angPrev, angCur); // короткая дельта за шаг
|
||
const float rel = AngleDiff(angPrev, ang); // где цель относительно старта
|
||
|
||
// Направление на цель попало в заметённую за шаг полосу — значит лезвие
|
||
// прошло через неё (дистанцию уже проверили сверху).
|
||
const float lo = std::min(0.0f, sweep) - bladeHalf;
|
||
const float hi = std::max(0.0f, sweep) + bladeHalf;
|
||
if (rel >= lo && rel <= hi) return true;
|
||
|
||
// Иначе цель могла быть задета краем: проверяем оба крайних положения.
|
||
const float ends[2] = {angPrev, angCur};
|
||
for (float a : ends)
|
||
if (DistancePointToSegment(p, from, from + FromAngle(a) * reach) <= pRadius) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
Vec2 MeleeStrikeEnd(const Agent& a, const SolverContext& ctx)
|
||
{
|
||
const MeleeWeapon& m = a.melee;
|
||
// На замахе и ударе ось уже зафиксирована, до него — направление на цель.
|
||
float axis = m.dir;
|
||
if (!m.Committed())
|
||
{
|
||
if (m.targetId < 0) return a.pos;
|
||
axis = AngleOf((*ctx.targets)[m.targetId].pos - a.pos);
|
||
}
|
||
return a.pos + FromAngle(axis) * m.reach;
|
||
}
|