#include "engine/room.h" #include #include #include #include #include "engine/text_parse.h" namespace fs = std::filesystem; namespace { constexpr char CH_EMPTY = '.'; constexpr char CH_WALL = '#'; constexpr char CH_DOOR = '+'; char CharOf(RoomCell c) { switch (c) { case RoomCell::WALL: return CH_WALL; case RoomCell::DOOR: return CH_DOOR; default: return CH_EMPTY; } } bool CellOf(char ch, RoomCell& out) { switch (ch) { case CH_EMPTY: out = RoomCell::EMPTY; return true; case CH_WALL: out = RoomCell::WALL; return true; case CH_DOOR: out = RoomCell::DOOR; return true; default: return false; } } // Обход рамки по стороне: сколько на ней клеток и какая клетка под номером i. int SideLength(const Room& r, RoomSide side) { return (side == RoomSide::NORTH || side == RoomSide::SOUTH) ? r.w : r.h; } void SideCell(const Room& r, RoomSide side, int i, int& x, int& y) { switch (side) { case RoomSide::NORTH: x = i; y = 0; break; case RoomSide::SOUTH: x = i; y = r.h - 1; break; case RoomSide::WEST: x = 0; y = i; break; default: x = r.w - 1; y = i; break; } } } // namespace // ----------------------------------------------------------------------------- // Точка спавна // ----------------------------------------------------------------------------- int SpawnPoint::TotalWeight() const { int total = 0; for (const SpawnOption& o : options) total += (o.weight > 0) ? o.weight : 0; return total; } int SpawnPoint::Pick(float roll01) const { const int total = TotalWeight(); if (total <= 0) return -1; // Бросок приводится к [0, total) и идёт по накопленной сумме. Никаких // делений на веса: одинаковый бросок обязан давать одинаковый результат // на любой платформе, а плавающая арифметика здесь только одна — умножение. float acc = roll01 * float(total); for (size_t i = 0; i < options.size(); ++i) { const int wgt = (options[i].weight > 0) ? options[i].weight : 0; if (wgt <= 0) continue; acc -= float(wgt); if (acc < 0.0f) return int(i); } // Бросок ровно 1.0 или накопленная погрешность: отдаём последний непустой. for (int i = int(options.size()) - 1; i >= 0; --i) if (options[size_t(i)].weight > 0) return i; return -1; } int SpawnPoint::WeightOf(const std::string& id) const { for (const SpawnOption& o : options) if (o.id == id) return o.weight; return 0; } void SpawnPoint::SetWeight(const std::string& id, int weight) { for (size_t i = 0; i < options.size(); ++i) { if (options[i].id != id) continue; if (weight > 0) options[i].weight = weight; else options.erase(options.begin() + long(i)); return; } if (weight > 0) options.push_back(SpawnOption{id, weight}); } // ----------------------------------------------------------------------------- // Комната // ----------------------------------------------------------------------------- Room::Room() { for (RoomCell& c : cells) c = RoomCell::WALL; ClearInterior(); FillBorder(); } int Room::SpawnIndexAt(int x, int y) const { for (size_t i = 0; i < spawns.size(); ++i) if (spawns[i].x == x && spawns[i].y == y) return int(i); return -1; } SpawnPoint* Room::SpawnAt(int x, int y) { const int i = SpawnIndexAt(x, y); return (i >= 0) ? &spawns[size_t(i)] : nullptr; } const SpawnPoint* Room::SpawnAt(int x, int y) const { const int i = SpawnIndexAt(x, y); return (i >= 0) ? &spawns[size_t(i)] : nullptr; } SpawnPoint& Room::TouchSpawnAt(int x, int y) { if (SpawnPoint* p = SpawnAt(x, y)) return *p; SpawnPoint p; p.x = x; p.y = y; spawns.push_back(std::move(p)); return spawns.back(); } void Room::RemoveSpawnAt(int x, int y) { const int i = SpawnIndexAt(x, y); if (i >= 0) spawns.erase(spawns.begin() + long(i)); } void Room::Resize(int newW, int newH) { newW = std::max(ROOM_MIN_W, std::min(ROOM_MAX_W, newW)); newH = std::max(ROOM_MIN_H, std::min(ROOM_MAX_H, newH)); if (newW == w && newH == h) return; // Содержимое переезжает как есть, левым верхним углом: якорь комнаты — // её угол, и «расти вправо-вниз» единственное, что предсказуемо на глаз. RoomCell old[ROOM_MAX_W * ROOM_MAX_H]; std::copy(std::begin(cells), std::end(cells), std::begin(old)); const int oldW = w, oldH = h; for (RoomCell& c : cells) c = RoomCell::WALL; w = newW; h = newH; for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) { const bool had = (x < oldW && y < oldH); RoomCell c = had ? old[size_t(y) * ROOM_MAX_W + x] : RoomCell::EMPTY; // Дверь, оказавшаяся ВНУТРИ новой комнаты, — это уже не дверь. // Оставить её значило бы отдать генератору проход в никуда. if (c == RoomCell::DOOR && !(x == 0 || y == 0 || x == w - 1 || y == h - 1)) c = RoomCell::EMPTY; Set(x, y, c); } FillBorder(); // Точки, выехавшие за новую границу или попавшие в стену, снимаются здесь // же: иначе они молча висели бы в файле и не срабатывали никогда. spawns.erase(std::remove_if(spawns.begin(), spawns.end(), [this](const SpawnPoint& p) { return !Walkable(p.x, p.y); }), spawns.end()); } void Room::FillBorder() { for (int x = 0; x < w; ++x) { if (At(x, 0) == RoomCell::EMPTY) Set(x, 0, RoomCell::WALL); if (At(x, h - 1) == RoomCell::EMPTY) Set(x, h - 1, RoomCell::WALL); } for (int y = 0; y < h; ++y) { if (At(0, y) == RoomCell::EMPTY) Set(0, y, RoomCell::WALL); if (At(w - 1, y) == RoomCell::EMPTY) Set(w - 1, y, RoomCell::WALL); } // Углы дверью быть не могут: коридор пришлось бы тянуть сразу в две // стороны, и генератор не смог бы выбрать, в какую именно. Set(0, 0, RoomCell::WALL); Set(w - 1, 0, RoomCell::WALL); Set(0, h - 1, RoomCell::WALL); Set(w - 1, h - 1, RoomCell::WALL); } void Room::ClearInterior() { for (int y = 1; y < h - 1; ++y) for (int x = 1; x < w - 1; ++x) Set(x, y, RoomCell::EMPTY); spawns.clear(); } int Room::CountDoors() const { int n = 0; for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) if (At(x, y) == RoomCell::DOOR) ++n; return n; } int Room::CountDoorsOn(RoomSide side) const { int n = 0; const int len = SideLength(*this, side); for (int i = 0; i < len; ++i) { int x = 0, y = 0; SideCell(*this, side, i, x, y); if (At(x, y) == RoomCell::DOOR) ++n; } return n; } bool Room::FirstDoorOn(RoomSide side, int& outX, int& outY) const { const int len = SideLength(*this, side); for (int i = 0; i < len; ++i) { int x = 0, y = 0; SideCell(*this, side, i, x, y); if (At(x, y) != RoomCell::DOOR) continue; outX = x; outY = y; return true; } return false; } void Room::DefaultBreachOn(RoomSide side, int& outX, int& outY) const { SideCell(*this, side, SideLength(*this, side) / 2, outX, outY); } // Сообщения об ошибках — латиницей, и это не мелочь: их показывает редактор, // а встроенный шрифт raylib кириллицы не знает и рисует её вопросами. // Комментарии при этом остаются русскими: их читают в исходнике, а не на экране. std::string RoomProblem(const Room& r) { if (!Room::SizeOk(r.w, r.h)) return "size out of range"; if (r.theme >= THEME_COUNT) return "unknown theme"; for (int x = 0; x < r.w; ++x) for (int y = 0; y < r.h; ++y) if (r.At(x, y) == RoomCell::DOOR && !r.OnBorder(x, y)) return "door is not on the border"; if (r.CountDoors() == 0) return "no doors at all"; int floorCells = 0; for (int y = 1; y < r.h - 1; ++y) for (int x = 1; x < r.w - 1; ++x) if (r.At(x, y) == RoomCell::EMPTY) ++floorCells; if (floorCells == 0) return "no floor inside"; for (const SpawnPoint& p : r.spawns) { if (!r.Walkable(p.x, p.y)) return "spawn point inside a wall"; if (p.options.empty()) return "spawn point has an empty table"; } return {}; } // ----------------------------------------------------------------------------- // Чтение и запись // ----------------------------------------------------------------------------- namespace { // spawn ... bool ParseSpawn(const std::vector& p, SpawnPoint& out) { if (p.size() < 5) return false; out.x = text::ToInt(p[1], -1); out.y = text::ToInt(p[2], -1); out.chance = text::ToFloat(p[3], 1.0f); if (out.chance < 0.0f) out.chance = 0.0f; if (out.chance > 1.0f) out.chance = 1.0f; for (size_t i = 4; i < p.size(); ++i) { std::string id, weight; if (!text::SplitPair(p[i], id, weight)) return false; const int wgt = text::ToInt(weight, 0); if (id.empty() || wgt <= 0) return false; out.options.push_back(SpawnOption{id, wgt}); } return !out.options.empty(); } } // namespace bool LoadRoom(const std::string& path, Room& out, std::string* err) { std::ifstream in(path); if (!in) { if (err) *err = "cannot open file"; return false; } Room r; // Планировка приходит целиком блоком `tiles`; пока он не прочитан, размер // ещё не известен, и заводить сетку не по чему. bool sized = false; bool tiled = false; int lineNo = 0; std::string line; auto fail = [&](const char* what) { if (err) *err = "line " + std::to_string(lineNo) + ": " + what; return false; }; while (std::getline(in, line)) { ++lineNo; const std::vector p = text::Split(text::StripComment(line)); if (p.empty()) continue; const std::string& key = p[0]; if (key == "name" && p.size() >= 2) { r.name = p[1]; continue; } if (key == "weight" && p.size() >= 2) { r.weight = std::max(1, text::ToInt(p[1], 1)); continue; } if (key == "theme" && p.size() >= 2) { r.theme = uint8_t(text::ToInt(p[1], 0)); continue; } if (key == "start" && p.size() >= 2) { r.start = text::ToInt(p[1], 0) != 0; continue; } // depth [max]; max опущен или 0 — до бесконечности. if (key == "depth" && p.size() >= 2) { r.minDepth = std::max(1, text::ToInt(p[1], 1)); r.maxDepth = (p.size() >= 3) ? text::ToInt(p[2], 0) : 0; if (r.maxDepth > 0 && r.maxDepth < r.minDepth) return fail("max глубины меньше min"); continue; } if (key == "size" && p.size() >= 3) { const int w = text::ToInt(p[1], 0); const int h = text::ToInt(p[2], 0); if (!Room::SizeOk(w, h)) return fail("bad room size"); r.w = w; r.h = h; for (RoomCell& c : r.cells) c = RoomCell::WALL; sized = true; continue; } if (key == "tiles") { if (!sized) return fail("tiles before size"); if (tiled) return fail("duplicate tiles block"); // Строки сетки читаются СЫРЫМИ: '#' здесь означает стену, а не // начало комментария, и через StripComment их пропускать нельзя. for (int y = 0; y < r.h; ++y) { if (!std::getline(in, line)) return fail("tiles block truncated"); ++lineNo; // Хвостовой \r от файла с windows-переводами строк — не тайл. while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); if (int(line.size()) != r.w) return fail("row length differs from room width"); for (int x = 0; x < r.w; ++x) { RoomCell c = RoomCell::WALL; if (!CellOf(line[size_t(x)], c)) return fail("unknown tile char"); r.Set(x, y, c); } } tiled = true; continue; } if (key == "spawn") { if (!tiled) return fail("spawn before tiles block"); SpawnPoint sp; if (!ParseSpawn(p, sp)) return fail("malformed spawn record"); if (!r.Walkable(sp.x, sp.y)) return fail("spawn point off the room floor"); if (r.SpawnIndexAt(sp.x, sp.y) >= 0) return fail("two spawn points on one cell"); r.spawns.push_back(std::move(sp)); continue; } return fail("unknown field"); } if (!tiled) { if (err) *err = "no tiles block"; return false; } // Имя по умолчанию — имя файла: оно и так уникально в каталоге, а поле // `name` нужно ровно для того, чтобы имя пережило переименование файла. if (r.name.empty()) r.name = fs::path(path).stem().string(); out = std::move(r); return true; } bool SaveRoom(const std::string& path, const Room& r, std::string* err) { std::error_code ec; fs::create_directories(fs::path(path).parent_path(), ec); std::ofstream f(path, std::ios::binary); // \n без \r: файл лежит в git if (!f) { if (err) *err = "cannot open file for writing"; return false; } f << "# комната: планировка и таблицы спавна. Формат — engine/room.h\n"; f << "name " << (r.name.empty() ? "room" : r.name) << "\n"; f << "size " << r.w << " " << r.h << "\n"; f << "weight " << r.weight << "\n"; f << "theme " << int(r.theme) << "\n"; f << "start " << (r.start ? 1 : 0) << "\n"; f << "depth " << r.minDepth << " " << r.maxDepth << "\n"; f << "tiles\n"; for (int y = 0; y < r.h; ++y) { for (int x = 0; x < r.w; ++x) f << CharOf(r.At(x, y)); f << "\n"; } // Точки пишутся в порядке обхода сетки, а не в порядке добавления: иначе // перестановка кистей в редакторе давала бы diff на ровном месте. std::vector sorted; sorted.reserve(r.spawns.size()); for (const SpawnPoint& p : r.spawns) sorted.push_back(&p); std::sort(sorted.begin(), sorted.end(), [](const SpawnPoint* a, const SpawnPoint* b) { return (a->y != b->y) ? (a->y < b->y) : (a->x < b->x); }); for (const SpawnPoint* p : sorted) { if (p->options.empty()) continue; // пустая таблица в файле — мусор char chance[16]; std::snprintf(chance, sizeof(chance), "%.2f", double(p->chance)); f << "spawn " << p->x << " " << p->y << " " << chance; for (const SpawnOption& o : p->options) f << " " << o.id << ":" << o.weight; f << "\n"; } if (!f.good()) { if (err) *err = "write failed"; return false; } return true; } // ----------------------------------------------------------------------------- // Набор комнат // ----------------------------------------------------------------------------- bool RoomLibrary::Load(const std::string& dirPath, std::string* err) { rooms.clear(); failures.clear(); dir = dirPath; std::error_code ec; if (!fs::is_directory(dirPath, ec)) { if (err) *err = "no rooms directory: " + dirPath; return false; } // Порядок обхода каталога файловой системой не определён, а генератор // обязан быть детерминирован по сиду. Поэтому сначала собираем имена // и сортируем, и только потом читаем. std::vector files; for (const fs::directory_entry& e : fs::directory_iterator(dirPath, ec)) if (e.is_regular_file(ec) && e.path().extension() == ".room") files.push_back(e.path()); std::sort(files.begin(), files.end()); for (const fs::path& p : files) { Room r; std::string why; if (LoadRoom(p.string(), r, &why)) rooms.push_back(std::move(r)); else failures.push_back(p.filename().string() + ": " + why); } if (rooms.empty()) { if (err) *err = "no readable rooms in " + dirPath; return false; } return true; } int RoomLibrary::TotalWeight() const { int total = 0; for (const Room& r : rooms) total += std::max(1, r.weight); return total; } int RoomLibrary::IndexOf(const std::string& name) const { for (size_t i = 0; i < rooms.size(); ++i) if (rooms[i].name == name) return int(i); return -1; } bool RoomLibrary::HasStartRoom() const { for (const Room& r : rooms) if (r.start) return true; return false; } int RoomLibrary::CountAt(int depth) const { int n = 0; for (const Room& r : rooms) if (r.InDepth(depth)) ++n; return n; } bool RoomLibrary::HasStartRoomAt(int depth) const { for (const Room& r : rooms) if (r.start && r.InDepth(depth)) return true; return false; } // FindRoomsDir() здесь БОЛЬШЕ НЕТ. Каталог комнат называет проект // (engine/project.h), и движок, который сам ищет чьё-то assets/rooms, — это // движок с зашитым именем ровно одной игры.