Files
mapwright/module/lib/dungeon.mjs
T
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

115 lines
3.8 KiB
JavaScript

/**
* Dungeon generator — organic caves (no right angles).
*
* Wraps the organic core into a usable model: floor blob, smoothed contour
* loops for rendering, simplified diagonal wall segments for Foundry, and a
* handful of labelled feature points. Pure / Node-testable.
*/
import { Rng } from "./rng.mjs";
import {
generateBlob, traceContours, loopToCells,
simplifyClosed, chaikin, signedArea
} from "./organic.mjs";
const FEATURE_LABELS = [
"Entrance", "Collapsed Tunnel", "Underground Pool", "Old Campsite",
"Mushroom Grove", "Bone Pile", "Crystal Vein", "Narrow Squeeze",
"Cave-in", "Stagnant Water", "Ancient Carving"
];
function clean(loop) {
const out = [];
for (const p of loop) {
const last = out[out.length - 1];
if (!last || last.x !== p.x || last.y !== p.y) out.push(p);
}
if (out.length > 1) {
const a = out[0], b = out[out.length - 1];
if (a.x === b.x && a.y === b.y) out.pop();
}
return out;
}
/**
* @param {object} opts cols, rows, seed, subRes, fill, steps, simplifyTol, smooth, features
* @returns {{ cols, rows, subRes, floor, loops, segments, features, floorCells }}
*/
export function generateCave(opts) {
const blob = generateBlob({
cols: opts.cols, rows: opts.rows, subRes: opts.subRes ?? 3,
seed: opts.seed, fill: opts.fill ?? 0.46, steps: opts.steps ?? 5, margin: opts.margin ?? 1
});
const rawLoops = traceContours(blob.floor, blob.sw, blob.sh);
const simplifyTol = opts.simplifyTol ?? 0.45;
const smoothIter = opts.smooth ?? 2;
const minLoopArea = opts.minLoopArea ?? 1.2;
const loops = [];
for (const raw of rawLoops) {
const cells = clean(loopToCells(raw, blob.subRes));
if (cells.length < 4) continue;
const simp = simplifyClosed(cells, simplifyTol);
if (simp.length < 3) continue;
const area = signedArea(simp);
if (Math.abs(area) < minLoopArea) continue;
loops.push({ simp, smooth: chaikin(simp, smoothIter), area });
}
// Wall segments (diagonal, organic) for Foundry from the simplified loops.
const segments = [];
for (const lp of loops) {
const p = lp.simp;
for (let i = 0; i < p.length; i += 1) {
const a = p[i], b = p[(i + 1) % p.length];
segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y, kind: "wall" });
}
}
const features = placeFeatures(blob, opts);
return {
cols: opts.cols, rows: opts.rows, subRes: blob.subRes,
floor: blob.floor, loops, segments, features
};
}
/** Scatter a few labelled feature points on floor cells, well spaced. */
function placeFeatures(blob, opts) {
const rng = new Rng(`${opts.seed}::features`);
const { floor, sw, sh, subRes } = blob;
const floorPts = [];
for (let y = 0; y < sh; y += 1) {
for (let x = 0; x < sw; x += 1) {
if (floor[y * sw + x] === 1) floorPts.push({ x, y });
}
}
if (!floorPts.length) return [];
const want = Math.min(opts.featureCount ?? Math.round((opts.cols * opts.rows) / 90) + 1, FEATURE_LABELS.length);
const minSep = Math.max(3, Math.min(blob.cols, blob.rows) * 0.18) * subRes;
const labels = rng.shuffle(FEATURE_LABELS.slice());
const chosen = [];
// Entrance: floor point closest to the grid edge.
let entrance = floorPts[0];
let bestEdge = Infinity;
for (const p of floorPts) {
const d = Math.min(p.x, p.y, sw - p.x, sh - p.y);
if (d < bestEdge) { bestEdge = d; entrance = p; }
}
chosen.push({ x: entrance.x / subRes, y: entrance.y / subRes, label: "Entrance" });
let guard = 0;
while (chosen.length < want && guard++ < 2000) {
const c = rng.pick(floorPts);
const tooClose = chosen.some((o) => Math.hypot(o.x * subRes - c.x, o.y * subRes - c.y) < minSep);
if (tooClose) continue;
const label = labels[chosen.length % labels.length];
if (label === "Entrance") continue;
chosen.push({ x: c.x / subRes, y: c.y / subRes, label });
}
return chosen;
}