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

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 };
}