Десять вех одним состоянием: разделять их пофайлово нечем, потому что
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>
565 lines
19 KiB
C++
565 lines
19 KiB
C++
#include "engine/room.h"
|
||
|
||
#include <algorithm>
|
||
#include <cstdio>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
|
||
#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 <x> <y> <chance> <id:вес> ...
|
||
bool ParseSpawn(const std::vector<std::string>& 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<std::string> 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 <min> [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<const SpawnPoint*> 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<fs::path> 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, — это
|
||
// движок с зашитым именем ровно одной игры.
|