Files
deepgrain-foundry/src/sim/worldgen.js
T
44r0n7andClaude Opus 5.5 daeb3b2238 Rebuild lake generation: bowls in valleys filled to their spill level (t96)
Lakes no longer pour water to a guessed lip and repair it afterwards. Each lake lowers the land
in the nearest valley with a smoothstep bowl, and water fills it to its spill level (the lower of
the highest ground on each side, Priority-Flood in one dimension), so it is flat and contained by
construction. Clay beds, no caves or loose pockets close under lakes, trees kept off the shore.
Removes the levelling pass, the lower-rim lip and the bank-slope cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cj2RPXdPykKMEz5BXXczsj
2026-10-29 20:55:52 -04:00

310 lines
18 KiB
JavaScript

// World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
// ---------- World generation ----------
function* worldSteps(seedIn, w, h) {
yield [0, 'Shaping the land'];
worldSeed = seedIn >>> 0;
alloc(w, h);
RS = (worldSeed ^ 0x9E3779B9) | 1;
const rng = mulberry32(worldSeed);
const nSurf = makeNoise(rng), nDetail = makeNoise(rng), nLayer = makeNoise(rng), nPocket = makeNoise(rng), nPocket2 = makeNoise(rng);
const nCoal = makeNoise(rng), nCu = makeNoise(rng), nFe = makeNoise(rng), nCave = makeNoise(rng), nCave2 = makeNoise(rng), nCave3 = makeNoise(rng), nWater = makeNoise(rng);
const rng2 = mulberry32(worldSeed ^ 0x5bd1e995); // separate stream so older worlds keep their layout
const nSn = makeNoise(rng2), nZn = makeNoise(rng2), nAg = makeNoise(rng2), nAu = makeNoise(rng2);
const rng3 = mulberry32(worldSeed ^ 0x27d4eb2d); // the shapes of the deep rock layers
const nLay2 = makeNoise(rng3), nWarp = makeNoise(rng3), nIntr = makeNoise(rng3), nDike = makeNoise(rng3), nMagC = makeNoise(rng3);
const spawnX = w >> 1;
// Layout: a third of the height is sky. The shallow layers keep a fixed depth (see shallowD),
// the deep ones stretch to fill the rest, and the air cools gently enough to build tall.
surfBase = Math.round(h * 0.3); underD = h - surfBase; shallowD = Math.min(140, Math.round(underD * 0.3)); lapse = 0.22;
const base = surfBase;
const U = depthY; // y at a fraction of the underground depth
for (let x = 0; x < w; x++) {
let s = base + fbm(nSurf, x / 230, 0.3, 4) * 46 + nDetail(x / 38, 4.2) * 5;
const ds = Math.abs(x - spawnX);
if (ds < 60) { const k = 1 - ds / 60; s = s * (1 - k * 0.75) + base * k * 0.75; }
surfY[x] = Math.round(s);
dirtY[x] = surfY[x] + Math.round(14 + nLayer(x / 60, 1.5) * 7);
// the deep layers rise and sink in long swells as well as short wiggles, more in deeper worlds
// (a shared fold moves them together, and each also has its own swells)
const fold = fbm(nLay2, x / 520, 3.3, 3);
const gA = underD * 0.16, bA = underD * 0.12, mA = underD * 0.06;
graniteY[x] = U(0.5) + Math.round(fold * gA * 1.8 + fbm(nLay2, x / 170, 9.1, 3) * gA + nLayer(x / 60, 9.1) * 10);
basaltY[x] = U(0.72) + Math.round(fold * bA * 1.6 + fbm(nLay2, x / 150, 17.3, 3) * bA + nLayer(x / 50, 17.3) * 8);
magmaY[x] = U(0.905) + Math.round(fold * mA * 1.4 + fbm(nLay2, x / 110, 23.7, 3) * mA + nLayer(x / 40, 23.7) * 5);
// and stay in order, with room between them
graniteY[x] = Math.max(graniteY[x], dirtY[x] + 60);
basaltY[x] = Math.max(basaltY[x], graniteY[x] + 40);
magmaY[x] = Math.min(h - 12, Math.max(magmaY[x], basaltY[x] + 30));
}
// Landmarks: a lake on one side of spawn and a desert on the other, then mountains, lakes,
// deserts and hills spaced out toward both edges. The first one past the lake is always a
// mountain tall enough to reach the freezing air and hold snow.
const lakeSide = rng() < 0.5 ? -1 : 1;
const feats = [{ k: 'lake', x: Math.round(spawnX + lakeSide * (170 + rng() * 80)) }, { k: 'desert', x: Math.round(spawnX - lakeSide * (250 + rng() * 90)) }];
for (const side of [lakeSide, -lakeSide]) {
let d = 600 + rng() * 90, prev = side === lakeSide ? '' : 'desert';
for (;;) {
const x = Math.round(spawnX + side * d);
if (x < 140 || x > w - 140) break;
// never the same kind twice in a row
let k = 'mountain';
if (prev) { const ks = ['lake', 'desert', 'mountain', 'hills'].filter(q => q !== prev), wt = { lake: 3, desert: 2.5, mountain: 2.5, hills: 2 }; let r = rng() * ks.reduce((a, q) => a + wt[q], 0); k = ks.find(q => (r -= wt[q]) < 0) || ks[0]; }
feats.push({ k, x });
prev = k;
d += 340 + rng() * 260;
}
}
if (!feats.some(f => f.k === 'mountain')) feats.push({ k: 'mountain', x: lakeSide > 0 ? w - 160 : 160 });
const maxMt = Math.max(60, Math.min(surfBase - 80, 200));
for (const f of feats) {
if (f.k !== 'mountain' && f.k !== 'hills') continue;
const mt = f.k === 'mountain', sig = mt ? 55 + rng() * 30 : 45 + rng() * 35, hgt = mt ? maxMt * (0.8 + rng() * 0.2) : 35 + rng() * 35;
for (let x = Math.max(0, Math.round(f.x - sig * 3.2)); x < Math.min(w, f.x + sig * 3.2); x++) {
const g = Math.exp(-0.5 * ((x - f.x) / sig) ** 2);
if (g < 0.01) continue;
surfY[x] = Math.round(surfY[x] - hgt * g - nDetail(x / 9, 5.7) * 5 * g);
if (mt) dirtY[x] = surfY[x] + Math.round(3 + (1 - g) * 11);
else dirtY[x] = Math.min(dirtY[x], surfY[x] + 20);
}
}
// Lakes: lower the land in the nearest valley with a smooth bump (no slope at its edges, so the
// banks keep the land's own shape), then fill the basin to its spill level. Water is flat and
// contained by construction; lakes keep 80 grains clear of spawn and the dock.
const lakeTop = new Int16Array(w).fill(h), noTree = new Uint8Array(w), lakeBed = new Uint8Array(w), desAt = new Uint8Array(w);
for (const f of feats) {
if (f.k !== 'lake') continue;
const lwDraw = Math.round(90 + rng() * 40), deep = 22 + rng() * 8;
// water gathers in valleys: take the lowest smoothed ground within 60 grains of the plan
let c = f.x, low = -1;
for (let x = f.x - 60; x <= f.x + 60; x++) {
if (x - lwDraw < 0 || x + lwDraw >= w || Math.abs(x - spawnX) - 80 < lwDraw * 0.85) continue;
let s = 0; for (let k = -15; k <= 15; k++) s += surfY[x + k];
if (s > low) { low = s; c = x; }
}
const lw = Math.min(lwDraw, Math.abs(c - spawnX) - 80), x0 = Math.max(0, c - lw), x1 = Math.min(w - 1, c + lw);
const nat = surfY.slice(x0, x1 + 1);
// on sloping ground the low rim spills early, so deepen the bump until the lake holds water
for (let scale = 1; scale <= 3; scale += 0.25) {
for (let x = x0; x <= x1; x++) {
// a bowl: flat floor in the middle 40%, banks easing in and out along a smoothstep
const t = Math.min(1, Math.max(0, (1 - Math.abs(x - c) / lw) / 0.6)), b = t * t * (3 - 2 * t);
surfY[x] = nat[x - x0] + Math.round(deep * scale * b * (1 + 0.15 * nDetail(x / 25, 6.6)));
}
if (spillLevels(x0, x1, lakeTop) >= 12) break;
}
let wet0 = w, wet1 = -1;
for (let x = x0; x <= x1; x++) {
dirtY[x] = Math.max(dirtY[x], surfY[x] + 8);
if (lakeTop[x] < surfY[x]) { wet0 = Math.min(wet0, x); wet1 = Math.max(wet1, x); } else lakeTop[x] = h;
}
for (let x = Math.max(0, wet0 - 6); x <= Math.min(w - 1, wet1 + 6); x++) noTree[x] = 1;
for (let x = Math.max(0, wet0 - 12); x <= Math.min(w - 1, wet1 + 12); x++) lakeBed[x] = 1;
}
for (const f of feats) {
if (f.k !== 'desert') continue;
const dw = Math.round(90 + rng() * 40);
for (let x = Math.max(0, f.x - dw); x <= Math.min(w - 1, f.x + dw); x++) if (Math.abs(x - f.x) < dw - Math.abs(nDetail(x / 20, 3.3)) * 10) desAt[x] = 1;
}
computeAmbient();
yield [0, 'Laying rock and ore'];
for (let y = 0; y < h; y++) {
const fy = depthU(y); // fraction of the underground depth (negative in the sky)
for (let x = 0; x < w; x++) {
const i = y * w + x;
const sy = surfY[x];
let m = EMPTY;
if (y < sy) {
if (y >= lakeTop[x]) m = WATER;
} else {
const d = y - sy;
const inDes = desAt[x] === 1, bed = lakeBed[x] === 1;
// a lake bottom is clay; the grass stops at the waterline
if (y < dirtY[x]) m = lakeTop[x] < sy && d < 3 ? CLAY : d === 0 && !inDes ? GRASS : DIRT;
else if (y < graniteY[x] + nWarp(x / 70, y / 45) * 26 + nWarp(x / 22 + 40, y / 15) * 8 + nPocket(x / 9, y / 9) * 3) m = STONE;
else if (y < basaltY[x] + nWarp(x / 64 + 50, y / 40) * 22 + nWarp(x / 20 + 90, y / 14) * 7 + nPocket(x / 7, y / 7) * 3) m = GRANITE;
else m = BASALT;
// granite bulges up into the stone in rounded masses, and stone is caught in the granite in lumps
if (m === STONE && y > graniteY[x] - 100 && nIntr(x / 46, y / 34) > 0.24 + (graniteY[x] - y) / 100 * 0.3) m = GRANITE;
else if (m === GRANITE && y < basaltY[x] - 20 && nIntr(x / 38 + 70, y / 30) < -0.36) m = STONE;
void nDike; // (kept so the noises after it stay the same)
if (inDes && d < 12 + nDetail(x / 15, 8.1) * 4) m = SAND;
// pockets
const pk = nPocket(x / 14, y / 11), pk2 = nPocket2(x / 18, y / 13);
if (m === DIRT && d > 2 && pk2 > 0.42) m = CLAY;
else if (m === STONE) {
if (d < 150 && pk > 0.52) m = DIRT;
else if (d < 110 && pk2 > 0.5) m = CLAY;
// no loose pockets close under a lake: one falling into a cave would open a hole in its bed
else if (pk < -0.56 && (!bed || d > 40)) m = GRAVEL;
else if (pk2 < -0.6 && d < 220 && (!bed || d > 40)) m = SAND;
}
// ores
if (m === STONE || m === DIRT || m === GRANITE || m === BASALT || m === CLAY) {
const host = m;
// Leave the shallow quarter unchanged; deeper rock has broader, richer lenses.
const rich = Math.min(1, Math.max(0, (fy - 0.25) / 0.6));
const c = nCoal(x / 13, y / 9);
if ((d > 6 && fy < 0.45 && c > 0.5 - 0.55 * rich && m !== CLAY) ||
(fy >= 0.45 && (m === STONE || m === GRANITE) && c > 0.62 - 0.3 * rich) ||
(m === BASALT && fy > 0.6 && c > 0.58 - 0.4 * rich)) m = COALV;
else {
const cu = nCu(x / 9, y / 8);
const cuBand = d > 14 && fy < 0.48;
if ((cuBand && (m === STONE || m === DIRT) && cu > (d < 140 ? 0.5 : 0.56) - 0.65 * rich) ||
(fy > 0.48 && fy < 0.8 && (m === STONE || m === GRANITE) && cu > 0.6 - 0.46 * rich)) m = CUV;
else {
// iron and zinc are harder than the stone around them, so they come in fewer, chunkier
// lenses rather than a scatter of specks that a stone bit would leave in the way
const fe = nFe(x / 24, y / 13);
if (fy > 0.28 && (m === STONE || m === GRANITE || m === BASALT) && fe > (m === STONE ? 0.55 : 0.47) - 0.55 * rich) m = FEV;
// rarer ores in deeper bands: tin, then zinc, silver in granite, gold deepest
else if (((fy > 0.15 && fy < 0.55 && m === STONE) ||
(fy >= 0.55 && fy < 0.8 && (m === STONE || m === GRANITE))) &&
nSn(x / 8, y / 7) > 0.62 + Math.max(0, fy - 0.55) * 0.4 - 0.425 * rich) m = SNV;
else if (fy > 0.35 && fy < 0.78 && (m === STONE || m === GRANITE) && nZn(x / 22, y / 13) > 0.6 - 0.49 * rich) m = ZNV;
else if (fy > 0.5 && fy < 0.88 && m === GRANITE && nAg(x / 7, y / 6) > 0.66 - 0.32 * rich) m = AGV;
else if (fy > 0.68 && (m === GRANITE || m === BASALT) && nAu(x / 6, y / 5) > 0.7 - 0.18 * rich) m = AUV;
}
}
// no ore where the rock is naturally hot enough to light or melt it: it would burn or run forever
if (m !== host && (IGN[m] ? AMB[y] > IGN[m] - 120 : MELT[m] && AMB[y] > MELT[m] - 200)) m = host;
}
// caves (kept deeper under lakes so none opens into one)
if (d > (bed ? 40 : 9)) {
const t1 = Math.abs(fbm(nCave, x / 130, y / 80, 2));
const big = fy > 0.2 ? fbm(nCave2, x / 95, y / 64, 3) : -1;
const small = nCave3(x / 26, y / 20);
const depthK = Math.min(1, (d - 9) / 50);
if (t1 < 0.06 * depthK || big > 0.34 || small > 0.6 - (fy > 0.35 ? 0.05 : 0)) m = EMPTY;
}
const my = magmaY[x] + nWarp(x / 40 + 120, y / 20) * 12;
if (y >= my) m = (nPocket(x / 12, y / 6) > 0.45 && y < my + 6) ? BASALT : MAGMA;
// pockets of magma sit in the basalt just above the magma sea
else if (m === BASALT && y > my - 50 && nMagC(x / 40, y / 22) > 0.3 + (my - y) / 50 * 0.3) m = MAGMA;
if (y >= h - 2) m = BASALT;
// aquifers fill the low parts of shallow caves
if (m === EMPTY && d > 20 && fy < 0.46) {
const wl = nWater(x / 140, 0.5);
if (wl > 0.15 && nWater(x / 40, y / 40) > -0.1 && ((y + Math.floor(wl * 40)) % 23) > 13) m = WATER;
}
}
mat[i] = m;
if (m !== EMPTY && m !== WATER && m !== MAGMA) flags[i] = F_NAT;
else if (m === WATER || m === MAGMA) flags[i] = F_NAT;
temp[i] = m === MAGMA ? Math.max(AMB[y], 1180) : AMB[y];
if (PH[m] === PH_LIQUID && FRZ[m]) life[i] = 255;
}
if ((y & 63) === 0) yield [y / h * 0.7, 'Laying rock and ore'];
}
yield [0.7, 'Carving caves'];
clearHardSpecks(false);
yield [0.71, 'Snow and trees'];
// (tree bases are collected after the trees below)
// snow lies on the high ground wherever the air is below freezing
for (let x = 1; x < w - 1; x++) {
const sy = surfY[x];
if (sy < 3 || AMB[sy - 1] > 0) continue;
const i0 = sy * w + x;
if (mat[i0] === GRASS) mat[i0] = DIRT;
const depth = Math.abs(surfY[x + 1] - surfY[x - 1]) <= 3 ? 3 : 1;
for (let k = 1; k <= depth; k++) { const i = (sy - k) * w + x; if (mat[i] === EMPTY) { mat[i] = SNOW; flags[i] = F_NAT; temp[i] = AMB[sy - k]; } }
}
yield [0.72, 'Snow and trees'];
// trees
for (let x = 12; x < w - 12;) {
const sy = surfY[x];
const ok = mat[sy * w + x] === GRASS && mat[sy * w + x + 1] === GRASS && !noTree[x] && Math.abs(x - spawnX) > 6 && !(x > spawnX + 14 && x < spawnX + 72); // none where the dock goes
if (ok && rng() < 0.7) {
const th = 9 + Math.floor(rng() * 8);
const setW = (xx, yy) => { if (xx < 0 || yy < 1 || xx >= w) return; const i = yy * w + xx; if (mat[i] === EMPTY) bgm[i] = WOOD; };
for (let k = 1; k <= th; k++) for (let dx = 0; dx < 2; dx++) setW(x + dx, sy - k);
// two branches
for (const side of [-1, 1]) { const by = sy - th + 3 + Math.floor(rng() * 3); for (let k = 1; k <= 3; k++) setW(x + (side < 0 ? -k : 1 + k), by - (k >> 1)); }
// lumpy canopy made of a few overlapping blobs
const blobs = [[0.5, -th - 1, 6 + rng() * 2.5], [-3.5, -th + 1, 4 + rng() * 2], [4.5, -th + 1, 4 + rng() * 2], [0.5, -th - 5, 4 + rng() * 2]];
for (const [bx, by, r] of blobs) {
const cx = x + bx, cy = sy + by;
for (let yy = Math.floor(cy - r); yy <= cy + r; yy++) for (let xx = Math.floor(cx - r); xx <= cx + r; xx++) {
if (xx < 0 || yy < 1 || xx >= w) continue;
const ex = (xx - cx) / r, ey = (yy - cy) / (r * 0.85);
if (ex * ex + ey * ey <= 1 && hash2(xx, yy, worldSeed + 7) % 9 !== 0) { const i = yy * w + xx; if (mat[i] === EMPTY && !bgm[i]) bgm[i] = LEAVES; }
}
}
x += 16 + Math.floor(rng() * 20);
} else x += 4;
}
yield [0.73, 'Springs and water'];
// springs: in cave side walls just above the cave floor
{
const cands = [];
for (let y = 0; y < h - 4; y++) {
if (depthU(y) > 0.45) break;
for (let x = 2; x < w - 2; x++) {
const i = y * w + x;
if (!(flags[i] & F_NAT) || PH[mat[i]] !== PH_SOLID || mat[i] === LEAVES || mat[i] === WOOD) continue;
if (y - surfY[x] < 16) continue;
for (const dx of [-1, 1]) {
const e = i + dx;
if (mat[e] !== EMPTY) continue;
// floor 2-4 cells below the open cell, and solid above the spring
if (PH[mat[e + w]] === PH_EMPTY && PH[mat[e + 2 * w]] !== PH_EMPTY && PH[mat[i - w]] === PH_SOLID && PH[mat[i + w]] === PH_SOLID) cands.push(i);
}
}
}
const picks = [];
const near = cands.slice().sort((a, b) => Math.abs(a % w - spawnX) - Math.abs(b % w - spawnX));
for (const c of near) { if (picks.length >= 2) break; if (picks.every(p => Math.abs(p % w - c % w) > 30)) picks.push(c); }
for (let k = 0; k < 400 && picks.length < 10 && cands.length; k++) {
const c = cands[Math.floor(rng() * cands.length)];
if (picks.every(p => Math.abs(p % w - c % w) > 50 || Math.abs(((p / w) | 0) - ((c / w) | 0)) > 40)) picks.push(c);
}
// Springs were taken out of the game (rain refills the world instead). The spots are still
// drawn above so every seed keeps making the same world it always did.
void picks;
}
fillNoise();
yield [0.75, 'Settling loose ground'];
// settle loose material at the water and sorting speeds the seeds were made with
awake.fill(3);
const lstep = LSTEP, lfilm = LFILM, sortp = SORTP, cornerSeal = CORNER_SEAL, powderFan = POWDER_FAN; LSTEP = LFILM = 1; SORTP = 0.003; CORNER_SEAL = POWDER_FAN = false;
for (let k = 0; k < 260; k++) {
tick++; tickStamp = (tickStamp % 255) + 1;
simMove();
let any = 0; for (let c = 0; c < NCH; c++) if (awake[c]) { awake[c]--; any++; }
if (!any) break;
if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground'];
}
LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal; POWDER_FAN = powderFan;
placeDock(spawnX + 34);
yield [0.99, 'Finishing'];
// re-ambient after settling, then start clean
for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; }
tick = 0;
findTreeBases();
hot.fill(0); awake.fill(0); updateRoof();
// Wake generated water so it responds from the first tick.
for (let i = 0; i < N; i++) if (mat[i] === WATER) wakeIdx(i);
resetEconomy(); resetWeather();
weather.base = worldWater();
bot.x = spawnX - 2; bot.y = surfY[spawnX] - 14; bot.vx = 0; bot.vy = 0;
while (botOverlapSolid(bot.x, bot.y) && bot.y > 2) bot.y -= 1;
bag = new Int32Array(NMAT); bagTotal = 0;
player.bitTier = 0; player.tankTier = 0; player.lens = false; player.flask = false;
seen = new Uint8Array(NMAT); ledger = {};
stats.dug = stats.moved = stats.placed = stats.ignitions = stats.cast = stats.bars = 0;
EV.length = 0;
fogInit(); fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
yield [1, 'Finishing'];
}
function generateWorld(seedIn, w, h, onProgress) {
for (const [f, label] of worldSteps(seedIn, w, h)) if (onProgress) onProgress(f, label);
}
async function generateWorldAsync(seedIn, w, h, onProgress) {
let last = Date.now();
for (const [f, label] of worldSteps(seedIn, w, h)) {
if (onProgress) onProgress(f, label);
if (Date.now() - last >= 30) { await new Promise(r => setTimeout(r, 0)); last = Date.now(); }
}
}