Поле путей: отставший возвращается в строй (M37)

Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил.
Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец,
оказавшийся за стеной от своего слота, seek'ом тянулся В СТЕНУ, обход стены
толкал его обратно, и он замирал в углу навсегда.

Теперь у каждого бойца своё поле путей (BFS от клетки его цели) с натягиванием
верёвки: он берёт самую дальнюю точку пути, которую видит. Включается обход по
двум условиям сразу — боец упёрся (хочет идти и не едет) И потерял строй. Счётчик
упора сбрасывается по факту «цель видно», а не «поехал»: сброс по скорости давал
дребезг, и боец полз шаг за две секунды.

По дороге вскрылись две неверные МЕРКИ:

* критерий 11.6 считал колонной «разброс вдоль больше, чем поперёк» — и потому
  награждал отряд, у которого двое навсегда остались по ту сторону стены. Теперь
  колонна меряется самим проёмом: разом в горловине не больше двух, и прошли ВСЕ.
* зонд --wall читал «стены не нашлось» (ноль) как «стена на нулевом этаже» и
  объявлял пятёрку, не сломавшуюся нигде, худшей из трёх.

Обе переписаны и проверены на сломанной сборке.

Замеры: --play 6 5 на восьми сидах 47/48 (было 0/48), --accept PASS, шаг 6.1 мкс,
--wall 30 одиночка D20 / трое D20 / пятеро D31 (не найдена), --descend 8/30,
hollow --descend 6, --ui-check, оба проекта --check — без ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-18 18:31:34 +03:00
parent aa9a175751
commit c1f79a1fb8
8 changed files with 411 additions and 35 deletions

View File

@ -148,8 +148,50 @@ muzzle = from + normalize(target - from) * muzzleOffset // 0.3 юнита
отталкивание уравновешиваются, и агент залипает в «тени» препятствия — это
реально ловилось в прогонах (агент оставался в комнате, когда отряд ушёл).
Скорость 1.75 юнита/с, 2.7 на `Shift`. Скорость подтягивается к желаемой с
ограничением `steerAccel`, чтобы резкие развороты не телепортировали агента.
Скорость 2.6 юнита/с, 4.4 на пределе натяжения. Скорость подтягивается к
желаемой с ограничением `steerAccel`, чтобы резкие развороты не телепортировали
агента.
## 5b. Поле путей: как отставший возвращается в строй
Слои выше — локальные, и этого хватает ровно до первого угла. Боец, оказавшийся
за стеной от своего слота, seek'ом тянулся В СТЕНУ, слой 4 толкал его обратно, и
он замирал в углу **навсегда**. Снаружи это выглядело как «пятеро не проходят
там, где одиночка проходит»: `--play 6 5` вставал на первом же этаже на всех
восьми сидах. Ширина коридоров тут ни при чём — отряд вставал и на проходах в
три, четыре и пять клеток.
Устройство (`Squad::RebuildNav`, `Squad::NavWaypoint`, `sim/squad.cpp`):
- BFS по проходимым клеткам **от клетки цели**, четыре соседа (по диагонали
круглое тело в щель между углами не пролезает);
- боец спускается по градиенту и берёт **самую дальнюю точку пути, которую ещё
видит** — натянутая верёвка; идёт он по ней как по прямой;
- поле **у каждого своё**. Одно общее поле до якоря сводило всех обходящих в
одну точку, и отряд входил в проход лужей вместо колонны.
Включается обход по ДВУМ условиям сразу:
| Условие | Зачем |
|---|---|
| `stuckFor > navStuckTime` (0.35 с) — хочет идти и не едет | обход нужен тому, кто реально упёрся |
| отстал от слота дальше `straggleDist` (2.8) | боец на своём месте не потерялся, его держит бой |
Счётчик `stuckFor` сбрасывается **не по факту «поехал», а по факту «цель
видно»**. Разница принципиальная: сброс по скорости давал дребезг — обход трогал
бойца с места, поле выключалось, боец упирался в ту же стену, и так по кругу со
средней скоростью шаг за две секунды.
Платим только когда обход реально включён: поле пересчитывается на смену клетки
цели и только у того, кто сейчас идёт в обход. Средний шаг симуляции — 6.1 мкс
при бюджете кадра 16666 мкс.
**Замеры** (`--play 6 5`, восемь сидов):
| Сборка | Этажей пройдено |
|---|---|
| до поля путей | 0 из 48 — отряд вставал на первом этаже |
| поле путей | 47 из 48 (единственная потеря — вайп в бою, не затык) |
## 5a. Диск и поводок

