squad-proto/editor/tools.cpp
z.kirill 7d6266aaf3 Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.

ДВИЖОК (engine/, цель tile2d)

  Каталог свой, и решение «это движок» принимается тем, куда положен файл.
  Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
  и карты уехали в engine/config.h, потолок населения этажа стал своим
  (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
  центром карты вместо позиции отряда.

  engine/projection.*  единственное место, где движок знает про изометрию;
                       видов сразу два — абстракция с одной реализацией ничем
                       не проверена и обычно оказывается неправильной
  engine/project.*     манифест проекта: пути, вид, кривая глубины
  engine/app.*         окно, кадр, fixed timestep — вместо двух копий цикла
  engine/tilemap_draw  движок научился показывать свою карту

РЕДАКТОР (editor/, цель tile2d_editor)

  Открывает любой проект и ни одной игры по имени не знает.
  --tool  одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
          этажа, ASCII-карта, проверка набора. Правку делают файлами,
          последствия читают командой (docs/19-agent.md)
  терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
          открытый проект уходит потомкам через TILE2D_PROJECT

ПРИМЕР (samples/walk, цель tile2d_walk)

  Второй потребитель движка. Игра и редактор самостоятельность движка
  проверить не могут: редактор мира не рисует, а игра — то место, куда общий
  код и уезжает незаметно.

ИГРА (src/)

  Экстракшен: вес и два порога переноски, конечный боезапас, расходники
  применяются сами, общая сумка отряда, трата опыта на улучшения.

  Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
  вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
  боец с клинком не тратил найденные патроны при запасном стволе в сумке;
  массив виджетов экрана переполнился и молча съел хвост схрона; проверка
  улучшений сама ломала забег, вызывая NewRun посреди спуска.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:07:19 +03:00

592 lines
22 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "editor/tools.h"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "engine/catalog.h"
#include "engine/level_gen.h"
#include "engine/progression.h"
#include "engine/project.h"
#include "engine/room.h"
#include "engine/tilemap.h"
namespace
{
// --- разбор аргументов -------------------------------------------------------
struct Args
{
std::string cmd;
std::vector<std::string> rest; // позиционные, без ключей
std::string project;
const std::string& At(size_t i) const
{
static const std::string empty;
return (i < rest.size()) ? rest[i] : empty;
}
int Int(size_t i, int fallback) const
{
const std::string& s = At(i);
if (s.empty()) return fallback;
return std::atoi(s.c_str());
}
};
Args Parse(int argc, char** argv)
{
Args a;
for (int i = 0; i < argc; ++i)
{
const char* s = argv[i];
if (std::strcmp(s, "--project") == 0 && i + 1 < argc) { a.project = argv[++i]; continue; }
if (std::strcmp(s, "--tool") == 0) continue;
if (s[0] == '-') continue;
if (a.cmd.empty()) a.cmd = s;
else a.rest.push_back(s);
}
return a;
}
// --- общая часть: поднять проект ---------------------------------------------
//
// Проект нужен почти каждой команде, и «не выбран» — самая частая ошибка
// вызова. Поэтому текст отказа один на все команды и всегда со списком.
struct Loaded
{
Project project;
EntityCatalog catalog;
RoomLibrary rooms;
bool ok = false;
};
bool OpenProject(const Args& a, Loaded& out, bool needRooms = true)
{
std::string why;
const std::string dir = FindProjectDir(a.project);
if (dir.empty())
{
std::printf("ОШИБКА: проект не выбран (не найден или их несколько)\n");
for (const std::string& n : AvailableProjects())
std::printf(" доступен: --project %s\n", n.c_str());
return false;
}
if (!out.project.Load(dir, &why))
{
std::printf("ОШИБКА: %s\n", why.c_str());
return false;
}
out.project.Apply();
if (!out.catalog.Load(out.project.catalogPath, &why))
{
std::printf("ОШИБКА: каталог сущностей: %s\n", why.c_str());
return false;
}
if (needRooms && !out.rooms.Load(out.project.roomsDir, &why))
{
std::printf("ОШИБКА: набор комнат: %s\n", why.c_str());
return false;
}
out.ok = true;
return true;
}
std::string DepthRange(int minDepth, int maxDepth)
{
char buf[24];
if (maxDepth > 0) std::snprintf(buf, sizeof(buf), "%d-%d", minDepth, maxDepth);
else std::snprintf(buf, sizeof(buf), "%d+", minDepth);
return buf;
}
// --- команды -----------------------------------------------------------------
int CmdHelp()
{
std::printf(
"Tile2D tools — работа с проектом без окна.\n"
"\n"
" tile2d_editor --tool <команда> [аргументы] [--project <имя>]\n"
"\n"
"ЧТО ЕСТЬ В ПРОЕКТЕ\n"
" projects какие проекты видны отсюда\n"
" project манифест: вид, пути, кривая глубины\n"
" rooms набор комнат таблицей\n"
" room <имя> одна комната: двери, точки спавна, проблемы\n"
" ascii <имя> сетка комнаты текстом\n"
" catalog [класс] [этаж] что вообще можно ставить, с ценами и глубинами\n"
"\n"
"ЧТО ИЗ ЭТОГО ПОЛУЧАЕТСЯ\n"
" curve <от> <до> во что кривая превращает этажи с <от> по <до>\n"
" floor <этаж> [сид] собрать этаж и показать цифры\n"
" map <этаж> [сид] собранный этаж картой текстом\n"
" validate весь набор: комнаты, покрытие глубин, сборка\n"
"\n"
"Код возврата 1 означает «не выполнено или найдена поломка» — на это можно\n"
"вешать хуки и скрипты, а не читать вывод глазами.\n");
return 0;
}
int CmdProjects()
{
const std::vector<std::string> names = AvailableProjects();
if (names.empty())
{
std::printf("проектов не найдено (ожидается projects/<имя>/project.txt)\n");
return 1;
}
for (const std::string& n : names)
{
Project p;
std::string why;
const std::string dir = FindProjectDir(n);
if (dir.empty() || !p.Load(dir, &why))
{
std::printf("%-12s [СЛОМАН] %s\n", n.c_str(), why.c_str());
continue;
}
std::printf("%-12s %-8s %-8s %s\n", n.c_str(), p.name.c_str(), ViewModeName(p.view),
p.dir.c_str());
}
return 0;
}
int CmdProject(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const Project& p = L.project;
const FloorCurve& c = p.curve;
std::printf("имя %s\n", p.name.c_str());
std::printf("каталог %s\n", p.dir.c_str());
std::printf("вид %s\n", ViewModeName(p.view));
std::printf("комнаты %s (%d шт.)\n", p.roomsDir.c_str(), L.rooms.Count());
std::printf("сущности %s (%d шт.)\n", p.catalogPath.c_str(), L.catalog.Count());
std::printf("первый этаж %d\n", p.startDepth);
std::printf("\nкривая глубины\n");
std::printf(" danger %.2f + %.2f*(d-1) + %.3f*(d-1)^2\n", double(c.dangerBase),
double(c.dangerLinear), double(c.dangerQuad));
std::printf(" density 1 + %.3f*(d-1), не выше %.2f\n", double(c.densityPerFloor),
double(c.densityCap));
std::printf(" loot %d + d/%d предметов, тир каждые %d этажа\n", c.lootBase,
c.floorsPerDrop, c.floorsPerTier);
std::printf(" sight 1 - %.3f*(d-1), не ниже %.2f\n", double(c.sightPerFloor),
double(c.sightFloorAt));
std::printf(" xp 1 + %.2f*(d-1)\n", double(c.xpPerFloor));
std::printf(" themes ");
for (int i = 0; i < THEME_COUNT; ++i)
std::printf(" %s c %d", ThemeName(uint8_t(i)), c.themeFrom[i]);
std::printf("\n");
return 0;
}
int CmdRooms(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
// Заголовки латиницей: ширина в printf считается в БАЙТАХ, и кириллица
// в UTF-8 разъезжается со столбцами ровно вдвое.
std::printf("%-14s %6s %5s %6s %6s %7s %6s %s\n", "ROOM", "SIZE", "DOORS", "SPAWNS", "THEME",
"DEPTH", "WEIGHT", "STATE");
int bad = 0;
for (const Room& r : L.rooms.rooms)
{
std::string state = RoomProblem(r);
if (state.empty() && r.start) state = "start";
if (!RoomProblem(r).empty()) ++bad;
std::printf("%-14s %3dx%-2d %5d %6d %6s %7s %6d %s\n", r.name.c_str(), r.w, r.h,
r.CountDoors(), int(r.spawns.size()), ThemeName(r.theme),
DepthRange(r.minDepth, r.maxDepth).c_str(), r.weight, state.c_str());
}
for (const std::string& f : L.rooms.failures)
{
std::printf("НЕ ЧИТАЕТСЯ %s\n", f.c_str());
++bad;
}
return bad > 0 ? 1 : 0;
}
const Room* FindRoom(const RoomLibrary& lib, const std::string& name)
{
const int i = lib.IndexOf(name);
return (i >= 0) ? &lib.rooms[size_t(i)] : nullptr;
}
int CmdRoom(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const Room* r = FindRoom(L.rooms, a.At(0));
if (!r)
{
std::printf("ОШИБКА: нет комнаты `%s`\n", a.At(0).c_str());
return 1;
}
std::printf("комната %s\n", r->name.c_str());
std::printf("размер %dx%d, дверей %d, вес %d\n", r->w, r->h, r->CountDoors(), r->weight);
std::printf("тема %s\n", ThemeName(r->theme));
std::printf("глубина %s%s\n", DepthRange(r->minDepth, r->maxDepth).c_str(),
r->start ? ", стартовая" : "");
const std::string problem = RoomProblem(*r);
std::printf("состояние %s\n", problem.empty() ? "в порядке" : problem.c_str());
std::printf("\nточки спавна (%d)\n", int(r->spawns.size()));
for (const SpawnPoint& p : r->spawns)
{
std::printf(" (%2d,%2d) шанс %.2f :", p.x, p.y, double(p.chance));
const int total = p.TotalWeight();
for (const SpawnOption& o : p.options)
{
const int idx = L.catalog.IndexOf(o.id);
const double share = (total > 0) ? 100.0 * double(o.weight) / double(total) : 0.0;
std::printf(" %s:%d(%.0f%%%s)", o.id.c_str(), o.weight, share,
idx < 0 ? ", НЕТ В КАТАЛОГЕ" : "");
}
std::printf("\n");
}
return problem.empty() ? 0 : 1;
}
int CmdAscii(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const Room* r = FindRoom(L.rooms, a.At(0));
if (!r)
{
std::printf("ОШИБКА: нет комнаты `%s`\n", a.At(0).c_str());
return 1;
}
// Точка спавна рисуется поверх сетки цифрой — её номером в списке. Так по
// карте видно и ГДЕ она, и какую строку `spawn` смотреть.
std::printf("%s %dx%d\n\n", r->name.c_str(), r->w, r->h);
std::printf(" ");
for (int x = 0; x < r->w; ++x) std::printf("%d", x % 10);
std::printf("\n");
for (int y = 0; y < r->h; ++y)
{
std::printf("%3d ", y);
for (int x = 0; x < r->w; ++x)
{
char c = '.';
switch (r->At(x, y))
{
case RoomCell::WALL: c = '#'; break;
case RoomCell::DOOR: c = '+'; break;
default: break;
}
for (size_t i = 0; i < r->spawns.size(); ++i)
if (r->spawns[i].x == x && r->spawns[i].y == y) c = char('0' + int(i % 10));
std::printf("%c", c);
}
std::printf("\n");
}
std::printf("\n#стена +дверь .пол цифра — точка спавна (её номер в списке)\n");
return 0;
}
int CmdCatalog(const Args& a)
{
Loaded L;
if (!OpenProject(a, L, false)) return 1;
bool byClass = false;
EntityClass want = EntityClass::MONSTER;
const std::string& cls = a.At(0);
if (cls == "monster") { byClass = true; want = EntityClass::MONSTER; }
else if (cls == "prop") { byClass = true; want = EntityClass::PROP; }
else if (cls == "loot") { byClass = true; want = EntityClass::LOOT; }
else if (!cls.empty())
{
std::printf("ОШИБКА: класс бывает monster, prop или loot\n");
return 1;
}
const int depth = a.Int(1, 0); // 0 — не фильтровать по глубине
std::printf("%-12s %-8s %7s %7s %5s %s\n", "ID", "CLASS", "DEPTH", "DANGER", "TIER", "NAME");
int shown = 0;
for (int i = 0; i < L.catalog.Count(); ++i)
{
const EntityDesc& d = L.catalog.At(i);
if (byClass && d.cls != want) continue;
if (depth > 0 && !(depth >= d.minDepth && (d.maxDepth <= 0 || depth <= d.maxDepth)))
continue;
std::printf("%-12s %-8s %7s %7.1f %5d %s\n", d.id.c_str(), EntityClassName(d.cls),
DepthRange(d.minDepth, d.maxDepth).c_str(), double(d.danger), d.tier,
d.name.c_str());
++shown;
}
if (depth > 0) std::printf("\nна этаже %d доступно записей: %d\n", depth, shown);
return 0;
}
int CmdCurve(const Args& a)
{
Loaded L;
if (!OpenProject(a, L, false)) return 1;
const int from = std::max(1, a.Int(0, 1));
const int to = std::max(from, a.Int(1, from + 11));
std::printf("%-6s %-7s %8s %8s %6s %5s %7s %6s\n", "DEPTH", "THEME", "DANGER", "DENSITY",
"LOOT", "TIER", "SIGHT", "XP");
for (int d = from; d <= to; ++d)
{
const FloorSpec s = DescribeFloor(d);
std::printf("%-6d %-7s %8.1f %8.2f %6d %5d %7.2f %6.2f\n", s.depth, ThemeName(s.theme),
double(s.dangerBudget), double(s.spawnChanceScale), s.lootDrops, s.lootTier,
double(s.sightScale), double(s.xpScale));
}
return 0;
}
// Сборка этажа — общая часть для floor и map.
bool BuildFloor(Loaded& L, int depth, uint32_t seed, Tilemap& map, GeneratedLevel& lvl)
{
const FloorSpec spec = DescribeFloor(depth);
std::string why;
if (!GenerateLevel(L.rooms, L.catalog, spec, seed, map, lvl, &why))
{
std::printf("ОШИБКА: этаж %d сид %u не собрался: %s\n", depth, unsigned(seed), why.c_str());
return false;
}
return true;
}
int CmdFloor(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const int depth = std::max(1, a.Int(0, 1));
const uint32_t seed = uint32_t(a.Int(1, 1));
static Tilemap map;
static GeneratedLevel lvl;
if (!BuildFloor(L, depth, seed, map, lvl)) return 1;
std::printf("этаж %d, сид %u, тема %s\n", lvl.floor.depth, unsigned(seed),
ThemeName(lvl.floor.theme));
std::printf("комнат %d, коридоров %d, достижимо тайлов %d, до спуска %d шагов\n",
lvl.roomsPlaced, lvl.corridors, lvl.reachableTiles, lvl.exitDistance);
std::printf("опасность %.1f из %.1f, тварей %d (долив %d), лута %d из тира %d\n",
double(lvl.dangerSpent), double(lvl.floor.dangerBudget), lvl.monstersPlaced,
lvl.monstersFilled, int(lvl.loot.size()), lvl.floor.lootTier);
std::printf("старт (%.1f,%.1f), спуск (%.1f,%.1f), эвакуация (%.1f,%.1f)\n",
double(lvl.squadStart.x), double(lvl.squadStart.y), double(lvl.exitPos.x),
double(lvl.exitPos.y), double(lvl.extractPos.x), double(lvl.extractPos.y));
// Всё, из-за чего этаж населён не так, как задумано, печатается отдельно:
// молчаливая усушка населения — это не «баланс».
int bad = 0;
if (lvl.spawnsDropped > 0)
{
std::printf("ПОТЕРИ: срезано потолком %d: %d\n", MAX_LEVEL_SPAWNS, lvl.spawnsDropped);
++bad;
}
if (lvl.spawnsUnreachable > 0)
{
std::printf("ПОТЕРИ: недостижимо от старта: %d\n", lvl.spawnsUnreachable);
++bad;
}
if (lvl.dangerSpent < lvl.floor.dangerBudget * 0.75f)
std::printf("ЗАМЕЧАНИЕ: набор отстаёт от кривой, бюджет выбран не полностью\n");
// Кого именно выкатило — по количеству, а не списком: список из полусотни
// строк невозможно сравнить с таким же списком другого сида.
std::vector<std::pair<std::string, int>> tally;
for (const LevelSpawn& s : lvl.spawns)
{
auto it = std::find_if(tally.begin(), tally.end(),
[&](const std::pair<std::string, int>& p) { return p.first == s.id; });
if (it == tally.end()) tally.push_back({s.id, 1});
else ++it->second;
}
std::sort(tally.begin(), tally.end(),
[](const std::pair<std::string, int>& x, const std::pair<std::string, int>& y) {
return x.second > y.second;
});
std::printf("\nнаселение:");
for (const std::pair<std::string, int>& p : tally) std::printf(" %s x%d", p.first.c_str(), p.second);
std::printf("\nлут: ");
for (const LevelSpawn& s : lvl.loot) std::printf(" %s", s.id.c_str());
std::printf("\n");
return bad > 0 ? 1 : 0;
}
int CmdMap(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
const int depth = std::max(1, a.Int(0, 1));
const uint32_t seed = uint32_t(a.Int(1, 1));
static Tilemap map;
static GeneratedLevel lvl;
if (!BuildFloor(L, depth, seed, map, lvl)) return 1;
// Карта строится в буфер символов, потом на неё кладутся сущности: иначе
// тварь, стоящая в дверях, потерялась бы под символом двери.
std::vector<char> grid(size_t(MAP_W) * size_t(MAP_H), ' ');
for (int y = 0; y < MAP_H; ++y)
for (int x = 0; x < MAP_W; ++x)
grid[size_t(y) * MAP_W + x] = map.IsWall(x, y) ? '#' : '.';
auto put = [&](Vec2 p, char c) {
const int x = int(p.x);
const int y = int(p.y);
if (Tilemap::InBounds(x, y)) grid[size_t(y) * MAP_W + x] = c;
};
for (const LevelSpawn& s : lvl.spawns)
{
const EntityDesc* d = L.catalog.Find(s.id);
put(s.pos, (d && d->cls == EntityClass::PROP) ? 'o' : 'm');
}
for (const LevelSpawn& s : lvl.loot) put(s.pos, '$');
put(lvl.exitPos, '>');
put(lvl.extractPos, '<');
put(lvl.squadStart, '@');
std::printf("этаж %d, сид %u, тема %s, комнат %d\n\n", lvl.floor.depth, unsigned(seed),
ThemeName(lvl.floor.theme), lvl.roomsPlaced);
for (int y = 0; y < MAP_H; ++y)
{
std::printf("%3d ", y);
for (int x = 0; x < MAP_W; ++x) std::printf("%c", grid[size_t(y) * MAP_W + x]);
std::printf("\n");
}
std::printf("\n# стена . пол @ старт > спуск < эвакуация m тварь o обстановка $ лут\n");
return 0;
}
int CmdValidate(const Args& a)
{
Loaded L;
if (!OpenProject(a, L)) return 1;
int bad = 0;
int warn = 0;
for (const Room& r : L.rooms.rooms)
{
const std::string problem = RoomProblem(r);
if (!problem.empty())
{
std::printf("СЛОМАНО %s: %s\n", r.name.c_str(), problem.c_str());
++bad;
}
for (const SpawnPoint& p : r.spawns)
for (const SpawnOption& o : p.options)
if (L.catalog.IndexOf(o.id) < 0)
{
std::printf("СЛОМАНО %s (%d,%d): id `%s` нет в каталоге\n", r.name.c_str(),
p.x, p.y, o.id.c_str());
++bad;
}
}
for (const std::string& f : L.rooms.failures)
{
std::printf("СЛОМАНО не читается %s\n", f.c_str());
++bad;
}
if (!L.rooms.HasStartRoom())
{
std::printf("СЛОМАНО в наборе нет ни одной стартовой комнаты\n");
++bad;
}
// Покрытие глубин: главный вопрос бесконечного спуска — не «хороша ли
// комната», а «есть ли чем застроить сотый этаж».
const int probeMax = std::max(L.catalog.DeepestIntroduction(), 12) + 4;
for (int d = 1; d <= probeMax; ++d)
{
const bool roomsHere = L.rooms.CountAt(d) > 0;
const bool startHere = L.rooms.HasStartRoomAt(d);
const bool monsters = L.catalog.HasAnyAt(EntityClass::MONSTER, d);
const bool loot = L.catalog.HasAnyAt(EntityClass::LOOT, d);
if (roomsHere && startHere && monsters && loot) continue;
std::printf("СЛОМАНО этаж %d:%s%s%s%s\n", d, roomsHere ? "" : " нет комнат",
startHere ? "" : " нет стартовой", monsters ? "" : " нет тварей",
loot ? "" : " нет лута");
++bad;
}
static Tilemap map;
static GeneratedLevel lvl;
for (const int d : {1, 3, 6, 10, 16, 24})
for (const uint32_t seed : {1u, 42u})
{
const FloorSpec spec = DescribeFloor(d);
std::string why;
if (!GenerateLevel(L.rooms, L.catalog, spec, seed, map, lvl, &why))
{
std::printf("СЛОМАНО этаж %d сид %u: %s\n", d, unsigned(seed), why.c_str());
++bad;
continue;
}
if (lvl.spawnsUnreachable > 0 || lvl.spawnsDropped > 0 || lvl.loot.empty() ||
lvl.exitDistance <= 0)
{
std::printf("СЛОМАНО этаж %d сид %u: недостижимых %d, срезано %d, лута %d, "
"до спуска %d\n",
d, unsigned(seed), lvl.spawnsUnreachable, lvl.spawnsDropped,
int(lvl.loot.size()), lvl.exitDistance);
++bad;
}
else if (lvl.dangerSpent < spec.dangerBudget * 0.75f)
{
std::printf("ЗАМЕЧАНИЕ этаж %d сид %u: опасность %.0f из %.0f\n", d, unsigned(seed),
double(lvl.dangerSpent), double(spec.dangerBudget));
++warn;
}
}
std::printf("\n%s: комнат %d, сущностей %d, поломок %d, замечаний %d\n",
L.project.name.c_str(), L.rooms.Count(), L.catalog.Count(), bad, warn);
return bad > 0 ? 1 : 0;
}
} // namespace
int RunTool(int argc, char** argv)
{
const Args a = Parse(argc, argv);
if (a.cmd.empty() || a.cmd == "help") return CmdHelp();
if (a.cmd == "projects") return CmdProjects();
if (a.cmd == "project") return CmdProject(a);
if (a.cmd == "rooms") return CmdRooms(a);
if (a.cmd == "room") return CmdRoom(a);
if (a.cmd == "ascii") return CmdAscii(a);
if (a.cmd == "catalog") return CmdCatalog(a);
if (a.cmd == "curve") return CmdCurve(a);
if (a.cmd == "floor") return CmdFloor(a);
if (a.cmd == "map") return CmdMap(a);
if (a.cmd == "validate") return CmdValidate(a);
std::printf("ОШИБКА: нет команды `%s`\n\n", a.cmd.c_str());
CmdHelp();
return 1;
}