/** * 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; } }