squad-proto/engine/level_gen.cpp
z.kirill ef103050b1 Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.

НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.

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

ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.

ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.

НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.

ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.

Формат профиля 6: строки merc и rescued.

Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 04:09:26 +03:00

690 lines
32 KiB
C++
Raw 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: отряд из пяти человек в проходе шириной 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};
}
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;
}