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>
115 lines
3.8 KiB
JavaScript
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;
|
|
}
|