Запуск грузит проект; автопилот --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>
This commit is contained in:
z.kirill 2026-08-16 13:20:21 +03:00
parent e3294cffd4
commit 9bcb5ca013
6 changed files with 299 additions and 5 deletions

View File

@ -32,6 +32,17 @@ inline Vec2 ScreenDirToWorld(Vec2 screenDir)
return Normalized(ScreenToWorld(screenDir));
}
// Обратное: направление в МИРЕ -> направление в экранных осях. Нужно тому, кто
// водит отряд не рукой, а кодом (автопилот балансового прогона): ввод игры
// экранный, и подсовывать в него мировой вектор значило бы проверять не тот
// путь, которым ходит живой игрок.
inline Vec2 WorldDirToScreen(Vec2 worldDir)
{
const Vec2 a = g_proj.WorldToScreen(Vec2{0.0f, 0.0f});
const Vec2 b = g_proj.WorldToScreen(worldDir);
return Normalized(Vec2{b.x - a.x, b.y - a.y});
}
// Камера следует за якорем со сглаживанием; смещение округляется до пикселя.
struct Camera
{

View File

@ -2446,3 +2446,217 @@ int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
}
return bad == 0 ? 0 : 1;
}
// -----------------------------------------------------------------------------
// --play N: автопилот. Отряд ИДЁТ К СПУСКУ и дерётся по дороге.
//
// Зачем ещё один прогон, когда есть `--descend` и `--wall`: они меряют разные
// куски, и ни один не меряет ИГРУ. `--descend` собирает этажи и ни с кем не
// воюет, `--wall` ставит отряд в толпу и запрещает ему двигаться. А жалоба
// игрока звучит иначе: «дольше пяти секунд не продержаться» — это про то, как
// проходится этаж целиком.
//
// Автопилот тупой намеренно: он ведёт курсор к спуску и всё. Он не отступает,
// не выбирает цели, не лечится вручную (лечение и так автоматическое). Это
// нижняя граница проходимости: игрок сыграет лучше. Если этаж не проходит даже
// автопилот — этаж непроходим.
// -----------------------------------------------------------------------------
int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors,
int squadSize)
{
Profile profile;
profile.ResetLoadouts();
profile.squadSize = std::max(1, std::min(NUM_AGENTS, squadSize));
profile.maxDepth = 100000; // чекпоинты не должны резать стартовую глубину
Game game;
game.combatMode = true;
game.rooms = &rooms;
game.catalog = &catalog;
game.profile = &profile;
game.levelSeed = seed;
game.Init();
game.NewRun(1);
std::printf("автопилот: отряд из %d, ведём к спуску, без отступлений\n\n",
profile.squadSize);
std::printf("%-6s %7s %7s %8s %8s %9s %8s %s\n", "DEPTH", "ТВАРЕЙ", "УБИЛ", "ЖИВЫХ",
"ЗДОРОВЬЕ", "ПАТРОНЫ", "СВЕТ", "ИТОГ");
int deepest = 0;
int wipedAt = 0;
for (int f = 0; f < floors && game.AliveAgents() > 0; ++f)
{
const int startMonsters = [&]() {
int n = 0;
for (const Target& t : game.targets)
if (t.alive && !t.IsProp() && !t.IsDummy()) ++n;
return n;
}();
// Поле расстояний до спуска. Считается один раз на этаж обычной
// заливкой по проходимым клеткам — тем же способом, каким генератор
// ищет саму точку спуска (engine/level_gen.cpp).
std::vector<int> flow(size_t(MAP_W) * MAP_H, -1);
{
std::vector<int> queue;
queue.reserve(size_t(MAP_W) * MAP_H);
const int ex = int(game.level.exitPos.x), ey = int(game.level.exitPos.y);
if (ex >= 0 && ey >= 0 && ex < MAP_W && ey < MAP_H)
{
flow[size_t(ey * MAP_W + ex)] = 0;
queue.push_back(ey * MAP_W + ex);
}
for (size_t head = 0; head < queue.size(); ++head)
{
const int cur = queue[head];
const int x = cur % MAP_W, y = cur / MAP_W;
// Только четыре соседа, БЕЗ диагоналей. По диагонали между
// двумя углами стен путь есть на сетке, но нет в мире: тело
// круглое и в щель не пролезает. Первая версия автопилота
// ходила по восьми и намертво вставала в первом же таком
// проходе — четыре клетки от старта.
const int step4[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (const auto& d4 : step4)
{
const int dx = d4[0], dy = d4[1];
const int nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
if (game.map.IsWall(nx, ny)) continue;
int& cell = flow[size_t(ny * MAP_W + nx)];
if (cell >= 0) continue;
cell = flow[size_t(cur)] + 1;
queue.push_back(ny * MAP_W + nx);
}
}
}
// Три минуты на этаж — с запасом: путь до спуска редко длиннее полутора.
constexpr int MAX_STEPS = 60 * 180;
bool reached = false;
int steps = 0;
int lastDist = -1; // на сколько клеток отряд был близок к спуску
int stuckFor = 0; // сколько шагов это число не меняется
for (; steps < MAX_STEPS && game.AliveAgents() > 0; ++steps)
{
// Куда идти — берём из ПОЛЯ РАССТОЯНИЙ до спуска, а не по прямой.
// Прямая упирается в первую же стену: у отряда нет поиска пути, он
// умеет только «иди туда». Первая версия автопилота именно так и
// застревала, показывая «этаж непроходим» там, где непроходимым был
// сам автопилот.
// Ведём КУРСОР, а не центр отряда: игрок водит именно его, а центр
// пятерых вообще может оказаться внутри стены, пока сами бойцы
// обходят её с двух сторон. На этом автопилот с пятёркой вставал на
// первом же этаже, хотя одиночка тот же этаж проходил.
const Vec2 c = game.squad.anchor;
const int cx = int(c.x), cy = int(c.y);
const int here = (cx >= 0 && cy >= 0 && cx < MAP_W && cy < MAP_H)
? flow[size_t(cy * MAP_W + cx)]
: -1;
// Цель — ЦЕНТР соседней клетки, а не «ось». По оси курсор дёргается
// между двумя клетками на границе и стоит на месте; по центру он
// идёт ровно.
// Цель — точка на НЕСКОЛЬКО клеток вперёд по спуску поля, а не
// соседняя клетка. По соседней курсор дёргается между двумя
// вариантами на каждой границе и стоит на месте; дальняя точка
// тянет ровно, как рука игрока.
int bestDist = here;
Vec2 goal = c;
{
int wx = cx, wy = cy;
for (int hop = 0; hop < 6; ++hop)
{
int nextX = wx, nextY = wy;
int nextD = flow[size_t(wy * MAP_W + wx)];
const int look4[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (const auto& l4 : look4)
{
const int nx = wx + l4[0], ny = wy + l4[1];
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const int d = flow[size_t(ny * MAP_W + nx)];
if (d < 0 || (nextD >= 0 && d >= nextD)) continue;
nextD = d;
nextX = nx;
nextY = ny;
}
if (nextX == wx && nextY == wy) break;
wx = nextX;
wy = nextY;
bestDist = nextD;
}
goal = Vec2{float(wx) + 0.5f, float(wy) + 0.5f};
}
// Застряли — идём вторым по качеству соседом. Отряд упирается в
// угол или в собственную толпу, и «лучшее направление» перестаёт
// быть проходимым; живой игрок в этот момент просто обходит.
(void)bestDist;
if (here >= 0 && here == lastDist) ++stuckFor;
else stuckFor = 0;
lastDist = here;
game.in = InputState{};
const Vec2 toGoal = goal - c;
if (LengthSq(toGoal) > 1e-4f)
game.in.moveScreen = v2d::WorldDirToScreen(Normalized(toGoal));
game.Step(FIXED_DT);
if (game.atExit)
{
reached = true;
break;
}
}
int aliveNow = 0;
for (const Target& t : game.targets)
if (t.alive && !t.IsProp() && !t.IsDummy()) ++aliveNow;
float hp = 0.0f, maxHp = 0.0f;
int ammo = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
if (!a.present) continue;
hp += std::max(0.0f, a.hp);
maxHp += a.maxHp;
ammo += std::max(0, a.ammoReserve);
}
const char* verdict = reached ? "дошёл" : (game.AliveAgents() > 0 ? "ЗАСТРЯЛ" : "ВАЙП");
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));
if (game.AliveAgents() <= 0)
{
wipedAt = game.depth;
break;
}
deepest = game.depth;
if (!reached) break; // застрял — дальше мерить нечего
game.Descend();
}
std::printf("\nпройдено этажей: %d", deepest);
if (wipedAt > 0) std::printf(", вайп на D%d", wipedAt);
std::printf("\n");
int bad = 0;
if (deepest < 1)
{
std::printf(" [FAIL] автопилот не проходит ПЕРВЫЙ этаж — игра непроходима\n");
++bad;
}
if (wipedAt == 1)
{
std::printf(" [FAIL] отряд гибнет на первом этаже\n");
++bad;
}
return bad == 0 ? 0 : 1;
}

