squad-proto/engine/project.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

226 lines
8.7 KiB
C++
Raw 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.

#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;
}