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

200 lines
9.3 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/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;
}