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

262 lines
8.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Organic geometry core — shared by dungeons (caves) and outdoor biomes.
*
* Cellular-automata blob -> traced boundary loops -> simplified (diagonal,
* NON-axis-aligned) wall segments for Foundry + smoothed curves for the SVG.
*
* This is what frees us from the right-angle grid. Pure / Node-testable.
* Works on a SUB-CELL grid (subRes sub-cells per map cell) for smooth contours;
* all output is converted back to map-cell coordinates.
*/
import { Rng } from "./rng.mjs";
/**
* Generate a cellular-automata cave/blob as a floor mask on the sub grid.
* @returns {{ floor: Uint8Array, sw: number, sh: number, subRes: number, cols: number, rows: number }}
*/
export function generateBlob(opts) {
const cols = opts.cols;
const rows = opts.rows;
const subRes = opts.subRes ?? 3;
const sw = cols * subRes;
const sh = rows * subRes;
const rng = new Rng(opts.seed ?? "cave");
const fill = opts.fill ?? 0.46;
const steps = opts.steps ?? 5;
const margin = Math.max(1, Math.round((opts.margin ?? 1) * subRes));
let grid = new Uint8Array(sw * sh); // 1 = floor, 0 = wall
const at = (g, x, y) => (x < 0 || y < 0 || x >= sw || y >= sh ? 0 : g[y * sw + x]);
// Seed noise, with a wall margin so the blob never touches the edge.
for (let y = 0; y < sh; y += 1) {
for (let x = 0; x < sw; x += 1) {
const edge = x < margin || y < margin || x >= sw - margin || y >= sh - margin;
grid[y * sw + x] = edge ? 0 : (rng.float() < fill ? 0 : 1);
}
}
// Smooth with the classic 4-5 rule (+ fill large voids).
for (let s = 0; s < steps; s += 1) {
const next = new Uint8Array(sw * sh);
for (let y = 0; y < sh; y += 1) {
for (let x = 0; x < sw; x += 1) {
let walls1 = 0;
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue;
if (at(grid, x + dx, y + dy) === 0) walls1 += 1;
}
}
const isWall = grid[y * sw + x] === 0;
// Wall if surrounded; open up otherwise.
next[y * sw + x] = (isWall ? walls1 >= 4 : walls1 >= 5) ? 0 : 1;
}
}
grid = next;
}
// Keep only the largest connected floor region (guarantees one cave).
grid = keepLargestRegion(grid, sw, sh);
// Remove small enclosed wall pockets (declutter tiny pillars).
const holeFloor = (opts.removeHolesBelow ?? 2.2) * subRes * subRes;
grid = removeSmallHoles(grid, sw, sh, holeFloor);
return { floor: grid, sw, sh, subRes, cols, rows };
}
/** Fill enclosed wall regions (not touching the border) smaller than minArea. */
function removeSmallHoles(grid, sw, sh, minArea) {
const seen = new Uint8Array(sw * sh);
const stack = [];
for (let i = 0; i < grid.length; i += 1) {
if (grid[i] !== 0 || seen[i]) continue;
const cells = [];
let touchesBorder = false;
stack.length = 0;
stack.push(i);
seen[i] = 1;
while (stack.length) {
const idx = stack.pop();
cells.push(idx);
const x = idx % sw, y = (idx / sw) | 0;
if (x === 0 || y === 0 || x === sw - 1 || y === sh - 1) touchesBorder = true;
const ns = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]];
for (const [nx, ny] of ns) {
if (nx < 0 || ny < 0 || nx >= sw || ny >= sh) continue;
const ni = ny * sw + nx;
if (grid[ni] === 0 && !seen[ni]) { seen[ni] = 1; stack.push(ni); }
}
}
if (!touchesBorder && cells.length < minArea) {
for (const idx of cells) grid[idx] = 1;
}
}
return grid;
}
function keepLargestRegion(grid, sw, sh) {
const label = new Int32Array(sw * sh).fill(-1);
let best = -1, bestSize = 0, current = 0;
const stack = [];
for (let i = 0; i < grid.length; i += 1) {
if (grid[i] !== 1 || label[i] !== -1) continue;
let size = 0;
stack.length = 0;
stack.push(i);
label[i] = current;
while (stack.length) {
const idx = stack.pop();
size += 1;
const x = idx % sw, y = (idx / sw) | 0;
const ns = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]];
for (const [nx, ny] of ns) {
if (nx < 0 || ny < 0 || nx >= sw || ny >= sh) continue;
const ni = ny * sw + nx;
if (grid[ni] === 1 && label[ni] === -1) { label[ni] = current; stack.push(ni); }
}
}
if (size > bestSize) { bestSize = size; best = current; }
current += 1;
}
const out = new Uint8Array(sw * sh);
if (best < 0) return out;
for (let i = 0; i < grid.length; i += 1) out[i] = label[i] === best ? 1 : 0;
return out;
}
/**
* Trace the floor/wall boundary into ordered, oriented loops.
* Each loop is an array of {x, y} points in SUB-GRID coordinates.
*/
export function traceContours(floor, sw, sh) {
const at = (x, y) => (x < 0 || y < 0 || x >= sw || y >= sh ? 0 : floor[y * sw + x]);
// Directed boundary edges, floor-cell traversed clockwise (screen y-down).
const edges = new Map(); // tailKey -> [{tail,head}]
const addEdge = (ax, ay, bx, by) => {
const key = `${ax},${ay}`;
if (!edges.has(key)) edges.set(key, []);
edges.get(key).push({ head: { x: bx, y: by } });
};
for (let y = 0; y < sh; y += 1) {
for (let x = 0; x < sw; x += 1) {
if (at(x, y) !== 1) continue;
if (at(x, y - 1) === 0) addEdge(x, y, x + 1, y); // top
if (at(x + 1, y) === 0) addEdge(x + 1, y, x + 1, y + 1); // right
if (at(x, y + 1) === 0) addEdge(x + 1, y + 1, x, y + 1); // bottom
if (at(x - 1, y) === 0) addEdge(x, y + 1, x, y); // left
}
}
const loops = [];
for (const [startKey, list] of edges) {
while (list.length) {
const first = list.pop();
const startPt = startKey.split(",").map(Number);
const loop = [{ x: startPt[0], y: startPt[1] }];
let cur = first.head;
let guard = 0;
while (guard++ < sw * sh * 4) {
loop.push({ x: cur.x, y: cur.y });
const key = `${cur.x},${cur.y}`;
const outs = edges.get(key);
if (!outs || !outs.length) break;
const next = outs.pop();
cur = next.head;
if (cur.x === startPt[0] && cur.y === startPt[1]) break;
}
if (loop.length > 3) loops.push(loop);
}
}
return loops;
}
/** Chaikin corner-cutting smoothing for a closed loop. */
export function chaikin(points, iterations = 2) {
let pts = points;
for (let it = 0; it < iterations; it += 1) {
const out = [];
const n = pts.length;
for (let i = 0; i < n; i += 1) {
const a = pts[i];
const b = pts[(i + 1) % n];
out.push({ x: a.x * 0.75 + b.x * 0.25, y: a.y * 0.75 + b.y * 0.25 });
out.push({ x: a.x * 0.25 + b.x * 0.75, y: a.y * 0.25 + b.y * 0.75 });
}
pts = out;
}
return pts;
}
/** Douglas–Peucker simplification of a CLOSED loop. tol in same units as points. */
export function simplifyClosed(points, tol) {
const n = points.length;
if (n < 5) return points.slice();
// Split the loop at the two most distant points, DP each half.
let iFar = 0, jFar = 1, dMax = -1;
// cheap diameter estimate: farthest from point 0, then farthest from that
iFar = 0;
for (let k = 1; k < n; k += 1) {
const d = dist2(points[0], points[k]);
if (d > dMax) { dMax = d; jFar = k; }
}
dMax = -1; iFar = jFar; let opp = 0;
for (let k = 0; k < n; k += 1) {
const d = dist2(points[iFar], points[k]);
if (d > dMax) { dMax = d; opp = k; }
}
const a = Math.min(iFar, opp), b = Math.max(iFar, opp);
const half1 = points.slice(a, b + 1);
const half2 = points.slice(b).concat(points.slice(0, a + 1));
const s1 = dpOpen(half1, tol);
const s2 = dpOpen(half2, tol);
// stitch, dropping duplicated shared endpoints
const merged = s1.concat(s2.slice(1, -1));
return merged.length >= 3 ? merged : points.slice();
}
function dpOpen(points, tol) {
if (points.length < 3) return points.slice();
const keep = new Array(points.length).fill(false);
keep[0] = keep[points.length - 1] = true;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [s, e] = stack.pop();
let maxD = -1, idx = -1;
for (let i = s + 1; i < e; i += 1) {
const d = perpDist(points[i], points[s], points[e]);
if (d > maxD) { maxD = d; idx = i; }
}
if (maxD > tol && idx !== -1) {
keep[idx] = true;
stack.push([s, idx], [idx, e]);
}
}
return points.filter((_, i) => keep[i]);
}
function dist2(a, b) { const dx = a.x - b.x, dy = a.y - b.y; return dx * dx + dy * dy; }
function perpDist(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
return Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / len;
}
/** Convert a loop of sub-grid points to map-cell coords. */
export function loopToCells(loop, subRes) {
return loop.map((p) => ({ x: p.x / subRes, y: p.y / subRes }));
}
/** A loop's signed area (screen y-down): >0 clockwise (outer), <0 hole. */
export function signedArea(loop) {
let a = 0;
for (let i = 0; i < loop.length; i += 1) {
const p = loop[i], q = loop[(i + 1) % loop.length];
a += p.x * q.y - q.x * p.y;
}
return a / 2;
}