// Heat: conduction, melting and freezing, reactions, fire and air, steel. // ---------- Heat ---------- function curAir() { return (tick & 0x7fff) + 1; } function airflowAt(i) { const a = airS[i]; return a !== 0 && ((curAir() - a) & 0x7fff) < 8; } // Air reaches a fuel grain if an open cell touches it, or sits just behind a // porous neighbor (ash or loose coal), so a fuel bed burns a couple of grains deep. const POROUS = new Uint8Array(NMAT); POROUS[ASH] = 1; POROUS[SLAG] = 1; POROUS[COAL] = 1; POROUS[CHARCOAL] = 1; const SOIL = new Uint8Array(NMAT); SOIL[DIRT] = 1; SOIL[GRASS] = 1; SOIL[LDIRT] = 1; const HOTLIQ = new Uint8Array(NMAT); for (const m of [MAGMA, MCU, MFE, MST, MGL, MAG, MAU, MBZ, MBS, MSLAG]) HOTLIQ[m] = 1; // Cold solids and powders cannot react below their first applicable threshold. const REACT_MIN = new Float64Array(NMAT).fill(Infinity); for (let m = 0; m < NMAT; m++) { if (PH[m] === PH_LIQUID || PH[m] === PH_GAS) REACT_MIN[m] = -Infinity; else { if (IGN[m] !== 0) REACT_MIN[m] = Math.min(REACT_MIN[m], IGN[m]); if (MELT[m] !== 0) REACT_MIN[m] = Math.min(REACT_MIN[m], MELT[m]); } } REACT_MIN[CLAY] = Math.min(REACT_MIN[CLAY], 880); REACT_MIN[LCLAY] = Math.min(REACT_MIN[LCLAY], 880); REACT_MIN[WOOD] = Math.min(REACT_MIN[WOOD], 380); REACT_MIN[LWOOD] = Math.min(REACT_MIN[LWOOD], 380); REACT_MIN[GRASS] = Math.min(REACT_MIN[GRASS], 260); // Alloys: a molten additive grain turns neighbouring molten copper into the alloy, // and is itself used up after about this many (the ratio: bronze ~1:7 tin, brass ~1:2 zinc). const ALLOY = { [MSN]: { into: MBZ, per: 7 }, [MZN]: { into: MBS, per: 2 } }; function alloyMix(i, x, y, m) { const a = ALLOY[m]; for (let d = 0; d < 4; d++) { const j = neighborDir(i, x, y, (d + tick) & 3); if (j < 0 || mat[j] !== MCU || rnd() > 0.25) continue; mat[j] = a.into; wakeIdx(j); discover(a.into, j % W, (j / W) | 0, m, 'alloy'); if (rnd() < 1 / a.per) { mat[i] = a.into; wakeXY(x, y); return; } } } function airy(m) { return m === EMPTY || m === FIRE; } function hasO2(i, x, y) { let m; if (x > 0) { m = mat[i - 1]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (x > 1 && airy(mat[i - 2]))))) return true; } if (x < W - 1) { m = mat[i + 1]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (x < W - 2 && airy(mat[i + 2]))))) return true; } if (y > 0) { m = mat[i - W]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (y > 1 && airy(mat[i - 2 * W]))))) return true; } if (y < H - 1) { m = mat[i + W]; if (airy(m) || (POROUS[m] && (m === ASH || m === SLAG || (y < H - 2 && airy(mat[i + 2 * W]))))) return true; } return false; } // Conducts heat between a and b. `da` = AMB(a) - AMB(b): only the excess over // the local ambient flows, so the built-in geothermal gradient stays put. function condPair(a, b, sc, nc, da) { const ma = mat[a], mb = mat[b]; const d = temp[a] - temp[b] - da; if (d < 0.05 && d > -0.05) return; const k = KC[ma] < KC[mb] ? KC[ma] : KC[mb]; const q = d * k * sc; temp[a] -= q * ICAP[ma]; temp[b] += q * ICAP[mb]; if (nc >= 0 && (q > 0.15 || q < -0.15)) { if (!hot[nc]) hotLvl[nc] = 2; hot[nc] = 6; } } function heatPass() { for (let c = 0; c < NCH; c++) { if (!hot[c]) continue; let sc = 1; if (hotLvl[c] === 1) { if (((tick + c) & 3) !== 0) continue; sc = 4; } heatChunk(c, sc); } } function heatChunk(c, sc) { const cx = c % CW, cy = (c / CW) | 0; const x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32); roofDirty[cx] = 1; const leftCold = cx > 0 && !hot[c - 1], upCold = cy > 0 && !hot[c - CW]; let maxDev = 0, burning = false; for (let y = y0; y < y1; y++) { const amb = AMB[y], yw = y * W; const dDown = y + 1 < H ? amb - AMB[y + 1] : 0, dUp = y > 0 ? AMB[y - 1] - amb : 0; for (let x = x0; x < x1; x++) { const i = yw + x; if (x + 1 < W) condPair(i, i + 1, sc, x + 1 === x1 ? c + 1 : -1, 0); if (y + 1 < H) condPair(i, i + W, sc, y + 1 === y1 ? c + CW : -1, dDown); if (x === x0 && leftCold) condPair(i - 1, i, sc, c - 1, 0); if (y === y0 && upCold) condPair(i - W, i, sc, c - CW, dUp); let m = mat[i]; const ph = PH[m]; let t = temp[i]; let rel; if (ph === PH_EMPTY || ph === PH_GAS) { if (y > 0) { const u = i - W, pu = PH[mat[u]]; const ex = (t - amb) - (temp[u] - AMB[y - 1]); if ((pu === PH_EMPTY || pu === PH_GAS) && ex > 2) { const a = ex * (sc > 1 ? 0.45 : 0.25); t -= a; temp[u] += a; if (y === y0 && upCold && a > 0.5) { hot[c - CW] = 6; hotLvl[c - CW] = 2; } } } rel = y < roofY[x] ? 0.02 : 0.002; } else rel = RELAX[m]; t += (amb - t) * rel * sc; temp[i] = t; if (m !== EMPTY) { // Test the stored Float32 temperature, just as react() reads it. if ((flags[i] & F_BURN) || !(temp[i] < REACT_MIN[m])) react(i, x, y, m); m = mat[i]; t = temp[i]; } const dv = t > amb ? t - amb : amb - t; if (dv > maxDev) maxDev = dv; if (flags[i] & F_BURN) burning = true; } } if (burning || maxDev > 1.5) { hot[c] = 6; hotLvl[c] = (burning || maxDev > 60) ? 2 : 1; } else hot[c]--; } function neighborDir(i, x, y, d) { if (d === 0) return y > 0 ? i - W : -1; if (d === 1) return x < W - 1 ? i + 1 : -1; if (d === 2) return y < H - 1 ? i + W : -1; return x > 0 ? i - 1 : -1; } function react(i, x, y, m) { const t = temp[i]; const fl = flags[i]; if (fl & F_BURN) { burn(i, x, y, m, t); return; } if (IGN[m] !== 0 && t >= IGN[m] && (hasO2(i, x, y) || burningNear(i, x, y))) { flags[i] = fl | F_BURN; life[i] = FUEL[m]; stats.ignitions++; discover(FIRE, x, y, m, 'ignite', t); return; } const ph = PH[m]; if (ph === PH_LIQUID) { if (m === WATER && t < -0.5) { // freezes: into ice on something solid, into snowflakes in open air if (rnd() < 0.02 - t * 0.004) { const below = y < H - 1 ? PH[mat[i + W]] : PH_SOLID; const to = below === PH_EMPTY || below === PH_GAS ? SNOW : ICE; mat[i] = to; life[i] = 0; flags[i] = to === ICE ? F_NAT : fl & F_NAT; wakeXY(x, y); discover(to, x, y, WATER, 'freeze', 0); } return; } if (HOTLIQ[m] && t > 600 && rnd() < 0.5) quench(i, x, y, m); if (ALLOY[m]) alloyMix(i, x, y, m); const fz = FRZ[m]; if (fz !== 0) { if (t < fz) { const L = life[i] - (fz - t) * 0.28; if (L <= 0) { const to = FRZTO[m]; mat[i] = to; life[i] = 0; temp[i] = fz - 1; // metal and glass set into castings; any other melt cools into ordinary rock you can drill, wherever it came from flags[i] = CASTMETAL[m] ? (fl & F_NAT) | F_CAST : PH[to] === PH_SOLID ? F_NAT : fl & F_NAT; wakeXY(x, y); discover(to, x, y, m, 'freeze', fz); } else { life[i] = L; temp[i] = fz; } return; } if (life[i] < 255 && t > fz + 1) { const add = Math.min(255 - life[i], (t - fz) * 0.28); life[i] += add; temp[i] = t - add / 0.28; } if (m === MFE && t >= 1530) steelCheck(i, x, y); return; } const bp = BOIL[m]; if (bp !== 0) { if (t > bp) { const L = life[i] + (t - bp) * 2.0; if (L >= 255) { mat[i] = BOILTO[m]; life[i] = 0; temp[i] = bp + 3; flags[i] = 0; wakeXY(x, y); discover(BOILTO[m], x, y, m, 'boil', bp); if (rnd() < 0.02) emit({ t: 'sfx', s: 'sizzle', x, y }); } else { life[i] = L; temp[i] = bp; } } else if (life[i] > 0) life[i] = life[i] > 2 ? life[i] - 2 : 0; } return; } if (ph === PH_GAS) { if (CONDT[m] !== 0 && t < CONDT[m] && rnd() < 0.05 && !(m === STEAM && y < roofY[x])) { mat[i] = CONDTO[m]; life[i] = 0; wakeXY(x, y); discover(CONDTO[m], x, y, m, 'condense', CONDT[m]); } return; } const mp = MELT[m]; if (mp !== 0 && t >= mp) { if (rnd() < 0.03 + (t - mp) * 0.002) { let to = MELTTO[m]; if (SMELTP[m] !== 0 && rnd() >= SMELTP[m]) to = t >= FRZ[MSLAG] ? MSLAG : SLAG; // the waste runs off molten when it is hot enough if (PARTMAT[m]) { partLost(i); if (m === HULL && mach[i]) brokenMachines.push(mach[i]); } mat[i] = to; flags[i] = 0; life[i] = PH[to] === PH_LIQUID ? 255 : 0; digp[i] = 0; wakeXY(x, y); discover(to, x, y, m, SMELTP[m] !== 0 ? 'smelt' : 'melt', mp); } return; } if (m === CLAY) { if (t >= 880 && rnd() < 0.004 + (t - 880) * 0.00012) { mat[i] = BRICK; discover(BRICK, x, y, CLAY, 'fire', 880); } } else if (m === LCLAY) { if (t >= 880 && rnd() < 0.004 + (t - 880) * 0.00012) { mat[i] = RBRICK; discover(RBRICK, x, y, LCLAY, 'fire', 880); } } else if ((m === LWOOD || m === WOOD) && t >= 380) { // baked hot with no air to burn in: wood turns into charcoal and gives off its volatiles if (rnd() < 0.006 + (t - 380) * 0.00002) char(i, x, y, m); } else if (m === GRASS) { if (t >= 260) { mat[i] = DIRT; } } } // Wood with heat but no air chars instead of burning (a kiln, or a smothered pile). function char(i, x, y, m) { const keep = rnd() < 0.6; mat[i] = keep ? CHARCOAL : EMPTY; flags[i] = 0; life[i] = 0; digp[i] = 0; wakeXY(x, y); if (keep) discover(CHARCOAL, x, y, m, 'char'); } // A very hot liquid meeting water flashes it to steam and is chilled hard; // snow and ice next to it melt at once. function quench(i, x, y, m) { for (let d = 0; d < 4; d++) { const j = neighborDir(i, x, y, d); if (j < 0) continue; const n = mat[j]; if (n === WATER) { mat[j] = STEAM; temp[j] = 110; life[j] = 0; flags[j] = 0; wakeIdx(j); wakeHeatIdx(j); temp[i] -= 40; discover(STEAM, j % W, (j / W) | 0, WATER, 'boil', 100); if (rnd() < 0.05) emit({ t: 'sfx', s: 'sizzle', x, y }); } else if (n === SNOW || n === ICE) { mat[j] = WATER; temp[j] = 20; life[j] = 0; flags[j] = 0; wakeIdx(j); wakeHeatIdx(j); temp[i] -= 15; } } } function burningNear(i, x, y) { return (x > 0 && (flags[i - 1] & F_BURN)) || (x < W - 1 && (flags[i + 1] & F_BURN)) || (y > 0 && (flags[i - W] & F_BURN)) || (y < H - 1 && (flags[i + W] & F_BURN)); } function burn(i, x, y, m, t) { const forced = airflowAt(i); const open = forced || hasO2(i, x, y); // smothered wood chars into charcoal (a covered pile, like a charcoal clamp) if (!open && (m === LWOOD || m === WOOD) && rnd() < 0.006) { char(i, x, y, m); return; } // buried fuel next to burning fuel smolders: less heat, slower if (!open && !burningNear(i, x, y)) { if (rnd() < 0.004) flags[i] &= ~F_BURN; return; } const target = forced ? FLAMEF[m] : open ? FLAME[m] : FLAME[m] * 0.95; if (t < target) temp[i] = t + (target - t) * (forced ? 0.2 : open ? 0.07 : 0.035); if (rnd() < (forced ? 0.5 : open ? 0.22 : 0.1)) { const l = life[i] - 1; if (l <= 0) { if (PARTMAT[m]) partLost(i); const to = BURNTO[m]; mat[i] = (to === ASH && rnd() < ASHP[m]) ? ASH : EMPTY; flags[i] = 0; life[i] = 0; wakeXY(x, y); if (mat[i] === ASH) discover(ASH, x, y, m, 'burn'); return; } life[i] = l; } if (open && rnd() < 0.3) { const j = neighborDir(i, x, y, (rnd() * 4) | 0); if (j >= 0 && mat[j] === EMPTY && part[j] !== P_INTAKE) { mat[j] = FIRE; temp[j] = temp[i]; life[j] = 8 + ((rnd() * 18) | 0); flags[j] = 0; wakeIdx(j); } } if (rnd() < 0.012) { const j = neighborDir(i, x, y, (rnd() * 4) | 0); if (j >= 0 && mat[j] === EMPTY) { mat[j] = SMOKE; temp[j] = temp[i] * 0.8; life[j] = 200 + ((rnd() * 55) | 0); flags[j] = 0; wakeIdx(j); } } } // Bessemer: air blown through molten iron burns off excess carbon (and gives off heat). function steelCheck(i, x, y) { if (airflowAt(i) && rnd() < 0.025) { mat[i] = MST; life[i] = 255; temp[i] += 45; discover(MST, x, y, MFE, 'blow'); } }