/** * Region grid -> wall / door / window segments. * * This is the single source of truth that feeds BOTH the SVG renderer and the * Foundry wall/door creation, so the picture and the playable walls are always * identical. Pure / Node-testable. All coordinates in GRID CELLS. * * A "unit edge" lives on a cell boundary: * vertical edge at line X, row y -> from (X, y) to (X, y+1) * horizontal edge at line Y, col x -> from (x, Y) to (x+1, Y) * * Output segment shape: * { x1, y1, x2, y2, orient: "v"|"h", kind, sides: [a, b], inside } * kind: "wall" | "door" | "window" * locked / secret flags carried on door segments */ const OUTSIDE = -1; function reg(region, x, y, cols, rows) { if (x < 0 || y < 0 || x >= cols || y >= rows) return OUTSIDE; return region[y][x]; } function pairKey(a, b) { return a < b ? `${a}:${b}` : `${b}:${a}`; } // --- Union-find for the door spanning tree ------------------------------- function makeDSU(n) { const parent = Array.from({ length: n }, (_, i) => i); const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }; const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra === rb) return false; parent[ra] = rb; return true; }; return { find, union }; } /** * @param {object} layout output of generateBuilding() * @param {object} opts * @param {import("./rng.mjs").Rng} opts.rng * @param {number} [opts.loopProb] chance of an extra (non-tree) door, 0..1 * @param {number} [opts.windowEvery] spacing of windows along exterior runs * @param {boolean} [opts.windows] place windows at all * @param {number} [opts.extraDoors] extra exterior doors for large buildings * @returns {{ segments, doors, windows, walls }} */ export function buildWalls(layout, opts) { const { region, rooms, cols, rows, footprint } = layout; const rng = opts.rng; const loopProb = opts.loopProb ?? 0.3; const windowEvery = opts.windowEvery ?? 4; const wantWindows = opts.windows !== false; // edges keyed by "orient:x:y" -> { orient, x, y, a, b, kind, exterior } const edges = new Map(); const key = (o, x, y) => `${o}:${x}:${y}`; // Vertical edges (line X between cell x-1 and x). for (let x = 0; x <= cols; x += 1) { for (let y = 0; y < rows; y += 1) { const a = reg(region, x - 1, y, cols, rows); const b = reg(region, x, y, cols, rows); if (a === b) continue; edges.set(key("v", x, y), { orient: "v", x, y, a, b, kind: "wall", exterior: a === OUTSIDE || b === OUTSIDE }); } } // Horizontal edges (line Y between cell y-1 and y). for (let y = 0; y <= rows; y += 1) { for (let x = 0; x < cols; x += 1) { const a = reg(region, x, y - 1, cols, rows); const b = reg(region, x, y, cols, rows); if (a === b) continue; edges.set(key("h", x, y), { orient: "h", x, y, a, b, kind: "wall", exterior: a === OUTSIDE || b === OUTSIDE }); } } // --- Interior doors: connect all rooms (spanning tree) + a few loops ------ const interior = [...edges.values()].filter((e) => !e.exterior); const pairs = new Map(); // pairKey -> edge list for (const e of interior) { const k = pairKey(e.a, e.b); if (!pairs.has(k)) pairs.set(k, []); pairs.get(k).push(e); } const dsu = makeDSU(rooms.length); const pairEntries = rng.shuffle([...pairs.entries()]); for (const [k, list] of pairEntries) { const [a, b] = k.split(":").map(Number); const joined = dsu.union(a, b); if (joined || rng.float() < loopProb) { markDoor(centreEdge(list), rooms); } } // --- Exterior runs: doors + windows -------------------------------------- const exterior = [...edges.values()].filter((e) => e.exterior); const runs = groupRuns(exterior); // Main entrance: middle of the entrance room's south exterior edges. const entrance = rooms.find((r) => r.type === "entrance") ?? rooms[0]; placeEntranceDoor(entrance, footprint, edges, key); // A side door for larger buildings. const extraDoors = opts.extraDoors ?? (rooms.length >= 10 ? 1 : 0); for (let i = 0; i < extraDoors; i += 1) { const run = rng.pick(runs.filter((rn) => rn.length >= 3)); if (run) markDoorEdge(run[Math.floor(run.length / 2)]); } if (wantWindows) { for (const run of runs) { if (run.length < 3) continue; for (let i = 1; i < run.length - 1; i += windowEvery) { const e = run[i]; if (e.kind === "wall") e.kind = "window"; } } } // --- Merge colinear walls; emit doors/windows as unit segments ----------- const segments = mergeSegments(edges); return { segments, walls: segments.filter((s) => s.kind === "wall"), doors: segments.filter((s) => s.kind === "door"), windows: segments.filter((s) => s.kind === "window") }; } /** Pick the middle edge of the longest contiguous run within an edge list. */ function centreEdge(list) { const runs = groupRuns(list); runs.sort((a, b) => b.length - a.length); const run = runs[0]; return run[Math.floor(run.length / 2)]; } function markDoor(edge, rooms) { edge.kind = "door"; const ra = rooms[edge.a]; const rb = rooms[edge.b]; edge.locked = !!(ra?.locked || rb?.locked); // Hinge swings into the higher-id / non-locked room by convention. edge.inside = edge.orient === "v" ? "b" : "b"; } function markDoorEdge(edge) { edge.kind = "door"; edge.locked = false; edge.inside = "b"; } /** Group a flat edge list into contiguous colinear runs. */ function groupRuns(list) { const byLine = new Map(); for (const e of list) { // For vertical edges runs go along y on a fixed x; horizontal along x on fixed y. const lineKey = e.orient === "v" ? `v:${e.x}` : `h:${e.y}`; if (!byLine.has(lineKey)) byLine.set(lineKey, []); byLine.get(lineKey).push(e); } const runs = []; for (const [, group] of byLine) { const axis = group[0].orient === "v" ? "y" : "x"; group.sort((a, b) => a[axis] - b[axis]); let run = [group[0]]; for (let i = 1; i < group.length; i += 1) { if (group[i][axis] === group[i - 1][axis] + 1) { run.push(group[i]); } else { runs.push(run); run = [group[i]]; } } runs.push(run); } return runs; } function placeEntranceDoor(entrance, footprint, edges, key) { if (!entrance) return; const southY = entrance.y + entrance.h; const onSouthEdge = southY === footprint.y + footprint.h; let target = null; if (onSouthEdge) { const midX = Math.floor(entrance.x + entrance.w / 2); target = edges.get(key("h", midX, southY)); } if (!target) { // Fall back: any exterior edge of the entrance room. for (const e of edges.values()) { if (!e.exterior) continue; if (e.a === entrance.id || e.b === entrance.id) { target = e; break; } } } if (target) { target.kind = "door"; target.locked = false; target.inside = "a"; // swing inward } } /** Merge unit wall edges into long segments; doors/windows stay unit-length. */ function mergeSegments(edges) { const segments = []; const walls = []; for (const e of edges.values()) { if (e.kind === "wall") walls.push(e); else segments.push(unitSegment(e)); } // group walls by line and merge consecutive const byLine = new Map(); for (const e of walls) { const lineKey = e.orient === "v" ? `v:${e.x}` : `h:${e.y}`; if (!byLine.has(lineKey)) byLine.set(lineKey, []); byLine.get(lineKey).push(e); } for (const [, group] of byLine) { const o = group[0].orient; const axis = o === "v" ? "y" : "x"; group.sort((a, b) => a[axis] - b[axis]); let start = group[0]; let prev = group[0]; const flush = (endEdge) => { if (o === "v") { segments.push({ x1: start.x, y1: start.y, x2: start.x, y2: endEdge.y + 1, orient: "v", kind: "wall" }); } else { segments.push({ x1: start.x, y1: start.y, x2: endEdge.x + 1, y2: start.y, orient: "h", kind: "wall" }); } }; for (let i = 1; i < group.length; i += 1) { if (group[i][axis] === prev[axis] + 1) { prev = group[i]; } else { flush(prev); start = group[i]; prev = group[i]; } } flush(prev); } return segments; } function unitSegment(e) { const seg = e.orient === "v" ? { x1: e.x, y1: e.y, x2: e.x, y2: e.y + 1 } : { x1: e.x, y1: e.y, x2: e.x + 1, y2: e.y }; seg.orient = e.orient; seg.kind = e.kind; seg.sides = [e.a, e.b]; if (e.locked) seg.locked = true; if (e.inside) seg.inside = e.inside; return seg; }