Files
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

301 lines
14 KiB
JavaScript

/**
* 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));
}