- One helper each for wall and mold refunds, tube edges and lanes, pipe text, piece dimensions, font lookup, reach circles and surface averaging. - previews, smooth, notebook, materials, worldgen and selection split into topic files (preview-drill, preview-selection, smooth-shaders, notebook-market, notebook-blueprints, material-tables, worldgen-helpers, selection-pickers, selection-panels). - Every suite's output identical apart from timings; seeds make the same worlds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
287 lines
16 KiB
JavaScript
287 lines
16 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: 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 at the water and sorting speeds the seeds were made with
|
|
awake.fill(3);
|
|
const lstep = LSTEP, lfilm = LFILM, sortp = SORTP; LSTEP = LFILM = 1; SORTP = 0.003;
|
|
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;
|
|
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(); }
|
|
}
|
|
}
|