Files
mapwright/module/lib/render-settlement.mjs
T
slaguru666andClaude Opus 4.8 100e514c4d 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>
2026-06-17 15:05:25 +01:00

268 lines
20 KiB
JavaScript

/**
* Settlement renderer — detailed top-down village / city-block tactical maps.
* Rich, colourful baked-in scenery (cars, trees, lamps, crosswalks, market
* stalls, wells, flower beds…). Buildings are rooftop blocks; streets/grass are
* the open token-scaled play space. Pure + deterministic.
*/
import { Rng } from "./rng.mjs";
const CITY = {
asphalt: "#33373d", asphaltAlt: "#2c3035", sidewalk: "#b3bac2",
curb: "#7f868e", laneYellow: "#e8c64a", laneWhite: "#d7dde3",
grid: "#3c4954", roofTrim: "#23262b", park: "#5d7c3c", parkPath: "#b9a988", label: "#eef3f7"
};
const VILLAGE = {
grass: "#5f7e3e", grassAlt: "#6e8d48", tuft: "#47602c", road: "#9c814f", roadEdge: "#6f5a34",
cobble: "#8c7448", water: "#3f6f86", waterEdge: "#2c5468", square: "#b7a079",
garden: ["#6b8a3a", "#8a7a3a", "#5a7b34"], gardenRow: "#42501f", fence: "#6a4a2c",
grid: "#42562a", roofTrim: "#2a1d12", label: "#f3f7e6"
};
const TREE_GREENS = [["#2f5526", "#3f6e30"], ["#355e2a", "#4a7a38"], ["#2a4a22", "#386026"]];
const PX = (v, gs) => +(v * gs).toFixed(1);
export function renderSettlementSVG(args) {
const m = args.model;
const gs = args.gridSize ?? 100;
const showGrid = args.showGrid !== false;
const showLabels = args.showLabels !== false;
const rng = new Rng(`${args.seed ?? "town"}::settle`);
const W = m.cols * gs, H = m.rows * gs;
const out = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`];
if (m.style === "city") renderCity(out, m, gs, W, H, rng);
else renderVillage(out, m, gs, W, H, rng);
if (showGrid) out.push(grid(m, gs, m.style === "city" ? CITY.grid : VILLAGE.grid));
if (showLabels && m.features?.length) {
out.push(`<g font-family="'Segoe UI', sans-serif" text-anchor="middle" fill="${m.style === "city" ? CITY.label : VILLAGE.label}">`);
for (const f of m.features) out.push(`<text x="${PX(f.x, gs)}" y="${PX(f.y, gs)}" font-size="${Math.max(11, gs * 0.2).toFixed(1)}" font-weight="700" stroke="#000" stroke-width="0.7" paint-order="stroke" opacity="0.85">${esc(f.label)}</text>`);
out.push(`</g>`);
}
out.push(`</svg>`);
return out.join("");
}
// ============================ CITY ========================================
function renderCity(out, m, gs, W, H, rng) {
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${CITY.asphalt}"/>`);
out.push(speckle(W, H, gs, "#000", 0.07, 4, rng));
// crosswalks (under sidewalks/markings)
out.push(`<g fill="${CITY.laneWhite}" opacity="0.8">`);
for (const cw of m.crosswalks) out.push(crosswalk(cw, gs));
out.push(`</g>`);
// lane markings: solid white edge + dashed yellow centre
out.push(`<g stroke="${CITY.laneWhite}" stroke-width="${(gs * 0.025).toFixed(1)}" opacity="0.5">`);
for (const ln of m.roadLines) out.push(laneLine(ln, gs, W, H, 0.55));
for (const ln of m.roadLines) out.push(laneLine(ln, gs, W, H, -0.55));
out.push(`</g>`);
out.push(`<g stroke="${CITY.laneYellow}" stroke-width="${(gs * 0.045).toFixed(1)}" stroke-dasharray="${(gs * 0.5).toFixed(1)} ${(gs * 0.4).toFixed(1)}" opacity="0.9">`);
for (const ln of m.roadLines) out.push(laneLine(ln, gs, W, H, 0));
out.push(`</g>`);
// sidewalks
for (const blk of m.blocks) {
const x = PX(blk.x, gs), y = PX(blk.y, gs), w = PX(blk.w, gs), h = PX(blk.h, gs);
out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${CITY.sidewalk}" stroke="${CITY.curb}" stroke-width="${(gs * 0.04).toFixed(1)}"/>`);
}
// sidewalk paving grid (subtle)
out.push(`<g stroke="${CITY.curb}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.25">`);
for (const blk of m.blocks) for (let i = 1; i < blk.w; i += 1) out.push(`<line x1="${PX(blk.x + i, gs)}" y1="${PX(blk.y, gs)}" x2="${PX(blk.x + i, gs)}" y2="${PX(blk.y + blk.h, gs)}"/>`);
out.push(`</g>`);
// parks
for (const p of m.parks) out.push(park(p, gs, rng));
// cars (under buildings so buildings cast over them slightly; fine on road)
out.push(`<g>`);
for (const c of m.cars) out.push(car(c, gs));
out.push(`</g>`);
// road props that sit on asphalt (manhole/patch)
for (const p of m.props) if (p.kind === "manhole" || p.kind === "patch") out.push(prop(p, gs, rng));
// buildings
for (const b of m.buildings) cityRoof(out, b, gs, rng);
// sidewalk props (above buildings' shadow, on sidewalk)
for (const p of m.props) if (!(p.kind === "manhole" || p.kind === "patch")) out.push(prop(p, gs, rng));
}
function crosswalk(cw, gs) {
let s = "";
if (cw.vertical) {
for (let i = 0; i < cw.w; i += 1) if (i % 2 === 0) s += `<rect x="${PX(cw.x + i + 0.12, gs)}" y="${PX(cw.y + 0.15, gs)}" width="${PX(0.55, gs)}" height="${PX(cw.h - 0.3, gs)}"/>`;
} else {
for (let i = 0; i < cw.h; i += 1) if (i % 2 === 0) s += `<rect x="${PX(cw.x + 0.15, gs)}" y="${PX(cw.y + i + 0.12, gs)}" width="${PX(cw.w - 0.3, gs)}" height="${PX(0.55, gs)}"/>`;
}
return s;
}
function laneLine(ln, gs, W, H, off) {
if (ln.vertical) { const x = PX(ln.at + off, gs); return `<line x1="${x}" y1="0" x2="${x}" y2="${H}"/>`; }
const y = PX(ln.at + off, gs); return `<line x1="0" y1="${y}" x2="${W}" y2="${y}"/>`;
}
function cityRoof(out, b, gs, rng) {
const x = PX(b.x, gs), y = PX(b.y, gs), w = PX(b.w, gs), h = PX(b.h, gs), off = gs * 0.1;
out.push(`<rect x="${(x + off).toFixed(1)}" y="${(y + off).toFixed(1)}" width="${w}" height="${h}" rx="${(gs * 0.03).toFixed(1)}" fill="#000" opacity="0.3"/>`);
out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${(gs * 0.03).toFixed(1)}" fill="${b.roof}" stroke="${CITY.roofTrim}" stroke-width="${(gs * 0.05).toFixed(1)}"/>`);
// inner trim
out.push(`<rect x="${(x + gs * 0.12).toFixed(1)}" y="${(y + gs * 0.12).toFixed(1)}" width="${(w - gs * 0.24).toFixed(1)}" height="${(h - gs * 0.24).toFixed(1)}" fill="none" stroke="#fff" stroke-width="${(gs * 0.015).toFixed(1)}" opacity="0.1"/>`);
const cx = b.x + b.w / 2, cy = b.y + b.h / 2;
if (b.roofStyle === 1 && b.w >= 3 && b.h >= 3) { // rooftop garden
out.push(`<rect x="${PX(b.x + 0.4, gs)}" y="${PX(b.y + 0.4, gs)}" width="${PX(b.w - 0.8, gs)}" height="${PX(b.h - 0.8, gs)}" fill="${CITY.park}" opacity="0.6" rx="${(gs * 0.05).toFixed(1)}"/>`);
} else if (b.roofStyle === 2) { // water tank
out.push(`<circle cx="${PX(cx, gs)}" cy="${PX(cy, gs)}" r="${(gs * 0.28).toFixed(1)}" fill="#5e524a" stroke="#000" stroke-width="${(gs * 0.02).toFixed(1)}" opacity="0.85"/>`);
} else { // AC units / skylights
const units = Math.min(4, Math.max(1, Math.floor(b.w * b.h / 5)));
for (let i = 0; i < units; i += 1) {
const ux = PX(b.x + 0.3 + rng.float() * (b.w - 0.8), gs), uy = PX(b.y + 0.3 + rng.float() * (b.h - 0.8), gs), us = gs * (0.22 + rng.float() * 0.16);
out.push(`<rect x="${ux}" y="${uy}" width="${us.toFixed(1)}" height="${us.toFixed(1)}" fill="${rng.chance(0.5) ? "#3a3d42" : "#8fb6d6"}" opacity="0.5"/>`);
}
}
}
function car(c, gs) {
const long = gs * 1.7, wide = gs * 0.8;
const w = c.horizontal ? long : wide, h = c.horizontal ? wide : long;
const x = PX(c.x, gs) - w / 2, y = PX(c.y, gs) - h / 2;
const wx = c.horizontal ? w * 0.32 : w, wy = c.horizontal ? h : h * 0.32;
return `<g>` +
`<rect x="${(x + gs * 0.06).toFixed(1)}" y="${(y + gs * 0.06).toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${(gs * 0.12).toFixed(1)}" fill="#000" opacity="0.25"/>` +
`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${(gs * 0.14).toFixed(1)}" fill="${c.color}" stroke="#000" stroke-width="${(gs * 0.02).toFixed(1)}"/>` +
`<rect x="${(x + (w - wx) / 2).toFixed(1)}" y="${(y + (h - wy) / 2).toFixed(1)}" width="${wx.toFixed(1)}" height="${wy.toFixed(1)}" rx="${(gs * 0.06).toFixed(1)}" fill="#1c2024" opacity="0.55"/>` +
`<rect x="${(x + w * 0.08).toFixed(1)}" y="${(y + h * 0.08).toFixed(1)}" width="${(w * 0.84).toFixed(1)}" height="${(h * 0.84).toFixed(1)}" rx="${(gs * 0.1).toFixed(1)}" fill="none" stroke="#fff" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.18"/></g>`;
}
function park(p, gs, rng) {
const x = PX(p.x, gs), y = PX(p.y, gs), w = PX(p.w, gs), h = PX(p.h, gs);
let s = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${(gs * 0.06).toFixed(1)}" fill="${CITY.park}"/>`;
// diagonal path
s += `<path d="M${x},${(y + h * 0.7).toFixed(1)} L${(x + w).toFixed(1)},${(y + h * 0.3).toFixed(1)}" stroke="${CITY.parkPath}" stroke-width="${(gs * 0.3).toFixed(1)}" fill="none" opacity="0.8"/>`;
s += `<circle cx="${(x + w / 2).toFixed(1)}" cy="${(y + h / 2).toFixed(1)}" r="${(gs * 0.3).toFixed(1)}" fill="${VILLAGE.water}" stroke="${CITY.parkPath}" stroke-width="${(gs * 0.08).toFixed(1)}"/>`;
const n = Math.max(2, Math.floor(p.w * p.h / 4));
for (let i = 0; i < n; i += 1) s += treeSVG(p.x + 0.5 + rng.float() * (p.w - 1), p.y + 0.5 + rng.float() * (p.h - 1), 0.5 + rng.float() * 0.3, 0, gs);
return s;
}
function prop(p, gs, rng) {
const cx = PX(p.x, gs), cy = PX(p.y, gs);
switch (p.kind) {
case "tree": return treeSVG(p.x, p.y, 0.42 + (p.x % 1) * 0.001 + 0.16, 1, gs);
case "lamp": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.3).toFixed(1)}" fill="#ffe9a0" opacity="0.12"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.08).toFixed(1)}" fill="#2a2d31"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.045).toFixed(1)}" fill="#ffe9a0"/></g>`;
case "bench": return `<rect x="${(cx - gs * 0.22).toFixed(1)}" y="${(cy - gs * 0.1).toFixed(1)}" width="${(gs * 0.44).toFixed(1)}" height="${(gs * 0.2).toFixed(1)}" rx="${(gs * 0.03).toFixed(1)}" fill="#7a5a36" stroke="#3a2614" stroke-width="${(gs * 0.015).toFixed(1)}"/>`;
case "hydrant": return `<circle cx="${cx}" cy="${cy}" r="${(gs * 0.1).toFixed(1)}" fill="#c0392b" stroke="#000" stroke-width="${(gs * 0.015).toFixed(1)}"/>`;
case "planter": return `<rect x="${(cx - gs * 0.18).toFixed(1)}" y="${(cy - gs * 0.18).toFixed(1)}" width="${(gs * 0.36).toFixed(1)}" height="${(gs * 0.36).toFixed(1)}" rx="${(gs * 0.04).toFixed(1)}" fill="#6b4a2c"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.13).toFixed(1)}" fill="#3f6e30"/>`;
case "manhole": return `<circle cx="${cx}" cy="${cy}" r="${(gs * 0.13).toFixed(1)}" fill="#23262b" stroke="#454a50" stroke-width="${(gs * 0.02).toFixed(1)}"/>`;
case "patch": return `<rect x="${(cx - gs * 0.3).toFixed(1)}" y="${(cy - gs * 0.2).toFixed(1)}" width="${(gs * 0.6).toFixed(1)}" height="${(gs * 0.4).toFixed(1)}" fill="#23262b" opacity="0.5"/>`;
// village props
case "well": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.32).toFixed(1)}" fill="#7c7066" stroke="#3a3026" stroke-width="${(gs * 0.05).toFixed(1)}"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.18).toFixed(1)}" fill="#1c2a30"/></g>`;
case "fountain": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.4).toFixed(1)}" fill="#9aa0a8" stroke="#5a5f66" stroke-width="${(gs * 0.05).toFixed(1)}"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.26).toFixed(1)}" fill="${VILLAGE.water}"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.07).toFixed(1)}" fill="#cfe6f0"/></g>`;
case "stall": return `<g><rect x="${(cx - gs * 0.38).toFixed(1)}" y="${(cy - gs * 0.3).toFixed(1)}" width="${(gs * 0.76).toFixed(1)}" height="${(gs * 0.6).toFixed(1)}" rx="${(gs * 0.04).toFixed(1)}" fill="#8a6b4a"/><rect x="${(cx - gs * 0.42).toFixed(1)}" y="${(cy - gs * 0.34).toFixed(1)}" width="${(gs * 0.84).toFixed(1)}" height="${(gs * 0.26).toFixed(1)}" rx="${(gs * 0.03).toFixed(1)}" fill="${p.color}"/><line x1="${(cx - gs * 0.42).toFixed(1)}" y1="${(cy - gs * 0.34).toFixed(1)}" x2="${(cx - gs * 0.42).toFixed(1)}" y2="${(cy - gs * 0.08).toFixed(1)}" stroke="#fff" stroke-width="${(gs * 0.04).toFixed(1)}" opacity="0.5"/></g>`;
case "cart": return `<g><rect x="${(cx - gs * 0.3).toFixed(1)}" y="${(cy - gs * 0.2).toFixed(1)}" width="${(gs * 0.6).toFixed(1)}" height="${(gs * 0.4).toFixed(1)}" rx="${(gs * 0.03).toFixed(1)}" fill="#7a5236" stroke="#3a2614" stroke-width="${(gs * 0.02).toFixed(1)}"/><circle cx="${(cx - gs * 0.22).toFixed(1)}" cy="${(cy + gs * 0.24).toFixed(1)}" r="${(gs * 0.1).toFixed(1)}" fill="#3a2614"/><circle cx="${(cx + gs * 0.22).toFixed(1)}" cy="${(cy + gs * 0.24).toFixed(1)}" r="${(gs * 0.1).toFixed(1)}" fill="#3a2614"/></g>`;
case "barrel": return `<circle cx="${cx}" cy="${cy}" r="${(gs * 0.14).toFixed(1)}" fill="#7a5236" stroke="#3a2614" stroke-width="${(gs * 0.025).toFixed(1)}"/>`;
case "haybale": return `<rect x="${(cx - gs * 0.18).toFixed(1)}" y="${(cy - gs * 0.14).toFixed(1)}" width="${(gs * 0.36).toFixed(1)}" height="${(gs * 0.28).toFixed(1)}" rx="${(gs * 0.06).toFixed(1)}" fill="#c7aa52" stroke="#9a7a3a" stroke-width="${(gs * 0.02).toFixed(1)}"/>`;
case "bush": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.18).toFixed(1)}" fill="#3a5e2a"/><circle cx="${(cx - gs * 0.08).toFixed(1)}" cy="${(cy - gs * 0.06).toFixed(1)}" r="${(gs * 0.1).toFixed(1)}" fill="#4a7a38"/></g>`;
case "flowers": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.16).toFixed(1)}" fill="#4a7a38"/><circle cx="${(cx - gs * 0.06).toFixed(1)}" cy="${cy}" r="${(gs * 0.04).toFixed(1)}" fill="${p.color}"/><circle cx="${(cx + gs * 0.05).toFixed(1)}" cy="${(cy - gs * 0.05).toFixed(1)}" r="${(gs * 0.04).toFixed(1)}" fill="#e8d24a"/><circle cx="${(cx + gs * 0.04).toFixed(1)}" cy="${(cy + gs * 0.06).toFixed(1)}" r="${(gs * 0.04).toFixed(1)}" fill="#e85a7a"/></g>`;
default: return "";
}
}
// ============================ VILLAGE =====================================
function renderVillage(out, m, gs, W, H, rng) {
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${VILLAGE.grass}"/>`);
out.push(grassPatches(m, gs, rng));
out.push(grassTufts(m, gs, rng));
if (m.road?.length) {
const d = "M" + m.road.map((p) => `${PX(p.x, gs)},${PX(p.y, gs)}`).join(" L");
const wpx = (m.roadHalf * 2 * gs);
out.push(`<path d="${d}" fill="none" stroke="${VILLAGE.roadEdge}" stroke-width="${(wpx + gs * 0.14).toFixed(1)}" stroke-linecap="round" stroke-linejoin="round"/>`);
out.push(`<path d="${d}" fill="none" stroke="${VILLAGE.road}" stroke-width="${wpx.toFixed(1)}" stroke-linecap="round" stroke-linejoin="round"/>`);
out.push(`<path d="${d}" fill="none" stroke="${VILLAGE.cobble}" stroke-width="${wpx.toFixed(1)}" stroke-linecap="round" stroke-dasharray="${(gs * 0.18).toFixed(1)} ${(gs * 0.16).toFixed(1)}" opacity="0.3"/>`);
}
if (m.square) out.push(`<rect x="${PX(m.square.x, gs)}" y="${PX(m.square.y, gs)}" width="${PX(m.square.w, gs)}" height="${PX(m.square.h, gs)}" rx="${(gs * 0.1).toFixed(1)}" fill="${VILLAGE.square}" opacity="0.95"/>`);
if (m.water) {
const d = "M" + m.water.loop.map((p) => `${PX(p.x, gs)},${PX(p.y, gs)}`).join(" L") + "Z";
out.push(`<path d="${d}" fill="${VILLAGE.water}" stroke="${VILLAGE.waterEdge}" stroke-width="${(gs * 0.06).toFixed(1)}"/>`);
}
for (const p of m.plots) {
const x = PX(p.x, gs), y = PX(p.y, gs), w = PX(p.w, gs), h = PX(p.h, gs);
out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${VILLAGE.garden[p.crop ?? 0]}" stroke="${VILLAGE.fence}" stroke-width="${(gs * 0.03).toFixed(1)}" stroke-dasharray="${(gs * 0.12).toFixed(1)} ${(gs * 0.08).toFixed(1)}"/>`);
for (let r = 0; r < p.h * 2; r += 1) out.push(`<line x1="${x}" y1="${PX(p.y + r / 2, gs)}" x2="${(x + w).toFixed(1)}" y2="${PX(p.y + r / 2, gs)}" stroke="${VILLAGE.gardenRow}" stroke-width="${(gs * 0.015).toFixed(1)}" opacity="0.5"/>`);
}
// ground props that sit under buildings (well/fountain/stall/cart on square/grass)
for (const p of m.props) if (p.kind !== "bush" && p.kind !== "flowers") out.push(prop(p, gs, rng));
for (const b of m.buildings) villageRoof(out, b, gs);
// trees + small props above buildings
const trees = m.trees.slice().sort((a, b) => a.y - b.y);
out.push(`<g>`);
for (const t of trees) out.push(treeSVG(t.x, t.y, t.r, t.green ?? 0, gs));
out.push(`</g>`);
for (const p of m.props) if (p.kind === "bush" || p.kind === "flowers") out.push(prop(p, gs, rng));
}
function villageRoof(out, b, gs) {
const x = PX(b.x, gs), y = PX(b.y, gs), w = PX(b.w, gs), h = PX(b.h, gs), off = gs * 0.1;
out.push(`<rect x="${(x + off).toFixed(1)}" y="${(y + off).toFixed(1)}" width="${w}" height="${h}" rx="${(gs * 0.04).toFixed(1)}" fill="#000" opacity="0.3"/>`);
out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${(gs * 0.03).toFixed(1)}" fill="${b.roof}" stroke="${VILLAGE.roofTrim}" stroke-width="${(gs * 0.05).toFixed(1)}"/>`);
// ridge line + thatch/tile rows
if (b.w >= b.h) {
const my = y + h / 2;
out.push(`<line x1="${(x + gs * 0.12).toFixed(1)}" y1="${my.toFixed(1)}" x2="${(x + w - gs * 0.12).toFixed(1)}" y2="${my.toFixed(1)}" stroke="#fff" stroke-width="${(gs * 0.03).toFixed(1)}" opacity="0.18"/>`);
for (let i = 1; i < b.w; i += 1) out.push(`<line x1="${PX(b.x + i, gs)}" y1="${y}" x2="${PX(b.x + i, gs)}" y2="${(y + h).toFixed(1)}" stroke="${VILLAGE.roofTrim}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.3"/>`);
} else {
const mx = x + w / 2;
out.push(`<line x1="${mx.toFixed(1)}" y1="${(y + gs * 0.12).toFixed(1)}" x2="${mx.toFixed(1)}" y2="${(y + h - gs * 0.12).toFixed(1)}" stroke="#fff" stroke-width="${(gs * 0.03).toFixed(1)}" opacity="0.18"/>`);
for (let i = 1; i < b.h; i += 1) out.push(`<line x1="${x}" y1="${PX(b.y + i, gs)}" x2="${(x + w).toFixed(1)}" y2="${PX(b.y + i, gs)}" stroke="${VILLAGE.roofTrim}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.3"/>`);
}
if (b.chimney) out.push(`<rect x="${(x + w * 0.72).toFixed(1)}" y="${(y + h * 0.16).toFixed(1)}" width="${(gs * 0.28).toFixed(1)}" height="${(gs * 0.28).toFixed(1)}" fill="#5a4636" stroke="#2a1d12" stroke-width="${(gs * 0.02).toFixed(1)}"/>`);
}
// ============================ shared ======================================
function treeSVG(x, y, r, greenIdx, gs) {
const cx = PX(x, gs), cy = PX(y, gs), rr = r * gs, g = TREE_GREENS[greenIdx % TREE_GREENS.length];
return `<g><circle cx="${(cx + gs * 0.06).toFixed(1)}" cy="${(cy + gs * 0.08).toFixed(1)}" r="${rr.toFixed(1)}" fill="#000" opacity="0.22"/>` +
`<circle cx="${cx}" cy="${cy}" r="${rr.toFixed(1)}" fill="${g[0]}"/>` +
`<circle cx="${(cx - rr * 0.25).toFixed(1)}" cy="${(cy - rr * 0.25).toFixed(1)}" r="${(rr * 0.6).toFixed(1)}" fill="${g[1]}" opacity="0.9"/></g>`;
}
function speckle(W, H, gs, color, opacity, density, rng) {
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.015).toFixed(1)}" opacity="${opacity}">`];
const n = Math.round((W * H) / (gs * gs) / density);
for (let i = 0; i < n; i += 1) { const x = rng.float() * W, y = rng.float() * H, l = gs * (0.2 + rng.float() * 0.4), a = rng.float() * Math.PI; out.push(`<line x1="${x.toFixed(1)}" y1="${y.toFixed(1)}" x2="${(x + Math.cos(a) * l).toFixed(1)}" y2="${(y + Math.sin(a) * l).toFixed(1)}"/>`); }
return out.join("") + `</g>`;
}
function grassPatches(m, gs, 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, rx = (0.8 + rng.float() * 2) * gs; out.push(`<ellipse cx="${PX(x, gs)}" cy="${PX(y, gs)}" rx="${rx.toFixed(1)}" ry="${(rx * 0.7).toFixed(1)}" fill="${VILLAGE.grassAlt}" opacity="0.3"/>`); }
return out.join("") + `</g>`;
}
function grassTufts(m, gs, rng) {
const out = [`<g stroke="${VILLAGE.tuft}" stroke-width="${(gs * 0.02).toFixed(1)}" stroke-linecap="round" opacity="0.45">`];
const n = Math.round(m.cols * m.rows * 0.8);
for (let i = 0; i < n; i += 1) { const x = PX(rng.float() * m.cols, gs), y = PX(rng.float() * m.rows, gs), hh = gs * (0.05 + rng.float() * 0.07); out.push(`<path d="M${x},${y} l${(-hh * 0.3).toFixed(1)},${(-hh).toFixed(1)} M${x},${y} l${(hh * 0.3).toFixed(1)},${(-hh * 0.9).toFixed(1)}"/>`); }
return out.join("") + `</g>`;
}
function grid(m, gs, color) {
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.2">`];
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)}"/>`);
return out.join("") + `</g>`;
}
function esc(s) { return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[c])); }