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>
156 lines
6.5 KiB
JavaScript
156 lines
6.5 KiB
JavaScript
/**
|
|
* Outdoor biome renderer: grass/sand/snow base, water with ripples, rock
|
|
* outcrops, layered tree canopies with shadows. Pure + deterministic.
|
|
*/
|
|
|
|
import { Rng } from "./rng.mjs";
|
|
|
|
const BIOME = {
|
|
grassland: {
|
|
ground: "#5b7a3a", groundAlt: "#6b8a44", tuft: "#46602c", patch: "#52722f",
|
|
water: "#3f6f86", waterHiSafe: "#5a8caa", waterEdge: "#2c5468",
|
|
rock: "#7d7a73", rockHi: "#9a968d", rockShadow: "#4a473f",
|
|
tree: "#2f5526", treeHi: "#3f6e30", treeShadow: "#1d3318",
|
|
grid: "#3c5226", label: "#f3f7e6"
|
|
},
|
|
desert: {
|
|
ground: "#c9a96a", groundAlt: "#d4b676", tuft: "#b3954f", patch: "#bf9f5d",
|
|
water: "#4f8f9e", waterHiSafe: "#74b3bf", waterEdge: "#386b78",
|
|
rock: "#a98f63", rockHi: "#c2a877", rockShadow: "#6e5a39",
|
|
tree: "#5a6e3a", treeHi: "#6f8347", treeShadow: "#3a4825",
|
|
grid: "#9b8048", label: "#3a2c12"
|
|
},
|
|
snow: {
|
|
ground: "#dfe6ec", groundAlt: "#eef3f7", tuft: "#c2ccd4", patch: "#d2dbe2",
|
|
water: "#6f97ad", waterHiSafe: "#9bbdcd", waterEdge: "#557e94",
|
|
rock: "#8b9099", rockHi: "#a7adb6", rockShadow: "#565b63",
|
|
tree: "#3c5847", treeHi: "#4e6f59", treeShadow: "#26382d",
|
|
grid: "#aebac4", label: "#2a323a"
|
|
},
|
|
badlands: {
|
|
ground: "#9c6a44", groundAlt: "#a9754d", tuft: "#824f30", patch: "#90613b",
|
|
water: "#5a7d6e", waterHiSafe: "#7ea293", waterEdge: "#415d51",
|
|
rock: "#8a5a3c", rockHi: "#a87149", rockShadow: "#5a3824",
|
|
tree: "#6a6232", treeHi: "#7e763f", treeShadow: "#453f1f",
|
|
grid: "#6e4628", label: "#f6e9da"
|
|
}
|
|
};
|
|
|
|
const PX = (v, gs) => +(v * gs).toFixed(1);
|
|
|
|
function pathFor(pts, gs) {
|
|
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";
|
|
}
|
|
|
|
export function renderOutdoorSVG(args) {
|
|
const m = args.model;
|
|
const gs = args.gridSize ?? 70;
|
|
const t = BIOME[m.biome] ?? BIOME.grassland;
|
|
const showGrid = args.showGrid !== false;
|
|
const showLabels = args.showLabels !== false;
|
|
const rng = new Rng(`${args.seed ?? "field"}::outrender`);
|
|
const W = m.cols * gs, H = m.rows * gs;
|
|
const out = [];
|
|
|
|
out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`);
|
|
out.push(`<defs>` +
|
|
`<filter id="mw-soft" x="-30%" y="-30%" width="160%" height="160%">` +
|
|
`<feDropShadow dx="0" dy="${(gs * 0.04).toFixed(1)}" stdDeviation="${(gs * 0.05).toFixed(1)}" flood-color="#000" flood-opacity="0.3"/></filter>` +
|
|
`</defs>`);
|
|
|
|
// Ground base + soft colour patches + tufts.
|
|
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${t.ground}"/>`);
|
|
out.push(groundPatches(m, gs, t, rng));
|
|
if (showGrid) out.push(grid(m, gs, t.grid));
|
|
out.push(tufts(m, gs, t, rng));
|
|
|
|
// Water.
|
|
if (m.water) {
|
|
out.push(`<path d="${pathFor(m.water.loop, gs)}" fill="${t.water}" stroke="${t.waterEdge}" stroke-width="${(gs * 0.06).toFixed(1)}"/>`);
|
|
out.push(ripples(m.water, gs, t, rng));
|
|
}
|
|
|
|
// Rocks.
|
|
for (const rk of m.rocks) {
|
|
out.push(`<g filter="url(#mw-soft)">`);
|
|
out.push(`<path d="${pathFor(rk.loop, gs)}" fill="${t.rock}" stroke="${t.rockShadow}" stroke-width="${(gs * 0.04).toFixed(1)}"/>`);
|
|
out.push(`</g>`);
|
|
// a couple of highlight facets
|
|
out.push(`<path d="${pathFor(rk.loop, gs)}" fill="none" stroke="${t.rockHi}" stroke-width="${(gs * 0.02).toFixed(1)}" opacity="0.5"/>`);
|
|
}
|
|
|
|
// Trees (sorted by y for overlap depth).
|
|
const trees = m.trees.slice().sort((a, b) => a.y - b.y);
|
|
out.push(`<g filter="url(#mw-soft)">`);
|
|
for (const tr of trees) {
|
|
const cx = PX(tr.x, gs), cy = PX(tr.y, gs), r = tr.r * gs;
|
|
out.push(`<circle cx="${cx}" cy="${cy}" r="${r.toFixed(1)}" fill="${t.tree}"/>`);
|
|
out.push(`<circle cx="${(cx - r * 0.25).toFixed(1)}" cy="${(cy - r * 0.25).toFixed(1)}" r="${(r * 0.62).toFixed(1)}" fill="${t.treeHi}" opacity="0.8"/>`);
|
|
}
|
|
out.push(`</g>`);
|
|
|
|
// Feature labels.
|
|
if (showLabels && m.features?.length) {
|
|
out.push(`<g font-family="'Segoe UI', sans-serif" text-anchor="middle" fill="${t.label}">`);
|
|
for (const f of m.features) {
|
|
out.push(`<text x="${PX(f.x, gs)}" y="${PX(f.y, gs)}" font-size="${Math.max(10, gs * 0.22).toFixed(1)}" font-weight="700" opacity="0.85" stroke="#000" stroke-width="0.6" paint-order="stroke">${escapeXml(f.label)}</text>`);
|
|
}
|
|
out.push(`</g>`);
|
|
}
|
|
|
|
out.push(`</svg>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function groundPatches(m, gs, t, rng) {
|
|
const out = [`<g>`];
|
|
const n = Math.round(m.cols * m.rows / 14);
|
|
for (let i = 0; i < n; i += 1) {
|
|
const x = rng.float() * m.cols, y = rng.float() * m.rows;
|
|
const rx = (0.8 + rng.float() * 2.2) * gs, ry = rx * (0.6 + rng.float() * 0.5);
|
|
const col = rng.float() < 0.5 ? t.groundAlt : t.patch;
|
|
out.push(`<ellipse cx="${PX(x, gs)}" cy="${PX(y, gs)}" rx="${rx.toFixed(1)}" ry="${ry.toFixed(1)}" fill="${col}" opacity="0.35"/>`);
|
|
}
|
|
out.push(`</g>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function tufts(m, gs, t, rng) {
|
|
const out = [`<g stroke="${t.tuft}" stroke-width="${(gs * 0.02).toFixed(1)}" stroke-linecap="round" opacity="0.5">`];
|
|
const n = Math.round(m.cols * m.rows * 1.1);
|
|
for (let i = 0; i < n; i += 1) {
|
|
const x = PX(rng.float() * m.cols, gs), y = PX(rng.float() * m.rows, gs);
|
|
const h = gs * (0.06 + rng.float() * 0.08);
|
|
out.push(`<path d="M${x},${y} l${(-h * 0.3).toFixed(1)},${(-h).toFixed(1)} M${x},${y} l${(h * 0.3).toFixed(1)},${(-h * 0.9).toFixed(1)}"/>`);
|
|
}
|
|
out.push(`</g>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function ripples(water, gs, t, rng) {
|
|
const out = [`<g stroke="${t.waterHiSafe}" stroke-width="${(gs * 0.02).toFixed(1)}" fill="none" opacity="0.35">`];
|
|
const n = 8;
|
|
for (let i = 0; i < n; i += 1) {
|
|
const x = water.cx + (rng.float() - 0.5) * water.r * 1.2;
|
|
const y = water.cy + (rng.float() - 0.5) * water.r * 1.2;
|
|
const w = gs * (0.3 + rng.float() * 0.5);
|
|
out.push(`<path d="M${PX(x, gs) - w},${PX(y, gs)} q${w.toFixed(1)},${(-w * 0.5).toFixed(1)} ${(w * 2).toFixed(1)},0"/>`);
|
|
}
|
|
out.push(`</g>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function grid(m, gs, color) {
|
|
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.01).toFixed(1)}" opacity="0.16">`];
|
|
for (let x = 0; x <= m.cols; x += 1) out.push(`<line x1="${PX(x, gs)}" y1="0" x2="${PX(x, gs)}" y2="${PX(m.rows, gs)}"/>`);
|
|
for (let y = 0; y <= m.rows; y += 1) out.push(`<line x1="0" y1="${PX(y, gs)}" x2="${PX(m.cols, gs)}" y2="${PX(y, gs)}"/>`);
|
|
out.push(`</g>`);
|
|
return out.join("");
|
|
}
|
|
|
|
function escapeXml(str) {
|
|
return String(str).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]));
|
|
}
|