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