БАГ С ПИСТОЛЕТОМ - на самом деле ДВА бага. Первый: экран отряда правит ЖИВОЙ МИР, а вылазка собирает бойцов заново из профиля (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>
758 lines
36 KiB
C++
758 lines
36 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 передний упирается в угол, задние — в
|
||
// переднего, и отряд встаёт намертво (прогон `--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;
|
||
}
|