Десять вех одним состоянием: разделять их пофайлово нечем, потому что
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>
666 lines
30 KiB
C++
666 lines
30 KiB
C++
#include "engine/level_gen.h"
|
||
|
||
#include <algorithm>
|
||
|
||
namespace
|
||
{
|
||
|
||
// Индекс слота и обратно. Слоты нумеруются построчно, как тайлы карты.
|
||
inline int SlotIdx(int gx, int gy) { return gy * LEVEL_GRID + gx; }
|
||
|
||
// Доля лишних связей поверх остовного дерева. Ноль — этаж-дерево: из любой
|
||
// комнаты ровно один путь назад, и отряд ходит по нему туда-обратно. Единица —
|
||
// решётка без тупиков, в которой не читается планировка. Треть — компромисс:
|
||
// петли есть, тупики тоже.
|
||
constexpr float EXTRA_LINK_CHANCE = 0.33f;
|
||
|
||
// Ближе этого к точке старта долитые твари не ставятся. Отряд обязан успеть
|
||
// встать в строй: тварь, выросшая вплотную к точке входа, отнимает не
|
||
// сложностью, а тем, что решение принять негде.
|
||
constexpr float FILL_MIN_DIST_FROM_START = 9.0f;
|
||
|
||
// Сколько раз генератор пытается долить бюджет, прежде чем сдаться. Защита от
|
||
// вырожденного этажа, где свободных далёких клеток просто нет.
|
||
constexpr int FILL_ATTEMPTS = 200;
|
||
|
||
// Выбрать комнату по весу среди разрешённых на этом этаже. Вес — это «как
|
||
// часто она встречается», и он живёт в самом файле комнаты: набор правится
|
||
// добавлением файла, а не правкой кода.
|
||
int PickRoom(const RoomLibrary& lib, LevelRng& rng, int depth, bool needStart)
|
||
{
|
||
int total = 0;
|
||
for (const Room& r : lib.rooms)
|
||
{
|
||
if (!r.InDepth(depth)) continue;
|
||
if (needStart && !r.start) continue;
|
||
total += std::max(1, r.weight);
|
||
}
|
||
if (total <= 0) return -1;
|
||
|
||
int roll = rng.Below(total);
|
||
for (size_t i = 0; i < lib.rooms.size(); ++i)
|
||
{
|
||
const Room& r = lib.rooms[i];
|
||
if (!r.InDepth(depth)) continue;
|
||
if (needStart && !r.start) continue;
|
||
roll -= std::max(1, r.weight);
|
||
if (roll < 0) return int(i);
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Прорубка. Коридор шириной 2: отряд из пяти человек в проходе шириной 1 идёт
|
||
// строго гуськом, и весь смысл строя пропадает. Ширина берётся ровно такой же,
|
||
// как у проходов захардкоженной карты (docs/04-world.md) — иначе поведение
|
||
// отряда в коридоре пришлось бы настраивать заново.
|
||
// -----------------------------------------------------------------------------
|
||
constexpr int CORRIDOR_W = 2;
|
||
|
||
// Внешнее кольцо карты остаётся камнем всегда: вне карты считается стеной, и
|
||
// коридор, дошедший до края, дал бы отряду выйти в это «вне».
|
||
inline bool CarveOk(int x, int y)
|
||
{
|
||
return x >= 1 && y >= 1 && x < MAP_W - 1 && y < MAP_H - 1;
|
||
}
|
||
|
||
void CarveCell(Tilemap& map, int x, int y, uint8_t theme)
|
||
{
|
||
if (!CarveOk(x, y)) return;
|
||
map.Set(x, y, Tile::EMPTY);
|
||
map.SetTheme(x, y, theme);
|
||
}
|
||
|
||
// Отрезок по оси, шириной CORRIDOR_W в перпендикулярном направлении.
|
||
void CarveRun(Tilemap& map, int x0, int y0, int x1, int y1, uint8_t theme)
|
||
{
|
||
const int stepX = (x1 > x0) ? 1 : (x1 < x0 ? -1 : 0);
|
||
const int stepY = (y1 > y0) ? 1 : (y1 < y0 ? -1 : 0);
|
||
// Толщина набирается поперёк хода: у вертикального прогона — по x.
|
||
const int thickX = (stepX == 0) ? 1 : 0;
|
||
const int thickY = (stepY == 0) ? 1 : 0;
|
||
|
||
int x = x0, y = y0;
|
||
for (;;)
|
||
{
|
||
for (int t = 0; t < CORRIDOR_W; ++t) CarveCell(map, x + t * thickX, y + t * thickY, theme);
|
||
if (x == x1 && y == y1) break;
|
||
x += stepX;
|
||
y += stepY;
|
||
}
|
||
}
|
||
|
||
// Где комната встречает коридор со стороны side: дверь, если она есть, иначе
|
||
// середина стены. Второе — не поблажка: комната без двери на нужной стороне
|
||
// остаётся легальной, просто генератор пробьёт её сам, и это видно на карте.
|
||
void ContactPoint(const Room& r, RoomSide side, int& outX, int& outY)
|
||
{
|
||
if (r.FirstDoorOn(side, outX, outY)) return;
|
||
r.DefaultBreachOn(side, outX, outY);
|
||
}
|
||
|
||
// Сшить два соседних слота. Путь — «вышли из двери, добрались до середины
|
||
// зазора, повернули, дошли до чужой двери»: три отрезка по осям, никакой
|
||
// диагонали. Диагональный коридор в изометрии читается как лестница и стоит
|
||
// вдвое больше тайлов.
|
||
//
|
||
// Коридор берёт тему ЭТАЖА, а не комнаты: комнаты на этаже могут быть разных
|
||
// тем, и если коридор наследует чью-то, переход между комнатами читается как
|
||
// продолжение одной из них, а не как переход.
|
||
void ConnectSlots(Tilemap& map, const RoomLibrary& lib, const GeneratedLevel& lvl, int ga, int gb,
|
||
RoomSide dir, uint8_t theme)
|
||
{
|
||
const LevelSlot& A = lvl.slots[ga];
|
||
const LevelSlot& B = lvl.slots[gb];
|
||
if (A.roomIndex < 0 || B.roomIndex < 0) return;
|
||
|
||
const Room& ra = lib.rooms[size_t(A.roomIndex)];
|
||
const Room& rb = lib.rooms[size_t(B.roomIndex)];
|
||
|
||
int ax = 0, ay = 0, bx = 0, by = 0;
|
||
ContactPoint(ra, dir, ax, ay);
|
||
ContactPoint(rb, OppositeSide(dir), bx, by);
|
||
|
||
// В координаты карты.
|
||
ax += A.originX; ay += A.originY;
|
||
bx += B.originX; by += B.originY;
|
||
|
||
const int dx = SideDX(dir);
|
||
const int dy = SideDY(dir);
|
||
|
||
// Сама дверь прорубается всегда: если её пробили по умолчанию, стены на
|
||
// этом месте больше нет, а если это нарисованная дверь — она и так пол.
|
||
CarveCell(map, ax, ay, theme);
|
||
CarveCell(map, bx, by, theme);
|
||
// Дверь шириной в один тайл упёрлась бы в коридор шириной два: узкое место
|
||
// осталось бы ровно в проёме, там, где его меньше всего ждёшь.
|
||
CarveCell(map, ax + (dy != 0 ? 1 : 0), ay + (dx != 0 ? 1 : 0), theme);
|
||
CarveCell(map, bx + (dy != 0 ? 1 : 0), by + (dx != 0 ? 1 : 0), theme);
|
||
|
||
// Шаг наружу из обеих дверей — в зазор между комнатами.
|
||
const int aox = ax + dx, aoy = ay + dy;
|
||
const int box = bx - dx, boy = by - dy;
|
||
|
||
if (dx != 0)
|
||
{
|
||
const int midX = (aox + box) / 2;
|
||
CarveRun(map, aox, aoy, midX, aoy, theme);
|
||
CarveRun(map, midX, aoy, midX, boy, theme);
|
||
CarveRun(map, midX, boy, box, boy, theme);
|
||
}
|
||
else
|
||
{
|
||
const int midY = (aoy + boy) / 2;
|
||
CarveRun(map, aox, aoy, aox, midY, theme);
|
||
CarveRun(map, aox, midY, box, midY, theme);
|
||
CarveRun(map, box, midY, box, boy, theme);
|
||
}
|
||
}
|
||
|
||
// Отпечатать комнату в карту. Дверь — это пол: генератор сам решает, куда
|
||
// от неё тянуть коридор, и оставлять её стеной означало бы запереть комнату.
|
||
void StampRoom(Tilemap& map, const Room& r, int ox, int oy)
|
||
{
|
||
for (int y = 0; y < r.h; ++y)
|
||
for (int x = 0; x < r.w; ++x)
|
||
{
|
||
const int mx = ox + x;
|
||
const int my = oy + y;
|
||
if (!Tilemap::InBounds(mx, my)) continue;
|
||
map.Set(mx, my, (r.At(x, y) == RoomCell::WALL) ? Tile::WALL : Tile::EMPTY);
|
||
map.SetTheme(mx, my, r.theme);
|
||
}
|
||
}
|
||
|
||
// Ближайший к (cx, cy) проходимый тайл. Спираль, а не поиск в ширину: искать
|
||
// надо на два-три тайла, и очередь на такой дистанции дороже перебора.
|
||
bool NearestFloor(const Tilemap& map, int cx, int cy, int& outX, int& outY)
|
||
{
|
||
for (int rad = 0; rad < ROOM_SLOT; ++rad)
|
||
{
|
||
for (int dy = -rad; dy <= rad; ++dy)
|
||
for (int dx = -rad; dx <= rad; ++dx)
|
||
{
|
||
if (std::max(std::abs(dx), std::abs(dy)) != rad) continue;
|
||
const int x = cx + dx;
|
||
const int y = cy + dy;
|
||
if (!Tilemap::InBounds(x, y) || map.IsWall(x, y)) continue;
|
||
outX = x;
|
||
outY = y;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Поиск в ширину от точки старта: расстояние в шагах до каждого тайла, -1 —
|
||
// недостижим. Одна заливка отвечает сразу на три вопроса: что достижимо, где
|
||
// поставить спуск (самый дальний тайл) и куда можно класть лут.
|
||
//
|
||
// Своя очередь вместо рекурсии: у карты 48x48 худший случай — 2300 вложенных
|
||
// вызовов.
|
||
void DistancesFrom(const Tilemap& map, int sx, int sy, std::vector<int>& dist)
|
||
{
|
||
dist.assign(size_t(MAP_W) * MAP_H, -1);
|
||
if (!Tilemap::InBounds(sx, sy) || map.IsWall(sx, sy)) return;
|
||
|
||
std::vector<int> queue;
|
||
queue.reserve(size_t(MAP_W) * MAP_H);
|
||
queue.push_back(sy * MAP_W + sx);
|
||
dist[size_t(queue.back())] = 0;
|
||
|
||
for (size_t head = 0; head < queue.size(); ++head)
|
||
{
|
||
const int cur = queue[head];
|
||
const int cx = cur % MAP_W;
|
||
const int cy = cur / MAP_W;
|
||
const int d = dist[size_t(cur)];
|
||
|
||
const int dx[4] = {1, -1, 0, 0};
|
||
const int dy[4] = {0, 0, 1, -1};
|
||
for (int k = 0; k < 4; ++k)
|
||
{
|
||
const int nx = cx + dx[k];
|
||
const int ny = cy + dy[k];
|
||
if (!Tilemap::InBounds(nx, ny) || map.IsWall(nx, ny)) continue;
|
||
const size_t ni = size_t(ny) * MAP_W + nx;
|
||
if (dist[ni] >= 0) continue;
|
||
dist[ni] = d + 1;
|
||
queue.push_back(int(ni));
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- население ---------------------------------------------------------------
|
||
|
||
// Один вариант, доживший до фильтра по этажу и бюджету.
|
||
struct Allowed
|
||
{
|
||
const EntityDesc* desc;
|
||
int weight;
|
||
};
|
||
|
||
// Отобрать из таблицы точки то, что разрешено на этом этаже и по карману.
|
||
// Обстановка бюджета не тратит и проходит всегда.
|
||
void CollectAllowed(const SpawnPoint& p, const EntityCatalog& catalog, int depth, float budget,
|
||
std::vector<Allowed>& out)
|
||
{
|
||
out.clear();
|
||
for (const SpawnOption& o : p.options)
|
||
{
|
||
if (o.weight <= 0) continue;
|
||
const EntityDesc* d = catalog.Find(o.id);
|
||
if (!d || !d->InDepth(depth)) continue;
|
||
if (d->danger > budget) continue;
|
||
out.push_back(Allowed{d, o.weight});
|
||
}
|
||
}
|
||
|
||
const EntityDesc* PickAllowed(const std::vector<Allowed>& list, float roll01)
|
||
{
|
||
int total = 0;
|
||
for (const Allowed& a : list) total += a.weight;
|
||
if (total <= 0) return nullptr;
|
||
|
||
float acc = roll01 * float(total);
|
||
for (const Allowed& a : list)
|
||
{
|
||
acc -= float(a.weight);
|
||
if (acc < 0.0f) return a.desc;
|
||
}
|
||
return list.back().desc;
|
||
}
|
||
|
||
// Самая дешёвая тварь, доступная на этаже. Ею доливается остаток бюджета, и
|
||
// ею же проверяется, есть ли смысл продолжать долив.
|
||
const EntityDesc* CheapestMonster(const EntityCatalog& catalog, int depth)
|
||
{
|
||
const EntityDesc* best = nullptr;
|
||
for (int i = 0; i < catalog.Count(); ++i)
|
||
{
|
||
const EntityDesc& d = catalog.At(i);
|
||
if (d.cls != EntityClass::MONSTER || d.danger <= 0.0f || !d.InDepth(depth)) continue;
|
||
if (!best || d.danger < best->danger) best = &d;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// Тварь для долива. Здесь живёт главное правило глубины: когда МЕСТ мало, а
|
||
// бюджета много, этаж обязан становиться злее, а не многолюднее.
|
||
//
|
||
// Тел в мире не может быть больше MAX_LEVEL_SPAWNS, и на глубине бюджет
|
||
// неизбежно перерастает это число. Если бы долив продолжал брать кого попало,
|
||
// глубже определённого этажа игра переставала бы меняться вовсе: все места
|
||
// заняты мелочью, бюджет не выбран, двадцатый этаж равен пятнадцатому.
|
||
//
|
||
// Поэтому на каждое место считается, сколько опасности ОБЯЗАНО на него
|
||
// прийтись, и всё, что дешевле, отбрасывается. По мере заполнения карты порог
|
||
// растёт сам собой, и хвост этажа набирается самыми дорогими тварями.
|
||
const EntityDesc* PickFillMonster(const EntityCatalog& catalog, int depth, float budget,
|
||
int slotsLeft, float roll01)
|
||
{
|
||
const float needPerSlot = (slotsLeft > 0) ? budget / float(slotsLeft) : budget;
|
||
|
||
std::vector<Allowed> list;
|
||
std::vector<Allowed> fallback;
|
||
|
||
for (int i = 0; i < catalog.Count(); ++i)
|
||
{
|
||
const EntityDesc& d = catalog.At(i);
|
||
if (d.cls != EntityClass::MONSTER || d.danger <= 0.0f || !d.InDepth(depth)) continue;
|
||
if (d.danger > budget) continue;
|
||
|
||
// Вес пропорционален цене: дорогое выпадает чаще, но дешёвое не
|
||
// исчезает — этаж из одних громил тоже перестаёт быть интересным.
|
||
const Allowed a{&d, std::max(1, int(d.danger * 2.0f))};
|
||
fallback.push_back(a);
|
||
if (d.danger >= needPerSlot) list.push_back(a);
|
||
}
|
||
|
||
// Порог недостижим (самая дорогая тварь дешевле, чем нужно на место) —
|
||
// берём что есть: недобрать бюджет лучше, чем не поставить никого.
|
||
return PickAllowed(list.empty() ? fallback : list, roll01);
|
||
}
|
||
|
||
// Предмет для этажа: тир не выше, чем у этажа, и глубина подходит. Ближе к
|
||
// потолку тира — вероятнее: находка обязана становиться лучше с глубиной, но
|
||
// бинт не должен исчезать совсем.
|
||
const EntityDesc* PickLoot(const EntityCatalog& catalog, int depth, int tierCap, float roll01)
|
||
{
|
||
std::vector<Allowed> list;
|
||
for (int i = 0; i < catalog.Count(); ++i)
|
||
{
|
||
const EntityDesc& d = catalog.At(i);
|
||
if (d.cls != EntityClass::LOOT || !d.InDepth(depth)) continue;
|
||
if (d.tier > tierCap) continue;
|
||
const int gap = tierCap - d.tier;
|
||
list.push_back(Allowed{&d, (gap == 0) ? 4 : (gap == 1 ? 2 : 1)});
|
||
}
|
||
return PickAllowed(list, roll01);
|
||
}
|
||
|
||
} // namespace
|
||
|
||
bool GenerateLevel(const RoomLibrary& lib, const EntityCatalog& catalog, const FloorSpec& floor,
|
||
uint32_t seed, Tilemap& map, GeneratedLevel& out, std::string* err)
|
||
{
|
||
if (lib.rooms.empty())
|
||
{
|
||
if (err) *err = "room set is empty";
|
||
return false;
|
||
}
|
||
|
||
const int depth = std::max(1, floor.depth);
|
||
if (lib.CountAt(depth) <= 0)
|
||
{
|
||
if (err) *err = "no rooms allowed at depth " + std::to_string(depth);
|
||
return false;
|
||
}
|
||
|
||
// Сид этажа мешается с глубиной: иначе один и тот же сид давал бы на всех
|
||
// этажах одну планировку, отличающуюся только населением.
|
||
LevelRng rng(seed ^ (uint32_t(depth) * 0x9E3779B9u));
|
||
|
||
GeneratedLevel lvl;
|
||
lvl.seed = seed;
|
||
lvl.floor = floor;
|
||
|
||
// --- 1. кто где стоит ----------------------------------------------------
|
||
for (int gy = 0; gy < LEVEL_GRID; ++gy)
|
||
for (int gx = 0; gx < LEVEL_GRID; ++gx)
|
||
{
|
||
const int idx = SlotIdx(gx, gy);
|
||
const int pick = PickRoom(lib, rng, depth, false);
|
||
if (pick < 0) continue;
|
||
|
||
const Room& r = lib.rooms[size_t(pick)];
|
||
lvl.slots[idx].roomIndex = pick;
|
||
// Комната центрируется в слоте: зазор до соседей одинаков со всех
|
||
// сторон, и коридору есть куда лечь при любой её ширине.
|
||
lvl.slots[idx].originX = gx * ROOM_SLOT + (ROOM_SLOT - r.w) / 2;
|
||
lvl.slots[idx].originY = gy * ROOM_SLOT + (ROOM_SLOT - r.h) / 2;
|
||
++lvl.roomsPlaced;
|
||
}
|
||
|
||
if (lvl.roomsPlaced == 0)
|
||
{
|
||
if (err) *err = "no room could be picked";
|
||
return false;
|
||
}
|
||
|
||
// --- 2. стартовая комната ------------------------------------------------
|
||
// Отряд обязан начинать там, где это задумано автором комнаты. Если после
|
||
// розыгрыша ни одной стартовой не выпало — ставим её принудительно в
|
||
// случайный слот, а не двигаем отряд в первую попавшуюся комнату.
|
||
int startSlot = -1;
|
||
for (int i = 0; i < LEVEL_SLOTS; ++i)
|
||
{
|
||
const int ri = lvl.slots[i].roomIndex;
|
||
if (ri >= 0 && lib.rooms[size_t(ri)].start) { startSlot = i; break; }
|
||
}
|
||
if (startSlot < 0 && lib.HasStartRoomAt(depth))
|
||
{
|
||
const int forced = PickRoom(lib, rng, depth, true);
|
||
if (forced >= 0)
|
||
{
|
||
startSlot = rng.Below(LEVEL_SLOTS);
|
||
const Room& r = lib.rooms[size_t(forced)];
|
||
if (lvl.slots[startSlot].roomIndex < 0) ++lvl.roomsPlaced;
|
||
lvl.slots[startSlot].roomIndex = forced;
|
||
lvl.slots[startSlot].originX =
|
||
(startSlot % LEVEL_GRID) * ROOM_SLOT + (ROOM_SLOT - r.w) / 2;
|
||
lvl.slots[startSlot].originY =
|
||
(startSlot / LEVEL_GRID) * ROOM_SLOT + (ROOM_SLOT - r.h) / 2;
|
||
}
|
||
}
|
||
if (startSlot < 0)
|
||
for (int i = 0; i < LEVEL_SLOTS && startSlot < 0; ++i)
|
||
if (lvl.slots[i].roomIndex >= 0) startSlot = i;
|
||
|
||
// --- 3. камень, потом комнаты -------------------------------------------
|
||
for (int y = 0; y < MAP_H; ++y)
|
||
for (int x = 0; x < MAP_W; ++x)
|
||
{
|
||
map.Set(x, y, Tile::WALL);
|
||
map.SetTheme(x, y, floor.theme);
|
||
}
|
||
|
||
for (int i = 0; i < LEVEL_SLOTS; ++i)
|
||
{
|
||
const LevelSlot& s = lvl.slots[i];
|
||
if (s.roomIndex < 0) continue;
|
||
StampRoom(map, lib.rooms[size_t(s.roomIndex)], s.originX, s.originY);
|
||
}
|
||
|
||
// --- 4. остовное дерево по слотам ---------------------------------------
|
||
// Обход в глубину со случайным порядком соседей. Дерево гарантирует, что
|
||
// из стартовой комнаты достижима каждая: этаж, часть которого отрезана,
|
||
// это не «интересная планировка», а брак.
|
||
bool visited[LEVEL_SLOTS] = {};
|
||
std::vector<int> stack;
|
||
stack.push_back(startSlot >= 0 ? startSlot : 0);
|
||
visited[stack.back()] = true;
|
||
|
||
// Связи запоминаются и режутся ПОСЛЕ обхода: прорубка на ходу меняла бы
|
||
// карту под ногами у следующих шагов, и порядок обхода стал бы важен.
|
||
struct Link { int a; int b; RoomSide dir; };
|
||
std::vector<Link> links;
|
||
|
||
while (!stack.empty())
|
||
{
|
||
const int cur = stack.back();
|
||
const int cx = cur % LEVEL_GRID;
|
||
const int cy = cur / LEVEL_GRID;
|
||
|
||
// Непосещённые соседи в случайном порядке.
|
||
int cand[4];
|
||
RoomSide dirs[4];
|
||
int n = 0;
|
||
for (int d = 0; d < 4; ++d)
|
||
{
|
||
const RoomSide side = RoomSide(d);
|
||
const int nx = cx + SideDX(side);
|
||
const int ny = cy + SideDY(side);
|
||
if (nx < 0 || ny < 0 || nx >= LEVEL_GRID || ny >= LEVEL_GRID) continue;
|
||
const int ni = SlotIdx(nx, ny);
|
||
if (visited[ni] || lvl.slots[ni].roomIndex < 0) continue;
|
||
cand[n] = ni;
|
||
dirs[n] = side;
|
||
++n;
|
||
}
|
||
|
||
if (n == 0) { stack.pop_back(); continue; }
|
||
|
||
const int k = rng.Below(n);
|
||
links.push_back(Link{cur, cand[k], dirs[k]});
|
||
visited[cand[k]] = true;
|
||
stack.push_back(cand[k]);
|
||
}
|
||
|
||
// --- 5. лишние связи -----------------------------------------------------
|
||
for (int gy = 0; gy < LEVEL_GRID; ++gy)
|
||
for (int gx = 0; gx < LEVEL_GRID; ++gx)
|
||
{
|
||
const int a = SlotIdx(gx, gy);
|
||
if (lvl.slots[a].roomIndex < 0) continue;
|
||
|
||
// Только восток и юг: иначе каждая пара рассматривалась бы дважды.
|
||
const RoomSide sides[2] = {RoomSide::EAST, RoomSide::SOUTH};
|
||
for (const RoomSide side : sides)
|
||
{
|
||
const int nx = gx + SideDX(side);
|
||
const int ny = gy + SideDY(side);
|
||
if (nx >= LEVEL_GRID || ny >= LEVEL_GRID) continue;
|
||
|
||
const int b = SlotIdx(nx, ny);
|
||
if (lvl.slots[b].roomIndex < 0) continue;
|
||
|
||
const bool already =
|
||
std::any_of(links.begin(), links.end(), [a, b](const Link& l) {
|
||
return (l.a == a && l.b == b) || (l.a == b && l.b == a);
|
||
});
|
||
if (already) continue;
|
||
if (rng.Float01() >= EXTRA_LINK_CHANCE) continue;
|
||
|
||
links.push_back(Link{a, b, side});
|
||
}
|
||
}
|
||
|
||
for (const Link& l : links)
|
||
{
|
||
ConnectSlots(map, lib, lvl, l.a, l.b, l.dir, floor.theme);
|
||
++lvl.corridors;
|
||
}
|
||
|
||
// --- 6. где встаёт отряд и где спуск -------------------------------------
|
||
// Старт считается ДО населения: и долив тварей, и лут смотрят на дистанцию
|
||
// от него, а заливка карты ни одного броска не тратит, поэтому сид от
|
||
// перестановки не страдает.
|
||
// Запасная точка — центр карты, а не место старта отряда из игры: движок
|
||
// не знает, что за отряд к нему приедет. Реально она берётся только если
|
||
// стартовой комнаты нет, а без неё этаж и так не собирается.
|
||
lvl.squadStart = Vec2{float(MAP_W) * 0.5f, float(MAP_H) * 0.5f};
|
||
if (startSlot >= 0)
|
||
{
|
||
const LevelSlot& s = lvl.slots[startSlot];
|
||
const Room& r = lib.rooms[size_t(s.roomIndex)];
|
||
int sx = 0, sy = 0;
|
||
if (NearestFloor(map, s.originX + r.w / 2, s.originY + r.h / 2, sx, sy))
|
||
lvl.squadStart = Vec2{float(sx) + 0.5f, float(sy) + 0.5f};
|
||
}
|
||
|
||
// Вошли и выйдем наверх в одном месте: лестница одна.
|
||
lvl.extractPos = lvl.squadStart;
|
||
|
||
std::vector<int> dist;
|
||
DistancesFrom(map, int(lvl.squadStart.x), int(lvl.squadStart.y), dist);
|
||
|
||
// Спуск — самый дальний ДОСТИЖИМЫЙ тайл. Не «дальняя комната»: комната
|
||
// может оказаться отрезанной, а этот выбор недостижимым быть не может
|
||
// по построению.
|
||
int exitTile = -1;
|
||
std::vector<int> freeTiles; // достижимый пол: пригодится луту и доливу
|
||
freeTiles.reserve(1024);
|
||
for (int i = 0; i < MAP_W * MAP_H; ++i)
|
||
{
|
||
if (dist[size_t(i)] < 0) continue;
|
||
++lvl.reachableTiles;
|
||
freeTiles.push_back(i);
|
||
if (exitTile < 0 || dist[size_t(i)] > dist[size_t(exitTile)]) exitTile = i;
|
||
}
|
||
|
||
lvl.exitPos = lvl.squadStart;
|
||
if (exitTile >= 0)
|
||
{
|
||
lvl.exitPos = Vec2{float(exitTile % MAP_W) + 0.5f, float(exitTile / MAP_W) + 0.5f};
|
||
lvl.exitDistance = dist[size_t(exitTile)];
|
||
}
|
||
|
||
// Занятые клетки: на спуск и друг на друга ничего не кладём.
|
||
std::vector<uint8_t> taken(size_t(MAP_W) * MAP_H, 0);
|
||
if (exitTile >= 0) taken[size_t(exitTile)] = 1;
|
||
|
||
// --- 7. население комнатных точек ----------------------------------------
|
||
// Обход слотов в фиксированном порядке: розыгрыш обязан зависеть от сида,
|
||
// а не от того, в каком порядке мы решили пройтись по карте.
|
||
float budget = floor.dangerBudget;
|
||
std::vector<Allowed> allowed;
|
||
|
||
for (int i = 0; i < LEVEL_SLOTS; ++i)
|
||
{
|
||
const LevelSlot& s = lvl.slots[i];
|
||
if (s.roomIndex < 0) continue;
|
||
const Room& r = lib.rooms[size_t(s.roomIndex)];
|
||
|
||
for (const SpawnPoint& p : r.spawns)
|
||
{
|
||
// Броски делаются ВСЕГДА и в том же порядке, даже когда точка не
|
||
// сработает: иначе полный список уводил бы последовательность вбок
|
||
// относительно неполного, и сид перестал бы значить одно и то же.
|
||
const float rollChance = rng.Float01();
|
||
const float rollPick = rng.Float01();
|
||
|
||
// Плотность этажа — это множитель к шансу точки. Шанс из комнаты
|
||
// остаётся тем, что задумал автор; глубина его только сгущает.
|
||
const float chance = std::min(1.0f, p.chance * floor.spawnChanceScale);
|
||
if (rollChance >= chance) continue;
|
||
|
||
CollectAllowed(p, catalog, depth, budget, allowed);
|
||
const EntityDesc* d = PickAllowed(allowed, rollPick);
|
||
if (!d) continue;
|
||
|
||
const int mx = s.originX + p.x;
|
||
const int my = s.originY + p.y;
|
||
if (!Tilemap::InBounds(mx, my) || map.IsWall(mx, my)) continue;
|
||
|
||
const size_t ti = size_t(my) * MAP_W + mx;
|
||
if (dist[ti] < 0) { ++lvl.spawnsUnreachable; continue; }
|
||
|
||
// Потолок — свойство симуляции (маска задетых целей в клинке,
|
||
// tuning.h). Молча срезать хвост нельзя: цифра уходит в отчёт.
|
||
if (int(lvl.spawns.size()) >= MAX_LEVEL_SPAWNS) { ++lvl.spawnsDropped; continue; }
|
||
|
||
budget -= d->danger;
|
||
lvl.dangerSpent += d->danger;
|
||
if (d->cls == EntityClass::MONSTER && d->danger > 0.0f) ++lvl.monstersPlaced;
|
||
taken[ti] = 1;
|
||
|
||
lvl.spawns.push_back(LevelSpawn{d->id, Vec2{float(mx) + 0.5f, float(my) + 0.5f}});
|
||
}
|
||
}
|
||
|
||
// --- 8. долив бюджета ----------------------------------------------------
|
||
// Без этого шага глубина упирается в число точек, нарисованных в комнатах:
|
||
// на двадцатом этаже все точки уже срабатывают и уже выдают самое дорогое,
|
||
// и дальше этаж не становится страшнее вообще. Долив превращает бюджет из
|
||
// потолка в норму, которую этаж обязан выбрать.
|
||
const EntityDesc* cheapest = CheapestMonster(catalog, depth);
|
||
for (int attempt = 0; attempt < FILL_ATTEMPTS && cheapest && budget >= cheapest->danger;
|
||
++attempt)
|
||
{
|
||
if (int(lvl.spawns.size()) >= MAX_LEVEL_SPAWNS) break;
|
||
if (freeTiles.empty()) break;
|
||
|
||
const int tile = freeTiles[size_t(rng.Below(int(freeTiles.size())))];
|
||
const float rollPick = rng.Float01();
|
||
|
||
if (taken[size_t(tile)]) continue;
|
||
|
||
const Vec2 pos{float(tile % MAP_W) + 0.5f, float(tile / MAP_W) + 0.5f};
|
||
if (Distance(pos, lvl.squadStart) < FILL_MIN_DIST_FROM_START) continue;
|
||
|
||
const EntityDesc* d =
|
||
PickFillMonster(catalog, depth, budget, MAX_LEVEL_SPAWNS - int(lvl.spawns.size()), rollPick);
|
||
if (!d) break;
|
||
|
||
budget -= d->danger;
|
||
lvl.dangerSpent += d->danger;
|
||
++lvl.monstersPlaced;
|
||
++lvl.monstersFilled;
|
||
taken[size_t(tile)] = 1;
|
||
lvl.spawns.push_back(LevelSpawn{d->id, pos});
|
||
}
|
||
|
||
// --- 9. лут --------------------------------------------------------------
|
||
const int wantLoot = std::min(MAX_LEVEL_LOOT, std::max(0, floor.lootDrops));
|
||
for (int attempt = 0; attempt < FILL_ATTEMPTS && int(lvl.loot.size()) < wantLoot; ++attempt)
|
||
{
|
||
if (freeTiles.empty()) break;
|
||
|
||
const int tile = freeTiles[size_t(rng.Below(int(freeTiles.size())))];
|
||
const float rollPick = rng.Float01();
|
||
|
||
if (taken[size_t(tile)]) continue;
|
||
|
||
const EntityDesc* d = PickLoot(catalog, depth, floor.lootTier, rollPick);
|
||
if (!d) break;
|
||
|
||
taken[size_t(tile)] = 1;
|
||
lvl.loot.push_back(
|
||
LevelSpawn{d->id, Vec2{float(tile % MAP_W) + 0.5f, float(tile / MAP_W) + 0.5f}});
|
||
}
|
||
|
||
out = std::move(lvl);
|
||
return true;
|
||
}
|