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

216 lines
6.9 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.

#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).