The game as of artifact version 24, ready for local development with coding agents. - Source split from three large files into 52 small ones under src/sim, src/render and src/ui, joined in the order in src/manifest.json by build.py. The joined code is unchanged apart from comments; every headless suite gives the same output, and screenshots in both looks are pixel-identical to before. - Light tidying with no change in behaviour: one movement scan loop, shared hazard-stripe and belt shader helpers, unused code removed, a shared check helper for the test suites. - World files can be exported as an ordinary download outside claude.ai. - Tests: broken old scripts removed, experiments moved to tools/, test/run_all.py added. Tools: shot.py (repeatable screenshots), where.py, map.py. - Docs: AGENTS.md, CLAUDE.md, NOTES.md (the owner's to-do inbox), docs/PROJECT_MAP.md, DESIGN.md, TESTING.md, systems notes, the owner's requests and an agent log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9kLmYADKSEjuwUN3rFxDA
177 lines
7.7 KiB
JavaScript
177 lines
7.7 KiB
JavaScript
// Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids
|
|
// and gases (simMove).
|
|
// ---------- Cell helpers ----------
|
|
function setCell(i, m, t, fl) {
|
|
const old = mat[i];
|
|
if (PARTMAT[old] && old !== m) partLost(i);
|
|
mat[i] = m; temp[i] = t; flags[i] = fl || 0; life[i] = (PH[m] === PH_LIQUID && FRZ[m]) ? 255 : 0; digp[i] = 0;
|
|
const x = i % W, y = (i / W) | 0;
|
|
wakeXY(x, y);
|
|
// far off the local air temperature, or something that cannot last in it (snow in warm air,
|
|
// water in freezing air): let the heat pass see it
|
|
if (t - AMB[y] > 20 || AMB[y] - t > 20 || (MELT[m] !== 0 && AMB[y] >= MELT[m]) || (m === WATER && AMB[y] < -0.5)) wakeHeatXY(x, y);
|
|
}
|
|
function swapCells(i, j) {
|
|
let a = mat[i]; mat[i] = mat[j]; mat[j] = a;
|
|
const f = temp[i]; temp[i] = temp[j]; temp[j] = f;
|
|
a = life[i]; life[i] = life[j]; life[j] = a;
|
|
a = flags[i]; flags[i] = flags[j]; flags[j] = a;
|
|
stamp[i] = tickStamp; stamp[j] = tickStamp;
|
|
}
|
|
function move(i, j, x, y, nx, ny) {
|
|
swapCells(i, j);
|
|
wakeXY(x, y); wakeXY(nx, ny);
|
|
if (part[j] === P_WHEEL) { const mm = machines[mach[j]]; if (mm) mm.flow += PH[mat[j]] === PH_LIQUID ? 1 : 0.5; }
|
|
const d = temp[j] - AMB[ny];
|
|
if (d > 25 || d < -25) wakeHeatXY(nx, ny);
|
|
}
|
|
|
|
// Can a grain of material m (phase ph) enter cell j moving by (dx,dy)?
|
|
// 0 = no, 1 = into empty, 2 = by displacing what is there.
|
|
function enter(m, ph, j, dx, dy) {
|
|
if (occ[j]) return 0;
|
|
const p = part[j];
|
|
if (p !== 0) {
|
|
if (p <= P_TR) { if (ph === PH_POWDER && TDX[p] === -dx && TDY[p] === -dy) return 0; } // liquids and gases flow through freely
|
|
else if (p === P_GRATE) { if (ph === PH_POWDER || m === MSLAG) return 0; } // thick slag does not run through a grate
|
|
else if (p === P_INTAKE) { if (ph !== PH_GAS) return 0; }
|
|
else if (p === P_FILTER) { if (ph === PH_POWDER && !filterPassAt(m, j)) return 0; }
|
|
}
|
|
const t = mat[j];
|
|
if (t === EMPTY) return 1;
|
|
const tp = PH[t];
|
|
if (tp === PH_SOLID) return 0;
|
|
if (stamp[j] === tickStamp && tp !== PH_GAS) return 0;
|
|
if (ph === PH_GAS) {
|
|
if (dy < 0 && (tp === PH_LIQUID || (tp === PH_GAS && DENS[t] > DENS[m]))) return 2;
|
|
return 0;
|
|
}
|
|
if (tp === PH_GAS) return 2;
|
|
if (dy > 0 && DENS[m] > DENS[t] && (tp === PH_LIQUID || ph === PH_LIQUID)) return rnd() < (DENS[m] > DENS[t] * 2 ? 0.55 : 0.3) ? 2 : 0;
|
|
return 0;
|
|
}
|
|
|
|
// ---------- Movement ----------
|
|
// Heavy grains settle through a loose pile of much lighter ones, slowly. A sorting
|
|
// step keeps its area awake for a while so a pile finishes sorting even at rest.
|
|
let SORTP = 0.003;
|
|
function powder(i, x, y, m) {
|
|
if (y >= H - 1) return;
|
|
const b = i + W;
|
|
if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
|
|
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel
|
|
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
|
|
const t = mat[b];
|
|
if (PH[t] === PH_POWDER && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < SORTP) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
|
|
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
|
|
if (t === BELTL || t === BELTR || part[b] === P_FILTER) return;
|
|
if (rnd() < STICK[m]) {
|
|
const d = (RS & 2) ? 1 : -1;
|
|
let nx = x + d;
|
|
if (nx >= 0 && nx < W && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
|
nx = x - d;
|
|
if (nx >= 0 && nx < W && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
|
}
|
|
}
|
|
function passable(m, ph, j, dx, dy) {
|
|
// pure check (no randomness): cell is empty or gas and part rules allow
|
|
if (occ[j]) return false;
|
|
const p = part[j];
|
|
if (p !== 0) {
|
|
if (p <= P_TR) { if (ph === PH_POWDER && TDX[p] === -dx && TDY[p] === -dy) return false; }
|
|
else if (p === P_GRATE) { if (ph === PH_POWDER || m === MSLAG) return false; }
|
|
else if (p === P_INTAKE) { if (ph !== PH_GAS) return false; }
|
|
else if (p === P_FILTER) { if (ph === PH_POWDER && !filterPassAt(m, j)) return false; }
|
|
}
|
|
const t = mat[j];
|
|
return t === EMPTY || PH[t] === PH_GAS;
|
|
}
|
|
function liquid(i, x, y, m) {
|
|
if (y < H - 1) {
|
|
const b = i + W;
|
|
if (enter(m, PH_LIQUID, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
|
|
const d = (RS & 2) ? 1 : -1;
|
|
let nx = x + d;
|
|
if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
|
|
nx = x - d;
|
|
if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
|
|
}
|
|
if (rnd() > FLUID[m]) return;
|
|
const sp = SPREAD[m];
|
|
const d0 = (RS & 4) ? 1 : -1;
|
|
for (let pass = 0; pass < 2; pass++) {
|
|
const d = pass ? -d0 : d0;
|
|
for (let s = 1; s <= sp; s++) {
|
|
const nx = x + d * s;
|
|
if (nx < 0 || nx >= W) break;
|
|
const j = i + d * s;
|
|
if (!passable(m, PH_LIQUID, j, d, 0)) break;
|
|
if (y < H - 1 && passable(m, PH_LIQUID, j + W, 0, 1)) {
|
|
// step one cell toward the drop (keeps flow continuous and local)
|
|
move(i, i + d, x, y, x + d, y);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function gas(i, x, y, m) {
|
|
if (m === FIRE) {
|
|
const l = life[i];
|
|
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
|
life[i] = l - 1;
|
|
// flames lick at trees behind them
|
|
if (rnd() < 0.25) { const j = neighborDir(i, x, y, (rnd() * 4) | 0); if (j >= 0 && bgm[j] && mat[j] === EMPTY && temp[i] > IGN[bgm[j]] && rnd() < 0.3) igniteTree(j); }
|
|
} else if (m === SMOKE) {
|
|
if (rnd() < (y < roofY[x] ? 0.5 : 0.05)) {
|
|
const l = life[i];
|
|
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
|
|
life[i] = l - 1;
|
|
}
|
|
}
|
|
if (y === 0 || (m === STEAM && y <= weather.skyY + 4 && y < roofY[x])) {
|
|
// steam that has risen to cloud height in the open sky joins the clouds
|
|
if (m === STEAM) absorbSteam(x);
|
|
mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return;
|
|
}
|
|
const u = i - W;
|
|
if (enter(m, PH_GAS, u, 0, -1)) { move(i, u, x, y, x, y - 1); return; }
|
|
const d = (RS & 2) ? 1 : -1;
|
|
let nx = x + d;
|
|
if (nx >= 0 && nx < W && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; }
|
|
nx = x - d;
|
|
if (nx >= 0 && nx < W && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; }
|
|
if (rnd() < 0.6) {
|
|
nx = x + d;
|
|
if (nx >= 0 && nx < W && mat[i + d] === EMPTY && passable(m, PH_GAS, i + d, d, 0)) { move(i, i + d, x, y, nx, y); }
|
|
}
|
|
}
|
|
function simMove() {
|
|
for (let cy = CH - 1; cy >= 0; cy--) {
|
|
const rowBase = cy * CW;
|
|
let any = false;
|
|
for (let cx = 0; cx < CW; cx++) if (awake[rowBase + cx]) { any = true; break; }
|
|
if (!any) continue;
|
|
const y0 = cy << 5, y1 = Math.min(H, y0 + 32);
|
|
for (let y = y1 - 1; y >= y0; y--) {
|
|
const ltr = ((y + tick) & 1) === 0;
|
|
const yw = y * W;
|
|
for (let k = 0; k < CW; k++) {
|
|
const cx = ltr ? k : CW - 1 - k;
|
|
if (!awake[rowBase + cx]) continue;
|
|
const x0 = cx << 5, x1 = Math.min(W, x0 + 32);
|
|
// alternate rows scan left to right and right to left, so nothing drifts one way
|
|
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
|
|
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
|
|
const i = yw + x, m = mat[i];
|
|
if (m === EMPTY || stamp[i] === tickStamp) continue;
|
|
const ph = PH[m];
|
|
if (ph === PH_SOLID) continue;
|
|
const p = part[i];
|
|
if (p >= P_TU && p <= P_TR && ph === PH_POWDER) continue; // a screw tube moves its own grains
|
|
if (ph === PH_POWDER) powder(i, x, y, m); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|