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

156 lines
9.6 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
// -----------------------------------------------------------------------------
// Сборка ЭТАЖА из комнат. Детерминирована по сиду и глубине, и только по ним:
// один и тот же набор комнат плюс тот же сид и тот же этаж дают побайтово ту же
// карту и то же население.
//
// Где живёт случайность. В симуляции её нет и не будет (docs/02-architecture.md,
// «Детерминизм»): RNG крутится ЗДЕСЬ, один раз, при сборке этажа, и к моменту
// первого Game::Step всё уже разложено. Поэтому прогон остаётся воспроизводимым,
// а `--accept` вообще не проходит по этому коду — приёмка меряется на прежней
// захардкоженной планировке.
//
// Раскладка. Карта 48x48 делится на сетку LEVEL_GRID x LEVEL_GRID слотов по
// ROOM_SLOT тайлов. В каждый слот ставится комната (не больше ROOM_MAX_*), она
// центрируется, и вокруг неё остаётся камень. Слоты сшиваются коридорами: сперва
// остовное дерево — оно гарантирует, что дойти можно до каждой комнаты, — потом
// несколько лишних связей, чтобы этаж не был деревом-кишкой.
//
// Что делает глубина. Всё, кроме планировки:
// * из набора отбираются комнаты, разрешённые на этом этаже;
// * из таблиц спавна отбираются сущности, разрешённые на этом этаже;
// * население тратит БЮДЖЕТ ОПАСНОСТИ, а не просто выпадает по шансу;
// * остаток бюджета доливается тварями вне комнатных точек — иначе глубина
// упиралась бы в число точек, нарисованных в комнатах, и переставала расти;
// * лут раскладывается тиром этажа.
// Сама кривая — в engine/progression.h, здесь только её применение.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <string>
#include <vector>
#include "engine/math.h"
#include "engine/catalog.h"
#include "engine/progression.h"
#include "engine/room.h"
#include "engine/tilemap.h"
constexpr int LEVEL_GRID = MAP_W / ROOM_SLOT;
constexpr int LEVEL_SLOTS = LEVEL_GRID * LEVEL_GRID;
// Потолок на лежащие предметы. Не «на всякий случай»: каждый предмет —
// сущность ECS и иконка в списке отрисовки, а этаж, засыпанный лутом,
// перестаёт быть находкой.
constexpr int MAX_LEVEL_LOOT = 24;
// Потолок на населённость этажа. Раньше здесь стояла игровая константа
// MAX_TARGETS из src/tuning.h — то есть генератор уровней знал, сколько битов
// в маске удара клинком. Теперь у движка своё число, а игра проверяет, что оно
// влезает в её пределы (static_assert в src/tuning.h).
//
// Это не формальность: бюджет опасности на глубине неизбежно перерастает любое
// такое число, и лишнее не теряется молча — оно уходит в GeneratedLevel::
// spawnsDropped и в отчёт --check.
constexpr int MAX_LEVEL_SPAWNS = 64;
static_assert(MAP_W == MAP_H, "генератор рассчитан на квадратную карту");
static_assert(MAP_W % ROOM_SLOT == 0, "карта обязана делиться на слоты нацело");
// -----------------------------------------------------------------------------
// Xorshift32. Свой, а не <random>: стандартные распределения не обязаны давать
// одинаковую последовательность на разных реализациях библиотеки, а сид этажа
// обязан значить одно и то же везде.
// -----------------------------------------------------------------------------
struct LevelRng
{
uint32_t state;
explicit LevelRng(uint32_t seed) : state(seed ? seed : 0x9E3779B9u) {}
uint32_t Next()
{
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
return state;
}
// [0, 1). Двадцать четыре бита: больше во float всё равно не влезает.
float Float01() { return float(Next() >> 8) * (1.0f / 16777216.0f); }
// [0, n). n <= 0 — ноль: вызывающему не приходится проверять пустой список.
int Below(int n) { return (n > 0) ? int(Next() % uint32_t(n)) : 0; }
};
// Одна сущность, которую генератор выкатил в мир. id — строка каталога:
// движок не знает, во что она превратится в игре.
struct LevelSpawn
{
std::string id;
Vec2 pos{};
};
struct LevelSlot
{
int roomIndex = -1; // индекс в RoomLibrary; -1 — слот пуст
int originX = 0; // угол комнаты в тайлах карты
int originY = 0;
};
struct GeneratedLevel
{
uint32_t seed = 0;
FloorSpec floor;
Vec2 squadStart{};
// Спуск на следующий этаж. Самая дальняя от старта ДОСТИЖИМАЯ точка: этаж
// обязан заставить пройти себя, а не открываться в двух шагах от входа.
Vec2 exitPos{};
// Эвакуация: та же точка, откуда отряд вошёл на этаж, — лестница наверх.
// Отдельным полем, а не «это же squadStart», потому что смысл у них разный
// и разойтись они могут: точку входа однажды захочется двигать, а выход
// наверх — нет.
//
// Совпадение входа и выхода — не экономия, а суть напряжения: спуск лежит
// на дальнем конце этажа, и решив уйти, отряд идёт обратно через всё, что
// успел растревожить, — раненый и с полными руками.
Vec2 extractPos{};
std::vector<LevelSpawn> spawns; // твари и обстановка
std::vector<LevelSpawn> loot; // предметы на полу
LevelSlot slots[LEVEL_SLOTS];
int roomsPlaced = 0;
int corridors = 0;
// Сколько опасности реально выпущено на этаж. Не равно бюджету: точек может
// не хватить, и это нормально. Сильно меньше бюджета на глубине — признак,
// что набор комнат перестал успевать за кривой.
float dangerSpent = 0.0f;
int monstersPlaced = 0;
// Сколько тварей долито вне комнатных точек, чтобы выбрать бюджет.
int monstersFilled = 0;
// Сколько точек спавна выпало из-за потолка MAX_LEVEL_SPAWNS. Не ноль — этаж
// населён не так, как задумано, и это обязано быть видно, а не молча съедено.
int spawnsDropped = 0;
// Сколько выпало как недостижимые от точки старта. Не ноль — где-то в наборе
// комнат есть карман, отрезанный от собственной двери: тварь в нём не сможет
// ни дойти до отряда, ни быть убитой, и этаж тихо станет легче.
int spawnsUnreachable = 0;
// Сколько тайлов пола достижимо от старта, и как далеко до спуска (в шагах
// по сетке). Обе цифры — про то, «есть ли на этаже что проходить».
int reachableTiles = 0;
int exitDistance = 0;
const LevelSlot& Slot(int gx, int gy) const { return slots[gy * LEVEL_GRID + gx]; }
};
// Собирает планировку прямо в map (включая слой тем) и население в out.
// false — из набора нельзя собрать этаж; map при этом не трогается.
//
// catalog нужен, потому что глубина и цена сущности живут в нём: без каталога
// генератор не может ни отобрать тварей по этажу, ни посчитать бюджет.
bool GenerateLevel(const RoomLibrary& lib, const EntityCatalog& catalog, const FloorSpec& floor,
uint32_t seed, Tilemap& map, GeneratedLevel& out, std::string* err = nullptr);