squad-proto/engine/level_gen.cpp
z.kirill 7d6266aaf3 Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.

ДВИЖОК (engine/, цель tile2d)

  Каталог свой, и решение «это движок» принимается тем, куда положен файл.
  Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
  и карты уехали в engine/config.h, потолок населения этажа стал своим
  (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
  центром карты вместо позиции отряда.

  engine/projection.*  единственное место, где движок знает про изометрию;
                       видов сразу два — абстракция с одной реализацией ничем
                       не проверена и обычно оказывается неправильной
  engine/project.*     манифест проекта: пути, вид, кривая глубины
  engine/app.*         окно, кадр, fixed timestep — вместо двух копий цикла
  engine/tilemap_draw  движок научился показывать свою карту

РЕДАКТОР (editor/, цель tile2d_editor)

  Открывает любой проект и ни одной игры по имени не знает.
  --tool  одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
          этажа, ASCII-карта, проверка набора. Правку делают файлами,
          последствия читают командой (docs/19-agent.md)
  терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
          открытый проект уходит потомкам через TILE2D_PROJECT

ПРИМЕР (samples/walk, цель tile2d_walk)

  Второй потребитель движка. Игра и редактор самостоятельность движка
  проверить не могут: редактор мира не рисует, а игра — то место, куда общий
  код и уезжает незаметно.

ИГРА (src/)

  Экстракшен: вес и два порога переноски, конечный боезапас, расходники
  применяются сами, общая сумка отряда, трата опыта на улучшения.

  Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
  вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
  боец с клинком не тратил найденные патроны при запасном стволе в сумке;
  массив виджетов экрана переполнился и молча съел хвост схрона; проверка
  улучшений сама ломала забег, вызывая NewRun посреди спуска.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:07:19 +03:00

666 lines
30 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};
}
// Вошли и выйдем наверх в одном месте: лестница одна.
lvl.extractPos = lvl.squadStart;
std::vector<int> dist;
DistancesFrom(map, int(lvl.squadStart.x), int(lvl.squadStart.y), dist);
// Спуск — самый дальний ДОСТИЖИМЫЙ тайл. Не «дальняя комната»: комната
// может оказаться отрезанной, а этот выбор недостижимым быть не может
// по построению.
int exitTile = -1;
std::vector<int> freeTiles; // достижимый пол: пригодится луту и доливу
freeTiles.reserve(1024);
for (int i = 0; i < MAP_W * MAP_H; ++i)
{
if (dist[size_t(i)] < 0) continue;
++lvl.reachableTiles;
freeTiles.push_back(i);
if (exitTile < 0 || dist[size_t(i)] > dist[size_t(exitTile)]) exitTile = i;
}
lvl.exitPos = lvl.squadStart;
if (exitTile >= 0)
{
lvl.exitPos = Vec2{float(exitTile % MAP_W) + 0.5f, float(exitTile / MAP_W) + 0.5f};
lvl.exitDistance = dist[size_t(exitTile)];
}
// Занятые клетки: на спуск и друг на друга ничего не кладём.
std::vector<uint8_t> taken(size_t(MAP_W) * MAP_H, 0);
if (exitTile >= 0) taken[size_t(exitTile)] = 1;
// --- 7. население комнатных точек ----------------------------------------
// Обход слотов в фиксированном порядке: розыгрыш обязан зависеть от сида,
// а не от того, в каком порядке мы решили пройтись по карте.
float budget = floor.dangerBudget;
std::vector<Allowed> allowed;
for (int i = 0; i < LEVEL_SLOTS; ++i)
{
const LevelSlot& s = lvl.slots[i];
if (s.roomIndex < 0) continue;
const Room& r = lib.rooms[size_t(s.roomIndex)];
for (const SpawnPoint& p : r.spawns)
{
// Броски делаются ВСЕГДА и в том же порядке, даже когда точка не
// сработает: иначе полный список уводил бы последовательность вбок
// относительно неполного, и сид перестал бы значить одно и то же.
const float rollChance = rng.Float01();
const float rollPick = rng.Float01();
// Плотность этажа — это множитель к шансу точки. Шанс из комнаты
// остаётся тем, что задумал автор; глубина его только сгущает.
const float chance = std::min(1.0f, p.chance * floor.spawnChanceScale);
if (rollChance >= chance) continue;
CollectAllowed(p, catalog, depth, budget, allowed);
const EntityDesc* d = PickAllowed(allowed, rollPick);
if (!d) continue;
const int mx = s.originX + p.x;
const int my = s.originY + p.y;
if (!Tilemap::InBounds(mx, my) || map.IsWall(mx, my)) continue;
const size_t ti = size_t(my) * MAP_W + mx;
if (dist[ti] < 0) { ++lvl.spawnsUnreachable; continue; }
// Потолок — свойство симуляции (маска задетых целей в клинке,
// tuning.h). Молча срезать хвост нельзя: цифра уходит в отчёт.
if (int(lvl.spawns.size()) >= MAX_LEVEL_SPAWNS) { ++lvl.spawnsDropped; continue; }
budget -= d->danger;
lvl.dangerSpent += d->danger;
if (d->cls == EntityClass::MONSTER && d->danger > 0.0f) ++lvl.monstersPlaced;
taken[ti] = 1;
lvl.spawns.push_back(LevelSpawn{d->id, Vec2{float(mx) + 0.5f, float(my) + 0.5f}});
}
}
// --- 8. долив бюджета ----------------------------------------------------
// Без этого шага глубина упирается в число точек, нарисованных в комнатах:
// на двадцатом этаже все точки уже срабатывают и уже выдают самое дорогое,
// и дальше этаж не становится страшнее вообще. Долив превращает бюджет из
// потолка в норму, которую этаж обязан выбрать.
const EntityDesc* cheapest = CheapestMonster(catalog, depth);
for (int attempt = 0; attempt < FILL_ATTEMPTS && cheapest && budget >= cheapest->danger;
++attempt)
{
if (int(lvl.spawns.size()) >= MAX_LEVEL_SPAWNS) break;
if (freeTiles.empty()) break;
const int tile = freeTiles[size_t(rng.Below(int(freeTiles.size())))];
const float rollPick = rng.Float01();
if (taken[size_t(tile)]) continue;
const Vec2 pos{float(tile % MAP_W) + 0.5f, float(tile / MAP_W) + 0.5f};
if (Distance(pos, lvl.squadStart) < FILL_MIN_DIST_FROM_START) continue;
const EntityDesc* d =
PickFillMonster(catalog, depth, budget, MAX_LEVEL_SPAWNS - int(lvl.spawns.size()), rollPick);
if (!d) break;
budget -= d->danger;
lvl.dangerSpent += d->danger;
++lvl.monstersPlaced;
++lvl.monstersFilled;
taken[size_t(tile)] = 1;
lvl.spawns.push_back(LevelSpawn{d->id, pos});
}
// --- 9. лут --------------------------------------------------------------
const int wantLoot = std::min(MAX_LEVEL_LOOT, std::max(0, floor.lootDrops));
for (int attempt = 0; attempt < FILL_ATTEMPTS && int(lvl.loot.size()) < wantLoot; ++attempt)
{
if (freeTiles.empty()) break;
const int tile = freeTiles[size_t(rng.Below(int(freeTiles.size())))];
const float rollPick = rng.Float01();
if (taken[size_t(tile)]) continue;
const EntityDesc* d = PickLoot(catalog, depth, floor.lootTier, rollPick);
if (!d) break;
taken[size_t(tile)] = 1;
lvl.loot.push_back(
LevelSpawn{d->id, Vec2{float(tile % MAP_W) + 0.5f, float(tile / MAP_W) + 0.5f}});
}
out = std::move(lvl);
return true;
}