squad-proto/src/sim/squad.cpp
z.kirill 9bcb5ca013 Запуск грузит проект; автопилот --play; строй сжимается в тесноте (M31)
ГЛАВНОЕ. "Дольше пяти секунд не продержаться на первом уровне" - это был
не баланс, а запуск. По умолчанию useRooms был false: обычный запуск exe
собирал ЗАХАРДКОЖЕННУЮ карту прототипа со старой расстановкой тварей и
высаживал туда ОДНОГО оперативника с ножом (размер отряда берётся из
профиля). Игрок попадал не на первый этаж игры, а вообще не в игру.
Теперь проект подключается по умолчанию, а старая карта осталась под
флагом --no-rooms.

--PLAY. Новый прогон: автопилот ведёт отряд к спуску и дерётся по дороге.
Ни --descend, ни --wall не меряли ПРОХОЖДЕНИЕ: первый собирает этажи и ни
с кем не воюет, второй ставит отряд в толпу и запрещает двигаться. Жалоба
же была именно про проход этажа.

Автопилот ходит по полю расстояний до спуска (заливка по проходимым
клеткам), целится на несколько клеток вперёд по спуску поля и правит
курсором - тем же вводом, каким играет человек. Три версии до рабочей:
по прямой упирался в первую стену, по восьми соседям лез в диагональную
щель между углами (тело круглое, не пролезает), по соседней клетке дёргался
на каждой границе.

Одиночка проходит три этажа и гибнет на четвёртом:

  1  тварей 3, убил 0, 100% здоровья, света 244  дошёл (78 с)
  2  тварей 6, убил 1, 100%,          света 155  дошёл (44 с)
  3  тварей 6, убил 0,  65%,          света   8  дошёл (74 с)
  4  тварей 9, убил 5,   ВАЙП                    (37 с)

Свет кончается к третьему этажу: расчёт "заряда на четыре пути до спуска"
считал прямые пути, а живой отряд ходит петлями.

СТРОЙ В ТЕСНОТЕ. Клин шириной в пять тел не проходит в дверь шириной в
клетку: бойцы упираются друг в друга, расталкивание держит их на месте,
отряд встаёт насмерть. Теперь строй сам сжимается в колонну, когда вокруг
тел мало свободных клеток.

ЧЕСТНО: это НЕ вылечило проход пятёрки до конца. Автопилот с полным
отрядом всё ещё застревает на первом этаже там, где одиночка проходит, и
причина не в ширине строя (проверено сжатием в точку и отрядом без
клинков). Похоже, нужна нормальная проводка строя по проходам - это
отдельная веха, а не патч в конце. Оставляю с числами, а не с обещанием.

--accept PASS, все семь проверок зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 13:20:21 +03:00

