#include "engine/level_gen.h" #include 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& dist) { dist.assign(size_t(MAP_W) * MAP_H, -1); if (!Tilemap::InBounds(sx, sy) || map.IsWall(sx, sy)) return; std::vector 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& 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& 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 list; std::vector 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 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 stack; stack.push_back(startSlot >= 0 ? startSlot : 0); visited[stack.back()] = true; // Связи запоминаются и режутся ПОСЛЕ обхода: прорубка на ходу меняла бы // карту под ногами у следующих шагов, и порядок обхода стал бы важен. struct Link { int a; int b; RoomSide dir; }; std::vector 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 dist; DistancesFrom(map, int(lvl.squadStart.x), int(lvl.squadStart.y), dist); // Спуск — самый дальний ДОСТИЖИМЫЙ тайл. Не «дальняя комната»: комната // может оказаться отрезанной, а этот выбор недостижимым быть не может // по построению. int exitTile = -1; std::vector 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 taken(size_t(MAP_W) * MAP_H, 0); if (exitTile >= 0) taken[size_t(exitTile)] = 1; // --- 7. население комнатных точек ---------------------------------------- // Обход слотов в фиксированном порядке: розыгрыш обязан зависеть от сида, // а не от того, в каком порядке мы решили пройтись по карте. float budget = floor.dangerBudget; std::vector 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; }