// ----------------------------------------------------------------------------- // Точка входа редактора комнат. Окно, цикл, финальный блит — и всё. // // Приложение отдельное от игры намеренно: редактор линкуется только с 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 #include #include #include #include #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; }