Files
deepgrain-foundry/src/sim/worldgen.js
T
44r0n7andClaude Opus 5.5 ea1dd387b1 Water pours off ledges several times faster, raining clouds spread boiled-off water (t21)
- Liquids step up to 8 cells a tick toward a drop (fewer the thicker they are), and a top-layer
  grain looks 4 times as far for one, so pools empty and thin film drains. A 100-wide pool
  empties in about 50 s; the old code left 24-650 grains stranded on wide ledges.
- Only a top-layer grain stepping sideways wakes the row ahead, so settled lakes still sleep.
- World generation settles at the old speed: seeds make the same worlds as before.
- Cooled steam under a roof no longer stays steam for ever (its heat area slept).
- Raining clouds keep drifting at half pace and rain over a band either side, so a waterfall into
  a hot shaft no longer rains straight back on its own lake.
- New test/t_water.js; t_weather rain-location checks; t_conveyors counts steam in flight;
  t_molds seeds the random burning-bed check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:44:06 -04:00

347 lines
20 KiB
JavaScript

// World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks.
// ---------- Perlin noise ----------
function makeNoise(rng) {
const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256);
const p = []; for (let i = 0; i < 256; i++) p.push(i);
for (let i = 255; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); const t = p[i]; p[i] = p[j]; p[j] = t; }
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
for (let i = 0; i < 256; i++) { const a = rng() * Math.PI * 2; gx[i] = Math.cos(a); gy[i] = Math.sin(a); }
return function (x, y) {
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const X = xi & 255, Y = yi & 255;
const p0 = perm[Y], p1 = perm[Y + 1];
const h00 = perm[X + p0], h10 = perm[X + 1 + p0], h01 = perm[X + p1], h11 = perm[X + 1 + p1];
const n00 = gx[h00] * xf + gy[h00] * yf, n10 = gx[h10] * (xf - 1) + gy[h10] * yf;
const n01 = gx[h01] * xf + gy[h01] * (yf - 1), n11 = gx[h11] * (xf - 1) + gy[h11] * (yf - 1);
const u = xf * xf * xf * (xf * (xf * 6 - 15) + 10), v = yf * yf * yf * (yf * (yf * 6 - 15) + 10);
return (n00 + u * (n10 - n00) + v * (n01 - n00) + u * v * (n00 - n10 - n01 + n11)) * 1.41;
};
}
function fbm(n, x, y, oct) { let s = 0, a = 1, f = 1, t = 0; for (let o = 0; o < oct; o++) { s += n(x * f, y * f) * a; t += a; a *= 0.5; f *= 2; } return s / t; }
// Small specks of rock harder than the rock around them (a few grains of iron ore in stone, a
// chip of granite, a sliver of basalt) are not worth having: a bit too weak to break them leaves
// them hanging in the way. Any such piece of SPECK_MIN grains or fewer becomes the softer rock
// around it. Bigger veins and pockets stay. live: the world is in play (an older save).
const SPECK_MIN = 30;
function clearHardSpecks(live) {
const seen = new Uint8Array(N), st = new Int32Array(N), cells = new Int32Array(SPECK_MIN + 1);
let n = 0;
for (const h0 of [6, 5, 4]) {
seen.fill(0);
const mem = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= h0 && HARD[m] <= 6 && YLD[m] !== 0 && !PARTMAT[m]; };
const soft = (i) => { const m = mat[i]; return (flags[i] & F_NAT) !== 0 && PH[m] === PH_SOLID && HARD[m] >= 1 && HARD[m] < h0 && YLD[m] !== 0 && !PARTMAT[m]; };
for (let i0 = 0; i0 < N; i0++) {
if (seen[i0] || !mem(i0)) continue;
let sp = 0, sz = 0, x0 = W, y0 = H, x1 = 0, y1 = 0;
const hosts = new Map();
st[sp++] = i0; seen[i0] = 1;
while (sp) {
const i = st[--sp], x = i % W, y = (i / W) | 0;
if (sz <= SPECK_MIN) cells[sz] = i;
sz++;
if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
for (const j of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) {
if (j < 0 || j >= N || seen[j]) continue;
if (mem(j)) { seen[j] = 1; st[sp++] = j; }
else if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1);
}
}
if (sz > SPECK_MIN) continue;
// a speck left floating (in a cave, or dug round) takes the rock nearest round it
if (!hosts.size) for (let y = Math.max(0, y0 - 5); y <= Math.min(H - 1, y1 + 5); y++) for (let x = Math.max(0, x0 - 5); x <= Math.min(W - 1, x1 + 5); x++) { const j = y * W + x; if (soft(j)) hosts.set(mat[j], (hosts.get(mat[j]) || 0) + 1); }
let host = 0, best = 0;
for (const [m, c] of hosts) if (c > best) { best = c; host = m; }
if (!host) continue;
for (let k = 0; k < sz; k++) { const i = cells[k]; if (live) setCell(i, host, temp[i], F_NAT); else mat[i] = host; }
n++;
}
}
return n;
}
// ---------- 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: a basin dug below the lowest point of its rim, filled to just above it
const lakeTop = new Int16Array(w).fill(h), noTree = new Uint8Array(w), desAt = new Uint8Array(w);
for (const f of feats) {
if (f.k !== 'lake') continue;
const lw = Math.round(90 + rng() * 40), deep = 22 + rng() * 8;
let lip = 1e9; for (let x = f.x - lw; x <= f.x + lw; x++) if (x >= 0 && x < w) lip = Math.min(lip, surfY[x]);
for (let x = f.x - lw; x <= f.x + lw; x++) {
if (x < 0 || x >= w) continue;
const u = (x - f.x) / lw, depth = Math.max(0, 1 - u * u) * deep;
if (depth > 0) { surfY[x] = Math.max(surfY[x], Math.round(lip + depth)); dirtY[x] = Math.max(dirtY[x], surfY[x] + 8); }
lakeTop[x] = lip + 2;
}
for (let x = Math.max(0, f.x - lw - 6); x <= Math.min(w - 1, f.x + lw + 6); x++) noTree[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;
if (y < dirtY[x]) m = 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;
else if (pk < -0.56) m = GRAVEL;
else if (pk2 < -0.6 && d < 220) 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
if (d > 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, with water at the speed it had when the seeds were made
awake.fill(3);
const lstep = LSTEP, lfilm = LFILM; LSTEP = LFILM = 1;
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;
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();
placeDock(spawnX + 34);
hot.fill(0); awake.fill(0); updateRoof();
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(); }
}
}