squad-proto/editor/tools.cpp

592 lines
22 KiB
C++
Raw Permalink Normal View History

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-14 01:07:19 +07:00
#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;
}