/** * Settlement generator — detailed top-down town/village and city-block maps. * * Buildings are SOLID rooftop blocks (perimeter walls block tokens); streets / * grass are the open, token-scaled play space. Generates dense building * subdivisions plus lots of scenery (cars, trees, lamps, crosswalks, market * stalls, wells, flower beds…) baked into the render. Pure / Node-testable. * * Model (fields vary by style): * { cols, rows, style, roadCells:Set, roadLines, blocks, buildings, * parks, crosswalks, cars, props, plots, trees, water, features, segments } */ import { Rng } from "./rng.mjs"; import { chaikin } from "./organic.mjs"; export const SETTLEMENT_STYLES = ["village", "city"]; // Colourful roof palettes. const CITY_ROOFS = [ "#6b7077", "#7a6a5e", "#566b7a", "#8a5b4a", "#5e7a5e", "#7a7488", "#4a6b78", "#9c7a4a", "#6a5570", "#aa6a52", "#4f7a6a", "#86563f" ]; const VILLAGE_ROOFS = [ "#9c4b34", "#b5673a", "#c79a52", "#7a5236", "#6f8a4a", "#8a8f5a", "#a85a40", "#5f7a8a", "#9a7a3a" ]; const key = (x, y) => `${x},${y}`; function rectPerimeterWalls(b) { return [ { x1: b.x, y1: b.y, x2: b.x + b.w, y2: b.y, kind: "wall" }, { x1: b.x + b.w, y1: b.y, x2: b.x + b.w, y2: b.y + b.h, kind: "wall" }, { x1: b.x + b.w, y1: b.y + b.h, x2: b.x, y2: b.y + b.h, kind: "wall" }, { x1: b.x, y1: b.y + b.h, x2: b.x, y2: b.y, kind: "wall" } ]; } export function generateSettlement(opts) { return opts.style === "city" ? generateCity(opts) : generateVillage(opts); } // --- City block ------------------------------------------------------------ function strips(total, roadW, bMin, bMax, rng) { const out = [{ type: "road", a: 0, b: roadW }]; let x = roadW; while (x < total - 2) { const bw = Math.min(rng.int(bMin, bMax), total - x); if (bw < 3) { out[out.length - 1].b = total; break; } out.push({ type: "block", a: x, b: x + bw }); x += bw; if (x >= total) break; const rw = Math.min(roadW, total - x); out.push({ type: "road", a: x, b: x + rw }); x += rw; } if (out[out.length - 1].b < total) out.push({ type: "road", a: out[out.length - 1].b, b: total }); return out; } /** Recursively split a buildable rect into many footprints with 1-cell alleys. */ function subdivide(rect, rng, depth, out) { const small = rect.w < 5 && rect.h < 5; if (depth <= 0 || small || (rect.w < 8 && rect.h < 8 && rng.chance(0.45))) { if (rect.w >= 2 && rect.h >= 2) out.push(rect); return; } const splitV = rect.w >= rect.h ? rect.w >= 5 : !(rect.h >= 5); if (splitV && rect.w >= 5) { const cut = rect.x + Math.floor(rect.w * (0.38 + rng.float() * 0.24)); subdivide({ x: rect.x, y: rect.y, w: cut - rect.x, h: rect.h }, rng, depth - 1, out); subdivide({ x: cut + 1, y: rect.y, w: rect.x + rect.w - (cut + 1), h: rect.h }, rng, depth - 1, out); } else if (rect.h >= 5) { const cut = rect.y + Math.floor(rect.h * (0.38 + rng.float() * 0.24)); subdivide({ x: rect.x, y: rect.y, w: rect.w, h: cut - rect.y }, rng, depth - 1, out); subdivide({ x: rect.x, y: cut + 1, w: rect.w, h: rect.y + rect.h - (cut + 1) }, rng, depth - 1, out); } else if (rect.w >= 2 && rect.h >= 2) out.push(rect); } function generateCity(opts) { const cols = opts.cols, rows = opts.rows; const rng = new Rng(opts.seed ?? "city"); const roadW = opts.roadWidth ?? 3; const colStrips = strips(cols, roadW, 8, 12, rng); const rowStrips = strips(rows, roadW, 8, 12, rng); const roadCells = new Set(); const isColBlock = (x) => colStrips.find((s) => x >= s.a && x < s.b)?.type === "block"; const isRowBlock = (y) => rowStrips.find((s) => y >= s.a && y < s.b)?.type === "block"; for (let y = 0; y < rows; y += 1) for (let x = 0; x < cols; x += 1) if (!(isColBlock(x) && isRowBlock(y))) roadCells.add(key(x, y)); const blocks = [], buildings = [], parks = [], segments = []; const sidewalk = 1; const buildingCells = new Set(); for (const cs of colStrips.filter((s) => s.type === "block")) { for (const rs of rowStrips.filter((s) => s.type === "block")) { const block = { x: cs.a, y: rs.a, w: cs.b - cs.a, h: rs.b - rs.a }; blocks.push(block); const inner = { x: block.x + sidewalk, y: block.y + sidewalk, w: block.w - sidewalk * 2, h: block.h - sidewalk * 2 }; if (inner.w < 2 || inner.h < 2) continue; if (rng.chance(0.16)) { parks.push(inner); continue; } // green space const parts = []; subdivide(inner, rng, 3, parts); for (const p of parts) { if (p.w < 2 || p.h < 2) continue; const b = { ...p, roof: rng.pick(CITY_ROOFS), kind: "city", roofStyle: rng.int(0, 3) }; buildings.push(b); segments.push(...rectPerimeterWalls(b)); for (let yy = p.y; yy < p.y + p.h; yy += 1) for (let xx = p.x; xx < p.x + p.w; xx += 1) buildingCells.add(key(xx, yy)); } } } // Scenery: crosswalks, cars, sidewalk props. const crosswalks = buildCrosswalks(colStrips, rowStrips); const cars = [], props = []; const sidewalkFree = (x, y) => x >= 0 && y >= 0 && x < cols && y < rows && !roadCells.has(key(x, y)) && !buildingCells.has(key(x, y)); const propIcons = ["tree", "lamp", "bench", "hydrant", "tree", "planter"]; for (const block of blocks) { // lamp posts at corners for (const [cx, cy] of [[block.x, block.y], [block.x + block.w - 1, block.y], [block.x, block.y + block.h - 1], [block.x + block.w - 1, block.y + block.h - 1]]) { if (sidewalkFree(cx, cy)) props.push({ x: cx + 0.5, y: cy + 0.5, kind: "lamp" }); } // sidewalk props + curb cars along each edge edgeWalk(block, (sx, sy, ox, oy, horizontal, i) => { if (i % 3 === 1 && sidewalkFree(sx, sy)) props.push({ x: sx + 0.5, y: sy + 0.5, kind: propIcons[(sx + sy) % propIcons.length] }); // parked car in the road lane just outside the curb const rx = sx + ox, ry = sy + oy; if (i % 4 === 2 && roadCells.has(key(rx, ry)) && !isIntersection(rx, ry, roadCells, cols, rows)) { cars.push({ x: rx + 0.5, y: ry + 0.5, horizontal, color: carColor(rng) }); } }); } // manholes / road patches for (const c of roadCells) { if (rng.chance(0.012)) { const [x, y] = c.split(",").map(Number); props.push({ x: x + 0.5, y: y + 0.5, kind: rng.chance(0.5) ? "manhole" : "patch" }); } } return { cols, rows, style: "city", roadCells, roadLines: roadCenterLines(colStrips, rowStrips), blocks, buildings, parks, crosswalks, cars, props, plots: [], trees: [], water: null, features: [], segments }; } function edgeWalk(block, fn) { let i = 0; for (let x = block.x; x < block.x + block.w; x += 1) { fn(x, block.y, 0, -1, true, i++); } for (let y = block.y; y < block.y + block.h; y += 1) { fn(block.x + block.w - 1, y, 1, 0, false, i++); } for (let x = block.x + block.w - 1; x >= block.x; x -= 1) { fn(x, block.y + block.h - 1, 0, 1, true, i++); } for (let y = block.y + block.h - 1; y >= block.y; y -= 1) { fn(block.x, y, -1, 0, false, i++); } } function isIntersection(x, y, roadCells, cols, rows) { // a road cell with road neighbours on perpendicular axes = intersection const r = (xx, yy) => roadCells.has(key(xx, yy)); return r(x - 1, y) && r(x + 1, y) && r(x, y - 1) && r(x, y + 1); } function buildCrosswalks(colStrips, rowStrips) { const out = []; const cBlocks = colStrips.filter((s) => s.type === "block"); const rBlocks = rowStrips.filter((s) => s.type === "block"); const cRoads = colStrips.filter((s) => s.type === "road"); const rRoads = rowStrips.filter((s) => s.type === "road"); // vertical crosswalks across horizontal roads at each block's x-span for (const rr of rRoads) for (const cb of cBlocks) out.push({ x: cb.a, y: rr.a, w: cb.b - cb.a, h: rr.b - rr.a, vertical: true }); for (const cr of cRoads) for (const rb of rBlocks) out.push({ x: cr.a, y: rb.a, w: cr.b - cr.a, h: rb.b - rb.a, vertical: false }); return out; } function carColor(rng) { return rng.pick(["#c9443a", "#3a6ec9", "#e0e3e8", "#2e3236", "#d8b53a", "#3aa05a", "#a0463a", "#5566cc", "#cc7a3a"]); } function roadCenterLines(colStrips, rowStrips) { const lines = []; for (const s of colStrips) if (s.type === "road" && s.b - s.a >= 2) lines.push({ vertical: true, at: (s.a + s.b) / 2 }); for (const s of rowStrips) if (s.type === "road" && s.b - s.a >= 2) lines.push({ vertical: false, at: (s.a + s.b) / 2 }); return lines; } // --- Village --------------------------------------------------------------- function generateVillage(opts) { const cols = opts.cols, rows = opts.rows; const rng = new Rng(opts.seed ?? "village"); const horizontal = rng.chance(0.5); const pts = []; for (let i = 0; i <= 5; i += 1) { const t = i / 5; if (horizontal) pts.push({ x: t * cols, y: rows * (0.5 + (rng.float() - 0.5) * 0.5) }); else pts.push({ x: cols * (0.5 + (rng.float() - 0.5) * 0.5), y: t * rows }); } const road = chaikin(pts, 3); const roadHalf = opts.roadHalf ?? 1.3; const roadCells = new Set(); const distToRoad = (cx, cy) => { let m = Infinity; for (let i = 0; i < road.length - 1; i += 1) m = Math.min(m, segDist(cx, cy, road[i], road[i + 1])); return m; }; for (let y = 0; y < rows; y += 1) for (let x = 0; x < cols; x += 1) if (distToRoad(x + 0.5, y + 0.5) <= roadHalf) roadCells.add(key(x, y)); let water = null; if (rng.chance(0.55)) { const cx = cols * (0.12 + rng.float() * 0.2), cy = rows * (0.62 + rng.float() * 0.3), r = Math.min(cols, rows) * 0.12; water = { loop: lumpy(cx, cy, r, rng), cx, cy, r }; } const inWater = (cx, cy) => water && Math.hypot(cx - water.cx, cy - water.cy) < water.r * 1.1; // Village square near road centre. const mid = road[Math.floor(road.length / 2)]; const square = { x: Math.round(mid.x - 2.5), y: Math.round(mid.y - 2.5), w: 5, h: 5 }; const buildings = []; const buildingCells = new Set(); const blocked = (x, y, w, h) => { for (let yy = y - 1; yy < y + h + 1; yy += 1) for (let xx = x - 1; xx < x + w + 1; xx += 1) { if (roadCells.has(key(xx, yy)) || inWater(xx + 0.5, yy + 0.5)) return true; if (xx >= square.x - 1 && xx < square.x + square.w + 1 && yy >= square.y - 1 && yy < square.y + square.h + 1) return true; } return buildings.some((b) => x < b.x + b.w + 1 && x + w + 1 > b.x && y < b.y + b.h + 1 && y + h + 1 > b.y); }; const want = opts.houses ?? Math.round((cols * rows) / 55); let guard = 0; while (buildings.length < want && guard++ < want * 80) { const w = rng.int(2, 4), h = rng.int(2, 4); const x = rng.int(1, cols - w - 1), y = rng.int(1, rows - h - 1); if (distToRoad(x + w / 2, y + h / 2) > 6) continue; if (blocked(x, y, w, h)) continue; const b = { x, y, w, h, roof: rng.pick(VILLAGE_ROOFS), kind: "house", chimney: rng.chance(0.6) }; buildings.push(b); for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) buildingCells.add(key(xx, yy)); } const plots = []; for (const b of buildings) { if (!rng.chance(0.4)) continue; const pw = rng.int(2, 3), ph = rng.int(2, 3), px = b.x, py = b.y + b.h + 1; if (py + ph >= rows - 1) continue; if (buildings.some((o) => px < o.x + o.w && px + pw > o.x && py < o.y + o.h && py + ph > o.y)) continue; if (roadCells.has(key(px, py))) continue; plots.push({ x: px, y: py, w: pw, h: ph, crop: rng.int(0, 2) }); } // Props: well/fountain on square, market stalls + carts near road, bushes/flowers on grass. const props = []; props.push({ x: square.x + square.w / 2, y: square.y + square.h / 2, kind: rng.chance(0.5) ? "well" : "fountain" }); const grassFree = (x, y) => x >= 0 && y >= 0 && x < cols && y < rows && !roadCells.has(key(x, y)) && !buildingCells.has(key(x, y)) && !inWater(x + 0.5, y + 0.5); // stalls/carts ringing the square let ring = 0; for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, 1]]) { const sx = square.x + (dx < 0 ? -1 : dx > 0 ? square.w : Math.floor(square.w / 2)); const sy = square.y + (dy < 0 ? -1 : dy > 0 ? square.h : Math.floor(square.h / 2)); if (grassFree(sx, sy)) { props.push({ x: sx + 0.5, y: sy + 0.5, kind: ring % 2 ? "stall" : "cart", color: stallColor(rng) }); ring += 1; } } // scattered bushes / flower beds / barrels / haybales const scatter = ["bush", "flowers", "bush", "barrel", "haybale", "bush", "lamp"]; let sg = 0, placed = 0; const wantScatter = Math.round((cols * rows) / 22); while (placed < wantScatter && sg++ < wantScatter * 30) { const x = rng.int(0, cols - 1), y = rng.int(0, rows - 1); if (!grassFree(x, y)) continue; if (props.some((p) => Math.abs(p.x - (x + 0.5)) < 1.4 && Math.abs(p.y - (y + 0.5)) < 1.4)) continue; props.push({ x: x + 0.5, y: y + 0.5, kind: scatter[(x * 3 + y) % scatter.length], color: stallColor(rng) }); placed += 1; } const trees = []; const treeCount = Math.round((cols * rows) / 32); let tg = 0; while (trees.length < treeCount && tg++ < treeCount * 30) { const x = rng.range(0.5, cols - 0.5), y = rng.range(0.5, rows - 0.5); if (!grassFree(Math.floor(x), Math.floor(y))) continue; trees.push({ x, y, r: 0.5 + rng.float() * 0.45, green: rng.int(0, 2) }); } const segments = []; for (const b of buildings) segments.push(...rectPerimeterWalls(b)); const features = []; if (water) features.push({ x: water.cx, y: water.cy, label: "Pond" }); features.push({ x: square.x + square.w / 2, y: square.y - 0.3, label: "Square" }); return { cols, rows, style: "village", roadCells, road, roadHalf, square, blocks: [], buildings, parks: [], crosswalks: [], cars: [], props, plots, trees, water, features, segments }; } function stallColor(rng) { return rng.pick(["#c0432f", "#3a78c0", "#d8a93a", "#4a9a55", "#9a4aa0", "#c87a2f"]); } function lumpy(cx, cy, baseR, rng) { const n = 14, j = 0.5, pts = []; for (let i = 0; i < n; i += 1) { const a = (i / n) * Math.PI * 2, r = baseR * (1 - j / 2 + rng.float() * j); pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }); } return chaikin(pts, 2); } function segDist(px, py, a, b) { const dx = b.x - a.x, dy = b.y - a.y, len2 = dx * dx + dy * dy || 1; let t = ((px - a.x) * dx + (py - a.y) * dy) / len2; t = Math.max(0, Math.min(1, t)); return Math.hypot(px - (a.x + t * dx), py - (a.y + t * dy)); }