View File

@ -54,6 +54,14 @@
| **M33** | крафт стал выбором: закалка (двигает рулон выбранного аффикса) и отпечаток (переносит аффикс с другой вещи, сжигая её), отсечение и возвышение работают по ВЫБРАННОМУ аффиксу, направленные операции вдвое дороже слепых | прежние пять операций были слепыми — игрок жал кнопку и смотрел, что выпало; проверка меряет направленность, а не срабатывание: отсечение пробуется на СРЕДНЕМ аффиксе из трёх, иначе слепая версия прошла бы её тоже; `--accept` PASS ([22-loot.md](22-loot.md)) |
| **M34** | семь новых тварей на глубины 973: акты IIVI перестали быть пустыми | `--tool enemies` и `--tool ttk`: каждая тварь попадает в свою полосу глубины, время убийства держится в коридоре ([16-descent.md](16-descent.md)) |
| **M35** | 19 новых вещей (тиры 16, глубины 331), исправлена кривая редкости и выбор тиров аффиксов | `--tool drops` и `--tool affix`: добыча перестала быть мусором, ядро тега гарантируется ([22-loot.md](22-loot.md)) |
| **M36** | пистолет не пропадает при переодевании, эвакуация с ЛЮБОГО этажа, кнопки игровых меню снизу HUD, твари не воскресают в пределах забега | пропажа была потерей экземпляра: снаряжение бойца не сохранялось в профиль после перекладывания; `--ui-check` и `--descend 8` PASS ([13-combat.md](13-combat.md)) |
| **M37** | **поле путей**: отставший боец возвращается в строй по геометрии уровня, а не по прямой; обход включается только тому, кто упёрся и потерял строй | это был тот самый «отряд не проходит там, где одиночка проходит»: `--play 6 5` вставал на первом этаже на всех восьми сидах, стало 47 из 48 (единственная потеря — вайп в бою); по дороге вскрылись ДВЕ неверные мерки: критерий 11.6 награждал брошенных за стеной бойцов (растянутый отряд = «колонна»), а `--wall` читал «стены не нашлось» как «стена на нулевом этаже» и объявлял лучший результат провалом; обе переписаны и проверены на сломанной сборке; шаг симуляции 6.1 мкс; `--accept` PASS ([05-squad-ai.md](05-squad-ai.md), [08-decisions.md](08-decisions.md)) |
## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -280,6 +280,45 @@ seek тянет вперёд, отталкивание держит — аген
---
### A22. Критерий 11.6: «колонна» меряется проёмом, а не разбросом
**Было**: колонной считалось «разброс вдоль движения больше, чем поперёк,
в полтора раза» — по бойцам в створе прохода вместе с подходами.
**Стало**: колонна — это про сам проём. Одновременно в горловине не больше
двух (проём шириной два тайла), и через неё прошли **все** живые бойцы.
**Почему**. Старая мерка награждала ровно ту поломку, ради которой писалась.
Отряд, у которого двое навсегда остались по ту сторону стены (у них не было
поиска пути и вернуться они не могли), растянут вдоль на шесть тайлов — и
проверку ПРОХОДИЛ. Тот же отряд, когда отставшие научились обходить стену и
собираться, стал компактным — и проверку ронял. Замер трасс показал это прямо:
без обхода бойцы 3 и 4 все 26 секунд стоят на y≈16.3 и y≈16.9, южнее стены.
**Замер**: на исправленной сборке — прошли 5 из 5, в горловине разом до 2,
FRIENDLY_HITS=0, сквозь своего 0. На сборке без обхода (`--set navStuckTime=99`)
критерий падает: прошли 3 из 5. То есть мерка стала строже: раньше она
пропускала брошенных за стеной, теперь нет.
---
### A23. Зонд `--wall`: «стены не нашлось» — это не «стена на нулевом этаже»
**Было**: глубина первой стены хранилась нулём как «ещё не найдена», и итог
сравнивал `wall[пятеро] <= wall[одиночка]` буквально.
**Стало**: не найденная стена читается как «глубже последней проверенной»
(`maxDepth + 1`) и печатается как `D31 (не найдена)`.
**Почему**. Пятёрка перестала ломаться на всех проверяемых глубинах — то есть
показала лучший возможный результат, — и зонд объявил это провалом: ноль меньше
двадцати. Ошибка мерки, а не игры, того же рода, что и A22.
**Замер**: `--wall 30` — одиночка D20, трое D20, пятеро D31 (не найдена),
код возврата 0.
---
## B. Структурные (поведение не меняют)
### B1. Дополнительные файлы
@ -323,5 +362,7 @@ seek тянет вперёд, отталкивание держит — аген
- `SETTLE TIME` по той же причине растёт, если отряд остановился там, где стрелять
не по чему; в общем прогоне попадаются значения до 5.7 с. В обстановке, к
которой относится критерий 11.1 (мишени в радиусе), — 0.02 с.
- Пути агентов чисто локальные, без поиска пути. Отряд проходит узкие коридоры,
но лабиринт с тупиками не осилит — это вне рамок прототипа.
- Поиск пути включается ТОЛЬКО для отставшего, который упёрся (см.
[05-squad-ai.md](05-squad-ai.md), «поле путей»). Боец в строю по-прежнему
ходит локальными слоями, и это осознанно: строй должен вести себя как строй,
а не как пять независимых искателей пути.

