diff --git a/CLAUDE.md b/CLAUDE.md index 432bb51..be6ec97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,9 @@ - Самопроверка: `squad_proto.exe --accept` печатает PASS/FAIL по всем измеримым критериям. Содержимое проверяется отдельно: `tile2d_editor.exe --check` (комнаты и покрытие глубин), `squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля), - `squad_proto.exe --ui-check` (пункты меню: каждый куда-то ведёт). + `squad_proto.exe --ui-check` (пункты меню: каждый куда-то ведёт), + `squad_proto.exe --respond` (отклик строя против одиночки), + `squad_proto.exe --no-profile --play 6 5` (отряд сам проходит этажи). - **У кнопки тоже есть код возврата.** Пункт UPGRADES четыре вехи закрывал игру вместо открытия экрана: в обработчике стоял `default: quit`, а новый пункт добавили четвёртым. Все прогоны меряли симуляцию, меню не проверял никто — diff --git a/docs/01-build-and-run.md b/docs/01-build-and-run.md index 4a0eea9..95d10fa 100644 --- a/docs/01-build-and-run.md +++ b/docs/01-build-and-run.md @@ -112,6 +112,8 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 | `--accept` | прогоняет сценарии критериев приёмки (спек 11) и печатает PASS/FAIL | | `--ui-check` | пункты меню без окна: каждый ведёт куда-то, выходит только QUIT | | `--wall N` | до какой глубины доходит отряд из 1/3/5 бойцов (нужен `--rooms`) | +| `--play N [размер]` | автопилот: отряд сам идёт к спуску N этажей — единственный прогон, меряющий ПРОХОЖДЕНИЕ | +| `--respond` | отзывчивость управления: старт, разворот и хвост строя у одиночки против пятёрки | | `--headless <сек>` | прогон по маршруту без окна + метрики каждые 10 с | | `--probe` | к `--headless`: подробности по каждому агенту на стоянках | | `--no-solver` | отключить решатель огневых позиций (A/B сравнение) | @@ -145,6 +147,8 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ```bash squad_proto.exe --accept squad_proto.exe --ui-check +squad_proto.exe --respond # отклик строя против одиночки +squad_proto.exe --no-profile --play 6 5 # пятёрка проходит шесть этажей squad_proto.exe --headless 300 squad_proto.exe --headless 300 --no-solver --no-laneavoid # каким прототип был бы без ядра squad_proto.exe --headless 300 --melee 0 # чистый отряд стрелков diff --git a/docs/05-squad-ai.md b/docs/05-squad-ai.md index 43a967b..b098249 100644 --- a/docs/05-squad-ai.md +++ b/docs/05-squad-ai.md @@ -186,6 +186,20 @@ muzzle = from + normalize(target - from) * muzzleOffset // 0.3 юнита цели и только у того, кто сейчас идёт в обход. Средний шаг симуляции — 6.1 мкс при бюджете кадра 16666 мкс. +**Отзывчивость** меряется отдельно (`--respond`), и требование к ней задано +сравнением: вести пятерых должно быть сопоставимо с ведением одного. + +| Отряд | старт | разворот | хвост | +|---|---|---|---| +| один | 0.13 с | 0.60 с | 2.0 | +| пятеро | 0.12 с | 0.58 с | 2.9 | + +Пятеро против одного: старт x0.87, разворот x0.97 при пределе x1.50. Сверх +сравнения стоит абсолютный потолок (0.30 с и 1.20 с): без него зонд пропустил бы +вязкость, одинаковую для одного и для пятерых — отношение осталось бы единицей, +а управление стало бы киселём. Проверено: `--set steerAccel=3` роняет обе +проверки. + **Замеры** (`--play 6 5`, восемь сидов): | Сборка | Этажей пройдено | diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index e8a4cee..8a1ac58 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -2504,6 +2504,158 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t return bad == 0 ? 0 : 1; } +// ----------------------------------------------------------------------------- +// --respond: ОТЗЫВЧИВОСТЬ управления. +// +// Требование к строю сформулировано сравнением: вести пятерых должно быть +// сопоставимо по отклику с ведением одного. Это не «ощущается лучше», это +// измеримо — сколько секунд проходит от нажатия до того, как отряд ПОЕХАЛ. +// +// Меряются три числа, и каждое отвечает на свой вопрос: +// +// СТАРТ — от нажатия до половины скорости хода. Сколько отряд «думает». +// РАЗВОРОТ — то же самое после смены направления на противоположное. Здесь +// видно инерцию: строй разворачивается тяжелее, чем трогается. +// ХВОСТ — насколько отстал самый дальний боец на установившемся ходу. +// Отряд, у которого хвост в пять тайлов, отзывчивым не ощущается, +// даже если центр масс тронулся мгновенно. +// +// Замер в ПУСТОЙ комнате и без боя: отклик управления не должен зависеть от +// того, кто сейчас стреляет. +// ----------------------------------------------------------------------------- +namespace +{ + +struct RespondResult +{ + float start = 0.0f; + float turn = 0.0f; + float tail = 0.0f; +}; + +RespondResult MeasureRespond(int squadSize) +{ + Profile profile; + profile.ResetLoadouts(); + profile.squadSize = std::max(1, std::min(NUM_AGENTS, squadSize)); + + Game game; + game.profile = &profile; // размер отряда берётся ОТСЮДА (Game::Init) + game.Init(); + + // Чистое поле: отклик управления не должен зависеть ни от стен, ни от того, + // кто сейчас стреляет. Иначе замер поймает не строй, а расстановку мишеней. + for (int y = 1; y < MAP_H - 1; ++y) + for (int x = 1; x < MAP_W - 1; ++x) game.map.Set(x, y, Tile::EMPTY); + game.targets.clear(); + + game.PlaceSquad({float(MAP_W / 2) + 0.5f, float(MAP_H / 2) + 0.5f}, false); + + const float half = 0.5f * g_tune.moveSpeed; + + auto drive = [&](Vec2 worldDir, float seconds, float* timeToHalf) { + const int steps = int(seconds / FIXED_DT); + float elapsed = 0.0f; + Vec2 prev = game.squad.Centroid(); + if (timeToHalf) *timeToHalf = -1.0f; + for (int i = 0; i < steps; ++i) + { + game.in = InputState{}; + game.in.moveScreen = v2d::WorldDirToScreen(worldDir); + game.Step(FIXED_DT); + elapsed += FIXED_DT; + + // «Отряд поехал» — это про ЦЕНТР МАСС, а не про переднего бойца: + // строй, у которого один улетел, а четверо стоят, отзывчивым не + // является, и центр это показывает сам собой. + const Vec2 now = game.squad.Centroid(); + const float speed = Dot((now - prev) * (1.0f / FIXED_DT), worldDir); + prev = now; + if (timeToHalf && *timeToHalf < 0.0f && speed >= half) *timeToHalf = elapsed; + } + }; + + RespondResult r; + drive({1.0f, 0.0f}, 3.0f, &r.start); + drive({-1.0f, 0.0f}, 3.0f, &r.turn); + + // Хвост на установившемся ходу: самый дальний от якоря. + float worst = 0.0f; + for (int k = 0; k < NUM_AGENTS; ++k) + { + const Agent& a = game.squad.agents[k]; + if (!a.alive) continue; + worst = std::max(worst, Distance(a.pos, game.squad.anchor)); + } + r.tail = worst; + return r; +} + +} // namespace + +int RunRespond() +{ + std::printf("отзывчивость управления: от нажатия до половины скорости хода\n\n"); + std::printf("%-8s %10s %10s %8s\n", "ОТРЯД", "СТАРТ", "РАЗВОРОТ", "ХВОСТ"); + + const RespondResult solo = MeasureRespond(1); + const RespondResult five = MeasureRespond(5); + + auto line = [](const char* name, const RespondResult& r) { + std::printf("%-8s %9.2fс %9.2fс %7.1f\n", name, double(r.start), double(r.turn), + double(r.tail)); + }; + line("один", solo); + line("пятеро", five); + + int bad = 0; + + // Ни один из замеров не должен быть «не случилось»: -1 означает, что отряд + // за три секунды так и не разогнался до половины скорости. + if (solo.start < 0.0f || five.start < 0.0f || solo.turn < 0.0f || five.turn < 0.0f) + { + std::printf("\n [FAIL] отряд не разогнался до половины скорости за три секунды\n"); + ++bad; + } + else + { + // Порог сравнения, а не абсолютный: требование к строю задано именно + // сравнением с одиночкой. Полуторный запас — на то, что пятерым надо + // ещё и разойтись по слотам, и это честная цена строя. + const float startRatio = five.start / std::max(0.01f, solo.start); + const float turnRatio = five.turn / std::max(0.01f, solo.turn); + std::printf("\nпятеро против одного: старт x%.2f, разворот x%.2f (предел x1.50)\n", + double(startRatio), double(turnRatio)); + if (startRatio > 1.5f || turnRatio > 1.5f) + { + std::printf(" [FAIL] строй отзывается заметно хуже одиночки\n"); + ++bad; + } + } + + // Абсолютный потолок — поверх сравнения. Без него зонд пропустил бы + // вязкость, одинаковую для одного и для пятерых: отношение осталось бы + // единицей, а управление стало бы киселём. Числа с запасом вдвое к + // нынешним (0.12 и 0.58): это порог «стало плохо», а не цель. + if (five.start > 0.30f || five.turn > 1.20f) + { + std::printf(" [FAIL] управление вязкое само по себе: старт %.2f с (предел 0.30), " + "разворот %.2f с (предел 1.20)\n", double(five.start), double(five.turn)); + ++bad; + } + + // Хвост: строй тянется за курсором, но не растягивается в цепочку. + if (five.tail > 4.0f) + { + std::printf(" [FAIL] хвост строя %.1f тайла — отряд идёт цепочкой, а не строем\n", + double(five.tail)); + ++bad; + } + + std::printf("\nитог: поломок %d\n", bad); + return bad == 0 ? 0 : 1; +} + // ----------------------------------------------------------------------------- // --ui-check: меню без окна. // diff --git a/src/debug/harness.h b/src/debug/harness.h index 6f01e21..8e43989 100644 --- a/src/debug/harness.h +++ b/src/debug/harness.h @@ -38,6 +38,11 @@ int RunAccept(); // четыре вехи молча закрывал игру (ui/menu.h). int RunUiCheck(); +// --respond: отзывчивость управления строем. Требование задано СРАВНЕНИЕМ — +// вести пятерых должно быть сопоставимо с ведением одного, — и меряется так же: +// время от нажатия до половины скорости у одиночки и у пятёрки. +int RunRespond(); + // --wall N: до какой глубины доходит отряд разной силы. Отвечает на вопрос, // который появился вместе с чекпоинтами через 50 этажей: растёт ли достижимая // глубина от вылазки к вылазке (docs/16-descent.md). diff --git a/src/main.cpp b/src/main.cpp index ffb7dbe..f88bce3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -124,6 +124,7 @@ int main(int argc, char** argv) const char* shotFile = "shot.png"; bool accept = false; bool uiCheck = false; + bool respond = false; bool clean = false; // --clean: стартовать без оверлея (для скриншотов) bool sfTiles = false; // --sf-tiles: пол и стены из библиотеки SpriteForge bool sfEnemies = false; // --sf-enemies: анимированные твари оттуда же @@ -225,6 +226,7 @@ int main(int argc, char** argv) } else if (std::strcmp(argv[i], "--accept") == 0) accept = true; else if (std::strcmp(argv[i], "--ui-check") == 0) uiCheck = true; + else if (std::strcmp(argv[i], "--respond") == 0) respond = true; else if (std::strcmp(argv[i], "--probe") == 0) harness.probe = true; else if (std::strcmp(argv[i], "--no-solver") == 0) harness.solver = false; else if (std::strcmp(argv[i], "--no-laneavoid") == 0) harness.laneAvoid = false; @@ -309,6 +311,7 @@ int main(int argc, char** argv) if (sfReport) return RunSpriteReport(); if (accept) return RunAccept(); if (uiCheck) return RunUiCheck(); + if (respond) return RunRespond(); if (harness.seconds > 0.0f) return RunHeadless(harness); if (playFloors > 0) {