Files
mapwright/module/lib/building.mjs
T
slaguru666andClaude Opus 4.8 100e514c4d Mapwright v0.5.0 — procedural battle map generator for Foundry VTT
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>
2026-06-17 15:05:25 +01:00

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