squad-proto/engine/room.h

199 lines
10 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
#pragma once
// -----------------------------------------------------------------------------
// Комната — ШАБЛОН, а не готовый кусок карты.
//
// В файле лежит планировка (пол, стены, двери) и точки спавна, но НЕ лежит
// ответ на вопрос «кто здесь стоит». Точка спавна несёт таблицу весов по id
// каталога и шанс срабатывания: одна и та же комната, поставленная дважды,
// населена по-разному. Именно поэтому уровень, собранный из комнат, читается
// как уровень, а не как одна и та же кишка, повторённая пять раз.
//
// Размер ограничен ROOM_MAX_*, потому что генератор ставит комнаты в слоты
// фиксированного размера (engine/level_gen.h). Комната меньше слота — вокруг
// неё остаётся камень, и в нём режутся коридоры; комната размером в слот
// оставила бы коридорам ноль места.
//
// Формат текстовый и построчный: комнаты лежат в git, и правка планировки
// обязана читаться в diff. Бинарный формат сэкономил бы килобайты и отнял бы
// единственный способ увидеть, что именно изменилось.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <string>
#include <vector>
#include "engine/theme.h"
// Слот генератора — 16 тайлов (48/3). Комната не может занять его целиком:
// хотя бы по тайлу с каждой стороны остаётся под коридоры и рамку.
constexpr int ROOM_SLOT = 16;
constexpr int ROOM_MAX_W = 14;
constexpr int ROOM_MAX_H = 14;
constexpr int ROOM_MIN_W = 5;
constexpr int ROOM_MIN_H = 5;
enum class RoomCell : uint8_t
{
EMPTY = 0, // пол
WALL = 1,
DOOR = 2, // проход наружу: пол, к которому генератор тянет коридор
};
// Стороны в мировых осях: y растёт на юг, x — на восток.
enum class RoomSide : uint8_t
{
NORTH = 0,
EAST,
SOUTH,
WEST,
COUNT,
};
inline int SideDX(RoomSide s) { return (s == RoomSide::EAST) ? 1 : (s == RoomSide::WEST ? -1 : 0); }
inline int SideDY(RoomSide s) { return (s == RoomSide::SOUTH) ? 1 : (s == RoomSide::NORTH ? -1 : 0); }
inline RoomSide OppositeSide(RoomSide s) { return RoomSide((int(s) + 2) % 4); }
// Один вариант в таблице точки спавна. id — строка каталога, а не индекс:
// каталог правится руками, и перестановка строк в нём не должна молча менять
// население всех комнат разом.
struct SpawnOption
{
std::string id;
int weight = 1;
};
// Точка спавна: место плюс таблица «кто здесь может оказаться».
struct SpawnPoint
{
int x = 0;
int y = 0;
float chance = 1.0f; // вероятность, что точка вообще сработает
std::vector<SpawnOption> options; // пусто — точка не даёт никого
int TotalWeight() const;
// Выбрать вариант по броску [0..1). -1 — таблица пуста или все веса нулевые.
int Pick(float roll01) const;
// Вес по id; 0 — такого варианта в таблице нет.
int WeightOf(const std::string& id) const;
// Выставить вес. 0 убирает вариант из таблицы: нулевые веса в файле —
// это мусор, который переживает правку и потом путает.
void SetWeight(const std::string& id, int weight);
};
struct Room
{
std::string name; // имя файла без расширения
int w = 12;
int h = 10;
int weight = 1; // вес комнаты при выборе генератором
uint8_t theme = 0;
bool start = false; // годится как стартовая комната отряда
// --- глубина --------------------------------------------------------------
// С какого этажа комната встречается и по какой (0 — до бесконечности).
// Планировка — тоже часть «чем глубже, тем хуже»: широкая арена первых
// этажей и теснота, где нельзя развернуть строй, — это разные игры, и
// отдавать их случайности на всех этажах поровну незачем.
int minDepth = 1;
int maxDepth = 0;
bool InDepth(int depth) const
{
return depth >= minDepth && (maxDepth <= 0 || depth <= maxDepth);
}
RoomCell cells[ROOM_MAX_W * ROOM_MAX_H] = {};
std::vector<SpawnPoint> spawns;
Room(); // пустая комната с рамкой стен
static bool SizeOk(int w, int h)
{
return w >= ROOM_MIN_W && w <= ROOM_MAX_W && h >= ROOM_MIN_H && h <= ROOM_MAX_H;
}
bool InBounds(int x, int y) const
{
return unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h);
}
// Вне комнаты — стена. То же правило, что у Tilemap: оно убирает граничные
// проверки из всего, что ходит по сетке.
RoomCell At(int x, int y) const
{
return InBounds(x, y) ? cells[size_t(y) * ROOM_MAX_W + x] : RoomCell::WALL;
}
void Set(int x, int y, RoomCell c)
{
if (InBounds(x, y)) cells[size_t(y) * ROOM_MAX_W + x] = c;
}
bool Walkable(int x, int y) const { return At(x, y) != RoomCell::WALL; }
bool OnBorder(int x, int y) const
{
return InBounds(x, y) && (x == 0 || y == 0 || x == w - 1 || y == h - 1);
}
// --- точки спавна ---------------------------------------------------------
int SpawnIndexAt(int x, int y) const;
SpawnPoint* SpawnAt(int x, int y);
const SpawnPoint* SpawnAt(int x, int y) const;
// Точка на этом месте либо уже есть, либо заводится пустой.
SpawnPoint& TouchSpawnAt(int x, int y);
void RemoveSpawnAt(int x, int y);
// --- правка планировки ----------------------------------------------------
// Размер меняется с сохранением содержимого; всё, что вышло за новую
// границу, теряется вместе с точками спавна на нём.
void Resize(int newW, int newH);
void FillBorder(); // обвести рамкой стен, двери сохранить
void ClearInterior(); // выскоблить всё внутри рамки
// --- двери ----------------------------------------------------------------
// Двери обязаны стоять на рамке: генератор тянет коридор НАРУЖУ, и дверь
// посреди комнаты вела бы в собственную стену.
int CountDoors() const;
int CountDoorsOn(RoomSide side) const;
// Первая дверь на стороне. false — на этой стороне дверей нет.
bool FirstDoorOn(RoomSide side, int& outX, int& outY) const;
// Куда генератор пробьёт стену, если дверей на стороне нет: середина стороны.
void DefaultBreachOn(RoomSide side, int& outX, int& outY) const;
};
// Что в комнате не так. Пустая строка — всё в порядке. Не «загрузилась ли»,
// а «сыграет ли»: комнату без дверей генератор поставить не сможет.
std::string RoomProblem(const Room& r);
bool LoadRoom(const std::string& path, Room& out, std::string* err = nullptr);
bool SaveRoom(const std::string& path, const Room& r, std::string* err = nullptr);
// -----------------------------------------------------------------------------
// Набор комнат: всё, что лежит в assets/rooms/*.room.
// -----------------------------------------------------------------------------
struct RoomLibrary
{
std::string dir;
std::vector<Room> rooms;
// Комнаты, которые не загрузились: имя файла и причина. Редактор обязан
// показать их списком, а не молча недосчитаться комнаты в наборе.
std::vector<std::string> failures;
// Порядок — алфавитный по имени файла: генератор детерминирован по сиду,
// а порядок обхода каталога файловой системой таковым не является.
bool Load(const std::string& dirPath, std::string* err = nullptr);
int Count() const { return int(rooms.size()); }
int TotalWeight() const;
int IndexOf(const std::string& name) const;
bool HasStartRoom() const;
// Есть ли чем застроить этот этаж. Разделено на два вопроса, потому что
// это две РАЗНЫЕ поломки забега: «нет обычных комнат» и «нет стартовой».
int CountAt(int depth) const;
bool HasStartRoomAt(int depth) const;
};
// Где лежит набор — знает проект (engine/project.h). Движок сам его не ищет.