squad-proto/engine/level_gen.cpp
z.kirill 53ea43f2a4 Пистолет не пропадает, эвакуация откуда угодно, кнопки в HUD (M36)
БАГ С ПИСТОЛЕТОМ - на самом деле ДВА бага.

Первый: экран отряда правит ЖИВОЙ МИР, а вылазка собирает бойцов заново
из профиля (NewRun пересоздаёт ecs::World и раздаёт снаряжение через
ApplyProfileLoadout). Всё, переложенное в меню, стиралось на старте, а
вещь, вынутая из схрона, исчезала вместе с миром - схрон её уже отдал.
Теперь всякая правка снаряжения пишется в профиль сразу.

Второй, рядом: перетаскивание мышью НЕ РАБОТАЛО ВООБЩЕ. Флаг dropOk
нигде не выставлялся в true - только сбрасывался, - и сброс молча
отменялся. Единственным рабочим способом переодеться оставался ENTER.
Теперь флаг считается каждый кадр, и ячейка честно красится в "нельзя".

Заодно: вне забега сумка не принимает вещей. Она принадлежит вылазке и
очищается при сборке этажа; вещь, положенная в неё из меню, исчезла бы
так же молча.

Проверка ловит именно цепочку "поменял в меню -> начал вылазку -> вышел
с этим": сам обмен работал и раньше, пропадал он на старте.

  сборы: в профиле после обмена PISTOL, на этаже PISTOL

ТВАРИ НЕ ВОСКРЕСАЮТ. Убитая в этой вылазке убита насовсем: с возвратом
любая расчищенная комната через полминуты снова была полна, и "пробиться
и уйти" не отличалось от "молотить бесконечную очередь". Исключение одно
- аура плакальщика, это его роль.

ЭВАКУАЦИЯ С ЛЮБОГО ЭТАЖА И ИЗ ЛЮБОЙ ТОЧКИ. Раньше уйти можно было только
стоя на кольце: отряд, решивший не рисковать, всё равно шёл через этаж и
умирал ровно там, где игрок уже принял решение. Чекпоинты теперь значат
только то, чем должны быть: откуда можно НАЧАТЬ следующую вылазку.

ОТЗЫВЧИВОСТЬ СТРОЯ. Отряд ходил 1.75 при курсоре 3.3 - он всегда
отставал, и управление ощущалось как "тяну за верёвку". Стало 2.6,
поводок короче (2.5 против 3.0), доворот строя вдвое резче.

Ориентация строя теперь ПОВОРАЧИВАЕТСЯ, а не щёлкает: раньше слоты клина
перепрыгивали через весь отряд на каждом повороте, и бойцы бросали дорогу
ради перестроения. В тесноте доворот почти мгновенный - там строй и так
колонна, а колонна, смотрящая в прошлую сторону, упирается в стену.

Автопилот поймал, что первая версия правок сделала хуже: при быстром
наборе натяжения (5.2) отряд уходил в вечный спринт, а спринт растягивает
строй и выключает огонь. Порог спринта поднят до 0.95, набор до 3.4 -
приёмка 11.4 держится (0.13 с при пороге 0.30).

КНОПКИ В HUD. Слева внизу четыре: SQUAD, TREE, WORK, MENU. Экраны
открывались только клавишами, о которых игра нигде не говорила, - без
кнопки мастерская и дерево не открываются никогда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 05:24:10 +03:00

758 lines
36 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.

