squad-proto/engine/room.cpp

565 lines
19 KiB
C++
Raw 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/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, — это
// движок с зашитым именем ровно одной игры.