View File

@ -42,3 +42,8 @@ int RunUiCheck();
// который появился вместе с чекпоинтами через 50 этажей: растёт ли достижимая
// глубина от вылазки к вылазке (docs/16-descent.md).
int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int maxDepth);
// --play N [размер отряда]: автопилот. Отряд идёт к спуску и дерётся по дороге —
// единственный прогон, который меряет ПРОХОЖДЕНИЕ, а не отдельные правила.
int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors,
int squadSize);

View File

@ -136,9 +136,16 @@ int main(int argc, char** argv)
// Нужен ровно для картинок в документации: экраны вне боя иначе не снять,
// потому что --shot никого не нажимает.
const char* uiShot = nullptr;
// --rooms: планировка собирается из комнат вместо захардкоженной карты.
// Приёмки и headless это не касается — они до сюда не доходят.
bool useRooms = false;
// Проект подключён ПО УМОЛЧАНИЮ: игра — это проект, и запускать её без него
// бессмысленно. Раньше по умолчанию собиралась захардкоженная карта
// прототипа, и обычный запуск exe высаживал ОДНОГО оперативника с ножом на
// арену, набитую тварями по старой расстановке. Это выглядело как «на
// первом этаже не прожить и пяти секунд» — и было правдой, только этаж был
// не первый, а вообще не из игры.
//
// Приёмки и headless это не касается: они до сюда не доходят и по-прежнему
// меряют песочницу (--accept, --headless).
bool useRooms = true;
// Какой проект открывать. Своё имя игра знает, движок — нет: для него это
// просто строка, которую ему передали.
const char* projectArg = nullptr;
@ -146,6 +153,8 @@ int main(int argc, char** argv)
uint32_t levelSeed = 1;
int startDepth = 1; // --depth N: начать спуск не с первого этажа
int descendFloors = 0;
int playFloors = 0; // --play N: автопилот на N этажей
int playSquad = 1;
int wallDepth = 0; // --wall N: до какой глубины доходит отряд // --descend N: прогнать N этажей без окна и выйти
const char* profilePath = nullptr;
bool useProfile = true; // --no-profile: не трогать диск (dev-прогоны)
@ -178,6 +187,7 @@ int main(int argc, char** argv)
else if (std::strcmp(argv[i], "--sf-agents") == 0) sfAgents = true;
else if (std::strcmp(argv[i], "--sf") == 0) sfTiles = sfEnemies = sfAgents = true;
else if (std::strcmp(argv[i], "--sprites") == 0) sfReport = true;
else if (std::strcmp(argv[i], "--no-rooms") == 0) useRooms = false;
else if (std::strcmp(argv[i], "--windowed") == 0) windowed = true;
else if (std::strcmp(argv[i], "--sandbox") == 0) sandbox = true;
else if (std::strcmp(argv[i], "--ui-shot") == 0 && i + 1 < argc) uiShot = argv[++i];
@ -186,6 +196,11 @@ int main(int argc, char** argv)
else if (std::strcmp(argv[i], "--profile") == 0 && i + 1 < argc)
profilePath = argv[++i];
else if (std::strcmp(argv[i], "--no-profile") == 0) useProfile = false;
else if (std::strcmp(argv[i], "--play") == 0 && i + 1 < argc)
{
playFloors = TextToInteger(argv[++i]);
if (i + 1 < argc && argv[i + 1][0] != '-') playSquad = TextToInteger(argv[++i]);
}
else if (std::strcmp(argv[i], "--wall") == 0 && i + 1 < argc)
wallDepth = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--descend") == 0 && i + 1 < argc)
@ -295,6 +310,11 @@ int main(int argc, char** argv)
if (accept) return RunAccept();
if (uiCheck) return RunUiCheck();
if (harness.seconds > 0.0f) return RunHeadless(harness);
if (playFloors > 0)
{
if (!contentOk) { std::printf("автопилоту нужен набор комнат\n"); return 1; }
return RunPlay(rooms, catalog, levelSeed, playFloors, playSquad);
}
if (wallDepth > 0)
{
if (!contentOk) { std::printf("проверка стены невозможна без набора комнат\n"); return 1; }

View File

@ -243,14 +243,53 @@ void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap&
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 += (wantSpacing - formSpacing) * k;
formStretch += (wantStretch - formStretch) * k;
formSpacing += (tightSpacing - formSpacing) * k;
formStretch += (tightStretch - formStretch) * k;
if (state != prevState)
{

View File

@ -256,6 +256,10 @@ struct Tuning
// --- дыхание строя --------------------------------------------------------
float holdSpacing = 0.90f; // на стоянке отряд собирается
// Строй в тесноте: во сколько раз сжимается вбок и вытягивается вдоль хода.
// 0.28 — это колонна: пять тел проходят в дверь шириной в одну клетку.
float tightSpacing = 0.28f;
float tightStretch = 1.60f;
float sprintSpacing = 1.30f; // в беге расходится
float sprintStretch = 1.55f; // и вытягивается вдоль движения
float formMorphRate = 4.0f; // скорость перестроения, 1/сек
@ -456,6 +460,7 @@ inline const TunableParam TUNABLES[] = {
{"sTooClose", &Tuning::sTooClose, 2.0f, 0.0f, 300.0f},
{"postLeash", &Tuning::postLeash, 0.1f, 0.0f, 10.0f},
{"formSpacing", &Tuning::formationSpacing, 0.05f, 0.4f, 4.0f},
{"tightSpacing", &Tuning::tightSpacing, 0.02f, 0.1f, 1.0f},
{"anchorSpeed", &Tuning::anchorSpeed, 0.1f, 0.5f, 12.0f},
{"anchorSpeedFast",&Tuning::anchorSpeedFast, 0.1f, 0.5f, 16.0f},
{"anchorLeash", &Tuning::anchorLeash, 0.1f, 0.2f, 12.0f},