squad-proto/editor/main.cpp

402 lines
20 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
// -----------------------------------------------------------------------------
// Точка входа редактора комнат. Окно, цикл, финальный блит — и всё.
//
// Приложение отдельное от игры намеренно: редактор линкуется только с tile2d,
// поэтому обратиться к правилам боя он физически не может. Общее у него с игрой
// ровно то, что и должно быть общим, — движок.
//
// Редактор принадлежит ДВИЖКУ, а не игре: он открывает любой проект и ни одной
// игры по имени не знает.
//
// Ключи:
// --windowed не разворачивать окно на весь экран
// --room <имя> открыть эту комнату сразу
// --select N выбрать N-ю точку спавна: иначе панель таблицы весов
// не снять на картинку — она появляется по клику мышью
// --level [сид] сразу показать сборку этажа из набора
// --depth N какой этаж собирать в предпросмотре
// --shot [к] [файл] снять кадр и выйти (для картинок в документации)
// --check проверить набор комнат без окна и выйти
// --project <имя> какой проект открыть (имя каталога в projects/ или путь)
// --tool <команда> инструменты без окна: room, floor, map, validate, ...
// (docs/19-agent.md; --tool help покажет все)
// --term "<кмд>" открыть терминал и сразу выполнить в нём команду
//
// В самом редакторе снизу живёт терминал (клавиша `): те же инструменты, git,
// сборка и запросы к claude/codex прямо из редактора (docs/19-agent.md).
// -----------------------------------------------------------------------------
#include <raylib.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include "engine/app.h"
#include "editor/editor.h"
#include "editor/terminal.h"
#include "editor/tools.h"
#include "engine/level_gen.h"
#include "engine/project.h"
#include "engine/framebuffer.h"
#include "engine/config.h"
namespace
{
// -----------------------------------------------------------------------------
// --check: проверка набора без окна. Это аналог `squad_proto --accept` для
// содержимого: набор комнат — такой же исходник, как код, и ломаться он обязан
// на проверке, а не на глазах у игрока.
//
// Круговой прогон через запись здесь не для полноты, а потому что записывающий
// путь — единственный, которым редактор может УНИЧТОЖИТЬ работу. Читающий
// проверяется сам собой при каждом запуске; пишущий — только здесь.
// -----------------------------------------------------------------------------
bool SameRoom(const Room& a, const Room& b)
{
if (a.name != b.name || a.w != b.w || a.h != b.h) return false;
if (a.weight != b.weight || a.theme != b.theme || a.start != b.start) return false;
for (int y = 0; y < a.h; ++y)
for (int x = 0; x < a.w; ++x)
if (a.At(x, y) != b.At(x, y)) return false;
if (a.spawns.size() != b.spawns.size()) return false;
for (const SpawnPoint& p : a.spawns)
{
const SpawnPoint* q = b.SpawnAt(p.x, p.y);
if (!q) return false;
// Шанс пишется с двумя знаками после точки, поэтому сравнивать его
// на равенство нельзя: 0.333 вернётся как 0.33, и это не ошибка.
if (std::fabs(p.chance - q->chance) > 0.006f) return false;
if (p.options.size() != q->options.size()) return false;
for (const SpawnOption& o : p.options)
if (q->WeightOf(o.id) != o.weight) return false;
}
return true;
}
int RunCheck(const char* projectName)
{
std::string why;
// Проект — первым: без него неизвестно ни где содержимое, ни по какой
// кривой проверять глубины. У движка проектов несколько, и проверять
// каждый надо ЕГО кривой, а не какой-то одной общей.
Project project;
const std::string projectDir = FindProjectDir(projectName ? projectName : "");
if (projectDir.empty() || !project.Load(projectDir, &why))
{
// «Не найден» и «их несколько» — разные беды с одинаковым исходом, и
// угадывать за игрока нельзя ни в том, ни в другом случае. Список
// доступных отвечает на оба вопроса разом.
std::printf("[FAIL] проект: %s\n",
projectDir.empty() ? "не выбран (не найден или их несколько)" : why.c_str());
for (const std::string& n : AvailableProjects())
std::printf(" доступен: --project %s\n", n.c_str());
return 1;
}
project.Apply();
std::printf("проект: %s (%s), вид %s\n", project.name.c_str(), project.dir.c_str(),
ViewModeName(project.view));
EntityCatalog catalog;
if (!catalog.Load(project.catalogPath, &why))
{
std::printf("[FAIL] каталог: %s\n", why.c_str());
return 1;
}
std::printf("каталог: %s, записей %d\n", project.catalogPath.c_str(), catalog.Count());
RoomLibrary lib;
if (!lib.Load(project.roomsDir, &why))
{
std::printf("[FAIL] набор комнат: %s\n", why.c_str());
return 1;
}
int bad = 0;
int warn = 0;
for (const std::string& f : lib.failures)
{
std::printf("[FAIL] %s\n", f.c_str());
++bad;
}
const std::filesystem::path tmp =
std::filesystem::temp_directory_path() / "tile2d_roundtrip.room";
// Заголовок латиницей: ширина в printf считается в БАЙТАХ, и кириллица в
// UTF-8 разъезжается со столбцами ровно вдвое.
std::printf("%-14s %6s %5s %6s %6s %7s %s\n", "ROOM", "SIZE", "DOORS", "SPAWNS", "THEME",
"DEPTH", "STATE");
for (const Room& r : lib.rooms)
{
std::string state = RoomProblem(r);
// Записали -> прочитали -> сравнили. Расхождение означает, что кнопка
// SAVE в редакторе тихо теряет часть комнаты.
Room back;
if (state.empty())
{
if (!SaveRoom(tmp.string(), r, &why)) state = "save failed: " + why;
else if (!LoadRoom(tmp.string(), back, &why)) state = "reload failed: " + why;
else if (!SameRoom(r, back)) state = "roundtrip mismatch";
}
// id, которого нет в каталоге, — это точка, которая не даст никого.
for (const SpawnPoint& p : r.spawns)
for (const SpawnOption& o : p.options)
if (catalog.IndexOf(o.id) < 0 && state.empty())
state = "unknown id `" + o.id + "`";
if (!state.empty()) ++bad;
char depthBuf[16];
if (r.maxDepth > 0) std::snprintf(depthBuf, sizeof(depthBuf), "%d-%d", r.minDepth, r.maxDepth);
else std::snprintf(depthBuf, sizeof(depthBuf), "%d+", r.minDepth);
std::printf("%-14s %3dx%-2d %5d %6d %6s %7s %s\n", r.name.c_str(), r.w, r.h,
r.CountDoors(), int(r.spawns.size()), ThemeName(r.theme), depthBuf,
state.empty() ? "OK" : ("[FAIL] " + state).c_str());
}
std::error_code ec;
std::filesystem::remove(tmp, ec);
if (!lib.HasStartRoom())
{
std::printf("[FAIL] в наборе нет ни одной стартовой комнаты\n");
++bad;
}
// -------------------------------------------------------------------------
// Покрытие глубин. Главный вопрос игры про бесконечный спуск: не «хороша ли
// комната», а «есть ли чем застроить сотый этаж». Набор, у которого на
// глубине не остаётся ни одной подходящей комнаты или ни одной твари,
// обрывает забег — и обрывает молча, если это не проверять.
//
// Проверяется с запасом за последнюю новинку каталога: дальше неё этажи
// собираются из того же набора, и если он там держится, то держится и на
// любой глубине.
// -------------------------------------------------------------------------
const int probeMax = std::max(catalog.DeepestIntroduction(), 12) + 4;
std::printf("\nпокрытие глубин 1..%d\n", probeMax);
for (int d = 1; d <= probeMax; ++d)
{
const int roomsHere = lib.CountAt(d);
const bool startHere = lib.HasStartRoomAt(d);
const bool monsters = catalog.HasAnyAt(EntityClass::MONSTER, d);
const bool loot = catalog.HasAnyAt(EntityClass::LOOT, d);
if (roomsHere > 0 && startHere && monsters && loot) continue;
std::printf("[FAIL] этаж %d:%s%s%s%s\n", d, roomsHere > 0 ? "" : " нет комнат",
startHere ? "" : " нет стартовой", monsters ? "" : " нет тварей",
loot ? "" : " нет лута");
++bad;
}
// Сборка на нескольких сидах И глубинах: комната может быть безупречной
// сама по себе и при этом никогда не стыковаться с соседями, а набор —
// перестать выбирать бюджет опасности на глубине.
Tilemap map;
GeneratedLevel lvl;
std::printf("\n%-6s %-6s %-5s %7s %8s %6s %5s %s\n", "DEPTH", "SEED", "ROOMS", "MONSTER",
"DANGER", "LOOT", "EXIT", "STATE");
for (const int d : {1, 3, 6, 10, 16, 24})
{
for (const uint32_t seed : {1u, 42u})
{
const FloorSpec spec = DescribeFloor(d);
if (!GenerateLevel(lib, catalog, spec, seed, map, lvl, &why))
{
std::printf("[FAIL] этаж %d сид %u: %s\n", d, unsigned(seed), why.c_str());
++bad;
continue;
}
// Авария и сигнал — разные вещи, и мешать их нельзя.
//
// FAIL — этаж собран НЕПРАВИЛЬНО: точки не встали, что-то
// отрезано от старта, некуда спускаться, нет находок;
// WARN — этаж собран правильно, но набор комнат не успевает за
// кривой сложности, и глубина перестаёт ощущаться. Это
// повод дорисовать комнат, а не поломка.
const bool starved = lvl.dangerSpent < spec.dangerBudget * 0.75f;
const bool broken = lvl.spawnsUnreachable > 0 || lvl.spawnsDropped > 0 ||
lvl.loot.empty() || lvl.exitDistance <= 0;
if (broken) ++bad;
else if (starved) ++warn;
std::printf("%-6d %-6u %5d %7d %4.0f/%-3.0f %6d %5d %s\n", d, unsigned(seed),
lvl.roomsPlaced, lvl.monstersPlaced, double(lvl.dangerSpent),
double(spec.dangerBudget), int(lvl.loot.size()), lvl.exitDistance,
broken ? "[FAIL]" : (starved ? "[WARN] набор отстаёт от кривой" : "OK"));
}
}
std::printf(bad == 0 ? "\nИТОГ: набор в порядке\n" : "\nИТОГ: замечаний %d\n", bad);
return bad == 0 ? 0 : 1;
}
} // namespace
int main(int argc, char** argv)
{
bool windowed = false;
const char* openRoom = nullptr;
int selectSpawn = -1;
bool showLevel = false;
int previewDepth = 0; // 0 — оставить тот, что в редакторе по умолчанию
uint32_t levelSeed = 1;
int shotFrame = -1;
const char* shotFile = "editor.png";
const char* projectName = nullptr;
// --term "<команда>": открыть терминал и сразу что-то в нём выполнить.
// Нужен для картинок в документации и для запуска редактора «с делом»:
// открыл — и уже видно, что не сходится.
const char* termCmd = nullptr;
// Имя проекта разбирается ПЕРВЫМ проходом: --check возвращается из цикла
// сразу, и написанный после него --project иначе бы не действовал.
for (int i = 1; i + 1 < argc; ++i)
if (std::strcmp(argv[i], "--project") == 0) projectName = argv[i + 1];
// --tool отдаёт весь хвост командной строки инструментам и выходит. Окна
// здесь тоже нет: этими командами работает не человек с мышью, а скрипт
// или языковая модель (docs/19-agent.md).
for (int i = 1; i < argc; ++i)
if (std::strcmp(argv[i], "--tool") == 0) return RunTool(argc - i - 1, argv + i + 1);
for (int i = 1; i < argc; ++i)
{
// Проверка идёт без окна и до всего остального: её запускают из
// консоли и из сборки, где никакого дисплея может не быть вовсе.
if (std::strcmp(argv[i], "--check") == 0) return RunCheck(projectName);
else if (std::strcmp(argv[i], "--project") == 0 && i + 1 < argc) ++i;
else if (std::strcmp(argv[i], "--windowed") == 0) windowed = true;
else if (std::strcmp(argv[i], "--term") == 0 && i + 1 < argc) termCmd = argv[++i];
else if (std::strcmp(argv[i], "--room") == 0 && i + 1 < argc) openRoom = argv[++i];
else if (std::strcmp(argv[i], "--select") == 0 && i + 1 < argc)
selectSpawn = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--depth") == 0 && i + 1 < argc)
previewDepth = TextToInteger(argv[++i]);
else if (std::strcmp(argv[i], "--level") == 0)
{
showLevel = true;
if (i + 1 < argc && argv[i + 1][0] != '-')
levelSeed = uint32_t(std::strtoul(argv[++i], nullptr, 10));
}
else if (std::strcmp(argv[i], "--shot") == 0)
{
shotFrame = (i + 1 < argc && argv[i + 1][0] != '-') ? TextToInteger(argv[i + 1]) : 30;
if (i + 2 < argc && argv[i + 2][0] != '-') shotFile = argv[i + 2];
}
}
// Окно и кадр — движковые (engine/app.h). Esc здесь обрабатывается вручную:
// пока открыт терминал, он закрывает терминал, а не редактор — отдать Esc
// raylib значило бы терять набранную команду вместе со всем приложением.
App app;
AppConfig cfg;
cfg.title = "Tile2D — редактор комнат";
cfg.borderless = !windowed;
if (!app.Init(cfg)) return 1;
// Редактор большой (кеш маркеров, набор комнат, карта предпросмотра) — на куче.
static Editor editor;
const bool ready = editor.Init(projectName ? projectName : "");
if (!ready) std::printf("редактор: %s\n", editor.status.c_str());
if (openRoom)
{
const int i = editor.lib.IndexOf(openRoom);
if (i >= 0) editor.room = editor.lib.rooms[size_t(i)], editor.roomIndex = i;
else std::printf("нет такой комнаты: %s\n", openRoom);
}
if (selectSpawn >= 0 && selectSpawn < int(editor.room.spawns.size()))
{
editor.selX = editor.room.spawns[size_t(selectSpawn)].x;
editor.selY = editor.room.spawns[size_t(selectSpawn)].y;
}
if (showLevel && ready)
{
editor.seed = levelSeed;
if (previewDepth > 0) editor.previewDepth = previewDepth;
editor.Apply(Act::PREVIEW_TOGGLE, 0);
}
// Терминал работает в КОРНЕ РЕПОЗИТОРИЯ, а не в каталоге проекта: работают
// не только над содержимым — рядом код движка, документация и git.
static Terminal term;
{
std::error_code ec;
std::filesystem::path root = ready ? std::filesystem::path(editor.project.dir) : ".";
if (ready) root = root.parent_path().parent_path(); // projects/<имя> -> корень
if (root.empty()) root = ".";
root = std::filesystem::absolute(root, ec).lexically_normal();
// Имя проекта, а не путь: по нему инструменты найдут его сами, откуда
// бы их ни запустили.
const std::string pname =
ready ? std::filesystem::path(editor.project.dir).filename().string() : std::string();
term.Init(root.string(), pname);
}
if (termCmd)
{
term.open = true;
term.RunShellFromArgv(termCmd);
}
while (app.NextFrame())
{
// Терминал забирает ввод целиком, пока открыт: иначе набранная в строке
// `w` вставала бы кистью стены на холст.
const bool termAte = term.Update(app.winW, app.winH, app.frameDt);
if (!termAte && IsKeyPressed(KEY_ESCAPE)) app.quit = true;
if (ready)
{
if (!termAte) editor.Update(app.fit, app.frameDt);
editor.DrawWorld(app.fb);
}
else
{
app.fb.Clear(RGBA(9, 9, 11));
}
app.Present();
if (ready)
{
editor.DrawUi(app.fit);
}
else
{
// Без каталога сущностей ставить в комнату нечего, и делать вид,
// что редактор работает, нельзя.
DrawText("no project (see projects/*/project.txt)", app.fit.x + 16, app.fit.y + 16,
8 * app.fit.scale, Color{198, 84, 66, 255});
DrawText(editor.status.c_str(), app.fit.x + 16, app.fit.y + 32, 8 * app.fit.scale,
Color{206, 200, 190, 255});
}
// Терминал рисуется ПОВЕРХ всего и в пикселях окна, а не кадра: это
// инструмент, а не часть пиксельной картинки, и строк в нём должно
// помещаться столько, сколько позволяет монитор.
term.Draw(app.winW, app.winH);
app.Finish();
if (shotFrame >= 0 && --shotFrame <= 0)
{
app.Screenshot(shotFile);
break;
}
}
term.Shutdown();
app.Shutdown();
return ready ? 0 : 1;
}