Buildings (modern/fantasy, multi-floor, footprint shapes), caves, outdoor biomes, and town/village + city-block settlements. Auto-places Foundry walls, doors, windows, lighting; furniture as locked tiles; live preview. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
12 KiB
JavaScript
294 lines
12 KiB
JavaScript
/**
|
|
* Building layout via Binary Space Partitioning over an arbitrary FOOTPRINT.
|
|
*
|
|
* The footprint is a boolean mask (rectangle / L / T / U / plus). BSP splits the
|
|
* bounding box; rooms are then clipped to the mask, so exterior walls follow the
|
|
* real (non-rectangular) outline. Supports a forced vertical-circulation room
|
|
* (stairwell / elevator) for multi-floor buildings.
|
|
*
|
|
* Pure / Node-testable. Coordinates in GRID CELLS.
|
|
*/
|
|
|
|
import { Rng } from "./rng.mjs";
|
|
|
|
const ROOM_VOCAB = {
|
|
modern: {
|
|
entrance: { label: "Lobby", type: "entrance" },
|
|
circulation: { label: "Elevator", type: "elevator" },
|
|
rooms: [
|
|
{ label: "Office", weight: 10, numbered: true },
|
|
{ label: "Meeting Room", weight: 4, numbered: true },
|
|
{ label: "Break Room", weight: 2, unique: true },
|
|
{ label: "Server Room", weight: 2, unique: true, locked: true },
|
|
{ label: "Restroom", weight: 3, numbered: true },
|
|
{ label: "Storage", weight: 4, numbered: true },
|
|
{ label: "Reception", weight: 1, unique: true },
|
|
{ label: "Exec Office", weight: 1, unique: true, locked: true }
|
|
]
|
|
},
|
|
fantasy: {
|
|
entrance: { label: "Entry Hall", type: "entrance" },
|
|
circulation: { label: "Stairwell", type: "stairs" },
|
|
rooms: [
|
|
{ label: "Bed Chamber", weight: 6, numbered: true },
|
|
{ label: "Common Room", weight: 2, unique: true },
|
|
{ label: "Kitchen", weight: 2, unique: true },
|
|
{ label: "Store Room", weight: 4, numbered: true },
|
|
{ label: "Study", weight: 2, unique: true },
|
|
{ label: "Guard Room", weight: 3, numbered: true },
|
|
{ label: "Cellar", weight: 1, unique: true, locked: true },
|
|
{ label: "Strong Room", weight: 1, unique: true, locked: true }
|
|
]
|
|
}
|
|
};
|
|
|
|
export const FOOTPRINT_SHAPES = ["rectangle", "l", "t", "u", "plus"];
|
|
|
|
function rectArea(r) { return r.w * r.h; }
|
|
function splittable(leaf, minRoom) { return leaf.w >= 2 * minRoom || leaf.h >= 2 * minRoom; }
|
|
|
|
function splitLeaf(leaf, minRoom, rng) {
|
|
const canV = leaf.w >= 2 * minRoom;
|
|
const canH = leaf.h >= 2 * minRoom;
|
|
let cutVertical;
|
|
if (canV && canH) cutVertical = rng.float() < leaf.w / (leaf.w + leaf.h);
|
|
else cutVertical = canV;
|
|
|
|
if (cutVertical) {
|
|
const lo = leaf.x + minRoom, hi = leaf.x + leaf.w - minRoom;
|
|
const p = Math.round(lo + ((rng.float() + rng.float()) / 2) * (hi - lo));
|
|
return [
|
|
{ x: leaf.x, y: leaf.y, w: p - leaf.x, h: leaf.h },
|
|
{ x: p, y: leaf.y, w: leaf.x + leaf.w - p, h: leaf.h }
|
|
];
|
|
}
|
|
const lo = leaf.y + minRoom, hi = leaf.y + leaf.h - minRoom;
|
|
const p = Math.round(lo + ((rng.float() + rng.float()) / 2) * (hi - lo));
|
|
return [
|
|
{ x: leaf.x, y: leaf.y, w: leaf.w, h: p - leaf.y },
|
|
{ x: leaf.x, y: p, w: leaf.w, h: leaf.y + leaf.h - p }
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Boolean footprint mask [rows][cols]. Deterministic from its own seed so all
|
|
* floors of a building share the same outline.
|
|
*/
|
|
export function buildFootprintMask(opts) {
|
|
const { cols, rows } = opts;
|
|
const margin = opts.margin ?? 1;
|
|
const rng = new Rng(`${opts.seed ?? "mask"}::mask`);
|
|
// "auto" (or anything unknown) -> pick a shape deterministically from the seed,
|
|
// weighted so plain rectangles still appear fairly often.
|
|
let shape = opts.shape;
|
|
if (!FOOTPRINT_SHAPES.includes(shape)) {
|
|
shape = rng.weighted([
|
|
{ v: "rectangle", weight: 3 }, { v: "l", weight: 2 }, { v: "t", weight: 2 },
|
|
{ v: "u", weight: 2 }, { v: "plus", weight: 1 }
|
|
]).v;
|
|
}
|
|
const x0 = margin, y0 = margin, x1 = cols - margin, y1 = rows - margin;
|
|
const w = x1 - x0, h = y1 - y0;
|
|
const mask = Array.from({ length: rows }, () => new Array(cols).fill(false));
|
|
const inRect = (x, y, rx, ry, rw, rh) => x >= rx && y >= ry && x < rx + rw && y < ry + rh;
|
|
|
|
// Per-shape "keep" predicate over the inner rect.
|
|
let keep;
|
|
if (shape === "l") {
|
|
const cutW = Math.round(w * (0.4 + rng.float() * 0.15));
|
|
const cutH = Math.round(h * (0.4 + rng.float() * 0.15));
|
|
const corner = rng.int(0, 3); // which corner is removed
|
|
keep = (x, y) => {
|
|
const rx = corner === 1 || corner === 3 ? x1 - cutW : x0;
|
|
const ry = corner === 2 || corner === 3 ? y1 - cutH : y0;
|
|
return !inRect(x, y, rx, ry, cutW, cutH);
|
|
};
|
|
} else if (shape === "t") {
|
|
const barH = Math.round(h * (0.32 + rng.float() * 0.12));
|
|
const stemX = x0 + Math.round(w * (0.3 + rng.float() * 0.1));
|
|
const stemW = Math.round(w * (0.3 + rng.float() * 0.1));
|
|
keep = (x, y) => (y < y0 + barH) || (x >= stemX && x < stemX + stemW);
|
|
} else if (shape === "u") {
|
|
const wingW = Math.round(w * (0.26 + rng.float() * 0.08));
|
|
const baseY = y1 - Math.round(h * (0.32 + rng.float() * 0.1));
|
|
keep = (x, y) => (x < x0 + wingW) || (x >= x1 - wingW) || (y >= baseY);
|
|
} else if (shape === "plus") {
|
|
const armW = Math.round(w * (0.34 + rng.float() * 0.08));
|
|
const armH = Math.round(h * (0.34 + rng.float() * 0.08));
|
|
const cx = x0 + Math.round((w - armW) / 2);
|
|
const cy = y0 + Math.round((h - armH) / 2);
|
|
keep = (x, y) => (x >= cx && x < cx + armW) || (y >= cy && y < cy + armH);
|
|
} else {
|
|
keep = () => true;
|
|
}
|
|
|
|
for (let y = y0; y < y1; y += 1) {
|
|
for (let x = x0; x < x1; x += 1) {
|
|
if (keep(x, y)) mask[y][x] = true;
|
|
}
|
|
}
|
|
return { mask, x0, y0, x1, y1, shape };
|
|
}
|
|
|
|
/**
|
|
* Generate one building floor.
|
|
* @param {object} opts cols, rows, targetRooms, subtype, seed, shape, margin,
|
|
* minRoom, circulation:{x,y}|null, floorName
|
|
*/
|
|
export function generateBuilding(opts) {
|
|
const cols = opts.cols, rows = opts.rows;
|
|
const margin = opts.margin ?? 1;
|
|
const minRoom = opts.minRoom ?? 3;
|
|
const subtype = ROOM_VOCAB[opts.subtype] ? opts.subtype : "modern";
|
|
const rng = new Rng(opts.seed ?? "mapwright");
|
|
|
|
const { mask, x0, y0, x1, y1, shape: resolvedShape } = buildFootprintMask({ cols, rows, margin, shape: opts.shape, seed: opts.maskSeed ?? opts.seed });
|
|
const bbox = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
|
|
// BSP the bounding box.
|
|
const target = Math.max(1, opts.targetRooms ?? 8);
|
|
let leaves = [{ ...bbox }];
|
|
let guard = 0;
|
|
while (leaves.length < target && guard < 500) {
|
|
guard += 1;
|
|
const cands = leaves.map((leaf, i) => ({ leaf, i })).filter(({ leaf }) => splittable(leaf, minRoom));
|
|
if (!cands.length) break;
|
|
cands.sort((a, b) => rectArea(b.leaf) - rectArea(a.leaf));
|
|
const choice = cands[Math.min(cands.length - 1, Math.floor(rng.float() * rng.float() * 2))];
|
|
const [a, b] = splitLeaf(choice.leaf, minRoom, rng);
|
|
leaves.splice(choice.i, 1, a, b);
|
|
}
|
|
|
|
// Assign masked cells to the leaf that contains them.
|
|
const leafAt = (x, y) => leaves.findIndex((l) => x >= l.x && y >= l.y && x < l.x + l.w && y < l.y + l.h);
|
|
const cellsByLeaf = leaves.map(() => []);
|
|
for (let y = 0; y < rows; y += 1) {
|
|
for (let x = 0; x < cols; x += 1) {
|
|
if (!mask[y][x]) continue;
|
|
const li = leafAt(x, y);
|
|
if (li >= 0) cellsByLeaf[li].push({ x, y });
|
|
}
|
|
}
|
|
|
|
// Materialise rooms (drop empty leaves, relabel ids).
|
|
const rooms = [];
|
|
for (let li = 0; li < leaves.length; li += 1) {
|
|
const cells = cellsByLeaf[li];
|
|
if (cells.length < 2) continue;
|
|
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity, sx = 0, sy = 0;
|
|
for (const c of cells) {
|
|
minx = Math.min(minx, c.x); miny = Math.min(miny, c.y);
|
|
maxx = Math.max(maxx, c.x + 1); maxy = Math.max(maxy, c.y + 1);
|
|
sx += c.x + 0.5; sy += c.y + 0.5;
|
|
}
|
|
rooms.push({
|
|
id: rooms.length, x: minx, y: miny, w: maxx - minx, h: maxy - miny,
|
|
cx: sx / cells.length, cy: sy / cells.length,
|
|
cells, type: "room", label: "", locked: false
|
|
});
|
|
}
|
|
|
|
// Region grid.
|
|
const region = Array.from({ length: rows }, () => new Array(cols).fill(-1));
|
|
for (const room of rooms) for (const c of room.cells) region[c.y][c.x] = room.id;
|
|
|
|
// Forced vertical-circulation room (multi-floor).
|
|
let circulation = null;
|
|
if (opts.circulation) {
|
|
const cc = opts.circulation;
|
|
const id = region[cc.y]?.[cc.x] ?? -1;
|
|
const room = rooms.find((r) => r.id === id);
|
|
if (room) {
|
|
const vocab = ROOM_VOCAB[subtype].circulation;
|
|
room.type = vocab.type;
|
|
room.label = vocab.label;
|
|
room.locked = false;
|
|
room.reserved = true;
|
|
circulation = { x: cc.x + 0.5, y: cc.y + 0.5, kind: vocab.type, label: vocab.label, roomId: room.id };
|
|
}
|
|
}
|
|
|
|
assignRoomTypes(rooms, bbox, subtype, rng);
|
|
|
|
return { footprint: bbox, mask, rooms, region, cols, rows, shape: resolvedShape, circulation, floorName: opts.floorName };
|
|
}
|
|
|
|
/**
|
|
* Generate a multi-floor building: an array of floor layouts sharing one
|
|
* footprint + a vertical-circulation column at a consistent location.
|
|
* @param {object} opts ...generateBuilding opts + floors:number
|
|
* @returns {{ floors: object[], shape, circulationCell }}
|
|
*/
|
|
export function generateBuildingComplex(opts) {
|
|
const floors = Math.max(1, opts.floors ?? 1);
|
|
const { mask, x0, y0, x1, y1 } = buildFootprintMask({ ...opts, seed: opts.seed });
|
|
|
|
// Choose a circulation cell near the footprint centroid that is inside the mask.
|
|
let circ = null;
|
|
if (floors > 1) {
|
|
const rng = new Rng(`${opts.seed}::circ`);
|
|
let cx = 0, cy = 0, n = 0;
|
|
for (let y = y0; y < y1; y += 1) for (let x = x0; x < x1; x += 1) if (mask[y][x]) { cx += x; cy += y; n += 1; }
|
|
cx = Math.round(cx / Math.max(1, n)); cy = Math.round(cy / Math.max(1, n));
|
|
// search outward for the nearest masked cell
|
|
let best = null, bestD = Infinity;
|
|
for (let y = y0; y < y1; y += 1) for (let x = x0; x < x1; x += 1) {
|
|
if (!mask[y][x]) continue;
|
|
const d = (x - cx) ** 2 + (y - cy) ** 2;
|
|
if (d < bestD) { bestD = d; best = { x, y }; }
|
|
}
|
|
circ = best ?? { x: cx, y: cy };
|
|
void rng;
|
|
}
|
|
|
|
const floorNames = makeFloorNames(opts.subtype, floors);
|
|
const out = [];
|
|
for (let i = 0; i < floors; i += 1) {
|
|
out.push(generateBuilding({
|
|
...opts,
|
|
seed: `${opts.seed}::floor${i}`,
|
|
maskSeed: opts.seed, // identical footprint on every floor
|
|
circulation: circ,
|
|
floorName: floorNames[i]
|
|
}));
|
|
}
|
|
return { floors: out, shape: opts.shape ?? "rectangle", circulationCell: circ, floorNames };
|
|
}
|
|
|
|
function makeFloorNames(subtype, n) {
|
|
if (subtype === "fantasy") {
|
|
const names = ["Ground Floor", "Upper Floor", "Top Floor", "Attic", "Cellar"];
|
|
return Array.from({ length: n }, (_, i) => names[i] ?? `Level ${i + 1}`);
|
|
}
|
|
return Array.from({ length: n }, (_, i) => (i === 0 ? "Ground Floor" : `Floor ${i + 1}`));
|
|
}
|
|
|
|
function assignRoomTypes(rooms, footprint, subtype, rng) {
|
|
const vocab = ROOM_VOCAB[subtype];
|
|
const touchesEdge = (r) =>
|
|
r.x === footprint.x || r.y === footprint.y ||
|
|
r.x + r.w === footprint.x + footprint.w || r.y + r.h === footprint.y + footprint.h;
|
|
|
|
const candidates = rooms.filter((r) => !r.reserved);
|
|
const edgeRooms = candidates.filter(touchesEdge);
|
|
const southRooms = edgeRooms.filter((r) => r.y + r.h === footprint.y + footprint.h);
|
|
const pool = (southRooms.length ? southRooms : edgeRooms).slice().sort((a, b) => rectArea(b) - rectArea(a));
|
|
const entrance = pool[0] ?? candidates[0];
|
|
if (entrance) { entrance.type = vocab.entrance.type; entrance.label = vocab.entrance.label; entrance.reserved = true; }
|
|
|
|
const used = new Set();
|
|
const counters = {};
|
|
for (const room of rooms) {
|
|
if (room.reserved || room === entrance) continue;
|
|
const available = vocab.rooms.filter((r) => !(r.unique && used.has(r.label)));
|
|
const pickList = available.length ? available : vocab.rooms;
|
|
const choice = rng.weighted(pickList);
|
|
used.add(choice.label);
|
|
room.locked = !!choice.locked;
|
|
if (choice.numbered) {
|
|
counters[choice.label] = (counters[choice.label] ?? 0) + 1;
|
|
room.label = `${choice.label} ${counters[choice.label]}`;
|
|
} else room.label = choice.label;
|
|
}
|
|
}
|