squad-proto/engine/level_gen.cpp

690 lines
32 KiB
C++
Raw Normal View History

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-14 01:07:19 +07:00
#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)];
}
Правки по игре: начало, ставка, наёмники (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 08:09:26 +07:00
// --- аномалия возврата ----------------------------------------------------
// Стоит В КОНЦЕ этажа, рядом со спуском, а не на входе. Раньше выход наверх
// совпадал с точкой входа: отряд, решивший уйти, шёл обратно через весь
// растревоженный этаж. Идея красивая, играется плохо — обратный путь ничего
// не решает и ничего не даёт, он просто ещё раз тот же этаж.
//
// Теперь оба выхода в одном месте, и решение принимается ТАМ ЖЕ, где этаж
// кончился: вниз или наверх. Это и есть развилка, ради которой всё
// затевалось, — а не «дойти обратно, чтобы ей воспользоваться».
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;
}
}
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-14 01:07:19 +07:00
// Занятые клетки: на спуск и друг на друга ничего не кладём.
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;
}