View File

@ -374,6 +374,10 @@ int RunAccept()
int throughAllyShots = 0; // выстрелов, прошедших через тело союзника
int samples = 0;
// --- проход гуськом --------------------------------------------------
int maxInThroat = 0; // сколько разом влезло в сам проём
bool crossed[NUM_AGENTS] = {}; // кто оказался по ту сторону стены
// Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем.
//
// Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет
@ -401,6 +405,22 @@ int RunAccept()
watch.Observe(game, FIXED_DT);
corridorWatch.Observe(game, FIXED_DT);
// Горловина — это САМ ПРОЁМ: стена y=13..15, проём x=30..31.
// Пятеро в проём шириной два тайла лезть разом не должны, а пройти
// его обязаны все до одного.
{
int inThroat = 0;
for (int k = 0; k < NUM_AGENTS; ++k)
{
const Agent& a = game.squad.agents[k];
if (!a.alive) continue;
if (a.pos.y > 12.8f && a.pos.y < 15.2f &&
a.pos.x > 29.6f && a.pos.x < 32.4f) ++inThroat;
if (a.pos.y < 12.8f) crossed[k] = true; // севернее стены
}
maxInThroat = std::max(maxInThroat, inThroat);
}
// Кто сейчас в створе прохода (горловина + подходы).
int inside[NUM_AGENTS];
int insideCount = 0;
@ -476,15 +496,35 @@ int RunAccept()
// Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой.
// Пороги с запасом: строгое неравенство на маленьких числах — монетка,
// а не тест.
const bool column = longAvg > lateralAvg * 1.5;
// Что здесь ПРАВИЛЬНО считать колонной.
//
// Первая мерка сравнивала разброс вдоль движения с разбросом поперёк — и
// была неверной насквозь. Отряд, у которого двое навсегда застряли по ту
// сторону стены, а трое стоят севернее, растянут вдоль на шесть тайлов и
// проверку ПРОХОДИЛ. Тот же отряд, когда отставшие научились обходить
// стену и собираться, стал компактным — и проверку ронял. Мерка
// награждала ровно ту поломку, ради которой писалась (docs/08-decisions.md).
//
// Честное «гуськом» — это про сам проём: одновременно в нём не больше
// двух (проём шириной два тайла), и прошли через него ВСЕ.
int crossedCount = 0, aliveCount = 0;
for (int k = 0; k < NUM_AGENTS; ++k)
{
if (!game.squad.agents[k].alive) continue;
++aliveCount;
if (crossed[k]) ++crossedCount;
}
const bool column = (maxInThroat <= 2) && (crossedCount == aliveCount);
const bool clean = (throughAllyShots == 0) && (game.metrics.friendlyHits == 0);
const bool enough = (frontShotsInside + rearShotsInside) >= 4;
const bool p6 = (samples > 0) && (maxInside >= 2) && column && clean && enough;
allPass = allPass && p6;
PrintVerdict("11.6 в проходе колонна, огонь не идёт сквозь своих", p6,
Fmt("до %d агентов в проходе, поперёк %.2f / вдоль %.2f тайла, "
Fmt("прошли проход %d из %d, в горловине разом до %d, обходов %d, "
"в створе до %d, поперёк %.2f / вдоль %.2f тайла, "
"выстрелов вперёд передний/хвост %d/%d (назад %d), "
"сквозь своего %d, FRIENDLY_HITS=%d",
crossedCount, aliveCount, maxInThroat, game.squad.navUses,
maxInside, lateralAvg, longAvg, frontShotsInside, rearShotsInside,
backwardShots, throughAllyShots, game.metrics.friendlyHits));
}
@ -2662,7 +2702,19 @@ int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
// этажах твари разбросаны, и отряд иногда доживает полторы минуты просто
// потому, что до него дошли не все. Последний выживший — шум, первая
// стена — сигнал.
std::printf("первая стена: одиночка D%d, трое D%d, пятеро D%d\n", wall[0], wall[1], wall[2]);
// Ноль в wall[] означает «стены не нашлось ВООБЩЕ»: отряд пережил все
// проверенные глубины. Читать этот ноль как «стена на нулевом этаже» —
// ошибка мерки, а не игры: пятёрка, не сломавшаяся нигде, объявлялась худшей
// из трёх и роняла проверку. Не нашлось — значит глубже последней.
const int noWall = maxDepth + 1;
for (int si = 0; si < 3; ++si)
if (wall[si] == 0) wall[si] = noWall;
std::printf("первая стена:");
const char* names[3] = {"одиночка", "трое", "пятеро"};
for (int si = 0; si < 3; ++si)
std::printf(" %s D%d%s%s", names[si], wall[si],
wall[si] == noWall ? " (не найдена)" : "", si < 2 ? "," : "\n");
std::printf("глубже всего кто-то дожил (шумно): D%d / D%d / D%d\n", lastAlive[0],
lastAlive[1], lastAlive[2]);
@ -2799,11 +2851,19 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
// соседняя клетка. По соседней курсор дёргается между двумя
// вариантами на каждой границе и стоит на месте; дальняя точка
// тянет ровно, как рука игрока.
// ...но НЕ ДАЛЬШЕ, ЧЕМ ВИДНО. Дальняя точка по полю может лежать за
// углом, и тогда прямая до неё идёт сквозь стену: курсор упирается
// в эту стену и стоит вплотную к спуску, до которого «две клетки по
// воздуху и шесть по полу». Берём самую дальнюю точку пути, которую
// видно, — ровно так же натягивает верёвку и сам отряд
// (Squad::NavWaypoint).
int bestDist = here;
Vec2 goal = c;
{
Vec2 hops[12];
int nHops = 0;
int wx = cx, wy = cy;
for (int hop = 0; hop < 6; ++hop)
for (int hop = 0; hop < 12; ++hop)
{
int nextX = wx, nextY = wy;
int nextD = flow[size_t(wy * MAP_W + wx)];
@ -2822,8 +2882,14 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
wx = nextX;
wy = nextY;
bestDist = nextD;
hops[nHops++] = Vec2{float(wx) + 0.5f, float(wy) + 0.5f};
}
if (nHops > 0)
{
goal = hops[0];
for (int h = nHops - 1; h >= 1; --h)
if (!game.map.RaycastBlocked(c, hops[h])) { goal = hops[h]; break; }
}
goal = Vec2{float(wx) + 0.5f, float(wy) + 0.5f};
}
// Застряли — идём вторым по качеству соседом. Отряд упирается в
@ -2840,30 +2906,6 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
game.in.moveScreen = v2d::WorldDirToScreen(Normalized(toGoal));
game.Step(FIXED_DT);
if (getenv("PLAYDBG") && (steps % 300) == 0)
{
std::printf(" DBG t=%5.1f anchor(%.2f,%.2f) here=%d goal(%.1f,%.1f) in(%.2f,%.2f)"
" state=%d\n",
double(steps) * double(FIXED_DT), double(c.x), double(c.y), here,
double(goal.x), double(goal.y), double(game.in.moveScreen.x),
double(game.in.moveScreen.y), int(game.squad.state));
for (int k = 0; k < NUM_AGENTS; ++k)
{
const Agent& a = game.squad.agents[k];
if (!a.present) continue;
std::printf(" #%d pos(%.2f,%.2f) post(%.2f,%.2f) lane=%d tgt=%d melee=%d\n",
k, double(a.pos.x), double(a.pos.y), double(a.postPos.x),
double(a.postPos.y), int(a.hasLane), a.currentTargetId,
int(a.IsMelee()));
}
for (int yy = int(c.y) - 2; yy <= int(c.y) + 2; ++yy)
{
std::printf(" MAP y=%2d ", yy);
for (int xx = int(c.x) - 6; xx <= int(c.x) + 6; ++xx)
std::printf("%c", game.map.IsWall(xx, yy) ? '#' : '.');
std::printf("\n");
}
}
if (game.atExit)
{
@ -2888,6 +2930,32 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
}
const char* verdict = reached ? "дошёл" : (game.AliveAgents() > 0 ? "ЗАСТРЯЛ" : "ВАЙП");
// Затык — самая дорогая поломка в игре и самая трудная в чтении: снаружи
// видно только «отряд не дошёл». Поэтому зонд обязан сказать, КТО встал
// и где, иначе следующий раз опять начнётся с ручной диагностики.
if (!reached && game.AliveAgents() > 0)
{
const Vec2 c = game.squad.anchor;
const int cx = int(c.x), cy = int(c.y);
const int fld = (cx >= 0 && cy >= 0 && cx < MAP_W && cy < MAP_H)
? flow[size_t(cy * MAP_W + cx)] : -1;
std::printf(" затык: курсор %.1f,%.1f (до спуска по полу %d кл.), "
"спуск %.1f,%.1f, состояние %d\n",
double(c.x), double(c.y), fld,
double(game.level.exitPos.x), double(game.level.exitPos.y),
int(game.squad.state));
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
if (!a.present || !a.alive) continue;
std::printf(" боец %d: %.1f,%.1f скорость %.2f отстал на %.1f%s\n", i,
double(a.pos.x), double(a.pos.y), double(Length(a.vel)),
double(Distance(a.pos, game.squad.SlotWorld(i))),
a.Grabbed() ? " (захвачен)" : "");
}
}
std::printf("%-6d %7d %7d %8d %7.0f%% %9d %7.0f %s (%.0f с)\n", game.depth, startMonsters,
startMonsters - aliveNow, game.AliveAgents(), double(100.0f * hp / std::max(1.0f, maxHp)),
ammo, double(game.squad.lightCharge), verdict, double(steps) * double(FIXED_DT));

