Files
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

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