squad-proto/engine/project.cpp

233 lines
9.0 KiB
C++
Raw Permalink Normal View History

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-14 01:07:19 +07:00
#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);
Правки по игре: начало, ставка, наёмники (M29) Семь изменений по итогам игры вживую. НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями, из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2 (ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного (0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и хоррор первого этажа в том, что игрок один, а не в том, что не видно стен. Темнота это инструмент глубины, и тратить её на первый этаж значит не иметь запаса дальше. СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп больше не отнимает всё - доезжает около половины добычи, и чем вещь реже, тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало единственным разумным поведением уход наверх при первой царапине. ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка "вниз или наверх" теперь там же, где этаж кончился. Обратный путь через растревоженный этаж ничего не решал - это был ещё раз тот же этаж. ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не перебежка между сохранениями. НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в двадцать. У каждого свои три прибавки из одного бюджета - разница в характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не зовёт. ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%). Первая версия проверки считала одну сумку и выдала "обычного доехало 112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику его числа. Формат профиля 6: строки merc и rescued. Не сделано и обсуждается: ростер сверх пятерых, задания для свободных, пассивные бонусы от сидящих на базе. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
Take(parts, 3, curve.sightStart); // необязательный третий: с чего начинать
}
else if (key == "ambient")
{
Take(parts, 1, curve.ambientStart);
Take(parts, 2, curve.ambientPerFloor);
Take(parts, 3, curve.ambientFloorAt);
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-14 01:07:19 +07:00
}
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;
}