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>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user