#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 передний упирается в угол, задние — в
// переднего, и отряд встаёт намертво (прогон `--play 4 5`).
//
// Три — это «двое плечом к плечу и место обойти третьего». Меньше не даёт
// разойтись, больше превращает коридор в комнату и съедает разницу между ними.
//
// Отсюда же требование к КОМНАТАМ: сторона не больше 12 при слоте в 16 тайлов.
// Комната шире оставляет между собой и соседкой меньше четырёх клеток камня, а
// в них полоса шириной 3 уже не ложится — поперечный отрезок начинает резать
// чужую стену. Комнату шире 12 генератор не сломает (полоса прижмётся клампом
// ниже), но зазор станет узким местом, ради устранения которого всё и делалось.
// -----------------------------------------------------------------------------
constexpr int CORRIDOR_W = 3;
// Проём в стене комнаты — ровно той же ширины, что коридор. Разойдись эти два
// числа — узкое место осталось бы В САМОМ ПРОЁМЕ, то есть там, где отряд
// заведомо идёт плотно и где его меньше всего ждёшь.
constexpr int DOORWAY_W = CORRIDOR_W;
// Внешнее кольцо карты остаётся камнем всегда: вне карты считается стеной, и
// коридор, дошедший до края, дал бы отряду выйти в это «вне».
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;
}
}
// Квадрат CORRIDOR_W x CORRIDOR_W с началом в (x, y).
//
// Нужен ровно на ПОВОРОТЕ. Два отрезка, сходящиеся углом, дают на стыке не
// широкий поворот, а выемку: полоса вдоль x кончается там, где полоса вдоль y
// начинается, и внешний угол остаётся камнем. Отряд в этом месте снова
// протискивается по одному, и весь выигрыш от широкого коридора съедается на
// первом же изгибе.
void CarveCorner(Tilemap& map, int x, int y, uint8_t theme)
{
for (int j = 0; j < CORRIDOR_W; ++j)
for (int i = 0; i < CORRIDOR_W; ++i) CarveCell(map, x + i, y + j, theme);
}
// Начало полосы прохода вдоль стены длиной `extent`.
//
// Полоса растёт от точки контакта в сторону роста координаты, и без клампа она
// вылезала бы за угол комнаты: угол — единственная клетка рамки, за которой
// сразу чужой слот, и дыра в нём читается как обвал, а не как проход. Поэтому
// полоса прижимается к последней НЕугловой клетке стены.
int DoorSpanStart(int contact, int extent, int width)
{
const int last = extent - 2; // последняя неугловая клетка стороны
return std::max(1, std::min(contact, last - width + 1));
}
// Где комната встречает коридор со стороны 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);
const int dx = SideDX(dir);
const int dy = SideDY(dir);
// Полоса проёма идёт ПОПЕРЁК хода коридора: у восточной двери — по y, у
// северной — по x. Точка контакта задаёт её начало, а не середину: комната
// рисует группу дверей '+++' с той же клетки, и проём совпадает с тем, что
// видно в файле комнаты, а не съезжает от него на тайл.
if (dx != 0)
{
ay = DoorSpanStart(ay, ra.h, DOORWAY_W);
by = DoorSpanStart(by, rb.h, DOORWAY_W);
}
else
{
ax = DoorSpanStart(ax, ra.w, DOORWAY_W);
bx = DoorSpanStart(bx, rb.w, DOORWAY_W);
}
// В координаты карты.
ax += A.originX; ay += A.originY;
bx += B.originX; by += B.originY;
// Сам проём прорубается всегда и на всю ширину: если его пробили по
// умолчанию, стены на этом месте больше нет, а если это нарисованная дверь —
// она и так пол.
for (int t = 0; t < DOORWAY_W; ++t)
{
CarveCell(map, ax + (dy != 0 ? t : 0), ay + (dx != 0 ? t : 0), theme);
CarveCell(map, bx + (dy != 0 ? t : 0), by + (dx != 0 ? t : 0), theme);
}
// Шаг наружу из обоих проёмов — в зазор между комнатами.
const int aox = ax + dx, aoy = ay + dy;
const int box = bx - dx, boy = by - dy;
if (dx != 0)
{
// Поперечный отрезок ложится полосой в +x, и удержать его надо В ЗАЗОРЕ:
// вылезь он на тайл — прорезал бы стену соседней комнаты на всю свою
// длину, и вместо комнаты вышел бы сарай с распахнутым боком.
const int lo = std::min(aox, box), hi = std::max(aox, box);
const int midX = std::max(lo, std::min((aox + box) / 2, hi - (CORRIDOR_W - 1)));
CarveRun(map, aox, aoy, midX, aoy, theme);
CarveCorner(map, midX, aoy, theme);
CarveRun(map, midX, aoy, midX, boy, theme);
CarveCorner(map, midX, boy, theme);
CarveRun(map, midX, boy, box, boy, theme);
}
else
{
const int lo = std::min(aoy, boy), hi = std::max(aoy, boy);
const int midY = std::max(lo, std::min((aoy + boy) / 2, hi - (CORRIDOR_W - 1)));
CarveRun(map, aox, aoy, aox, midY, theme);
CarveCorner(map, aox, midY, theme);
CarveRun(map, aox, midY, box, midY, theme);
CarveCorner(map, 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};
}
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)];
}
// --- аномалия возврата ----------------------------------------------------
// Стоит В КОНЦЕ этажа, рядом со спуском, а не на входе. Раньше выход наверх
// совпадал с точкой входа: отряд, решивший уйти, шёл обратно через весь
// растревоженный этаж. Идея красивая, играется плохо — обратный путь ничего
// не решает и ничего не даёт, он просто ещё раз тот же этаж.
//
// Теперь оба выхода в одном месте, и решение принимается ТАМ ЖЕ, где этаж
// кончился: вниз или наверх. Это и есть развилка, ради которой всё
// затевалось, — а не «дойти обратно, чтобы ей воспользоваться».
lvl.extractPos = lvl.exitPos;
if (exitTile >= 0)
{
// Отодвигаем на пару клеток: два кольца в одной точке не различить, и
// спуск бы срабатывал вместо возврата.
const int ex = exitTile % MAP_W, ey = exitTile / MAP_W;
const int off[8][2] = {{2, 0}, {-2, 0}, {0, 2}, {0, -2},
{2, 2}, {-2, 2}, {2, -2}, {-2, -2}};
for (const auto& o : off)
{
const int nx = ex + o[0], ny = ey + o[1];
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
if (dist[size_t(ny * MAP_W + nx)] < 0) continue; // недостижимо
lvl.extractPos = Vec2{float(nx) + 0.5f, float(ny) + 0.5f};
break;
}
}
// Занятые клетки: на спуск и друг на друга ничего не кладём.
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;
}