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,114 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user