View File

@ -42,6 +42,13 @@ struct Agent
float commitTimer = 0.0f; // блокировка перерешивания
float solveTimer = 0.0f; // стаггер решателя
// Сколько боец УЖЕ УПИРАЕТСЯ: хочет идти, а не едет. По этому счётчику
// включается поле путей (Squad::NavWaypoint) — только для того, кто реально
// застрял, и ни для кого больше. Разбирать «видно цель или нет» тут нельзя:
// на подходе к проходу цель не видна вообще никому, и отряд входил бы в
// горловину шеренгой.
float stuckFor = 0.0f;
// Сущности ECS: сам боец и его оружие. Статы оружия копируются отсюда
// в gun/melee каждый шаг (SyncLoadout), поэтому ai/ про ECS не знает вообще.
ecs::Entity self = ecs::NO_ENTITY;

View File

@ -22,6 +22,10 @@ const float kLine[NUM_AGENTS][2] = {
{0.0f, 2.0f},
};
// «Недостижимо» в поле путей и потолок длины натягиваемой верёвки.
constexpr uint16_t NAV_FAR = 0xFFFF;
constexpr int NAV_PATH_MAX = 64;
} // namespace
const char* FormationName(Formation f)
@ -142,6 +146,22 @@ Vec2 Squad::SlotWorld(int i) const
Vec2 Squad::GoalFor(int i) const
{
// ОТСТАВШИЙ ВОЗВРАЩАЕТСЯ В СТРОЙ, что бы ни говорил решатель.
//
// Без этого правила отряд намертво вставал, и вот почему. Якорь упирается в
// поводок до центра отряда -> отряд переходит в HOLDING -> отставшие в
// HOLDING держат ОГНЕВУЮ ПОЗИЦИЮ вместо возврата в слот -> центр не
// догоняет -> якорь не может идти дальше. Круг замкнулся, и снаружи это
// выглядело как «пятеро не проходят там, где одиночка проходит».
//
// Проверено отдельно: ширина коридоров тут ни при чём — отряд вставал и на
// проходах в три, четыре и пять клеток, и даже в пустых комнатах.
//
// Правило заодно честное по игре: отряд, растянувшийся на полэтажа, — это
// не строй, а цепочка одиночек, и собраться он обязан сам.
const float lag = Distance(agents[i].pos, SlotWorld(i));
if (lag > g_tune.straggleDist) return SlotWorld(i);
// Боец ближнего боя ведёт себя одинаково в обоих состояниях: пока цель
// внутри его круга, он идёт к ней, а не в слот. Иначе клинок оказывался бы
// полезен только на стоянке — а он нужен как раз в движении.
@ -153,6 +173,105 @@ Vec2 Squad::GoalFor(int i) const
return (state == AgentState::ADVANCING) ? SlotWorld(i) : agents[i].postPos;
}
// -----------------------------------------------------------------------------
// Поле путей: BFS от клетки цели по проходимым клеткам, четыре соседа.
// Пересчёт только когда цель сменила клетку — это несколько тысяч клеток и
// стоит меньше одного кадра рендера.
// -----------------------------------------------------------------------------
void Squad::RebuildNav(NavField& field, const Tilemap& map, Vec2 goal) const
{
int gx = int(std::floor(goal.x));
int gy = int(std::floor(goal.y));
// Якорь игрок может завести в стену — поле строим от ближайшей свободной
// клетки, иначе отряд остался бы вообще без путей.
if (!Tilemap::InBounds(gx, gy) || map.IsWall(gx, gy))
{
bool found = false;
for (int r = 1; r <= 5 && !found; ++r)
for (int dy = -r; dy <= r && !found; ++dy)
for (int dx = -r; dx <= r && !found; ++dx)
{
const int x = gx + dx, y = gy + dy;
if (!Tilemap::InBounds(x, y) || map.IsWall(x, y)) continue;
gx = x; gy = y; found = true;
}
if (!found) return;
}
if (gx == field.goalX && gy == field.goalY && !field.cost.empty()) return;
field.goalX = gx;
field.goalY = gy;
field.cost.assign(size_t(MAP_W) * MAP_H, NAV_FAR);
std::vector<int> queue;
queue.reserve(size_t(MAP_W) * MAP_H);
field.cost[size_t(gy) * MAP_W + gx] = 0;
queue.push_back(gy * MAP_W + gx);
static const int DX[4] = {1, -1, 0, 0};
static const int DY[4] = {0, 0, 1, -1};
for (size_t head = 0; head < queue.size(); ++head)
{
const int c = queue[head];
const int cx = c % MAP_W;
const int cy = c / MAP_W;
const uint16_t nd = uint16_t(field.cost[size_t(c)] + 1);
for (int k = 0; k < 4; ++k)
{
const int x = cx + DX[k], y = cy + DY[k];
if (!Tilemap::InBounds(x, y) || map.IsWall(x, y)) continue;
const size_t n = size_t(y) * MAP_W + x;
if (field.cost[n] <= nd) continue;
field.cost[n] = nd;
queue.push_back(int(n));
}
}
}
Vec2 Squad::NavWaypoint(const NavField& field, const Tilemap& map, Vec2 from, Vec2 goal) const
{
// Видно цель — идём в цель. Поле нужно ровно там, где прямой не хватает.
if (field.cost.empty() || !map.RaycastBlocked(from, goal)) return goal;
int cx = int(std::floor(from.x));
int cy = int(std::floor(from.y));
if (!Tilemap::InBounds(cx, cy)) return goal;
uint16_t here = field.cost[size_t(cy) * MAP_W + cx];
if (here == NAV_FAR || here == 0) return goal; // вне поля или уже пришли
// Спуск по градиенту. Длина ограничена: дальше горизонта верёвка всё равно
// не натягивается, а лишние клетки — лишняя работа на каждого бойца.
Vec2 path[NAV_PATH_MAX];
int n = 0;
static const int DX[4] = {1, -1, 0, 0};
static const int DY[4] = {0, 0, 1, -1};
while (n < NAV_PATH_MAX && here > 0)
{
int bx = -1, by = -1;
uint16_t best = here;
for (int k = 0; k < 4; ++k)
{
const int x = cx + DX[k], y = cy + DY[k];
if (!Tilemap::InBounds(x, y)) continue;
const uint16_t d = field.cost[size_t(y) * MAP_W + x];
if (d >= best) continue;
best = d; bx = x; by = y;
}
if (bx < 0) break;
cx = bx; cy = by; here = best;
path[n++] = Vec2{float(cx) + 0.5f, float(cy) + 0.5f};
}
if (n == 0) return goal;
// Натягиваем верёвку: самая дальняя точка пути, которую боец ещё видит.
for (int i = n - 1; i >= 1; --i)
if (!map.RaycastBlocked(from, path[i])) return path[i];
return path[0];
}
Vec2 Squad::Centroid() const
{
// Центр масс считается по ЖИВЫМ: поводок якоря не должен тянуть отряд
@ -318,6 +437,7 @@ void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
// Скорость — не тумблер «шаг/рывок», а непрерывная величина от натяжения.
const float baseSpeed = MoveSpeed();
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
@ -328,9 +448,47 @@ void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
// из чужой линии. В этом вся цена захвата — боец выпал из геометрии
// отряда и стоит там, где его взяли, пока тварь не отпустит или не умрёт.
// Оружие при этом работает: захват держит ноги, а не руки.
if (a.Grabbed()) { a.vel = {}; continue; }
if (a.Grabbed()) { a.vel = {}; a.stuckFor = 0.0f; continue; }
const Vec2 rawGoal = GoalFor(i);
// Упёрся — веди по геометрии. Боец хочет идти (цель не под ногами), но
// скорость почти нулевая: значит прямая упирается в стену, и обход
// стены толкает обратно. Именно так отставший за углом замирал навсегда.
//
// Счётчик сбрасывается НЕ по факту «поехал», а по факту «цель видно».
// Разница принципиальная: сброс по скорости давал дребезг — обход
// трогал бойца с места, поле тут же выключалось, боец упирался в ту же
// стену, и так по кругу со средней скоростью в шаг за две секунды.
// Пока цель за стеной, режим обхода ДЕРЖИТСЯ, и боец доходит по дуге.
const bool wantsMove = DistanceSq(a.pos, rawGoal) > g_tune.navGoalReached *
g_tune.navGoalReached;
if (!wantsMove || !map.RaycastBlocked(a.pos, rawGoal))
a.stuckFor = 0.0f;
else if (Length(a.vel) < g_tune.navStuckSpeed * baseSpeed)
a.stuckFor += dt;
// ЧТО именно держит бойца — вот вся развилка. Стена держит навсегда, и
// из-под неё надо выводить по геометрии. Спина соседа держит на секунду,
// и обходить её нельзя: в проходе очередь стоит именно так, и отряд,
// отправленный в обход своей же колонны, входит в горловину шеренгой
// (критерий 11.6 ловит это сразу).
//
// Пробовал развилку по расстоянию до цели — это угадайка: у очереди в
// проходе и у отставшего одиночки дистанции пересекаются, и любой порог
// ломал либо строй, либо проходимость.
// Кому обход положен: тому, кто УПЁРСЯ и при этом ПОТЕРЯЛ СТРОЙ.
// Второе условие важно не меньше первого — боец, стоящий на своём месте,
// обходить ничего не должен: он не потерялся, его держит бой.
Vec2 goal = rawGoal;
if (a.stuckFor > g_tune.navStuckTime &&
Distance(a.pos, SlotWorld(i)) > g_tune.straggleDist)
{
++navUses;
RebuildNav(nav[i], map, rawGoal);
goal = NavWaypoint(nav[i], map, a.pos, rawGoal);
}
const Vec2 goal = GoalFor(i);
const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE;
// --- рывок клинка --------------------------------------------------

View File

@ -123,8 +123,45 @@ struct Squad
// Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина. Ставится ДО Init.
int pistolCount = DEFAULT_PISTOL_COUNT;
// -------------------------------------------------------------------------
// ПОЛЕ ПУТЕЙ. Отряд ходит по геометрии уровня, а не по прямой линии.
//
// Без него отставший за углом не возвращался в строй НИКОГДА: seek тянул
// его в стену, обход стены толкал обратно, и боец замирал в углу навсегда.
// Снаружи это выглядело как «пятеро не проходят там, где одиночка проходит»
// — и никакая ширина коридоров этого не лечила, потому что дело было не в
// ширине.
//
// Устройство простое: BFS по проходимым клеткам от клетки цели. Боец,
// которому цель не видна, спускается по градиенту и берёт самую дальнюю
// точку пути, которую ещё видит, — это натянутая верёвка, и идёт он по ней
// как по прямой.
//
// Поле У КАЖДОГО СВОЁ, и это не расточительство. Одно общее поле до якоря
// сводило всех обходящих в одну точку: в горловине прохода отряд входил
// лужей вместо колонны, и критерий 11.6 падал. Боец идёт к СВОЕЙ цели —
// тогда строй сохраняется и в обходе.
//
// Платим за это только когда обход реально включён: поле пересчитывается
// на смену клетки цели и только у того, кто сейчас идёт в обход, а таких
// обычно ноль.
struct NavField
{
std::vector<uint16_t> cost;
int goalX = -1;
int goalY = -1;
};
NavField nav[NUM_AGENTS];
// Сколько раз за прогон включался обход. Чистая диагностика: по ней видно,
// работает поле путей или молчит, — без неё пришлось бы гадать.
int navUses = 0;
void Init(ecs::World& world, Vec2 at);
void RebuildNav(NavField& field, const Tilemap& map, Vec2 goal) const;
Vec2 NavWaypoint(const NavField& field, const Tilemap& map, Vec2 from, Vec2 goal) const;
Vec2 SlotLocal(int i) const; // локальные координаты слота (x = вперёд)
Vec2 SlotWorld(int i) const; // слот в мире
Vec2 GoalFor(int i) const; // куда агент реально идёт

View File

@ -84,6 +84,17 @@ struct Tuning
// Как быстро строй доворачивается к направлению хода. Мгновенный доворот
// заставлял бойцов бегать вбок на каждом повороте (sim/squad.cpp).
float formTurnRate = 7.0f;
// Насколько боец может отстать от своего слота, прежде чем бросит всё и
// вернётся в строй. Ниже 2 отряд перестаёт разбредаться по огневым
// позициям вообще, выше 4 — снова застревает цепочкой (sim/squad.cpp).
float straggleDist = 2.8f;
// Когда включается поле путей. Не по расстоянию до цели, а по факту упора:
// боец хочет идти и не едет. Расстоянием этот порог не задать — на подходе
// к проходу цель не видна никому, и по «не видно» все пятеро получили бы
// одну путевую точку и вошли бы в горловину шеренгой (критерий 11.6).
float navStuckTime = 0.35f; // секунд упора до обхода
float navStuckSpeed = 0.25f; // «не едет» — это доля от скорости хода
float navGoalReached = 0.35f; // ближе этого цель считается достигнутой
float wSeparation = 1.0f;
float wLaneAvoid = 2.4f; // строго выше wSeparation (спек 6.7)
float wWallAvoid = 1.2f;
@ -455,6 +466,10 @@ inline const TunableParam TUNABLES[] = {
{"wSeparation", &Tuning::wSeparation, 0.1f, 0.0f, 10.0f},
{"steerAccel", &Tuning::steerAccel, 1.0f, 2.0f, 60.0f},
{"formTurnRate", &Tuning::formTurnRate, 0.5f, 0.5f, 30.0f},
{"straggleDist", &Tuning::straggleDist, 0.2f, 1.0f, 12.0f},
{"navStuckTime", &Tuning::navStuckTime, 0.05f, 0.05f, 3.0f},
{"navStuckSpeed", &Tuning::navStuckSpeed, 0.05f, 0.02f, 1.0f},
{"navGoalReached", &Tuning::navGoalReached, 0.05f, 0.05f, 2.0f},
{"moveSpeed", &Tuning::moveSpeed, 0.1f, 0.5f, 10.0f},
{"moveSpeedFast", &Tuning::moveSpeedFast, 0.1f, 0.5f, 12.0f},
{"weaponRange", &Tuning::weaponRange, 0.25f, 2.0f, 20.0f},