Десять вех одним состоянием: разделять их пофайлово нечем, потому что
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>
216 lines
6.9 KiB
C++
216 lines
6.9 KiB
C++
#include "engine/catalog.h"
|
||
|
||
#include <algorithm>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
|
||
#include "engine/text_parse.h"
|
||
|
||
namespace fs = std::filesystem;
|
||
|
||
namespace
|
||
{
|
||
|
||
// color=116,52,52 — три байта через запятую. Отдельным ключом, а не тремя:
|
||
// цвет это одно свойство, и разносить его по трём ключам значило бы разрешить
|
||
// написать два из трёх.
|
||
bool ParseColor(const std::string& v, EntityDesc& out)
|
||
{
|
||
const size_t c1 = v.find(',');
|
||
if (c1 == std::string::npos) return false;
|
||
const size_t c2 = v.find(',', c1 + 1);
|
||
if (c2 == std::string::npos) return false;
|
||
|
||
out.r = uint8_t(text::ToInt(v.substr(0, c1), 180));
|
||
out.g = uint8_t(text::ToInt(v.substr(c1 + 1, c2 - c1 - 1), 180));
|
||
out.b = uint8_t(text::ToInt(v.substr(c2 + 1), 180));
|
||
return true;
|
||
}
|
||
|
||
// depth=3 с третьего этажа и глубже
|
||
// depth=1..4 с первого по четвёртый включительно
|
||
bool ParseDepth(const std::string& v, EntityDesc& out)
|
||
{
|
||
const size_t dots = v.find("..");
|
||
if (dots == std::string::npos)
|
||
{
|
||
out.minDepth = std::max(1, text::ToInt(v, 1));
|
||
out.maxDepth = 0;
|
||
return true;
|
||
}
|
||
out.minDepth = std::max(1, text::ToInt(v.substr(0, dots), 1));
|
||
out.maxDepth = text::ToInt(v.substr(dots + 2), 0);
|
||
return out.maxDepth <= 0 || out.maxDepth >= out.minDepth;
|
||
}
|
||
|
||
// entity <id> ключ=значение ...
|
||
bool ParseEntity(const std::vector<std::string>& p, EntityDesc& out, std::string& why)
|
||
{
|
||
if (p.size() < 2)
|
||
{
|
||
why = "у записи нет id";
|
||
return false;
|
||
}
|
||
out.id = p[1];
|
||
out.name = p[1];
|
||
|
||
bool haveClass = false;
|
||
for (size_t i = 2; i < p.size(); ++i)
|
||
{
|
||
std::string key, value;
|
||
if (!text::SplitKeyValue(p[i], key, value) || key.empty() || value.empty())
|
||
{
|
||
why = "не ключ=значение: " + p[i];
|
||
return false;
|
||
}
|
||
|
||
if (key == "class")
|
||
{
|
||
if (value == "monster") out.cls = EntityClass::MONSTER;
|
||
else if (value == "prop") out.cls = EntityClass::PROP;
|
||
else if (value == "loot") out.cls = EntityClass::LOOT;
|
||
else { why = "неизвестный класс: " + value; return false; }
|
||
haveClass = true;
|
||
}
|
||
else if (key == "name") out.name = value;
|
||
else if (key == "sprite") out.sprite = (value == "-") ? std::string{} : value;
|
||
else if (key == "radius") out.radius = text::ToFloat(value, 0.34f);
|
||
else if (key == "danger") out.danger = text::ToFloat(value, 1.0f);
|
||
else if (key == "tier") out.tier = text::ToInt(value, 0);
|
||
else if (key == "color")
|
||
{
|
||
if (!ParseColor(value, out)) { why = "цвет не разбирается: " + value; return false; }
|
||
}
|
||
else if (key == "depth")
|
||
{
|
||
if (!ParseDepth(value, out)) { why = "глубина не разбирается: " + value; return false; }
|
||
}
|
||
else
|
||
{
|
||
// Молча глотать незнакомый ключ нельзя: опечатка в `dangre=3`
|
||
// означала бы тварь с ценой по умолчанию и ровно ноль признаков
|
||
// того, что что-то не так.
|
||
why = "неизвестный ключ: " + key;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!haveClass)
|
||
{
|
||
why = "не указан class";
|
||
return false;
|
||
}
|
||
|
||
// Обстановка и предметы в бюджет опасности не входят по определению:
|
||
// ящик не делает этаж страшнее, он делает его обжитее.
|
||
if (out.cls != EntityClass::MONSTER) out.danger = 0.0f;
|
||
return true;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
const char* EntityClassName(EntityClass cls)
|
||
{
|
||
switch (cls)
|
||
{
|
||
case EntityClass::MONSTER: return "MONSTER";
|
||
case EntityClass::PROP: return "PROP";
|
||
default: return "LOOT";
|
||
}
|
||
}
|
||
|
||
bool EntityCatalog::Load(const std::string& path, std::string* err)
|
||
{
|
||
std::ifstream in(path);
|
||
if (!in)
|
||
{
|
||
if (err) *err = "cannot open catalog: " + path;
|
||
return false;
|
||
}
|
||
|
||
// Разбираем в СВОЙ вектор и подменяем только на успехе: битая правка
|
||
// каталога не должна оставлять редактор с половиной палитры.
|
||
std::vector<EntityDesc> parsed;
|
||
std::string line;
|
||
int lineNo = 0;
|
||
|
||
while (std::getline(in, line))
|
||
{
|
||
++lineNo;
|
||
const std::vector<std::string> p = text::Split(text::StripComment(line));
|
||
if (p.empty()) continue;
|
||
|
||
if (p[0] != "entity")
|
||
{
|
||
if (err) *err = "line " + std::to_string(lineNo) + ": expected `entity`";
|
||
return false;
|
||
}
|
||
|
||
EntityDesc d;
|
||
std::string why;
|
||
if (!ParseEntity(p, d, why))
|
||
{
|
||
if (err) *err = "line " + std::to_string(lineNo) + ": " + why;
|
||
return false;
|
||
}
|
||
|
||
// Дубль id молча перекрыл бы предыдущую запись, и в редакторе появились
|
||
// бы две одинаковые кнопки палитры с разным поведением.
|
||
for (const EntityDesc& prev : parsed)
|
||
{
|
||
if (prev.id != d.id) continue;
|
||
if (err) *err = "line " + std::to_string(lineNo) + ": duplicate id `" + d.id + "`";
|
||
return false;
|
||
}
|
||
|
||
parsed.push_back(std::move(d));
|
||
}
|
||
|
||
if (parsed.empty())
|
||
{
|
||
if (err) *err = "empty catalog: " + path;
|
||
return false;
|
||
}
|
||
|
||
entries = std::move(parsed);
|
||
return true;
|
||
}
|
||
|
||
int EntityCatalog::IndexOf(const std::string& id) const
|
||
{
|
||
for (size_t i = 0; i < entries.size(); ++i)
|
||
if (entries[i].id == id) return int(i);
|
||
return -1;
|
||
}
|
||
|
||
const EntityDesc* EntityCatalog::Find(const std::string& id) const
|
||
{
|
||
const int i = IndexOf(id);
|
||
return (i >= 0) ? &entries[size_t(i)] : nullptr;
|
||
}
|
||
|
||
int EntityCatalog::CountOf(EntityClass cls) const
|
||
{
|
||
int n = 0;
|
||
for (const EntityDesc& d : entries)
|
||
if (d.cls == cls) ++n;
|
||
return n;
|
||
}
|
||
|
||
bool EntityCatalog::HasAnyAt(EntityClass cls, int depth) const
|
||
{
|
||
for (const EntityDesc& d : entries)
|
||
if (d.cls == cls && d.InDepth(depth)) return true;
|
||
return false;
|
||
}
|
||
|
||
int EntityCatalog::DeepestIntroduction() const
|
||
{
|
||
int deepest = 1;
|
||
for (const EntityDesc& d : entries) deepest = std::max(deepest, d.minDepth);
|
||
return deepest;
|
||
}
|
||
|
||
// FindCatalogPath() здесь БОЛЬШЕ НЕТ — по той же причине, что и FindRoomsDir:
|
||
// путь к содержимому называет проект (engine/project.h).
|