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>
135 lines
5.6 KiB
JavaScript
135 lines
5.6 KiB
JavaScript
/**
|
|
* Organic cave renderer: smoothed contour loops -> SVG.
|
|
*
|
|
* The look: rocky floor inside flowing walls, thick rock walls with a cast
|
|
* shadow + inner highlight, scattered floor debris, optional grid clipped to
|
|
* floor. Pure + deterministic.
|
|
*/
|
|
|
|
import { Rng } from "./rng.mjs";
|
|
|
|
const THEMES = {
|
|
cave: {
|
|
outside: "#0f0d0b", floorBase: "#7d7164", floorSpeck: "#8d8173",
|
|
floorEdge: "#5e5349", wall: "#1d1813", wallHi: "#5a5042",
|
|
grid: "#3a342e", label: "#f3e9d4", speckLight: "#9a8e80"
|
|
},
|
|
crypt: {
|
|
outside: "#0c0e12", floorBase: "#6a6f7c", floorSpeck: "#7a8090",
|
|
floorEdge: "#50545f", wall: "#171a20", wallHi: "#566173",
|
|
grid: "#33363d", label: "#eef3fb", speckLight: "#8990a0"
|
|
},
|
|
sewer: {
|
|
outside: "#0c100e", floorBase: "#61705f", floorSpeck: "#6e7e69",
|
|
floorEdge: "#4a564a", wall: "#161d18", wallHi: "#566355",
|
|
grid: "#2f3733", label: "#e7f5ec", speckLight: "#7e8e78"
|
|
}
|
|
};
|
|
|
|
const PX = (v, gs) => +(v * gs).toFixed(1);
|
|
|
|
export function renderCaveSVG(args) {
|
|
const model = args.model;
|
|
const gs = args.gridSize ?? 70;
|
|
const theme = THEMES[args.theme] ?? THEMES.cave;
|
|
const showGrid = args.showGrid !== false;
|
|
const showLabels = args.showLabels !== false;
|
|
const rng = new Rng(`${args.seed ?? "cave"}::caverender`);
|
|
|
|
const W = model.cols * gs;
|
|
const H = model.rows * gs;
|
|
const out = [];
|
|
|
|
const pathFor = (pts) => {
|
|
let d = `M${PX(pts[0].x, gs)},${PX(pts[0].y, gs)}`;
|
|
for (let i = 1; i < pts.length; i += 1) d += `L${PX(pts[i].x, gs)},${PX(pts[i].y, gs)}`;
|
|
return d + "Z";
|
|
};
|
|
const floorPath = model.loops.map((lp) => pathFor(lp.smooth)).join(" ");
|
|
|
|
out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`);
|
|
|
|
out.push(`<defs>`);
|
|
out.push(
|
|
`<filter id="mw-rockshadow" x="-20%" y="-20%" width="140%" height="140%">` +
|
|
`<feDropShadow dx="0" dy="0" stdDeviation="${(gs * 0.09).toFixed(1)}" flood-color="#000" flood-opacity="0.55"/></filter>`
|
|
);
|
|
out.push(`<clipPath id="mw-cavefloor" clip-rule="evenodd"><path d="${floorPath}" clip-rule="evenodd"/></clipPath>`);
|
|
out.push(`</defs>`);
|
|
|
|
// Rock surround.
|
|
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${theme.outside}"/>`);
|
|
out.push(rockTexture(W, H, theme, gs, rng));
|
|
|
|
// Floor fill (evenodd handles pillars/holes).
|
|
out.push(`<path d="${floorPath}" fill="${theme.floorBase}" fill-rule="evenodd"/>`);
|
|
|
|
// Floor detail clipped to floor.
|
|
out.push(`<g clip-path="url(#mw-cavefloor)">`);
|
|
out.push(floorDebris(model, gs, theme, rng));
|
|
if (showGrid) out.push(caveGrid(model, gs, theme.grid));
|
|
// inner shading ring just inside the walls
|
|
out.push(`<path d="${floorPath}" fill="none" fill-rule="evenodd" stroke="${theme.floorEdge}" stroke-width="${(gs * 0.34).toFixed(1)}" opacity="0.5"/>`);
|
|
out.push(`</g>`);
|
|
|
|
// Walls: cast shadow, body, inner highlight.
|
|
const wallW = (gs * 0.26).toFixed(1);
|
|
out.push(`<g filter="url(#mw-rockshadow)" fill="none" stroke-linejoin="round">`);
|
|
out.push(`<path d="${floorPath}" fill-rule="evenodd" stroke="${theme.wall}" stroke-width="${wallW}"/>`);
|
|
out.push(`</g>`);
|
|
out.push(`<path d="${floorPath}" fill="none" fill-rule="evenodd" stroke="${theme.wallHi}" stroke-width="${(gs * 0.05).toFixed(1)}" opacity="0.5"/>`);
|
|
|
|
// Feature labels.
|
|
if (showLabels && model.features?.length) {
|
|
out.push(`<g font-family="'Segoe UI', sans-serif" text-anchor="middle" fill="${theme.label}">`);
|
|
for (const f of model.features) {
|
|
const fs = Math.max(10, gs * 0.22);
|
|
out.push(`<circle cx="${PX(f.x, gs)}" cy="${PX(f.y, gs)}" r="${(gs * 0.08).toFixed(1)}" fill="${theme.label}" opacity="0.5"/>`);
|
|
out.push(`<text x="${PX(f.x, gs)}" y="${(PX(f.y, gs) - gs * 0.18).toFixed(1)}" font-size="${fs.toFixed(1)}" font-weight="600" opacity="0.85">${escapeXml(f.label)}</text>`);
|
|
}
|
|
out.push(`</g>`);
|
|
}
|
|
|
|
out.push(`</svg>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function rockTexture(W, H, theme, gs, rng) {
|
|
// Sparse rock cracks/blotches outside, very subtle.
|
|
const out = [`<g stroke="${theme.wallHi}" stroke-width="${(gs * 0.02).toFixed(1)}" opacity="0.12" fill="none">`];
|
|
const n = Math.round((W * H) / (gs * gs) / 6);
|
|
for (let i = 0; i < n; i += 1) {
|
|
const x = rng.float() * W, y = rng.float() * H;
|
|
const len = gs * (0.3 + rng.float() * 0.5);
|
|
const ang = rng.float() * Math.PI;
|
|
out.push(`<line x1="${x.toFixed(1)}" y1="${y.toFixed(1)}" x2="${(x + Math.cos(ang) * len).toFixed(1)}" y2="${(y + Math.sin(ang) * len).toFixed(1)}"/>`);
|
|
}
|
|
out.push(`</g>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function floorDebris(model, gs, theme, rng) {
|
|
const out = [`<g>`];
|
|
const n = Math.round(model.cols * model.rows * 0.6);
|
|
for (let i = 0; i < n; i += 1) {
|
|
const x = rng.float() * model.cols, y = rng.float() * model.rows;
|
|
const r = gs * (0.02 + rng.float() * 0.06);
|
|
const col = rng.float() < 0.5 ? theme.floorSpeck : theme.speckLight;
|
|
out.push(`<circle cx="${PX(x, gs)}" cy="${PX(y, gs)}" r="${r.toFixed(1)}" fill="${col}" opacity="${(0.25 + rng.float() * 0.3).toFixed(2)}"/>`);
|
|
}
|
|
out.push(`</g>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function caveGrid(model, gs, color) {
|
|
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.18">`];
|
|
for (let x = 0; x <= model.cols; x += 1) out.push(`<line x1="${PX(x, gs)}" y1="0" x2="${PX(x, gs)}" y2="${PX(model.rows, gs)}"/>`);
|
|
for (let y = 0; y <= model.rows; y += 1) out.push(`<line x1="0" y1="${PX(y, gs)}" x2="${PX(model.cols, gs)}" y2="${PX(y, gs)}"/>`);
|
|
out.push(`</g>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function escapeXml(str) {
|
|
return String(str).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]));
|
|
}
|