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

199 lines
10 KiB
C++
Raw Permalink 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.

#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). Движок сам его не ищет.