/** * Outdoor biome generator. * * Open terrain with organic features: a water body (shoreline blocks movement, * not sight), rock outcrops (full blockers), and scattered/clustered trees * (decoration; large ones optionally block sight). Pure / Node-testable. * * Coordinates in map CELLS. Organic blobs are lumpy polygons smoothed with the * shared Chaikin routine, so shorelines and rocks have no straight edges. */ import { Rng } from "./rng.mjs"; import { chaikin } from "./organic.mjs"; export const BIOMES = ["grassland", "desert", "snow", "badlands"]; /** A smooth lumpy blob polygon centred at (cx,cy). Returns map-cell points. */ function lumpyPolygon(cx, cy, baseR, rng, opts = {}) { const n = opts.points ?? 12; const jitter = opts.jitter ?? 0.42; const squash = opts.squash ?? (0.75 + rng.float() * 0.5); const rot = rng.float() * Math.PI * 2; const pts = []; for (let i = 0; i < n; i += 1) { const a = rot + (i / n) * Math.PI * 2; const r = baseR * (1 - jitter / 2 + rng.float() * jitter); pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * squash }); } return chaikin(pts, opts.smooth ?? 2); } function polySegments(points, kind) { const segs = []; for (let i = 0; i < points.length; i += 1) { const a = points[i], b = points[(i + 1) % points.length]; segs.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y, kind }); } return segs; } function poissonScatter(rng, cols, rows, count, minDist, reject) { const pts = []; let guard = 0; while (pts.length < count && guard++ < count * 40) { const p = { x: rng.range(1, cols - 1), y: rng.range(1, rows - 1) }; if (reject && reject(p)) continue; if (pts.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < minDist)) continue; pts.push(p); } return pts; } /** * @param {object} opts cols, rows, seed, biome, water(bool), rockCount, treeDensity, deepWater(bool) */ export function generateOutdoor(opts) { const cols = opts.cols, rows = opts.rows; const rng = new Rng(opts.seed ?? "field"); const biome = BIOMES.includes(opts.biome) ? opts.biome : "grassland"; const segments = []; const features = []; // --- Water body (optional) ------------------------------------------------ let water = null; if (opts.water !== false && rng.float() < (opts.waterChance ?? 0.85)) { const edge = rng.pick(["left", "right", "top", "bottom", "corner"]); let cx = cols * 0.5, cy = rows * 0.5; const r = Math.min(cols, rows) * (0.22 + rng.float() * 0.12); if (edge === "left") { cx = cols * 0.18; cy = rows * (0.3 + rng.float() * 0.4); } else if (edge === "right") { cx = cols * 0.82; cy = rows * (0.3 + rng.float() * 0.4); } else if (edge === "top") { cx = cols * (0.3 + rng.float() * 0.4); cy = rows * 0.18; } else if (edge === "bottom") { cx = cols * (0.3 + rng.float() * 0.4); cy = rows * 0.82; } else { cx = cols * 0.8; cy = rows * 0.8; } const loop = lumpyPolygon(cx, cy, r, rng, { points: 16, jitter: 0.5, smooth: 3 }); water = { loop, cx, cy, r }; if (opts.deepWater !== false) segments.push(...polySegments(loop, "water")); features.push({ x: cx, y: cy, label: biome === "desert" ? "Oasis" : "Water" }); } const inWater = (p) => water && Math.hypot(p.x - water.cx, p.y - water.cy) < water.r * 1.1; // --- Rock outcrops -------------------------------------------------------- const rocks = []; const rockCount = opts.rockCount ?? (2 + Math.floor(rng.float() * 3)); const rockCenters = poissonScatter(rng, cols, rows, rockCount, Math.min(cols, rows) * 0.28, inWater); for (const c of rockCenters) { const r = Math.min(cols, rows) * (0.08 + rng.float() * 0.07); const loop = lumpyPolygon(c.x, c.y, r, rng, { points: 10, jitter: 0.55, smooth: 2 }); rocks.push({ loop, cx: c.x, cy: c.y, r }); segments.push(...polySegments(loop, "wall")); } const nearRock = (p) => rocks.some((rk) => Math.hypot(p.x - rk.cx, p.y - rk.cy) < rk.r + 1); // --- Trees (clustered) ---------------------------------------------------- const trees = []; const density = opts.treeDensity ?? (biome === "desert" || biome === "snow" ? 0.4 : 1); const clusterCount = Math.round((cols * rows) / 120 * density); const clusters = poissonScatter(rng, cols, rows, clusterCount, Math.min(cols, rows) * 0.16, (p) => inWater(p) || nearRock(p)); for (const cl of clusters) { const n = 2 + Math.floor(rng.float() * 5); for (let i = 0; i < n; i += 1) { const x = cl.x + (rng.float() - 0.5) * 2.4; const y = cl.y + (rng.float() - 0.5) * 2.4; if (x < 0.5 || y < 0.5 || x > cols - 0.5 || y > rows - 0.5) continue; if (inWater({ x, y }) || nearRock({ x, y })) continue; trees.push({ x, y, r: 0.45 + rng.float() * 0.4, shade: rng.float() }); } } return { cols, rows, biome, water, rocks, trees, segments, features }; }