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>
113 lines
4.8 KiB
JavaScript
113 lines
4.8 KiB
JavaScript
/**
|
|
* 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 };
|
|
}
|