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

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