/** * 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 = [``]; 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(``); for (const f of m.features) out.push(`${esc(f.label)}`); out.push(``); } out.push(``); return out.join(""); } // ============================ CITY ======================================== function renderCity(out, m, gs, W, H, rng) { out.push(``); out.push(speckle(W, H, gs, "#000", 0.07, 4, rng)); // crosswalks (under sidewalks/markings) out.push(``); for (const cw of m.crosswalks) out.push(crosswalk(cw, gs)); out.push(``); // lane markings: solid white edge + dashed yellow centre out.push(``); 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(``); out.push(``); for (const ln of m.roadLines) out.push(laneLine(ln, gs, W, H, 0)); out.push(``); // 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(``); } // sidewalk paving grid (subtle) out.push(``); for (const blk of m.blocks) for (let i = 1; i < blk.w; i += 1) out.push(``); out.push(``); // 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(``); for (const c of m.cars) out.push(car(c, gs)); out.push(``); // 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 += ``; } else { for (let i = 0; i < cw.h; i += 1) if (i % 2 === 0) s += ``; } return s; } function laneLine(ln, gs, W, H, off) { if (ln.vertical) { const x = PX(ln.at + off, gs); return ``; } const y = PX(ln.at + off, gs); return ``; } 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(``); out.push(``); // inner trim out.push(``); 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(``); } else if (b.roofStyle === 2) { // water tank out.push(``); } 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(``); } } } 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 `` + `` + `` + `` + ``; } 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 = ``; // diagonal path s += ``; s += ``; 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 ``; case "bench": return ``; case "hydrant": return ``; case "planter": return ``; case "manhole": return ``; case "patch": return ``; // village props case "well": return ``; case "fountain": return ``; case "stall": return ``; case "cart": return ``; case "barrel": return ``; case "haybale": return ``; case "bush": return ``; case "flowers": return ``; default: return ""; } } // ============================ VILLAGE ===================================== function renderVillage(out, m, gs, W, H, rng) { out.push(``); 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(``); out.push(``); out.push(``); } if (m.square) out.push(``); if (m.water) { const d = "M" + m.water.loop.map((p) => `${PX(p.x, gs)},${PX(p.y, gs)}`).join(" L") + "Z"; out.push(``); } 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(``); for (let r = 0; r < p.h * 2; r += 1) out.push(``); } // 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(``); for (const t of trees) out.push(treeSVG(t.x, t.y, t.r, t.green ?? 0, gs)); out.push(``); 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(``); out.push(``); // ridge line + thatch/tile rows if (b.w >= b.h) { const my = y + h / 2; out.push(``); for (let i = 1; i < b.w; i += 1) out.push(``); } else { const mx = x + w / 2; out.push(``); for (let i = 1; i < b.h; i += 1) out.push(``); } if (b.chimney) out.push(``); } // ============================ 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 `` + `` + ``; } function speckle(W, H, gs, color, opacity, density, rng) { const out = [``]; 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(``); } return out.join("") + ``; } function grassPatches(m, gs, rng) { const out = [``]; 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(``); } return out.join("") + ``; } function grassTufts(m, gs, rng) { const out = [``]; 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(``); } return out.join("") + ``; } function grid(m, gs, color) { const out = [``]; for (let x = 0; x <= m.cols; x += 1) out.push(``); for (let y = 0; y <= m.rows; y += 1) out.push(``); return out.join("") + ``; } function esc(s) { return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c])); }