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>
275 lines
8.5 KiB
JavaScript
275 lines
8.5 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|