From 9bcb5ca013626720307d1b85b0eff2d19fb5460c Mon Sep 17 00:00:00 2001 From: "z.kirill" Date: Sun, 16 Aug 2026 13:20:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=B8=D1=82=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82;=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=B8=D0=BB=D0=BE?= =?UTF-8?q?=D1=82=20--play;=20=D1=81=D1=82=D1=80=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D0=BD=D0=BE=D1=82=D0=B5=20(M31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ГЛАВНОЕ. "Дольше пяти секунд не продержаться на первом уровне" - это был не баланс, а запуск. По умолчанию 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 --- engine/view2d.h | 11 +++ src/debug/harness.cpp | 214 ++++++++++++++++++++++++++++++++++++++++++ src/debug/harness.h | 5 + src/main.cpp | 26 ++++- src/sim/squad.cpp | 43 ++++++++- src/tuning.h | 5 + 6 files changed, 299 insertions(+), 5 deletions(-) diff --git a/engine/view2d.h b/engine/view2d.h index e78a42c..84bfd09 100644 --- a/engine/view2d.h +++ b/engine/view2d.h @@ -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 { diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index 152a8e9..984b3b0 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -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 flow(size_t(MAP_W) * MAP_H, -1); + { + std::vector 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; +} diff --git a/src/debug/harness.h b/src/debug/harness.h index a1066e7..6f01e21 100644 --- a/src/debug/harness.h +++ b/src/debug/harness.h @@ -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); diff --git a/src/main.cpp b/src/main.cpp index 119e2cb..ffb7dbe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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; } diff --git a/src/sim/squad.cpp b/src/sim/squad.cpp index 5b7d800..6a457db 100644 --- a/src/sim/squad.cpp +++ b/src/sim/squad.cpp @@ -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) { diff --git a/src/tuning.h b/src/tuning.h index 5d99176..60094fe 100644 --- a/src/tuning.h +++ b/src/tuning.h @@ -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},