362 lines
18 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 "sim/squad.h"
#include "ai/lane_registry.h"
namespace
{
// Локальные слоты строя: x = вперёд, y = вправо. Множатся на formationSpacing.
const float kWedge[NUM_AGENTS][2] = {
{ 1.0f, 0.0f},
{ 0.0f, -0.9f},
{ 0.0f, 0.9f},
{-1.0f, -1.8f},
{-1.0f, 1.8f},
};
const float kLine[NUM_AGENTS][2] = {
{0.0f, -2.0f},
{0.0f, -1.0f},
{0.0f, 0.0f},
{0.0f, 1.0f},
{0.0f, 2.0f},
};
} // namespace
const char* FormationName(Formation f)
{
switch (f)
{
case Formation::WEDGE: return "WEDGE";
case Formation::LINE: return "LINE";
case Formation::RING: return "RING";
default: return "?";
}
}
void Squad::Init(ecs::World& world, Vec2 at)
{
anchor = at;
anchorPrev = at;
anchorVel = {};
forward = {1.0f, 0.0f};
state = AgentState::HOLDING;
idleTimer = g_tune.holdDelay;
holdElapsed = 0.0f;
urgency = 0.0f;
sprinting = false;
formSpacing = 1.0f;
formStretch = 1.0f;
meleeCount = std::min(std::max(meleeCount, 0), NUM_AGENTS);
pistolCount = std::min(std::max(pistolCount, 0), NUM_AGENTS - meleeCount);
// Сначала создаём все сущности бойцов, и только потом берём указатель на
// плотный массив пула: до конца заполнения он может переехать.
ecs::Entity ids[NUM_AGENTS];
for (int i = 0; i < NUM_AGENTS; ++i)
{
ids[i] = world.Create();
world.Add<Agent>(ids[i], Agent{});
}
agents.p = world.All<Agent>().data();
agents.n = NUM_AGENTS;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a = Agent{};
a.self = ids[i];
a.slotIndex = i;
// Клинки занимают первые слоты: в клине это остриё и левое крыло,
// то есть ровно те, кто и так упирается в противника первым.
// Пистолеты — последние слоты: короткий ствол уезжает в хвост клина.
const bool isMelee = (i < meleeCount);
const bool isPistol = !isMelee && (i >= NUM_AGENTS - pistolCount);
// Стартовое оружие — то же, что и было: клинок, карабин, пистолет.
// Снаряжения на бойце НЕТ: броня меняет скорость, а на скорости меряются
// критерии приёмки. Комплекты раздаёт игровой режим (Game::EquipKits).
const ItemId weapon = isMelee ? ItemId::SABER
: isPistol ? ItemId::PISTOL
: ItemId::RIFLE;
a.weaponEntity = SpawnWeapon(world, weapon);
world.Add<Inventory>(ids[i], Inventory{weapon, ItemId::NONE, ItemId::NONE});
a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f;
a.trig.rng = 1u + uint32_t(i) * 2654435761u; // свой поток на слот
a.pos = SlotWorld(i);
a.prevPos = a.pos;
a.postPos = a.pos;
a.facing = 0.0f;
a.state = state;
a.currentTargetId = -1;
a.commitTimer = 0.0f;
// Фазы решателя разведены: за кадр решает максимум один агент (6.5).
a.solveTimer = g_tune.solveInterval * (float(i) / float(NUM_AGENTS));
SyncLoadout(world, a);
a.gun.ammo = a.gun.def.magazine; // выходим с полным магазином
// Незанятый слот: сущность есть (по ней ходят все циклы), человека нет.
// Не alive — значит, ни решатель, ни твари, ни правила огня его не
// видят; не present — значит, его не видит и игрок.
a.present = (i < roster);
a.alive = a.present;
a.hp = a.alive ? a.maxHp : 0.0f;
}
}
Vec2 Squad::SlotLocal(int i) const
{
// Интервал и вытяжение — это ТЕКУЩЕЕ состояние строя, а не константа:
// на стоянке отряд собирается, в беге растягивается вдоль движения.
const float s = g_tune.formationSpacing * formSpacing;
const float fwd = s * formStretch;
switch (formation)
{
case Formation::LINE:
return {kLine[i][0] * fwd, kLine[i][1] * s};
case Formation::RING:
{
const float ang = float(i) * (TWO_PI_F / float(NUM_AGENTS));
const Vec2 d = FromAngle(ang) * 1.5f;
return {d.x * fwd, d.y * s};
}
case Formation::WEDGE:
default:
return {kWedge[i][0] * fwd, kWedge[i][1] * s};
}
}
Vec2 Squad::SlotWorld(int i) const
{
const Vec2 local = SlotLocal(i);
const Vec2 right = Perp(forward);
return anchor + forward * local.x + right * local.y;
}
Vec2 Squad::GoalFor(int i) const
{
// Боец ближнего боя ведёт себя одинаково в обоих состояниях: пока цель
// внутри его круга, он идёт к ней, а не в слот. Иначе клинок оказывался бы
// полезен только на стоянке — а он нужен как раз в движении.
// Но в спринте цель отменяется: бежим, а не деремся.
if (agents[i].IsMelee() && !sprinting) return agents[i].postPos;
if (agents[i].IsMelee()) return SlotWorld(i);
// В движении — слот строя, на месте — выбранная решателем огневая позиция.
return (state == AgentState::ADVANCING) ? SlotWorld(i) : agents[i].postPos;
}
Vec2 Squad::Centroid() const
{
// Центр масс считается по ЖИВЫМ: поводок якоря не должен тянуть отряд
// к телу павшего.
Vec2 c{};
int n = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
if (!agents[i].alive) continue;
c += agents[i].pos;
++n;
}
return (n > 0) ? c / float(n) : anchor;
}
void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& map, float dt)
{
anchorPrev = anchor;
const bool moving = LengthSq(moveWorldDir) > 1e-6f;
const AgentState prevState = state;
const Vec2 c = Centroid();
// --- натяжение поводка ----------------------------------------------------
// Поводок УПРУГИЙ, а не жёсткий. За мягкой границей курсор продолжает
// уходить, но идёт всё тяжелее, и до жёсткого предела не доходит никогда.
//
// Жёсткий поводок здесь не годился принципиально: он мгновенно упирается,
// дистанция намертво встаёт в одно значение, и по ней уже нельзя отличить
// «подправил направление» от «тяну отряд вперёд изо всех сил». Упругий
// поводок превращает дистанцию в непрерывную величину: чем сильнее тянешь,
// тем дальше уезжает курсор и тем быстрее бегут за ним.
const float soft = std::max(0.2f, g_tune.anchorLeash);
const float hard = std::max(soft + 0.2f, g_tune.anchorLeashMax);
const float start = soft * Clampf(g_tune.urgeStartFrac, 0.0f, 0.95f);
const float dist = Distance(anchor, c);
float pull = 0.0f;
if (moving) pull = Clampf((dist - start) / (hard - start), 0.0f, 1.0f);
if (moving && sprint) pull = 1.0f; // Shift — приказ бежать, без уговоров
const float rate = (pull > urgency) ? g_tune.urgeAttack : g_tune.urgeRelease;
urgency += (pull - urgency) * (1.0f - std::exp(-std::max(0.01f, rate) * dt));
urgency = Clampf(urgency, 0.0f, 1.0f);
// Бег без стрельбы — либо по прямому приказу, либо когда курсор реально
// уехал: отряд догоняет и ему не до целей.
sprinting = (moving && sprint) || (urgency >= g_tune.sprintAt);
// --- сам курсор -----------------------------------------------------------
// У него есть разгон и торможение: мгновенная смена скорости — это и есть
// «топорно». Отпустил клавиши — курсор доезжает, а не втыкается в грунт.
const float over = Clampf((dist - soft) / (hard - soft), 0.0f, 1.0f);
const float dragK = std::max(0.08f, 1.0f - Clampf(g_tune.leashDrag, 0.0f, 1.0f) * over);
const float speed = (sprint ? g_tune.anchorSpeedFast : g_tune.anchorSpeed) * dragK;
const Vec2 want = moving ? Normalized(moveWorldDir, forward) * speed : Vec2{};
const Vec2 dv = want - anchorVel;
anchorVel += ClampLength(dv, std::max(0.1f, g_tune.anchorAccel) * dt);
anchor += anchorVel * dt;
if (moving) forward = Normalized(moveWorldDir, forward);
// Жёсткий предел. Сопротивление обычно не даёт до него дойти, но отряд может
// и застрять в стене — тогда курсор без предела уехал бы за полкарты.
const Vec2 d = anchor - c;
const float len = Length(d);
if (len > hard && len > 1e-5f)
{
const Vec2 n = d / len;
anchor = c + n * hard;
const float vn = Dot(anchorVel, n);
if (vn > 0.0f) anchorVel -= n * vn;
}
map.ResolveCircle(anchor, g_tune.anchorRadius);
// --- состояние отряда ------------------------------------------------------
if (moving)
{
idleTimer = 0.0f;
state = AgentState::ADVANCING;
holdElapsed = 0.0f;
}
else
{
idleTimer += dt;
if (idleTimer >= g_tune.holdDelay) state = AgentState::HOLDING;
if (state == AgentState::HOLDING) holdElapsed += dt;
}
// --- теснота ---------------------------------------------------------------
// Сколько свободного места вокруг курсора. Считается грубо: доля
// проходимых клеток в квадрате рядом. Этого хватает, чтобы отличить зал от
// коридора и от дверного проёма, а честный замер ширины прохода стоил бы
// поиска пути там, где его нет.
//
// Зачем: клин шириной в пять тел не пролезает в дверь шириной в одну
// клетку. Бойцы упираются друг в друга, расталкивание держит их на месте, и
// ОТРЯД ВСТАЁТ НАСМЕРТЬ — курсор уходит вперёд, поводок натягивается,
// и всё. Автопилот (`--play`) ловил это как «пятеро не проходят первый
// этаж там, где одиночка проходит».
//
// Поэтому в тесноте строй сжимается сам, до колонны. Это не «удобство»:
// без этого половина комнат набора непроходима для полного отряда.
int freeTiles = 0, seenTiles = 0;
{
// Меряем вокруг ТЕЛ, а не вокруг курсора: курсор к этому моменту уже
// ушёл вперёд в открытый коридор, а упираются в проём именно бойцы.
// С замером у курсора сжатие включалось там, где оно уже не нужно.
const Vec2 body = Centroid();
const int ax = int(body.x), ay = int(body.y);
for (int dy = -2; dy <= 2; ++dy)
for (int dx = -2; dx <= 2; ++dx)
{
const int nx = ax + dx, ny = ay + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
++seenTiles;
if (!map.IsWall(nx, ny)) ++freeTiles;
}
}
// 1.0 — открытый зал, 0.0 — щель. Полностью свободный квадрат 5x5 это 25
// клеток; порог 0.6 отделяет коридор от комнаты.
const float openness = (seenTiles > 0) ? float(freeTiles) / float(seenTiles) : 1.0f;
const float tight = Clampf((0.72f - openness) / 0.32f, 0.0f, 1.0f);
// --- дыхание строя ---------------------------------------------------------
const float wantSpacing = (state == AgentState::HOLDING)
? g_tune.holdSpacing
: Lerpf(1.0f, g_tune.sprintSpacing, urgency);
// В тесноте строй И сжимается вбок, И вытягивается вдоль хода: это и есть
// колонна. Числа в tuning, чтобы крутились на живой игре.
const float wantStretch = Lerpf(1.0f, g_tune.sprintStretch, urgency);
const float tightSpacing = Lerpf(wantSpacing, g_tune.tightSpacing, tight);
const float tightStretch = Lerpf(wantStretch, g_tune.tightStretch, tight);
const float k = 1.0f - std::exp(-std::max(0.01f, g_tune.formMorphRate) * dt);
formSpacing += (tightSpacing - formSpacing) * k;
formStretch += (tightStretch - formStretch) * k;
if (state != prevState)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a.commitTimer = 0.0f;
// Встали — остаёмся на месте, пока решатель не найдёт лучше (6.5).
if (state == AgentState::HOLDING) a.postPos = a.pos;
}
if (state == AgentState::HOLDING) holdElapsed = 0.0f;
}
for (int i = 0; i < NUM_AGENTS; ++i) agents[i].state = state;
}
void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
const std::vector<Target>& targets, float dt)
{
// Скорость — не тумблер «шаг/рывок», а непрерывная величина от натяжения.
const float baseSpeed = MoveSpeed();
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a.prevPos = a.pos;
if (!a.alive) { a.vel = {}; continue; } // упавший лежит там, где упал
// Захваченного не двигает НИЧТО: ни строй, ни решатель, ни расступание
// из чужой линии. В этом вся цена захвата — боец выпал из геометрии
// отряда и стоит там, где его взяли, пока тварь не отпустит или не умрёт.
// Оружие при этом работает: захват держит ноги, а не руки.
if (a.Grabbed()) { a.vel = {}; continue; }
const Vec2 goal = GoalFor(i);
const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE;
// --- рывок клинка --------------------------------------------------
// В активном окне удара ось уже зафиксирована, и боец едет по ней.
// Seek/separation/расступание тут молчат: удар — это обязательство,
// а не предложение. Стены остаются: сквозь них не рвётся никто.
if (lunging)
{
const float speed = g_tune.meleeLungeSpeed;
const Vec2 dir = FromAngle(a.melee.dir);
Vec2 desired = dir * speed;
desired += WallAvoidForce(a, map, dir, speed) * g_tune.wWallAvoid;
IntegrateAgent(a, desired, speed, dt, map, g_tune.meleeLungeAccel);
continue;
}
// --- обычные слои, порядок 6.7 --------------------------------------
// speedMul — это снаряжение: плита тормозит, шпоры разгоняют. Без
// снаряжения он равен 1, и движение остаётся прежним до последнего бита.
const float maxSpeed = baseSpeed * a.SpeedScale() * a.speedMul;
Vec2 desired = SeekForce(a, goal, maxSpeed) * g_tune.wSeek;
desired += SeparationForce(a, agents.data(), NUM_AGENTS, maxSpeed) * g_tune.wSeparation;
// Замах — это обязательство. Выдёргивать бойца из чужой линии огня
// посреди него бессмысленно: стрелок всё равно не выстрелит (правило
// 6.3 запрещает), а удар сорвётся. Расступаться боец будет до и после.
if (lanes && !(a.IsMelee() && a.melee.Committed()))
desired += lanes->AvoidanceForce(i, a.pos, a.bodyRadius, maxSpeed) * g_tune.wLaneAvoid;
desired += TargetPushForce(a, targets, maxSpeed) * g_tune.wTargetPush;
desired += WallAvoidForce(a, map, Normalized(goal - a.pos), maxSpeed) * g_tune.wWallAvoid;
IntegrateAgent(a, desired, maxSpeed, dt, map);
}
}