/** * 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; }