Зонд отзывчивости управления --respond (M39)

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

Сейчас: один 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 роняет обе проверки.

Первая версия зонда меряла мусор: отряд гнали по карте приёмки, где он через
семь тайлов упирался в стену, а «поехал» считалось по ВСЕМ бойцам сразу, поэтому
разворот не засчитывался никогда. Меряем в пустом поле и по центру масс.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-18 18:47:34 +03:00
parent b29358ad12
commit 9e23c6f980
6 changed files with 181 additions and 1 deletions

View File

@ -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`, а новый пункт
добавили четвёртым. Все прогоны меряли симуляцию, меню не проверял никто —

View File

@ -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 # чистый отряд стрелков

View File

@ -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`, восемь сидов):
| Сборка | Этажей пройдено |

View File

@ -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: меню без окна.
//

View File

@ -38,6 +38,11 @@ int RunAccept();
// четыре вехи молча закрывал игру (ui/menu.h).
int RunUiCheck();
// --respond: отзывчивость управления строем. Требование задано СРАВНЕНИЕМ —
// вести пятерых должно быть сопоставимо с ведением одного, — и меряется так же:
// время от нажатия до половины скорости у одиночки и у пятёрки.
int RunRespond();
// --wall N: до какой глубины доходит отряд разной силы. Отвечает на вопрос,
// который появился вместе с чекпоинтами через 50 этажей: растёт ли достижимая
// глубина от вылазки к вылазке (docs/16-descent.md).

View File

@ -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)
{