squad-proto/editor/main.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

402 lines
20 KiB
C++
Raw Permalink 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.

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