Десять вех одним состоянием: разделять их пофайлово нечем, потому что
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>
226 lines
8.7 KiB
C++
226 lines
8.7 KiB
C++
#include "engine/project.h"
|
||
|
||
#include <algorithm>
|
||
#include <cstdlib>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
|
||
#include "engine/text_parse.h"
|
||
|
||
namespace fs = std::filesystem;
|
||
|
||
namespace
|
||
{
|
||
|
||
constexpr const char* MANIFEST = "project.txt";
|
||
constexpr const char* PROJECTS_DIR = "projects";
|
||
|
||
// Откуда смотрим. Игра запускается и из корня репозитория, и из build/bin —
|
||
// оба способа рабочие, и требовать за это ключ было бы налогом на каждый запуск.
|
||
const char* const BASES[] = {".", "..", "../..", "../../.."};
|
||
|
||
bool HasManifest(const fs::path& dir)
|
||
{
|
||
std::error_code ec;
|
||
return fs::exists(dir / MANIFEST, ec);
|
||
}
|
||
|
||
// Число с плавающей точкой из лексемы по индексу; нет лексемы — прежнее значение
|
||
// остаётся. Так частично заполненная строка не обнуляет незаданные поля.
|
||
void Take(const std::vector<std::string>& parts, size_t i, float& out)
|
||
{
|
||
if (i < parts.size()) out = text::ToFloat(parts[i], out);
|
||
}
|
||
|
||
void Take(const std::vector<std::string>& parts, size_t i, int& out)
|
||
{
|
||
if (i < parts.size()) out = text::ToInt(parts[i], out);
|
||
}
|
||
|
||
} // namespace
|
||
|
||
bool Project::Load(const std::string& projectDir, std::string* err)
|
||
{
|
||
auto fail = [&](const std::string& why) {
|
||
if (err) *err = why;
|
||
return false;
|
||
};
|
||
|
||
const fs::path root(projectDir);
|
||
const fs::path manifest = root / MANIFEST;
|
||
|
||
std::ifstream in(manifest);
|
||
if (!in) return fail("нет " + manifest.string());
|
||
|
||
*this = Project{};
|
||
dir = projectDir;
|
||
|
||
std::string line;
|
||
int lineNo = 0;
|
||
std::string rooms = "rooms";
|
||
std::string catalog = "catalog/entities.txt";
|
||
|
||
while (std::getline(in, line))
|
||
{
|
||
++lineNo;
|
||
const std::vector<std::string> parts = text::Split(text::Trim(text::StripComment(line)));
|
||
if (parts.empty()) continue;
|
||
|
||
const std::string& key = parts[0];
|
||
|
||
if (key == "name") name = text::JoinFrom(parts, 1);
|
||
else if (key == "rooms") rooms = text::JoinFrom(parts, 1);
|
||
else if (key == "catalog") catalog = text::JoinFrom(parts, 1);
|
||
else if (key == "startdepth") Take(parts, 1, startDepth);
|
||
else if (key == "view")
|
||
{
|
||
// Неизвестный вид — это ошибка, а не повод молча остаться в
|
||
// изометрии: проект, написанный под вид сверху, в изометрии
|
||
// выглядит не «чуть иначе», а сломанным.
|
||
if (parts.size() < 2 || !ParseViewMode(parts[1].c_str(), view))
|
||
return fail(manifest.string() + ": строка " + std::to_string(lineNo) +
|
||
": неизвестный вид отображения");
|
||
}
|
||
else if (key == "danger")
|
||
{
|
||
Take(parts, 1, curve.dangerBase);
|
||
Take(parts, 2, curve.dangerLinear);
|
||
Take(parts, 3, curve.dangerQuad);
|
||
}
|
||
else if (key == "density")
|
||
{
|
||
Take(parts, 1, curve.densityPerFloor);
|
||
Take(parts, 2, curve.densityCap);
|
||
}
|
||
else if (key == "loot")
|
||
{
|
||
Take(parts, 1, curve.lootBase);
|
||
Take(parts, 2, curve.floorsPerDrop);
|
||
Take(parts, 3, curve.floorsPerTier);
|
||
}
|
||
else if (key == "sight")
|
||
{
|
||
Take(parts, 1, curve.sightPerFloor);
|
||
Take(parts, 2, curve.sightFloorAt);
|
||
}
|
||
else if (key == "xp") Take(parts, 1, curve.xpPerFloor);
|
||
else if (key == "themes")
|
||
{
|
||
for (int i = 0; i < THEME_COUNT; ++i) Take(parts, size_t(i) + 1, curve.themeFrom[i]);
|
||
}
|
||
else
|
||
{
|
||
// Опечатка в ключе — самая дорогая ошибка в текстовом формате:
|
||
// строка просто не действует, и это выглядит как «движок не
|
||
// слушается». Поэтому она валит загрузку, а не пропускается.
|
||
return fail(manifest.string() + ": строка " + std::to_string(lineNo) +
|
||
": неизвестный ключ " + key);
|
||
}
|
||
}
|
||
|
||
roomsDir = (root / rooms).string();
|
||
catalogPath = (root / catalog).string();
|
||
|
||
std::error_code ec;
|
||
if (!fs::is_directory(fs::path(roomsDir), ec))
|
||
return fail("нет каталога комнат " + roomsDir);
|
||
if (!fs::exists(fs::path(catalogPath), ec))
|
||
return fail("нет каталога сущностей " + catalogPath);
|
||
|
||
// Полосы отделки обязаны идти по возрастанию: тема с большим номером — это
|
||
// «глубже». Перепутанные границы дали бы этаж, у которого тема зависит от
|
||
// порядка сравнений, а не от глубины.
|
||
for (int i = 1; i < THEME_COUNT; ++i)
|
||
if (curve.themeFrom[i] < curve.themeFrom[i - 1])
|
||
return fail(manifest.string() + ": полосы отделки идут не по возрастанию");
|
||
|
||
if (startDepth < 1) startDepth = 1;
|
||
return true;
|
||
}
|
||
|
||
void Project::Apply() const
|
||
{
|
||
g_proj = Projection::For(view);
|
||
g_curve = curve;
|
||
}
|
||
|
||
std::string FindProjectDir(const std::string& wantedArg)
|
||
{
|
||
std::error_code ec;
|
||
|
||
// Ключ важнее окружения, окружение важнее угадывания. Переменная нужна
|
||
// терминалу редактора и запущенным из него агентам: инструмент, вызванный
|
||
// изнутри открытого проекта, обязан говорить про ЭТОТ проект, а дописывать
|
||
// --project в чужие команды строковой подстановкой — способ однажды
|
||
// подставить его не туда.
|
||
std::string wanted = wantedArg;
|
||
if (wanted.empty())
|
||
{
|
||
#ifdef _MSC_VER
|
||
// getenv_s, а не getenv: под /W4 второй — предупреждение, а собираемся
|
||
// мы без предупреждений, и глушить их ради одной строки нельзя.
|
||
char buf[512] = {};
|
||
size_t len = 0;
|
||
if (getenv_s(&len, buf, sizeof(buf), "TILE2D_PROJECT") == 0 && len > 1) wanted = buf;
|
||
#else
|
||
const char* env = std::getenv("TILE2D_PROJECT");
|
||
if (env && *env) wanted = env;
|
||
#endif
|
||
}
|
||
|
||
// 1. Явный путь — как дали, так и берём.
|
||
if (!wanted.empty())
|
||
{
|
||
if (HasManifest(fs::path(wanted))) return wanted;
|
||
|
||
// 2. Или это имя проекта: <база>/projects/<имя>.
|
||
for (const char* b : BASES)
|
||
{
|
||
const fs::path p = fs::path(b) / PROJECTS_DIR / wanted;
|
||
if (HasManifest(p)) return p.string();
|
||
}
|
||
return {};
|
||
}
|
||
|
||
// 3. Без имени: проект под ногами, потом единственный в projects/.
|
||
for (const char* b : BASES)
|
||
{
|
||
if (HasManifest(fs::path(b))) return fs::path(b).string();
|
||
|
||
const fs::path pdir = fs::path(b) / PROJECTS_DIR;
|
||
if (!fs::is_directory(pdir, ec)) continue;
|
||
|
||
std::vector<std::string> found;
|
||
for (const fs::directory_entry& e : fs::directory_iterator(pdir, ec))
|
||
if (e.is_directory(ec) && HasManifest(e.path())) found.push_back(e.path().string());
|
||
|
||
// Несколько проектов и ни одного названного — выбирать за игрока
|
||
// нельзя: молча взятый «первый попавшийся» читается как «движок открыл
|
||
// не то». Пусть скажут, какой.
|
||
if (found.size() == 1) return found[0];
|
||
if (found.size() > 1) return {};
|
||
}
|
||
return {};
|
||
}
|
||
|
||
std::vector<std::string> AvailableProjects()
|
||
{
|
||
std::error_code ec;
|
||
std::vector<std::string> names;
|
||
|
||
for (const char* b : BASES)
|
||
{
|
||
const fs::path pdir = fs::path(b) / PROJECTS_DIR;
|
||
if (!fs::is_directory(pdir, ec)) continue;
|
||
|
||
for (const fs::directory_entry& e : fs::directory_iterator(pdir, ec))
|
||
if (e.is_directory(ec) && HasManifest(e.path()))
|
||
names.push_back(e.path().filename().string());
|
||
|
||
if (!names.empty()) break; // ближайший каталог projects/ и есть нужный
|
||
}
|
||
|
||
std::sort(names.begin(), names.end());
|
||
return names;
|
||
}
|