Deepgrain Foundry: move from a claude.ai artifact to a local repository

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
This commit is contained in:
2026-10-23 16:05:31 -04:00
co-authored by Claude Opus 5.5
commit 3f971a94c2
100 changed files with 11928 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
// Player actions on built things: take back, place a machine or mold, gates, F to interact.
// Take back whatever piece is at (x,y): machine, mold or block. Returns a message.
function removePieceAt(x, y) {
droppedLast = 0;
const msg = removePieceAt0(x, y);
return msg && droppedLast ? msg.replace('taken back.', 'taken back. Your bag is full, so the rest dropped where it stood.') : msg;
}
function removePieceAt0(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return '';
const i = y * W + x;
if (ghostBlocks.has(((y / BS) | 0) * BW + ((x / BS) | 0))) { ghostBlocks.delete(((y / BS) | 0) * BW + ((x / BS) | 0)); return 'Ghost removed.'; }
const gbx = (x / BS) | 0, gby = (y / BS) | 0;
for (let k = ghostMachines.length - 1; k >= 0; k--) { const g = ghostMachines[k], P = PIECE_BY_ID[g.type]; if (gbx >= g.bx && gby >= g.by && gbx < g.bx + P.bw && gby < g.by + P.bh) { ghostMachines.splice(k, 1); return 'Ghost removed.'; } }
for (let k = ghostMolds.length - 1; k >= 0; k--) { const g = ghostMolds[k], md = MOLDS[g.kind]; if (gbx >= g.bx && gby >= g.by && gbx < g.bx + md.bw && gby < g.by + md.bh) { ghostMolds.splice(k, 1); return 'Ghost removed.'; } }
if (mach[i] && machines[mach[i]]) {
const P = PIECE_BY_ID[machines[mach[i]].type];
if (P.fixed) return '';
if (siloFull(machines[mach[i]])) return `Empty the silo first: it holds ${machines[mach[i]].sn.toLocaleString('en-US')} ${MD[machines[mach[i]].sm].name.toLowerCase()}. Press F on it to open its gate.`;
removeMachine(mach[i], true, false); return `${P.name} taken back.`;
}
const mo = moldAt(x, y);
if (mo) { removeMold(mo); return `${MOLDS[mo.kind].name} taken back.`; }
const { bx, by } = blockOf(x, y), b = by * BW + bx;
if (blockP[b]) { const nm = PIECE_BY_CODE[blockP[b]].name; removeBlock(bx, by); return `${nm} taken back.`; }
return '';
}
function placeMachineAt(pid, dir, bx, by) {
const P = PIECE_BY_ID[pid];
const err = canPlaceMachine(pid, bx, by); if (err) return err;
if (!canAfford(P.cost, 1)) return needText(P.cost, 1);
payCost(P.cost, 1);
addMachine(pid, bx, by, dir);
stats.placed++;
undoNote({ t: 'mach+', type: pid, bx, by });
return '';
}
function placeMoldAt(kind, bx, by, wallLoose) {
const err = canPlaceMold(kind, bx, by); if (err) return err;
const r = addMold(kind, bx, by, wallLoose, false);
if (typeof r !== 'string') undoNote({ t: 'mold+', kind, bx, by });
return typeof r === 'string' ? r : '';
}
function toggleGateAt(x, y) {
const i0 = y * W + x;
if (!(mat[i0] === GATE || part[i0] === P_GOPEN)) return false;
const open = mat[i0] === GATE; // currently closed -> open
const seenS = new Set([i0]), st = [i0], cells = [];
while (st.length && cells.length < 1600) {
const i = st.pop(); cells.push(i);
const cx = i % W;
for (const j of [cx > 0 ? i - 1 : -1, cx < W - 1 ? i + 1 : -1, i - W, i + W]) {
if (j < 0 || j >= N || seenS.has(j)) continue;
if (mat[j] === GATE || part[j] === P_GOPEN) { seenS.add(j); st.push(j); }
}
}
for (const i of cells) {
const cx = i % W, cy = (i / W) | 0;
if (open) { if (mat[i] === GATE) { mat[i] = EMPTY; flags[i] = 0; part[i] = P_GOPEN; } }
else if (part[i] === P_GOPEN) {
const m = mat[i];
if (m !== EMPTY && PH[m] !== PH_GAS) {
const u = i - W;
if (u >= 0 && mat[u] === EMPTY && !part[u]) { mat[u] = m; temp[u] = temp[i]; life[u] = life[i]; flags[u] = flags[i]; }
}
part[i] = 0; mat[i] = GATE; flags[i] = 0; life[i] = 0;
}
wakeXY(cx, cy);
}
return open ? 'open' : 'closed';
}
function interactAt(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return '';
const g = toggleGateAt(x, y);
if (g) { emit({ t: 'sfx', s: 'gate' }); return g === 'open' ? 'Gate opened.' : 'Gate closed.'; }
const mo = moldAt(x, y);
if (mo) { const r = collectMold(mo); if (!r.startsWith('New') && !r.startsWith('Knocked')) emit({ t: 'sfx', s: 'ui' }); return r; }
const pb = ((y / BS) | 0) * BW + ((x / BS) | 0);
if (blockP[pb] === BP_PUMP || blockP[pb] === BP_OUTLET) {
blockV[pb] ^= 4; emit({ t: 'sfx', s: 'ui' });
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
}
const id = mach[y * W + x];
if (id && machines[id] && machines[id].type === 'silo') { const mm = machines[id]; mm.open = !mm.open; return mm.open ? (mm.sn ? 'Silo gate open.' : 'Silo gate open. It is empty.') : 'Silo gate shut.'; }
if (id && machines[id] && machines[id].type === 'fan') { machines[id].on = !machines[id].on; powerDirty = true; return machines[id].on ? 'Fan on.' : 'Fan off.'; }
return '';
}
+24
View File
@@ -0,0 +1,24 @@
'use strict';
/* Deepgrain Foundry: simulation core (src/sim/). No DOM anywhere in src/sim/: plain data and
functions, so the headless tests can run it and the layout can be ported to Rust later.
This file: the random number generators and the phase ids every material has. */
// ---------- RNG ----------
let RS = 0x2545F491 | 0;
function rnd() { RS ^= RS << 13; RS ^= RS >>> 17; RS ^= RS << 5; return (RS >>> 0) * 2.3283064365386963e-10; }
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function hash2(x, y, s) {
let h = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(s, 1442695041);
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0);
}
// ---------- Phases ----------
const PH_EMPTY = 0, PH_SOLID = 1, PH_POWDER = 2, PH_LIQUID = 3, PH_GAS = 4, PH_ITEM = 5; // items (bars) never sit in the grid
+132
View File
@@ -0,0 +1,132 @@
// Building blocks: placing, integrity, removing, lines and rectangles of blocks.
// ---------- Blocks ----------
function blockOf(x, y) { return { bx: Math.floor(x / BS), by: Math.floor(y / BS) }; }
function blockCellList(bx, by) { const out = []; for (let y = by * BS; y < by * BS + BS; y++) for (let x = bx * BS; x < bx * BS + BS; x++) out.push(y * W + x); return out; }
function pieceCost(code, v) { return code === BP_WALL ? wallCost(v, BS * BS) : PIECE_BY_CODE[code].cost; }
function canPlaceBlock(bx, by) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) return 'Out of bounds';
if (blockP[by * BW + bx]) return 'Something is already built there';
for (const i of blockCellList(bx, by)) {
if (mach[i] || part[i]) return 'Something is already built there';
if (PH[mat[i]] === PH_SOLID) return 'Needs open space. Clear the rock first.';
if (occ[i]) return 'A bar is in the way';
}
return '';
}
// v: wall material (loose id) for walls, direction for belts and tubes
function placeBlock(code, v, bx, by, free, extra) {
const err = canPlaceBlock(bx, by); if (err) return err;
if (code === BP_WALL && !WALL_OF[v]) return 'Pick a wall material with R';
const cost = pieceCost(code, v);
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
const cells = blockCellList(bx, by);
if (code !== BP_PIPE) evictCells(cells, by * BS); // a pipe is thin: what is there stays and flows past it
for (const i of cells) {
const y = (i / W) | 0, x = i % W;
if (code === BP_WALL) setCell(i, WALL_OF[v], AMB[y], 0);
else if (code === BP_BELT) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); }
else if (code === BP_GATE) setCell(i, GATE, AMB[y], 0);
else if (code === BP_GRATE) { part[i] = P_GRATE; wakeXY(x, y); }
else if (code === BP_TUBE) { part[i] = [P_TU, P_TR, P_TD, P_TL][v & 3]; markPow(i, true); wakeXY(x, y); }
else if (code === BP_FILTER) { part[i] = P_FILTER; wakeXY(x, y); }
else if (code === BP_PIPE) { part[i] = P_PIPE; wakeXY(x, y); }
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
}
if (isPipeCode(code)) pipesDirty = true;
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
stats.placed++;
undoNote({ t: 'block+', bx, by, code });
return '';
}
// How many of a block's cells still look like the piece the registry says is there
function blockIntegrity(b) {
const code = blockP[b], v = blockV[b];
if (!code) return 0;
const bx = b % BW, by = (b / BW) | 0;
let n = 0;
for (const i of blockCellList(bx, by)) {
const m = mat[i], p = part[i];
if (code === BP_WALL) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
else if (code === BP_BELT) { if (m === BELTL || m === BELTR) n++; }
else if (code === BP_SHAFT) { if (m === SHAFT) n++; }
else if (code === BP_GATE) { if (m === GATE || p === P_GOPEN) n++; }
else if (code === BP_GRATE) { if (p === P_GRATE) n++; }
else if (code === BP_TUBE) { if (p >= P_TU && p <= P_TR) n++; }
else if (code === BP_FILTER) { if (p === P_FILTER) n++; }
else if (isPipeCode(code)) { if (m === PIPE || p === P_PIPE) n++; }
}
void v;
return n;
}
function removeBlock(bx, by) {
const b = by * BW + bx, code = blockP[b], v = blockV[b];
if (!code) return 0;
undoNote({ t: 'block-', bx, by, code, v, extra: code === BP_FILTER ? filterInfo(b) : undefined });
// a pipe taken out spills its share of what the line holds
const spill = isPipeCode(code) ? pipeTakeShare(b) : null;
let n = 0, metal = 0;
for (const i of blockCellList(bx, by)) {
const m = mat[i], p = part[i];
if (code === BP_WALL) {
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) {
if (BAR_OF[m] && PH[v] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); }
else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; } // bag full: it crumbles in place
else setCell(i, EMPTY, temp[i], 0);
n++;
}
} else if (isPipeCode(code)) {
if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; }
else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; }
} else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE) {
if (m === BELTL || m === BELTR || m === SHAFT || m === GATE) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; }
if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); }
} else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; }
}
if (code === BP_WALL && metal) giveOrDrop(BAR_OF[WALL_OF[v]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), bx * BS + 2, by * BS + 2);
else if (code !== BP_WALL && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); }
blockP[b] = 0; blockV[b] = 0;
if (isPipeCode(code)) {
pipesDirty = true;
if (spill) { let k = spill.n; for (let y = by * BS + BS - 1; y >= by * BS && k > 0; y--) for (let x = bx * BS; x < bx * BS + BS && k > 0; x++) { const i = y * W + x; if (mat[i] === EMPTY) { setCell(i, spill.m, spill.T, 0); k--; } } }
}
return n;
}
// Straight line of blocks along the dominant axis
function blockLine(bx0, by0, bx1, by1) {
const out = [];
if (Math.abs(bx1 - bx0) >= Math.abs(by1 - by0)) { const s = bx1 >= bx0 ? 1 : -1; for (let x = bx0; ; x += s) { out.push([x, by0]); if (x === bx1) break; } }
else { const s = by1 >= by0 ? 1 : -1; for (let y = by0; ; y += s) { out.push([bx0, y]); if (y === by1) break; } }
return out;
}
function blockRect(bx0, by0, bx1, by1) {
const out = [];
for (let y = Math.min(by0, by1); y <= Math.max(by0, by1); y++) for (let x = Math.min(bx0, bx1); x <= Math.max(bx0, bx1); x++) out.push([x, y]);
return out;
}
function blockInReach(bx, by) { return inReach(bx * BS + BS / 2, by * BS + BS / 2); }
// Is block b already exactly this piece (same kind, variant and, for a filter, settings)?
function blockIs(b, code, v, extra) {
if (blockP[b] !== code) return false;
if (code === BP_FILTER) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
return blockV[b] === v;
}
// replace: a block already built in the way is taken down (and refunded) to make room
function placeBlocks(code, v, list, extra, replace) {
let n = 0, last = '';
for (const [bx, by] of list) {
if (!blockInReach(bx, by)) { last = 'Out of reach'; continue; }
const b = by * BW + bx;
if (replace && blockP[b]) {
if (blockIs(b, code, v, extra)) continue;
if (blockP[b] === BP_FILTER && code === BP_FILTER) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = v & 128; setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
if (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; }
removeBlock(bx, by);
}
const r = placeBlock(code, v, bx, by, false, extra);
if (r) { last = r; if (r.startsWith('Not enough') || r.startsWith('Pick')) break; } else n++;
}
return { n, status: n ? '' : last };
}
+105
View File
@@ -0,0 +1,105 @@
// Blueprints: copy, cut, flip, cost and paste (as ghosts) a region of built pieces.
// ---------- Blueprints ----------
// A blueprint is a list of pieces in block coordinates, so it always pastes on the grid.
function copyRegion(x0, y0, x1, y1) {
const bx0 = Math.max(0, Math.floor(Math.min(x0, x1) / BS)), bx1 = Math.min(BW - 1, Math.floor(Math.max(x0, x1) / BS));
const by0 = Math.max(0, Math.floor(Math.min(y0, y1) / BS)), by1 = Math.min(BH - 1, Math.floor(Math.max(y0, y1) / BS));
const blocks = [], ms = [], ols = [];
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx;
if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(blockP[b] === BP_FILTER ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]);
}
for (const mm of machines) {
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
const mbx = mm.x / BS, mby = mm.y / BS, P = PIECE_BY_ID[mm.type];
if (mbx >= bx0 && mby >= by0 && mbx + P.bw - 1 <= bx1 && mby + P.bh - 1 <= by1) ms.push([mm.type, mbx - bx0, mby - by0, mm.dir]);
}
for (const mo of molds) {
const mbx = mo.x / BS, mby = mo.y / BS, md = MOLDS[mo.kind];
if (mbx >= bx0 && mby >= by0 && mbx + md.bw - 1 <= bx1 && mby + md.bh - 1 <= by1) ols.push([mo.kind, mbx - bx0, mby - by0, mo.wall]);
}
if (!blocks.length && !ms.length && !ols.length) return null;
return { v: 2, bw: bx1 - bx0 + 1, bh: by1 - by0 + 1, blocks, machines: ms, molds: ols };
}
function flipBlueprint(bp) {
const bw = bp.bw;
const flipV = (code, v) => code === BP_BELT ? 1 - v : code === BP_FILTER ? v ^ 128 : code === BP_TUBE ? (v === 1 ? 3 : v === 3 ? 1 : v)
: code === BP_PUMP || code === BP_OUTLET ? (((v & 3) === 1 ? 3 : (v & 3) === 3 ? 1 : v & 3) | (v & 4)) : v;
return {
v: 2, bw, bh: bp.bh, name: bp.name,
blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, c, flipV(c, v), e] : [bw - 1 - x, y, c, flipV(c, v)]),
machines: bp.machines.map(([t, x, y, d]) => {
const P = PIECE_BY_ID[t];
let nd = d;
if (t === 'fan') nd = d === 0 ? 2 : d === 2 ? 0 : d;
if (t === 'engine') nd = d ? 0 : 1;
return [t, bw - P.bw - x, y, nd];
}),
molds: bp.molds.map(([k, x, y, wl]) => [k, bw - MOLDS[k].bw - x, y, wl]),
};
}
function bpCost(bp) {
const c = new Map();
const add = (m, n) => c.set(m, (c.get(m) || 0) + n);
for (const [, , code, v] of bp.blocks) for (const [m, n] of pieceCost(code, v)) add(m, n);
for (const [t] of bp.machines) for (const [m, n] of PIECE_BY_ID[t].cost) add(m, n);
for (const [k, , , wl] of bp.molds) for (const [m, n] of wallCost(wl, MOLDS[k].walls)) add(m, n);
return [...c.entries()];
}
function pasteGhost(bp, obx, oby) {
let n = 0;
const rec = { t: 'paste', blocks: [], machines: [], molds: [] };
for (const [dx, dy, code, v, e] of bp.blocks) {
const bx = obx + dx, by = oby + dy;
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
ghostBlocks.set(by * BW + bx, e ? [code, v, e] : [code, v]); n++;
rec.blocks.push([bx, by, code]);
}
for (const [t, dx, dy, d] of bp.machines) { const g = { type: t, bx: obx + dx, by: oby + dy, dir: d }; ghostMachines.push(g); rec.machines.push(g); }
for (const [k, dx, dy, wl] of bp.molds) { const g = { kind: k, bx: obx + dx, by: oby + dy, wall: wl }; ghostMolds.push(g); rec.molds.push(g); }
undoNote(rec);
return n;
}
// Cut: copy what you built in the box, then take it all down (refunded to the bag)
function cutRegion(x0, y0, x1, y1) {
const bp = copyRegion(x0, y0, x1, y1);
if (!bp) return null;
const bx0 = Math.max(0, Math.floor(Math.min(x0, x1) / BS)), by0 = Math.max(0, Math.floor(Math.min(y0, y1) / BS));
droppedLast = 0;
for (const mm of machines.slice()) {
if (!mm || PIECE_BY_ID[mm.type].fixed || siloFull(mm)) continue; // a full silo stays where it is
if (bp.machines.some(([t, dx, dy]) => t === mm.type && bx0 + dx === mm.x / BS && by0 + dy === mm.y / BS)) removeMachine(mm.id, true, false);
}
for (const mo of molds.slice()) if (bp.molds.some(([k, dx, dy]) => k === mo.kind && bx0 + dx === mo.x / BS && by0 + dy === mo.y / BS)) removeMold(mo);
for (const [dx, dy] of bp.blocks) removeBlock(bx0 + dx, by0 + dy);
return bp;
}
// Replace: make room for a machine or mold by taking down what you built in its way. Returns ''
// when the space is clear now, or why it cannot be (rock, a bar, something fixed).
function footprintBlocker(bx, by, bw, bh) {
for (let y = by * BS; y < (by + bh) * BS; y++) for (let x = bx * BS; x < (bx + bw) * BS; x++) {
if (x < 0 || y < 0 || x >= W || y >= H) return 'Out of bounds';
const i = y * W + x;
if (PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT)) return 'Needs open space. Clear the rock first.';
if (occ[i]) return 'A bar is in the way';
if (mach[i] && machines[mach[i]] && PIECE_BY_ID[machines[mach[i]].type].fixed) return 'That cannot be replaced';
if (mach[i] && siloFull(machines[mach[i]])) return 'Empty the silo first';
}
return '';
}
function clearFootprint(bx, by, bw, bh) {
const why = footprintBlocker(bx, by, bw, bh);
if (why) return why;
droppedLast = 0;
for (const mm of machines.slice()) {
if (!mm) continue;
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
if (mbx < bx + bw && mbx + P.bw > bx && mby < by + bh && mby + P.bh > by) removeMachine(mm.id, true, false);
}
for (const mo of molds.slice()) {
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
if (mbx < bx + bw && mbx + md.bw > bx && mby < by + bh && mby + md.bh > by) removeMold(mo);
}
for (let y = by; y < by + bh; y++) for (let x = bx; x < bx + bw; x++) if (blockP[y * BW + x]) removeBlock(x, y);
return '';
}
+55
View File
@@ -0,0 +1,55 @@
// The bot: movement and collision with the world.
// ---------- Bot ----------
// The bot flies through anything you built (walls, machines, molds, belts); only natural terrain stops it.
function botBlocked(i) { return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) !== 0; }
function botOverlapSolid(x, y) {
const x0 = Math.floor(x), y0 = Math.floor(y), x1 = Math.ceil(x + bot.w) - 1, y1 = Math.ceil(y + bot.h) - 1;
if (x0 < 0 || y0 < 0 || x1 >= W || y1 >= H) return true;
for (let cy = y0; cy <= y1; cy++) for (let cx = x0; cx <= x1; cx++) if (botBlocked(cy * W + cx)) return true;
return false;
}
// Flying straight into a lip of rock a grain or two deep at the edge of the bot, it slips
// sideways round it instead of stopping dead, so it threads tunnels and holes it nearly fits.
const SLIDE_MAX = 2.5, SLIDE_STEP = 0.34;
function botSlide(axis, step) {
let best = 0;
for (let k = 1; k <= SLIDE_MAX * 4; k++) {
for (const sg of [-1, 1]) {
const o = sg * k * 0.25;
const fx = axis ? o : step, fy = axis ? step : o;
// the spot just past the lip is clear, and nothing blocks the slide toward it
if (botOverlapSolid(bot.x + fx, bot.y + fy)) continue;
if (botOverlapSolid(bot.x + (axis ? o : 0), bot.y + (axis ? 0 : o))) continue;
best = o; break;
}
if (best) break;
}
if (!best) return false;
const d = Math.sign(best) * Math.min(SLIDE_STEP, Math.abs(best));
if (axis) { if (botOverlapSolid(bot.x + d, bot.y)) return false; bot.x += d; }
else { if (botOverlapSolid(bot.x, bot.y + d)) return false; bot.y += d; }
return true;
}
function botStep(ix, iy, boost) {
const acc = 0.075 * (boost ? 1.8 : 1);
bot.vx += ix * acc; bot.vy += iy * acc;
// no extra drag in sand or water: the bot flies through loose material at full speed
bot.vx *= 0.885; bot.vy *= 0.885;
if (ix) bot.face = ix > 0 ? 1 : -1;
bot.thrust = Math.min(1, Math.hypot(ix, iy));
if (botOverlapSolid(bot.x, bot.y)) {
// unstick: search upward then sideways for a free spot
for (let k = 1; k < 40; k++) {
if (!botOverlapSolid(bot.x, bot.y - k)) { bot.y -= k; break; }
if (!botOverlapSolid(bot.x - k, bot.y)) { bot.x -= k; break; }
if (!botOverlapSolid(bot.x + k, bot.y)) { bot.x += k; break; }
}
}
const steps = Math.ceil(Math.max(Math.abs(bot.vx), Math.abs(bot.vy)) / 0.45) || 1;
const sx = bot.vx / steps, sy = bot.vy / steps;
for (let s = 0; s < steps; s++) {
if (sx) { if (!botOverlapSolid(bot.x + sx, bot.y)) bot.x += sx; else if (!(ix && !iy && botSlide(0, sx))) { bot.vx *= -0.15; } }
if (sy) { if (!botOverlapSolid(bot.x, bot.y + sy)) bot.y += sy; else if (!(iy && !ix && botSlide(1, sy))) { bot.vy *= -0.15; } }
}
// loose grains, liquids and gases pass straight through the bot: it never moves them
}
+176
View File
@@ -0,0 +1,176 @@
// 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);
}
}
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// How belts, filters and screw tubes move what is on or in them.
// What rests on a belt or filter rides along: the whole stack standing on the carrying surface
// steps one cell toward d together (belts run front of the line first, so the way ahead is
// clear), the way a real belt carries a heap, not just its bottom layer. A filter's own
// material drops through it instead, unless the filter below is already full.
const CARRY_DEPTH = 96;
function carryColumn(a, d, pass) {
for (let L = 0; L < CARRY_DEPTH; L++, a -= W) {
if (a < 0) return;
const m = mat[a];
if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return; // the top of the heap, or a ceiling
if (PH[m] !== PH_POWDER || stamp[a] === tickStamp || occ[a]) continue;
if (pass && pass[m]) {
if (enter(m, PH_POWDER, a + W, 0, 1)) continue; // falls through on its own
// the rollers shake the load: what the filter passes works its way down through the rest
const u = mat[a + W];
if (L > 0 && !pass[u] && PH[u] === PH_POWDER && stamp[a + W] !== tickStamp && !occ[a + W] && rnd() < 0.5) { const x = a % W, y = (a / W) | 0; move(a, a + W, x, y, x, y + 1); continue; }
}
const x = a % W, nx = x + d;
if (nx < 0 || nx >= W) return;
const j = a + d, tj = mat[j];
// grains on a wet belt push through the water rather than stopping at it
const wet = PH[tj] === PH_LIQUID && !occ[j] && stamp[j] !== tickStamp && (!part[j] || part[j] === P_FILTER || part[j] === P_GOPEN);
if (!wet && !enter(m, PH_POWDER, j, d, 0)) continue;
const y = (a / W) | 0, bj = j + W;
// carried off the end with nothing under it: it leaves at belt speed and falls
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y, d * 0.5, 0);
else move(a, j, x, y, nx, y);
stats.moved++;
}
}
// Belts are perforated: liquid resting on one drips through to whatever open space is under it.
function beltDrain(b) {
const a = b - W, x = b % W;
let d = b;
for (let k = 0; k < 16 && d < N; k++) { const m = mat[d]; if (m !== BELTL && m !== BELTR) break; d += W; }
if (d >= N || mat[d] === BELTL || mat[d] === BELTR) return;
if (!passable(mat[a], PH_LIQUID, d, 0, 1)) return;
move(a, d, x, (a / W) | 0, x, (d / W) | 0);
}
// Take a grain out of cell i and send it flying from (x, y): what leaves a belt or a tube mouth
function launchGrain(i, x, y, vx, vy) {
const m = mat[i], T = temp[i];
mat[i] = EMPTY; flags[i] = 0; life[i] = 0; stamp[i] = tickStamp;
wakeXY(i % W, (i / W) | 0);
flyers.push({ x: x + 0.5, y: y + 0.5, vx, vy, age: 0, m, T, free: true, run: 0 });
}
let filterR = [], filterL = [], filtersDirty = true;
function rebuildFilters() {
filtersDirty = false; filterR = []; filterL = [];
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) (blockV[b] & 128 ? filterR : filterL).push(b);
// front of the line first, so a full run moves as one
filterR.sort((a, c) => (c % BW) - (a % BW)); filterL.sort((a, c) => (a % BW) - (c % BW));
}
function filterCarry(b, d) {
if (blockP[b] !== BP_FILTER) return;
const bx = b % BW, y = ((b / BW) | 0) * BS;
if (y < 1) return;
const c = filterCfg.get(b), pass = c ? c.pass : null;
for (let k = 0; k < BS; k++) {
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = y * W + x;
if (part[top] === P_FILTER) carryColumn(top - W, d, pass);
}
}
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
function setFilter(b, cfg) {
if (blockP[b] !== BP_FILTER) return false;
const rec = filterRec(cfg);
filterCfg.set(b, rec);
const bx = b % BW, by = (b / BW) | 0;
const cells = blockCellList(bx, by).filter(i => PH[mat[i]] === PH_POWDER && !rec.pass[mat[i]]);
for (let y = by * BS + BS - 1; y >= by * BS; y--) for (const i of cells) if (((i / W) | 0) === y) evictCells([i], by * BS);
return true;
}
// The run of filters in a row that touch block b and do the same thing it does
function filterRun(b) {
if (blockP[b] !== BP_FILTER) return [];
const f = filterInfo(b), dir = blockV[b] & 128, by = (b / BW) | 0, out = [b];
for (const s of [-1, 1]) for (let bx = (b % BW) + s; bx >= 0 && bx < BW; bx += s) {
const c = by * BW + bx;
if (blockP[c] !== BP_FILTER || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
out.push(c);
}
return out.sort((a, c) => a - c);
}
// A cell a grain can be pushed into: empty, or liquid or gas that simply trades places with it
function roomFor(j) { const t = mat[j]; return t === EMPTY || PH[t] === PH_GAS || PH[t] === PH_LIQUID; }
// An empty screw cell draws in loose material from behind it or from its sides
// (not from the cell it pushes into), so a tube end sitting in a pile feeds itself.
function tubeIntake(i, p, dx, dy) {
const x = i % W, y = (i / W) | 0;
const cands = [[-dx, -dy], [dy, dx], [-dy, -dx]];
const start = (tick + i) % 3;
for (let c = 0; c < 3; c++) {
const [ox, oy] = cands[(start + c) % 3];
const nx = x + ox, ny = y + oy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = ny * W + nx, m = mat[j];
if (m === EMPTY || occ[j] || stamp[j] === tickStamp) continue;
if (PH[m] !== PH_POWDER) continue; // the screw only takes loose material; liquids just flow through
if (stamp[i] === tickStamp) return;
const pj = part[j];
if (pj >= P_TU && pj <= P_TR) continue; // other tube cells move their own contents
if (pj === P_GRATE || pj === P_FILTER) continue;
move(j, i, nx, ny, x, y);
return;
}
}
+161
View File
@@ -0,0 +1,161 @@
// The resonance drill: tuned pulses that shatter one material.
// ---------- Resonance drill ----------
// Holding the drill sends pulses from the bot toward the cursor. Each pulse is a fan of rays
// leaving the whole face of the bot; where a ray passes through rock it shakes it (digp),
// losing some of its energy, and rock shaken hard enough shatters into its loose grains.
// The width is a real choice:
// narrow: a beam as wide as the bot that bores a tunnel you can fly down, fast and far
// medium: a cone, the all-rounder
// wide: a broad fan that clears a big face close to the bot, but reaches only a short way
// Tuned to one material, a pulse passes through everything else and shatters only that, and
// loose grains it passes are jostled toward the bot, so a shattered vein pours out of the wall.
// Built blocks, machines and molds soak pulses up unharmed.
// half: half the spread angle; mouth: half the width of the fan where it leaves the bot;
// en: energy per ray; damp: energy kept after each shake (higher bores deeper); reach: x bit reach
const PULSE_W = [
{ name: 'narrow', half: 0.03, mouth: 3.2, rays: 13, en: 1.75, damp: 0.9, reach: 1.45 },
{ name: 'medium', half: 0.24, mouth: 3, rays: 23, en: 1.4, damp: 0.84, reach: 1 },
{ name: 'wide', half: 0.62, mouth: 3.4, rays: 41, en: 1.32, damp: 0.78, reach: 0.7 },
];
const PULSE_EVERY = 8, PULSE_SPEED = 2.2, PULSE_K = 240; // ticks between pulses, cells per tick, shake per unit of energy
let drillPulses = [], lastPulseTick = -99, drillWidth = 1;
// pulses leave from the middle of the bot (the fan is as wide as the bot there)
function drillOrigin() { return { x: bot.x + bot.w / 2, y: bot.y + bot.h / 2 }; }
// the start point and direction of ray f (-1 .. 1 across the fan) of a pulse aimed at angle ang
function pulseRay(ox, oy, ang, Wd, f) {
const off = f * Wd.mouth, a = ang + f * Wd.half;
return { x: ox - Math.sin(ang) * off, y: oy + Math.cos(ang) * off, ux: Math.cos(a), uy: Math.sin(a) };
}
// Is a solid cell something the player built (or a part of a machine or mold)? Pulses leave those alone.
function builtCell(i, x, y) {
return PARTMAT[mat[i]] || part[i] || mach[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] !== 0 || (!(flags[i] & F_NAT) && moldAt(x, y) !== null);
}
// One press-and-hold step: fires a pulse when one is due. Returns { fired, status }.
function toolPulse(ax, ay, width, tune) {
if (PULSE_W[width]) drillWidth = width;
if (tick - lastPulseTick < PULSE_EVERY) return { fired: false, status: '' };
const tier = BIT_TIERS[player.bitTier], o = drillOrigin();
const dx = ax + 0.5 - o.x, dy = ay + 0.5 - o.y, d = Math.hypot(dx, dy);
if (d < 0.5) return { fired: false, status: '' };
const Wd = PULSE_W[drillWidth], ang = Math.atan2(dy, dx), reach = drillReach();
const range = Math.min(reach, d + 3);
// each pulse's rays sit at a slightly different place across the fan, so over a few pulses
// every cell in the cone is hit, with no untouched stripes between the rays
drillPulses.push({ ox: o.x, oy: o.y, ang, w: drillWidth, jit: rnd(), n: Wd.rays, damp: Wd.damp, en: new Float32Array(Wd.rays).fill(Wd.en), s: new Float32Array(Wd.rays), r: 0, range, tune: tune || 0 });
lastPulseTick = tick;
// say why nothing breaks: the first solid on the centre line is too hard, or out of reach
let status = '';
for (let k = 3; k < reach; k++) {
const x = Math.floor(o.x + dx / d * k), y = Math.floor(o.y + dy / d * k);
if (x < 0 || y < 0 || x >= W || y >= H) break;
const i = y * W + x, m = mat[i];
if (PH[m] !== PH_SOLID || builtCell(i, x, y) || (tune && YLD[m] !== tune)) continue;
if (HARD[m] > tier.hard) {
const need = BIT_TIERS.find(b => b.hard >= HARD[m]);
const nb = need ? need.name.toLowerCase() : 'better bit';
status = HARD[m] > 6 ? `${MD[m].name} cannot be drilled.` : `${MD[m].name} is too hard for your ${tier.name.toLowerCase()}. Needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
}
break;
}
if (!status && d > reach) status = `Out of reach. A ${Wd.name} pulse from your ${tier.name.toLowerCase()} reaches ${reach} grains from the bot.`;
return { fired: true, status };
}
const THIN_BONUS = 0.75, CRUMBLE_MAX = 16;
let THIN_BONUS_OFF = 0; // for tests that compare with and without
// After a cell breaks: any small piece of natural rock it leaves cut off from the rest (touching
// no other rock on any side) has nothing holding it and falls apart into its loose grains, so
// the drill does not leave floating bits in the way. Rock too hard for the bit never crumbles
// (it holds the piece up), so nothing ever comes loose that the bit could not have broken.
function crumbleAround(x, y) {
const i0 = y * W + x;
for (const j0 of [i0 - 1, i0 + 1, i0 - W, i0 + W]) {
if (j0 < 0 || j0 >= N || !crumbly(j0)) continue;
const got = [j0], st = [j0];
let small = true;
while (st.length && small) {
const i = st.pop(), cx = i % W;
for (const j of [cx > 0 ? i - 1 : -1, cx < W - 1 ? i + 1 : -1, i - W, i + W]) {
if (j < 0 || j >= N || got.includes(j)) continue;
if (PH[mat[j]] !== PH_SOLID) continue;
// touching anything solid that is not loose natural rock (built blocks, the dock, the
// edge of the world) holds the piece up
if (!crumbly(j) || got.length >= CRUMBLE_MAX) { small = false; break; }
got.push(j); st.push(j);
}
}
if (!small) continue;
for (const j of got) shatterCell(j, j % W, (j / W) | 0, mat[j]);
}
}
function crumbly(i) { const m = mat[i]; return PH[m] === PH_SOLID && (flags[i] & F_NAT) !== 0 && YLD[m] !== 0 && HARD[m] <= BIT_TIERS[player.bitTier].hard && !builtCell(i, i % W, (i / W) | 0); }
function shatterCell(i, x, y, m) {
const to = YLD[m];
setCell(i, to, temp[i], 0);
discover(to, x, y, m, 'dig');
stats.dug++;
if ((stats.dug & 3) === 0) emit({ t: 'debris', x, y, m });
if ((stats.dug & 7) === 0) emit({ t: 'sfx', s: 'dig', a: HARD[m] });
}
// Advance every pulse in flight by one tick
function pulseTick() {
if (!drillPulses.length) return;
const tier = BIT_TIERS[player.bitTier];
// cells inside the bot's body are not touched (it flies in its own little space)
const bx0 = Math.floor(bot.x), by0 = Math.floor(bot.y), bx1 = Math.ceil(bot.x + bot.w) - 1, by1 = Math.ceil(bot.y + bot.h) - 1;
for (const p of drillPulses) {
p.r += PULSE_SPEED;
const lim = Math.min(p.r, p.range), Wd = PULSE_W[p.w];
for (let k = 0; k < p.n; k++) {
let e = p.en[k];
if (e < 0.04) continue;
const ray = pulseRay(p.ox, p.oy, p.ang, Wd, -1 + 2 * (k + p.jit) / p.n), ux = ray.ux, uy = ray.uy;
let sd = p.s[k];
while (sd < lim && e >= 0.04) {
sd += 0.5;
const x = Math.floor(ray.x + ux * sd), y = Math.floor(ray.y + uy * sd);
if (x < 0 || y < 0 || x >= W || y >= H) { e = 0; break; }
if (x >= bx0 && x <= bx1 && y >= by0 && y <= by1) continue;
const i = y * W + x, m = mat[i], ph = PH[m];
if (ph === PH_EMPTY || ph === PH_GAS) {
// standing trees in the way are shaken apart too (not by a tuned pulse)
if (bgm[i] && !p.tune && (sd * 2 & 1) === 0) { cutTree(i, x, y, tier, e * 0.5); e *= bgm[i] === WOOD ? 0.85 : 0.97; }
continue;
}
if (ph === PH_LIQUID) { e *= 0.985; continue; }
if (ph === PH_POWDER) {
// loose grains are jostled back toward the bot, so a shattered pocket drains out of the
// wall and a bore clears itself: straight back if there is room, else down or up a step
e *= 0.94;
if (stamp[i] !== tickStamp && rnd() < 0.3 * e) {
const sx = Math.abs(ux) > 0.3 ? -Math.sign(ux) : 0, sy = Math.abs(uy) > 0.3 ? -Math.sign(uy) : 0;
for (const [mx, my] of [[sx, sy], [sx, 1], [sx, -1], [0, sy]]) {
if (!mx && !my) continue;
const nx = x + mx, ny = y + my;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = ny * W + nx;
if ((mat[j] === EMPTY || PH[mat[j]] === PH_GAS) && !occ[j] && !part[j]) { move(i, j, x, y, nx, ny); break; }
}
}
continue;
}
if (builtCell(i, x, y)) { e *= 0.25; continue; }
if (p.tune && YLD[m] !== p.tune) { e *= 0.993; continue; }
const h = HARD[m];
// thin rock (a corner, a nub, a sliver with open space on two or more sides) has little
// to hold it and shakes apart faster than a flat face, so ragged edges get cleaned up
let open = 0;
if (x > 0 && PH[mat[i - 1]] !== PH_SOLID) open++;
if (x < W - 1 && PH[mat[i + 1]] !== PH_SOLID) open++;
if (y > 0 && PH[mat[i - W]] !== PH_SOLID) open++;
if (y < H - 1 && PH[mat[i + W]] !== PH_SOLID) open++;
const thin = open > 1 && !THIN_BONUS_OFF ? 1 + (open - 1) * THIN_BONUS : 1;
if (h > tier.hard) { e *= 0.35; continue; }
const q = digp[i] + e * thin * tier.speed * PULSE_K / (1 + h * h * 0.6);
if (q >= 255) { shatterCell(i, x, y, m); if (!p.tune) crumbleAround(x, y); } else digp[i] = q;
e *= p.tune ? 0.96 : p.damp;
}
p.en[k] = e; p.s[k] = sd;
}
}
drillPulses = drillPulses.filter(p => p.r < p.range + 3);
}
+152
View File
@@ -0,0 +1,152 @@
// Money, prices, market saturation, the trade dock, buyer and builder orders.
// ---------- Economy ----------
// You sell by putting goods into the trade dock: they are paid for as they drop
// through its grate. Money buys machines; wall blocks, molds and bits you still
// make from your own materials. The claim on this land starts in debt.
let money = 250, debt = 20000, earned = 0;
const CLAIM_DEBT = 20000;
// base price per grain (per bar for bars). 0 = not wanted: it drops through for nothing.
const PRICE = new Float32Array(NMAT);
for (const [m, v] of [
[SAND, 0.1], [LCLAY, 0.2], [LWOOD, 0.3], [ACORN, 0.5], [COAL, 0.5], [RGRAN, 0.2], [RBAS, 0.4],
[CUORE, 1], [FEORE, 1.6], [SNORE, 2], [ZNORE, 2.2], [AGORE, 6], [AUORE, 14],
[CHARCOAL, 1.2], [RBRICK, 0.8], [CULLET, 1.2], [BARGL, 14], [RSTONE, 0.02], [GRAVEL, 0.03], [BARSB, 0.6],
[SCRCU, 3], [SCRFE, 5], [SCRST, 11], [SCRSN, 3.5], [SCRZN, 3.5], [SCRAG, 18], [SCRAU, 40], [SCRBZ, 8], [SCRBS, 7],
[BARCU, 40], [BARFE, 75], [BARST, 160], [BARSN, 50], [BARZN, 50], [BARAG, 220], [BARAU, 480], [BARBZ, 110], [BARBS, 95],
]) PRICE[m] = v;
// what a cell is sold as: cast metal sitting in the hopper sells as scrap
function saleAs(m) { return SOLIDMETAL[m] ? YLD[m] : CASTMETAL[m] ? YLD[FRZTO[m]] : m; }
// The market dips when you sell a lot of one thing, and recovers over time.
const SAT_K = 2500, SAT_FLOOR = 0.3, SAT_HALF = 28800; // half-life: 8 minutes of game time
let mkRecent = new Float32Array(NMAT);
function priceFactor(m) { return Math.max(SAT_FLOOR, 1 / (1 + mkRecent[m] / SAT_K)); }
function priceNow(m) { return PRICE[m] * priceFactor(m); }
function sellUnit(m) { const p = priceNow(m); mkRecent[m] += PRICE[m]; return p; }
function marketTick() { const f = Math.pow(0.5, 60 / SAT_HALF); for (let m = 0; m < NMAT; m++) if (mkRecent[m] > 0.01) mkRecent[m] *= f; else mkRecent[m] = 0; }
function resetEconomy() {
money = 250; debt = CLAIM_DEBT; earned = 0; mkRecent = new Float32Array(NMAT); shipLog = [];
saleAcc = { total: 0, items: {} }; floatAcc = 0;
orders = []; ordersDone = 0; nextOrderT = 3600; orderSeq = 1;
}
function payClaim(amount) {
const a = Math.max(0, Math.min(amount, money, debt));
money -= a; debt -= a;
if (a > 0 && debt <= 0) emit({ t: 'claim_paid' });
return a;
}
// The trade dock: an open bin with a grated floor over a chute to the claim office.
// Anything that reaches the grate (and is cool enough to handle) is sold on the spot.
let shipLog = [], saleAcc = { total: 0, items: {} }, floatAcc = 0;
function dock() { for (const mm of machines) if (mm && mm.type === 'dock') return mm; return null; }
function inDock(d, x, y) { return x >= d.x + 2 && x < d.x + 22 && y >= d.y && y < d.y + 14; }
function dockTick() {
const d = dock(); if (!d) return;
const fy = d.y + 13;
for (let x = d.x + 2; x < d.x + 22; x++) {
const i = fy * W + x, m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS || temp[i] >= 150 || (flags[i] & F_BURN)) continue;
setCell(i, EMPTY, AMB[fy], 0);
sellToDock(saleAs(m));
}
for (const e of ents.slice()) {
if (e.iy + BAR_H - 1 >= fy && inDock(d, e.ix + 2, fy) && e.T < 150) { removeEnt(e); sellToDock(e.m); }
}
if (tick % 45 === 0 && floatAcc >= 0.5) { emit({ t: 'sold', v: floatAcc, x: d.x + 9 + rnd() * 6, y: d.y - 9 }); floatAcc = 0; }
if (tick % 3600 === 0 && saleAcc.total > 0) { shipLog.unshift({ tick, total: Math.round(saleAcc.total), items: saleAcc.items }); if (shipLog.length > 12) shipLog.length = 12; saleAcc = { total: 0, items: {} }; }
}
// One unit reaches the grate: it goes to an open order for it first, else to the market.
function sellToDock(m) {
stats.sold = (stats.sold || 0) + 1;
const o = orders.find(o => o.m === m && o.got < o.qty);
if (o) { o.got++; if (o.got >= o.qty) completeOrder(o); return; }
const p = sellUnit(m);
money += p; earned += p; floatAcc += p;
if (p > 0) { saleAcc.total += p; saleAcc.items[m] = (saleAcc.items[m] || 0) + 1; }
}
// Orders: buyers ask for an amount of one good by a deadline and pay well over market.
// A missed deadline costs nothing: what you delivered is paid at market price.
let orders = [], ordersDone = 0, nextOrderT = 3600, orderSeq = 1;
const ORDER_SLOTS = 3;
const STANDING = ['Local buyers', 'County buyers', 'Regional buyers', 'National buyers', 'Export houses'];
function standing() { return Math.min(STANDING.length - 1, Math.floor(ordersDone / 4)); }
// Builders buy the cheap bulk stuff (rubble, gravel, sand, stone bricks) in big lots at a few
// times the market price, so waste rock earns a little. [what, smallest lot, largest lot]
const BUILD_GOODS = [[RSTONE, 400, 1500], [GRAVEL, 300, 1000], [SAND, 300, 900], [BARSB, 20, 80]];
function makeBuildersOrder() {
const cands = BUILD_GOODS.filter(([m]) => seen[m] && !orders.some(o => o.m === m));
if (!cands.length) return null;
const [m, lo, hi] = cands[(rnd() * cands.length) | 0];
const qty = Math.round((lo + rnd() * (hi - lo)) * (1 + Math.min(1, ordersDone * 0.05)) / (PH[m] === PH_ITEM ? 1 : 10)) * (PH[m] === PH_ITEM ? 1 : 10);
const mult = Math.round((PH[m] === PH_ITEM ? 2.5 : 4) + rnd() * 2) ;
const minutes = Math.max(6, Math.min(20, Math.round(qty / (PH[m] === PH_ITEM ? 6 : 90))));
return { id: orderSeq++, m, qty, got: 0, mult, pay: Math.max(5, Math.round(qty * PRICE[m] * mult)), due: tick + minutes * 3600, mins: minutes, who: 'Builders' };
}
function makeOrder() {
if (rnd() < 0.3) { const b = makeBuildersOrder(); if (b) return b; }
const cands = [];
for (let m = 0; m < NMAT; m++) if (PRICE[m] > 0 && seen[m] && !orders.some(o => o.m === m) && !BUILD_GOODS.some(([g]) => g === m && g !== SAND)) cands.push(m);
if (!cands.length) return null;
const weight = m => (PH[m] === PH_ITEM ? 4 : 1) * (m === SCRCU || m === SCRFE || m === SCRST || m === SCRSN || m === SCRZN ? 0.4 : 1) * (PRICE[m] >= 5 ? 1.5 : 1);
let sum = 0; for (const m of cands) sum += weight(m);
let r = rnd() * sum, m = cands[0];
for (const c of cands) { r -= weight(c); if (r <= 0) { m = c; break; } }
const target = (220 + ordersDone * 110) * (0.7 + rnd() * 0.7); // order size at base price
let qty = Math.round(target / PRICE[m]);
qty = PH[m] === PH_ITEM ? Math.max(2, Math.min(80, qty)) : Math.max(15, Math.min(900, Math.round(qty / 5) * 5));
const mult = Math.round((1.6 + rnd() * 0.7 + Math.min(0.6, ordersDone * 0.03)) * 10) / 10;
const minutes = Math.max(5, Math.min(20, Math.round(4 + qty * PRICE[m] / 90)));
return { id: orderSeq++, m, qty, got: 0, mult, pay: Math.round(qty * PRICE[m] * mult), due: tick + minutes * 3600, mins: minutes };
}
function ordersTick() {
for (let k = orders.length - 1; k >= 0; k--) {
const o = orders[k];
if (tick < o.due) continue;
orders.splice(k, 1);
let paid = 0; for (let n = 0; n < o.got; n++) paid += sellUnit(o.m);
money += paid; earned += paid;
emit({ t: 'order_expired', m: o.m, got: o.got, qty: o.qty, paid: Math.round(paid) });
}
if (orders.length < ORDER_SLOTS && tick >= nextOrderT) {
const o = makeOrder();
nextOrderT = tick + 3600 + ((rnd() * 3600) | 0);
if (o) { orders.push(o); emit({ t: 'order_new', o }); }
}
}
function completeOrder(o) {
orders = orders.filter(x => x !== o);
money += o.pay; earned += o.pay; ordersDone++;
emit({ t: 'order_done', m: o.m, qty: o.qty, pay: o.pay });
}
// Put the dock on open ground near x: the nearest spot with nothing built in it.
function placeDock(cx) {
const BWD = 6, BHD = 4;
for (let off = 0; off < W; off += 4) for (const sg of off ? [1, -1] : [1]) {
const bx = Math.floor((cx + sg * off) / BS);
if (bx < 2 || bx + BWD > BW - 2) continue;
const x0 = bx * BS, x1 = x0 + BWD * BS;
// the highest natural ground under it
let g = H, ok = true;
for (let x = x0; x < x1 && ok; x++) {
let y = 0; while (y < H - 1 && !(PH[mat[y * W + x]] === PH_SOLID && (flags[y * W + x] & F_NAT))) y++;
if (y < g) g = y;
if (y > H - 40) ok = false;
}
if (!ok) continue;
const by = Math.floor((g + 1) / BS) - BHD + 1;
if (by < 2) continue;
// nothing of yours may be in the way
for (let y = by * BS; y < (by + BHD) * BS && ok; y++) for (let x = x0; x < x1; x++) {
const i = y * W + x;
if (mach[i] || part[i] || occ[i] || blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] || (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || PH[mat[i]] === PH_LIQUID) { ok = false; break; }
}
if (!ok || molds.some(mo => mo.x < x1 && mo.x + mo.w > x0 && mo.y < (by + BHD) * BS && mo.y + mo.h > by * BS)) continue;
for (let y = by * BS; y < (by + BHD) * BS; y++) for (let x = x0; x < x1; x++) { const i = y * W + x; bgm[i] = 0; if (mat[i] !== EMPTY) setCell(i, EMPTY, AMB[y], 0); }
// fill under it down to the ground so it does not hang in the air
for (let x = x0; x < x1; x++) for (let y = (by + BHD) * BS; y < H; y++) { const i = y * W + x; if (PH[mat[i]] === PH_SOLID) break; setCell(i, DIRT, AMB[y], F_NAT); bgm[i] = 0; }
addMachine('dock', bx, by, 0);
return true;
}
return false;
}
+246
View File
@@ -0,0 +1,246 @@
// 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;
// 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);
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) { 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] = 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;
flags[i] = (fl & F_NAT) | (CASTMETAL[m] ? F_CAST : 0); // natural magma freezes into natural rock
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');
}
}
+134
View File
@@ -0,0 +1,134 @@
// Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
// ---------- Bars (loose objects) ----------
// A bar is 4x2 grains. It falls, rests on things, rides belts, and melts back
// into molten metal if it gets hot enough. While it sits somewhere, grains treat
// its cells as solid (occ).
const BAR_W = 4, BAR_H = 2;
let entCurM = 0; // the bar being moved, so a filter can let its own kind of bar through
function entSolidCell(j) { const m = mat[j], p = part[j]; return occ[j] || PH[m] === PH_SOLID || PH[m] === PH_POWDER || (p !== 0 && (p <= P_GRATE || (p === P_FILTER && !filterPassAt(entCurM, j)))); }
function entFreeAt(x, y) {
if (x < 0 || y < 0 || x + BAR_W > W || y + BAR_H > H) return false;
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) if (entSolidCell((y + dy) * W + x + dx)) return false;
return true;
}
function entMark(e, on) {
const x = e.ix, y = e.iy;
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
const xx = x + dx, yy = y + dy;
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
occ[yy * W + xx] = on ? 1 : 0;
if (!on) wakeXY(xx, yy);
}
}
function spawnBar(m, x, y, vx, vy, T) {
if (ents.length >= 4000 || !m) return null;
let ix = Math.round(x), iy = Math.round(y);
// find a free spot near the requested one
let ok = entFreeAt(ix, iy);
for (let k = 1; !ok && k < 12; k++) { if (entFreeAt(ix, iy - k)) { iy -= k; ok = true; } }
if (!ok) return null;
const e = { m, x: ix, y: iy, ix, iy, vx: vx || 0, vy: vy || 0, T: T === undefined ? AMB[Math.max(0, Math.min(H - 1, iy))] : T, rest: 0 };
ents.push(e); entMark(e, 1);
return e;
}
function removeEnt(e) { entMark(e, 0); const k = ents.indexOf(e); if (k >= 0) ents.splice(k, 1); }
function entTick() {
for (let k = ents.length - 1; k >= 0; k--) {
const e = ents[k];
entMark(e, 0);
entCurM = e.m;
const ix = e.ix, iy = e.iy;
// what is underneath?
let belt = 0, bsat = 0, support = iy + BAR_H >= H;
if (!support) {
const yb = iy + BAR_H;
for (let dx = 0; dx < BAR_W; dx++) {
const x = ix + dx; if (x < 0 || x >= W) continue;
const j = yb * W + x, m = mat[j];
if (m === BELTL || m === BELTR) { const s = netSat(j); if (s > bsat) { bsat = s; belt = m === BELTR ? 1 : -1; } }
else if (part[j] === P_FILTER && !filterPassAt(e.m, j) && (yb & 3) === 0) { bsat = 1; belt = blockV[(yb >> 2) * BW + (x >> 2)] & 128 ? 1 : -1; }
if (entSolidCell(j)) support = true;
}
}
const ci = Math.min(H - 1, iy + 1) * W + Math.min(W - 1, ix + 1);
const inLiq = PH[mat[ci]] === PH_LIQUID;
if (support) { if (e.vy > 0) e.vy = 0; e.vx = belt ? belt * 0.5 * bsat : e.vx * 0.55; }
else { e.vy = Math.min(inLiq ? 0.35 : 1.3, e.vy + 0.07); e.vx *= inLiq ? 0.8 : 0.985; }
if (belt && bsat > 0) stats.moved += 0.05;
e.x += e.vx; e.y += e.vy;
// step toward the new rounded position one cell at a time
let tx = Math.round(e.x), ty = Math.round(e.y), cx = ix, cy = iy;
while (cx !== tx) { const s = Math.sign(tx - cx); if (entFreeAt(cx + s, cy)) cx += s; else { e.vx = 0; e.x = cx; break; } }
while (cy !== ty) { const s = Math.sign(ty - cy); if (entFreeAt(cx, cy + s)) cy += s; else { e.vy = 0; e.y = cy; break; } }
if (!entFreeAt(cx, cy)) {
// something moved into us (a grain fell in): try to step up out of it
let freed = false;
for (let u = 1; u < 6; u++) if (entFreeAt(cx, cy - u)) { cy -= u; e.y = cy; freed = true; break; }
if (!freed) e.y = cy;
}
e.ix = cx; e.iy = cy;
// heat: the bar tends toward the temperature around it
const T = temp[Math.min(H - 1, cy + 1) * W + Math.min(W - 1, cx + 1)];
e.T += (T - e.T) * 0.04;
if (e.T >= MELT[e.m]) {
// melts back into 8 grains of molten metal if they fit here
let fits = true;
for (let dy = 0; dy < BAR_H && fits; dy++) for (let dx = 0; dx < BAR_W; dx++) { const j = (cy + dy) * W + cx + dx; if (PH[mat[j]] === PH_SOLID || PH[mat[j]] === PH_POWDER) { fits = false; break; } }
if (fits) {
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) setCell((cy + dy) * W + cx + dx, MELTTO[e.m], e.T, 0);
ents.splice(k, 1);
discover(MELTTO[e.m], cx, cy);
continue;
}
}
entMark(e, 1);
}
entCurM = 0;
}
function entsInCircle(cx, cy, r) {
const out = [];
for (const e of ents) {
const ex = e.ix + BAR_W / 2, ey = e.iy + BAR_H / 2;
if ((ex - cx) * (ex - cx) + (ey - cy) * (ey - cy) <= (r + 2.5) * (r + 2.5)) out.push(e);
}
return out;
}
// ---------- Bag ----------
function bagAdd(m, n, force) {
const room = force ? n : Math.max(0, bagCap() - bagTotal);
const k = Math.min(n, room);
bag[m] += k; bagTotal += k;
if (k > 0 && m < NMAT) discover(m, undefined, undefined, 0, 'pickup');
return k;
}
function bagTake(m, n) { const k = Math.min(n, bag[m]); bag[m] -= k; bagTotal -= k; return k; }
// Give back what fits in the bag; the rest drops into the world near (x, y):
// grains as loose material, bars as bar objects. Counts what dropped.
let droppedLast = 0;
function giveOrDrop(m, n, x, y) {
if (n <= 0) return;
if (m === CASH) { money += n; return; }
const k = bagAdd(m, n, false);
let rest = n - k;
if (!rest) return;
droppedLast += rest;
if (PH[m] === PH_ITEM) {
for (let j = 0; j < rest; j++) if (!spawnBar(m, x - 2 + (j % 3) * 2, y - 2 - j, (rnd() - 0.5) * 0.4, -0.3)) bagAdd(m, 1, true);
return;
}
for (let r = 0; r <= 12 && rest > 0; r++) for (let dy = -r; dy <= r && rest > 0; dy++) for (let dx = -r; dx <= r && rest > 0; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
const xx = Math.round(x) + dx, yy = Math.round(y) + dy;
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
const j = yy * W + xx;
if (mat[j] === EMPTY && !occ[j] && !part[j] && !mach[j]) { setCell(j, m, AMB[yy], 0); rest--; }
}
if (rest > 0) bagAdd(m, rest, true);
}
function refundAt(cost, mult, x, y) { for (const [m, n] of cost) giveOrDrop(m, n * mult, x, y); }
function canAfford(cost, mult) { for (const [m, n] of cost) if ((m === CASH ? money : bag[m]) < n * mult) return false; return true; }
function payCost(cost, mult) { for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } }
function fmtMoney(v) { return '$' + Math.round(v).toLocaleString('en-US'); }
function costText(cost, mult) { return cost.map(([m, n]) => { const k = n * (mult || 1); return m === CASH ? fmtMoney(k) : `${k} ${MD[m].name.toLowerCase()}${PH[m] === PH_ITEM && k !== 1 ? 's' : ''}`; }).join(' + '); }
function needText(cost, mult) { const c = cost.find(([m, n]) => (m === CASH ? money : bag[m]) < n * (mult || 1)); return c && c[0] === CASH ? `Costs ${fmtMoney(c[1] * (mult || 1))}; you have ${fmtMoney(money)}.` : 'Not enough ' + costText(cost, mult); }
+43
View File
@@ -0,0 +1,43 @@
// Milestone checks (the ledger) and the sky roof line used for cooling.
// ---------- Ledger ----------
function checkLedger() {
const done = id => ledger[id];
const mark = id => { if (!ledger[id]) { ledger[id] = tick || 1; emit({ t: 'milestone', id }); } };
if (stats.dug >= 15) mark('dig');
if (seen[FIRE]) { mark('fire'); if (!player.lens) { player.lens = true; } }
if (seen[BRICK] || seen[RBRICK]) mark('brick');
if (seen[CHARCOAL]) mark('charcoal');
if (stats.grown > 0) mark('grow');
if (seen[MCU]) mark('copper');
if (stats.bars > 0) mark('bars');
if (player.bitTier >= 1) mark('bit1');
let anyPower = false, steamOn = false;
for (const mm of machines) if (mm) { if ((mm.type === 'wheel' || mm.type === 'engine') && mm.out > 0.5) anyPower = true; if (mm.type === 'engine' && mm.out > 1) steamOn = true; }
if (anyPower && !POWER_FREE) mark('power');
if (stats.moved >= 300) mark('auto');
if (seen[MGL] || seen[GLASS] || seen[CULLET]) { mark('glass'); player.flask = true; }
if (steamOn && !POWER_FREE) mark('steam');
if (seen[MFE]) mark('iron');
if (player.bitTier >= 2) mark('bit2');
if (seen[MST]) mark('steel');
if (player.bitTier >= 3) mark('bit3');
if (player.tankTier >= 1) mark('tank');
if (depthU(bot.y) > 0.85) mark('deep');
if (stats.sold > 0) mark('sale');
if (ordersDone > 0) mark('order');
if (seen[MBZ] || seen[MBS]) mark('alloy');
if (seen[AGORE] || seen[AUORE] || seen[SILVER] || seen[GOLD]) mark('precious');
if (debt <= 0) mark('claim');
if (earned >= 50000) mark('rich1');
if (earned >= 200000) mark('rich2');
void done;
}
// Topmost non-air cell of each column: air above it can shed heat to the sky.
function updateRoof() {
for (let x = 0; x < W; x++) {
let y = 0;
for (; y < H; y++) { const ph = PH[mat[y * W + x]]; if (ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) break; }
roofY[x] = y;
}
}
+158
View File
@@ -0,0 +1,158 @@
// Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick).
function engineIntake(mm) {
for (const i of mm.intake) {
if (mat[i] === STEAM) {
const T = temp[i];
mm.energy += 1 + Math.max(0, T - 100) / 120;
mat[i] = EMPTY; life[i] = 0; temp[i] = AMB[(i / W) | 0];
wakeIdx(i);
const ex = mm.dir === 0 ? mm.x + mm.w : mm.x - 1, ey = mm.y + mm.h - 1;
if (ex >= 0 && ex < W) {
const j = ey * W + ex;
if (mat[j] === EMPTY && !part[j]) { mat[j] = WATER; temp[j] = 92; life[j] = 0; flags[j] = 0; wakeXY(ex, ey); }
}
}
}
}
function fanBlow(mm, sat) {
const dir = mm.dir; // 0 right, 1 down, 2 left, 3 up
const dx = [1, 0, -1, 0][dir], dy = [0, 1, 0, -1][dir];
const cur = curAir();
for (let lane = 0; lane < 4; lane++) {
let sx, sy;
if (dir === 0) { sx = mm.x + 4; sy = mm.y + lane; }
else if (dir === 2) { sx = mm.x - 1; sy = mm.y + lane; }
else if (dir === 1) { sx = mm.x + lane; sy = mm.y + 4; }
else { sx = mm.x + lane; sy = mm.y - 1; }
let powderDepth = 0;
const range = 28;
for (let k = 0; k < range; k++) {
const x = sx + dx * k, y = sy + dy * k;
if (x < 0 || y < 0 || x >= W || y >= H) break;
const i = y * W + x, m = mat[i], ph = PH[m];
if (ph === PH_SOLID && part[i] !== P_GRATE) break;
airS[i] = cur;
if (ph === PH_POWDER) { if (++powderDepth > 16) break; }
else if (ph === PH_GAS && k < range - 1 && rnd() < sat * 0.7 && stamp[i] !== tickStamp) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < W && ny < H) {
const j = ny * W + nx;
if (mat[j] === EMPTY && passable(m, PH_GAS, j, dx, dy)) move(i, j, x, y, nx, ny);
}
}
}
wakeHeatXY(Math.min(W - 1, Math.max(0, sx + dx * 4)), Math.min(H - 1, Math.max(0, sy + dy * 4)));
}
}
function machinesTick() {
if (powerDirty && tick >= nextPowerBuild) { rebuildPower(); nextPowerBuild = tick + 6; }
if (brokenMachines.length) {
const ids = brokenMachines; brokenMachines = [];
for (const id of ids) if (machines[id]) removeMachine(id, false, true);
}
for (const mm of machines) {
if (!mm) continue;
if (mm.type === 'wheel') {
const inst = mm.flow * WHEEL_K;
mm.out += (inst - mm.out) * 0.04; mm.flow = 0;
mm.angle += Math.min(0.35, mm.out * 0.012);
} else if (mm.type === 'refiner') {
refinerTick(mm);
} else if (mm.type === 'cutter') {
cutterTick(mm);
} else if (mm.type === 'silo') {
siloTick(mm);
} else if (mm.type === 'engine') {
engineIntake(mm);
mm.out += (mm.energy * ENGINE_K - mm.out) * 0.03; mm.energy = 0;
mm.angle += Math.min(0.4, mm.out * 0.01);
}
}
for (let n = 1; n < nets.length; n++) {
const net = nets[n];
let s = 0; for (const p of net.prods) s += p.out;
let d = net.nb * BELT_POWER + net.nt * TUBE_POWER;
for (const f of net.fans) if (f.on) d += FAN_POWER;
net.supply = s; net.demand = d;
net.sat = POWER_FREE ? 1 : d > 0 ? Math.min(1, s / d) : 0;
}
for (const mm of machines) {
if (!mm || mm.type !== 'fan') continue;
const net = nets[mm.net];
const sat = POWER_FREE ? 1 : net ? net.sat : 0;
mm.angle += sat * 0.5 * (mm.on ? 1 : 0);
if (mm.on && sat > 0.05) fanBlow(mm, sat);
// a fan getting close to melting says so (once every few seconds)
if ((tick + mm.id) % 30 === 0) {
let hotT = 0;
for (let y = mm.y; y < mm.y + 4; y++) for (let x = mm.x; x < mm.x + 4; x++) { const tt = temp[y * W + x]; if (tt > hotT) hotT = tt; }
if (hotT > 760 && (!mm.warnT || tick - mm.warnT > 600)) { mm.warnT = tick; emit({ t: 'toast', text: `A fan is at ${Math.round(hotT)}°C and melts at 1085°C. Move it out of the heat: outside the furnace wall, blowing in through a grate.` }); }
}
}
// belts and filters move what rests on them every other tick: half a grain per tick
if ((tick & 1) === 0) {
for (let pass = 0; pass < 2; pass++) {
const list = pass ? beltR : beltL, d = pass ? 1 : -1, bm = pass ? BELTR : BELTL;
for (let k = 0; k < list.length; k++) {
const b = list[k];
if (mat[b] !== bm || b < W) continue;
const above = mat[b - W];
if (above === BELTL || above === BELTR) continue; // only the top surface carries
if (netSat(b) <= 0) continue;
if (PH[above] === PH_LIQUID) beltDrain(b);
carryColumn(b - W, d, null);
}
}
if (filtersDirty) rebuildFilters();
for (const b of filterR) filterCarry(b, 1);
for (const b of filterL) filterCarry(b, -1);
}
// tubes: every loose grain inside steps one cell along, front of the line first, on the same
// beat as belts (every other tick), so a block-wide screw carries as much as a block-deep belt
if ((tick & 1) === 0) for (let p = P_TU; p <= P_TR; p++) {
const list = tubeLists[p], dx = TDX[p], dy = TDY[p], dj = dy * W + dx, flat = dy === 0;
for (let k = 0; k < list.length; k++) {
const i = list[k];
if (part[i] !== p) continue;
const m = mat[i];
if (m === EMPTY || PH[m] !== PH_POWDER) { if (m === EMPTY || PH[m] !== PH_SOLID) tubeIntake(i, p, dx, dy); continue; }
if (stamp[i] === tickStamp) continue;
if (temp[i] > 1000 && rnd() < 0.02) {
part[i] = 0; markPow(i, false); emit({ t: 'toast', text: 'A screw tube melted. Copper softens around 1000°C.' }); wakeIdx(i);
continue;
}
const s = netSat(i);
if (s <= 0 || rnd() > s) continue;
const x = i % W, y = (i / W) | 0;
// a sideways screw carries its load along the bottom: grains settle into empty lanes below
if (flat && y + 1 < H && part[i + W] === p && roomFor(i + W) && !occ[i + W]) { move(i, i + W, x, y, x, y + 1); continue; }
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = i + dj;
if (occ[j]) continue;
const pj = part[j], tube = pj >= P_TU && pj <= P_TR;
if (tube && TDX[pj] === -dx && TDY[pj] === -dy) continue;
const t = mat[j];
if (!roomFor(j)) continue;
if (pj === P_GRATE || pj === P_INTAKE || (pj === P_FILTER && !filterPassAt(m, j))) continue;
if (!tube && !pj && t === EMPTY) {
// out of the mouth: the screw spits it clear, so the next grain is never blocked by the last
launchGrain(i, nx, ny, dy ? (rnd() - 0.5) * (dy < 0 ? 0.9 : 0.1) : dx * (0.42 + rnd() * 0.16), dy ? dy * (0.5 + rnd() * 0.2) : rnd() * 0.08);
stats.moved++; continue;
}
move(i, j, x, y, nx, ny); stats.moved++;
if (tube && pj !== p) {
// turning a corner: keep going across the new tube's width so every lane fills
let c = j, cx = nx, cy = ny;
const bxj = nx >> 2, byj = ny >> 2;
for (;;) {
const ex = cx + dx, ey = cy + dy;
if (ex < 0 || ey < 0 || ex >= W || ey >= H || (ex >> 2) !== bxj || (ey >> 2) !== byj) break;
const e = ey * W + ex;
if (part[e] !== pj || !roomFor(e) || occ[e]) break;
move(c, e, cx, cy, ex, ey); c = e; cx = ex; cy = ey;
}
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
// Machines: the power network, footprints, placing and removing machines.
// ---------- Power & machines ----------
function markPow(i, on) {
if (on) { if (!isPow[i]) { isPow[i] = 1; pcells.add(i); } }
else if (isPow[i]) { isPow[i] = 0; pcells.delete(i); netId[i] = 0; }
powerDirty = true;
}
function partLost(i) { markPow(i, false); }
let nextPowerBuild = 0;
function rebuildPower() {
powerDirty = false;
for (const i of pcells) netId[i] = 0;
nets = [null]; beltL = []; beltR = []; tubeLists = [[], [], [], [], []];
const stack = [];
for (const s of pcells) {
if (netId[s]) continue;
const id = nets.length;
if (id >= 65535) break;
const net = { id, nb: 0, nt: 0, fans: [], prods: [], supply: 0, demand: 0, sat: 0 };
nets.push(net);
netId[s] = id; stack.push(s);
while (stack.length) {
const i = stack.pop();
const m = mat[i], p = part[i];
if (m === BELTL) { net.nb++; beltL.push(i); } else if (m === BELTR) { net.nb++; beltR.push(i); }
if (p >= P_TU && p <= P_TR) { net.nt++; tubeLists[p].push(i); }
const x = i % W;
let j;
if (x > 0) { j = i - 1; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
if (x < W - 1) { j = i + 1; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
if (i >= W) { j = i - W; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
if (i < N - W) { j = i + W; if (isPow[j] && !netId[j]) { netId[j] = id; stack.push(j); } }
}
}
for (const mm of machines) {
if (!mm) continue;
mm.net = netId[mm.cell0] || 0;
const net = nets[mm.net];
if (!net) continue;
if (mm.type === 'fan') net.fans.push(mm); else if (mm.type === 'wheel' || mm.type === 'engine') net.prods.push(mm);
}
beltL.sort((a, b) => a - b); beltR.sort((a, b) => b - a);
tubeLists[P_TU].sort((a, b) => a - b); tubeLists[P_TD].sort((a, b) => b - a);
tubeLists[P_TL].sort((a, b) => a - b); tubeLists[P_TR].sort((a, b) => b - a);
}
function netSat(i) { if (POWER_FREE) return 1; const n = nets[netId[i]]; return n ? n.sat : 0; }
// Move loose grains out of the given cells, to the nearest open cell above
// the footprint in the same column (they then fall back on top). Gas just vanishes.
function evictCells(cells, topY) {
for (const i of cells) bgm[i] = 0;
for (const i of cells) {
const m = mat[i], ph = PH[m];
if (m === EMPTY || ph === PH_SOLID) continue;
if (ph === PH_GAS) { mat[i] = EMPTY; life[i] = 0; continue; }
const x = i % W;
let moved = false;
for (let y = topY - 1; y >= Math.max(0, topY - 80); y--) {
const j = y * W + x;
if (mat[j] === EMPTY && !part[j] && !occ[j] && !mach[j]) { swapCells(i, j); wakeXY(x, y); moved = true; break; }
if (PH[mat[j]] === PH_SOLID) break;
}
if (!moved) { mat[i] = EMPTY; life[i] = 0; flags[i] = 0; }
wakeIdx(i);
}
}
function cellBuildable(i) { return PH[mat[i]] !== PH_SOLID && !part[i] && !mach[i] && !occ[i]; }
function machineFootprint(type, x, y) {
// [cx, cy, kind] where kind is 'hull', 'void' (inside the machine's area but open) or a part code
const out = [];
if (type === 'wheel') {
for (let dy = 0; dy < 12; dy++) for (let dx = 0; dx < 12; dx++) {
const ex = dx - 5.5, ey = dy - 5.5;
out.push([x + dx, y + dy, ex * ex + ey * ey <= 36.5 ? P_WHEEL : 'void']);
}
} else if (type === 'fan') {
for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 4; dx++) out.push([x + dx, y + dy, 'hull']);
} else if (type === 'cutter') {
// a hopper 4 grains wide over the saw floor; bricks drop out underneath
for (let dy = 0; dy < 8; dy++) for (let dx = 0; dx < 8; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 6 || dy >= 5 ? 'hull' : 'void']);
} else if (type === 'silo') {
// roof with a hatch, the body, a cone narrowing to the gate, and two legs; the gaps beside
// the cone are left open
for (let dy = 0; dy < 24; dy++) for (let dx = 0; dx < 12; dx++) {
let k = null;
if (dy < 2) k = dx >= 3 && dx <= 8 ? 'void' : 'hull';
else if (dy < 20) k = 'hull';
else if (dx === 0 || dx === 11) k = 'hull'; // legs
else if (dy === 20) k = dx >= 1 && dx <= 10 ? 'hull' : null;
else if (dy === 21) k = dx >= 2 && dx <= 9 ? 'hull' : null;
else k = dx >= 4 && dx <= 7 ? 'void' : dx === 3 || dx === 8 ? 'hull' : null; // the gate
if (k) out.push([x + dx, y + dy, k]);
}
} else if (type === 'refiner') {
// an open hopper 8 grains wide over the grinding floor; what it lets out drops from under the floor
for (let dy = 0; dy < 12; dy++) for (let dx = 0; dx < 12; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 10 || dy >= 10 ? 'hull' : 'void']);
} else if (type === 'dock') {
// an open-topped hopper: 2-grain walls and floor around a 20x14 bin
for (let dy = 0; dy < 16; dy++) for (let dx = 0; dx < 24; dx++) out.push([x + dx, y + dy, dx < 2 || dx >= 22 || dy >= 14 ? 'hull' : 'void']);
} else if (type === 'engine') {
for (let dy = 0; dy < 8; dy++) for (let dx = 0; dx < 12; dx++) {
if (dy === 7 && dx >= 4 && dx <= 7) out.push([x + dx, y + dy, P_INTAKE]);
else out.push([x + dx, y + dy, 'hull']);
}
}
return out;
}
function canPlaceMachine(type, bx, by) {
const P = PIECE_BY_ID[type];
if (bx < 0 || by < 0 || bx + P.bw > BW || by + P.bh > BH) return 'Out of bounds';
for (const [cx, cy] of machineFootprint(type, bx * BS, by * BS)) {
const i = cy * W + cx;
if (mach[i] || part[i]) return 'Something is already built there';
if (PH[mat[i]] === PH_SOLID) return (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there';
if (occ[i]) return 'A bar is in the way';
}
for (let y = by; y < by + P.bh; y++) for (let x = bx; x < bx + P.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
return null;
}
function addMachine(type, bx, by, dir) {
let id = machines.indexOf(null, 1);
if (id < 0) id = machines.length;
const P = PIECE_BY_ID[type];
const x = bx * BS, y = by * BS;
const mm = { id, type, x, y, w: P.w, h: P.h, dir: dir | 0, on: true, out: 0, flow: 0, energy: 0, angle: 0, net: 0, cell0: y * W + x, intake: [] };
machines[id] = mm;
const fp = machineFootprint(type, x, y);
evictCells(fp.filter(f => f[2] === 'hull').map(([cx, cy]) => cy * W + cx), y);
for (const [cx, cy, k] of fp) {
const i = cy * W + cx;
if (k === 'hull') { mat[i] = type === 'dock' ? DOCKW : HULL; flags[i] = 0; life[i] = 0; temp[i] = AMB[cy]; if (type !== 'dock') markPow(i, true); }
else if (k !== 'void') { part[i] = k; markPow(i, true); if (k === P_INTAKE) mm.intake.push(i); }
mach[i] = id;
wakeXY(cx, cy);
}
if (type === 'wheel') mm.cell0 = (y + 6) * W + x + 6;
return mm;
}
function removeMachine(id, refund, scrap) {
const mm = machines[id];
if (!mm) return;
for (const [cx, cy, k] of machineFootprint(mm.type, mm.x, mm.y)) {
const i = cy * W + cx;
if (mach[i] !== id) continue;
mach[i] = 0; markPow(i, false);
if (k === 'hull') { if (mat[i] === HULL || mat[i] === DOCKW) { mat[i] = scrap ? SCRCU : EMPTY; flags[i] = 0; } }
else if (k !== 'void') part[i] = 0;
wakeXY(cx, cy);
}
machines[id] = null;
if (refund) { refundAt(PIECE_BY_ID[mm.type].cost, 1, mm.x + mm.w / 2, mm.y + mm.h / 2); undoNote({ t: 'mach-', type: mm.type, bx: mm.x / BS, by: mm.y / BS, dir: mm.dir, on: mm.on }); }
if (scrap) emit({ t: 'toast', text: `${PIECE_BY_ID[mm.type].name} was destroyed by heat.` });
}
+159
View File
@@ -0,0 +1,159 @@
// Material ids, the material table (def), per-material property arrays, cell flags and part
// ids, and drill/tank tiers.
// ---------- Material ids ----------
const EMPTY = 0, GRASS = 1, DIRT = 2, LDIRT = 3, SAND = 4, GRAVEL = 5, CLAY = 6, STONE = 7, GRANITE = 8, BASALT = 9,
WOOD = 10, LEAVES = 11, COALV = 12, COAL = 13, CUV = 14, CUORE = 15, FEV = 16, FEORE = 17, ASH = 18, SLAG = 19,
BRICK = 20, GLASS = 21, COPPER = 22, IRON = 23, STEEL = 24, WATER = 25, MAGMA = 26, MCU = 27, MFE = 28, MST = 29,
MGL = 30, STEAM = 31, SMOKE = 32, FIRE = 33, BELTL = 34, BELTR = 35, SHAFT = 36, GATE = 37, HULL = 38, SPRING = 39,
RSTONE = 40, RGRAN = 41, RBAS = 42, LCLAY = 43, LWOOD = 44, RBRICK = 45, CULLET = 46, SCRCU = 47, SCRFE = 48, SCRST = 49,
BARCU = 50, BARFE = 51, BARST = 52, ACORN = 53, CHARCOAL = 54, SNOW = 55, ICE = 56,
SNV = 57, SNORE = 58, TIN = 59, MSN = 60, SCRSN = 61, BARSN = 62,
ZNV = 63, ZNORE = 64, ZINC = 65, MZN = 66, SCRZN = 67, BARZN = 68,
AGV = 69, AGORE = 70, SILVER = 71, MAG = 72, SCRAG = 73, BARAG = 74,
AUV = 75, AUORE = 76, GOLD = 77, MAU = 78, SCRAU = 79, BARAU = 80,
BRONZE = 81, MBZ = 82, SCRBZ = 83, BARBZ = 84, BRASS = 85, MBS = 86, SCRBS = 87, BARBS = 88, DOCKW = 89, MSLAG = 90, PIPE = 91, BARGL = 92, CUTST = 93, BARSB = 94;
const NMAT = 95;
const MD = [];
function def(id, o) {
MD[id] = Object.assign({
id, name: '?', ph: PH_SOLID, col: [255, 0, 255], v: 10, dens: 2, k: 0.01, cap: 1, hard: 1, yld: id,
melt: 0, meltTo: 0, smelt: 0, freeze: 0, freezeTo: 0, boil: 0, boilTo: 0, condT: 0, condTo: 0,
ign: 0, flame: 0, flameF: 0, fuel: 0, burnTo: 0, ashP: 0, fluid: 1, spread: 4, stick: 1, alpha: 1,
liqT: 0, bag: false, partMat: false, desc: ''
}, o);
}
def(EMPTY, { name: 'Air', ph: PH_EMPTY, k: 0.006, cap: 0.3, hard: 0, yld: 0 });
def(GRASS, { name: 'Grass', col: [78, 142, 62], v: 16, k: 0.008, hard: 1, yld: LDIRT });
def(DIRT, { name: 'Packed dirt', col: [108, 76, 50], v: 14, k: 0.008, hard: 1, yld: LDIRT });
def(LDIRT, { name: 'Dirt', ph: PH_POWDER, col: [124, 88, 58], v: 14, dens: 1.3, k: 0.007, hard: 0, stick: 0.55, bag: true, desc: 'Loose soil. Falls and piles up.' });
def(SAND, { name: 'Sand', ph: PH_POWDER, col: [216, 192, 132], v: 12, dens: 1.6, k: 0.007, hard: 0, melt: 1300, meltTo: MGL, bag: true, desc: 'Melts into glass near 1300°C.' });
def(GRAVEL, { name: 'Gravel', ph: PH_POWDER, col: [124, 121, 116], v: 30, dens: 1.9, k: 0.01, hard: 0, melt: 1250, meltTo: MAGMA, bag: true, desc: 'Loose rock. Falls like sand.' });
def(CLAY, { name: 'Clay', col: [172, 108, 88], v: 8, dens: 1.8, k: 0.009, hard: 1, yld: LCLAY, desc: 'Holds its shape. Fires into brick above ~900°C.' });
def(STONE, { name: 'Stone', col: [106, 106, 112], v: 12, k: 0.012, hard: 3, melt: 1250, meltTo: MAGMA, yld: RSTONE, desc: 'Common rock. Slumps into magma at 1250°C.' });
def(GRANITE, { name: 'Granite', col: [152, 118, 116], v: 24, k: 0.012, hard: 5, melt: 1260, meltTo: MAGMA, yld: RGRAN, desc: 'Hard rock. Needs an iron bit.' });
def(BASALT, { name: 'Basalt', col: [50, 50, 58], v: 10, k: 0.012, hard: 6, melt: 1350, meltTo: MAGMA, yld: RBAS, desc: 'The hardest rock. Needs a steel bit.' });
def(WOOD, { name: 'Wood', col: [184, 136, 82], v: 10, k: 0.005, cap: 1.2, hard: 2, ign: 300, flame: 850, flameF: 1150, fuel: 200, burnTo: ASH, ashP: 0.5, yld: LWOOD, desc: 'Burns around 850°C. Builds water wheels and shafts.' });
def(LEAVES, { name: 'Leaves', col: [54, 118, 52], v: 24, k: 0.004, hard: 0, yld: 0, ign: 240, flame: 650, flameF: 800, fuel: 20, burnTo: EMPTY });
def(COALV, { name: 'Coal seam', col: [40, 38, 41], v: 12, k: 0.01, hard: 2, yld: COAL, ign: 430, flame: 1350, flameF: 2000, fuel: 250, burnTo: ASH, ashP: 0.15 });
def(COAL, { name: 'Coal', ph: PH_POWDER, col: [32, 31, 35], v: 12, dens: 1.1, k: 0.02, hard: 0, ign: 420, flame: 1350, flameF: 2000, fuel: 250, burnTo: ASH, ashP: 0.15, bag: true, desc: 'Fuel. ~1350°C in still air, ~2000°C with forced air.' });
def(CUV, { name: 'Copper ore vein', col: [66, 142, 116], v: 34, k: 0.012, hard: 3, yld: CUORE, melt: 1085, meltTo: MCU, smelt: 0.65 });
def(CUORE, { name: 'Copper ore', ph: PH_POWDER, col: [76, 152, 126], v: 30, dens: 3.0, k: 0.012, hard: 0, melt: 1085, meltTo: MCU, smelt: 0.65, bag: true, desc: 'Melts at 1085°C into copper and slag. Mix it about half and half with coal: ore blocks air, so a layer of ore smothers the fire under it.' });
def(FEV, { name: 'Iron ore vein', col: [142, 78, 60], v: 26, k: 0.012, hard: 4, yld: FEORE, melt: 1538, meltTo: MFE, smelt: 0.6 });
def(FEORE, { name: 'Iron ore', ph: PH_POWDER, col: [154, 88, 66], v: 26, dens: 3.5, k: 0.012, hard: 0, melt: 1538, meltTo: MFE, smelt: 0.6, bag: true, desc: 'Melts at 1538°C into iron and slag.' });
def(ASH, { name: 'Ash', ph: PH_POWDER, col: [152, 149, 144], v: 18, dens: 0.5, k: 0.003, hard: 0, stick: 0.5, melt: 1250, meltTo: MSLAG, bag: true, desc: 'What is left after a fire. Fuses into slag in a fire hotter than 1250°C.' });
def(SLAG, { name: 'Slag', ph: PH_POWDER, col: [76, 66, 64], v: 34, dens: 2.4, k: 0.006, hard: 0, melt: 1150, meltTo: MSLAG, bag: true, desc: 'Smelting waste. Melts again above 1150°C. Knocking out a mold rakes it off the grate above.' });
def(MSLAG, { name: 'Molten slag', ph: PH_LIQUID, col: [232, 112, 40], v: 16, dens: 3.6, k: 0.02, cap: 1.2, hard: 0, freeze: 1000, freezeTo: SLAG, fluid: 0.5, spread: 10, liqT: 1150, desc: 'Smelting waste. Trickles down through the charge but is too thick to pass a grate, so it pools there and sets into slag. Knocking out the mold below rakes it away.' });
def(BRICK, { name: 'Brick', col: [168, 76, 56], v: 16, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, yld: RBRICK, desc: 'Fired clay. Insulates and survives 1750°C.' });
def(GLASS, { name: 'Glass', col: [178, 216, 224], v: 6, k: 0.006, hard: 2, melt: 1300, meltTo: MGL, alpha: 0.45, yld: CULLET, desc: 'Clear. Lets light through.' });
def(COPPER, { name: 'Copper', col: [200, 122, 76], v: 8, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, yld: SCRCU, desc: 'Conducts heat well. Belts, tubes, fans, engines.' });
def(IRON, { name: 'Iron', col: [128, 131, 138], v: 8, k: 0.035, cap: 1.5, hard: 5, melt: 1538, meltTo: MFE, yld: SCRFE, desc: 'Strong metal. Melts at 1538°C.' });
def(STEEL, { name: 'Steel', col: [172, 180, 192], v: 6, k: 0.035, cap: 1.5, hard: 6, melt: 1450, meltTo: MST, yld: SCRST, desc: 'Iron refined by blowing air through it while molten. The best bit material.' });
def(WATER, { name: 'Water', ph: PH_LIQUID, col: [44, 100, 194], v: 6, dens: 1, k: 0.02, cap: 4, hard: 0, boil: 100, boilTo: STEAM, fluid: 1, spread: 48, alpha: 0.78, liqT: 20, bag: true, desc: 'Boils at 100°C. Steam that rises into the open sky comes back down as rain.' });
def(MAGMA, { name: 'Magma', ph: PH_LIQUID, col: [255, 96, 26], v: 20, dens: 2.7, k: 0.012, cap: 1.2, hard: 0, freeze: 1100, freezeTo: BASALT, fluid: 0.25, spread: 10, liqT: 1250, bag: true, desc: 'Molten rock. Freezes into basalt below 1100°C.' });
def(MCU, { name: 'Molten copper', ph: PH_LIQUID, col: [255, 152, 62], v: 10, dens: 8, k: 0.04, cap: 1.5, hard: 0, freeze: 1080, freezeTo: COPPER, fluid: 0.7, spread: 20, liqT: 1160, bag: true, desc: 'Sets into copper below 1080°C.' });
def(MFE, { name: 'Molten iron', ph: PH_LIQUID, col: [255, 192, 92], v: 10, dens: 7, k: 0.035, cap: 1.5, hard: 0, freeze: 1530, freezeTo: IRON, fluid: 0.6, spread: 20, liqT: 1610, bag: true, desc: 'Sets into iron below 1530°C.' });
def(MST, { name: 'Molten steel', ph: PH_LIQUID, col: [255, 222, 142], v: 10, dens: 7.2, k: 0.035, cap: 1.5, hard: 0, freeze: 1440, freezeTo: STEEL, fluid: 0.6, spread: 20, liqT: 1520, bag: true, desc: 'Sets into steel below 1440°C.' });
def(MGL, { name: 'Molten glass', ph: PH_LIQUID, col: [255, 172, 82], v: 8, dens: 2.3, k: 0.006, cap: 1, hard: 0, freeze: 1200, freezeTo: GLASS, fluid: 0.12, spread: 5, liqT: 1350, bag: true, desc: 'Thick and slow. Sets into glass below 1200°C.' });
def(STEAM, { name: 'Steam', ph: PH_GAS, col: [214, 222, 232], v: 10, dens: 0.3, k: 0.006, cap: 0.4, hard: 0, condT: 97, condTo: WATER, alpha: 0.42, desc: 'Rises. Under a roof it turns back into water below 97°C; in the open sky it rises into the clouds and comes back down as rain.' });
def(SMOKE, { name: 'Smoke', ph: PH_GAS, col: [62, 60, 62], v: 14, dens: 0.4, k: 0.004, cap: 0.3, hard: 0, alpha: 0.55, desc: 'Burning fuel gives it off. It rises, and a fire that cannot vent it chokes.' });
def(FIRE, { name: 'Flame', ph: PH_GAS, col: [255, 150, 40], v: 40, dens: 0.2, k: 0.02, cap: 0.3, hard: 0, alpha: 0.9 });
def(BELTL, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085, meltTo: MCU, partMat: true });
def(BELTR, { name: 'Belt', col: [66, 68, 74], v: 4, k: 0.03, hard: 2, melt: 1085, meltTo: MCU, partMat: true });
def(SHAFT, { name: 'Shaft', col: [150, 110, 64], v: 6, k: 0.005, hard: 2, ign: 340, flame: 800, flameF: 1000, fuel: 140, burnTo: ASH, ashP: 0.3, partMat: true });
def(GATE, { name: 'Gate', col: [146, 74, 58], v: 6, k: 0.004, hard: 3, melt: 1750, meltTo: MAGMA, partMat: true });
def(SPRING, { name: 'Spring', col: [92, 112, 128], v: 30, k: 0.012, hard: 9, yld: 0, desc: 'Groundwater seeps out of this rock whenever there is room next to it.' });
def(RSTONE, { name: 'Stone rubble', ph: PH_POWDER, col: [126, 125, 130], v: 30, dens: 1.8, k: 0.01, hard: 0, melt: 1250, meltTo: MAGMA, bag: true, desc: 'Broken stone. 16 grains make a stone block.' });
def(RGRAN, { name: 'Granite rubble', ph: PH_POWDER, col: [164, 128, 124], v: 32, dens: 1.9, k: 0.01, hard: 0, melt: 1260, meltTo: MAGMA, bag: true, desc: 'Broken granite. Makes granite blocks.' });
def(RBAS, { name: 'Basalt rubble', ph: PH_POWDER, col: [72, 72, 80], v: 20, dens: 2.0, k: 0.01, hard: 0, melt: 1350, meltTo: MAGMA, bag: true, desc: 'Broken basalt. Basalt blocks survive 1350°C.' });
def(LCLAY, { name: 'Clay lumps', ph: PH_POWDER, col: [182, 116, 94], v: 16, dens: 1.6, k: 0.008, hard: 0, stick: 0.25, bag: true, desc: 'Sticky lumps. Clay blocks, grates and gates. Fires into brick above ~900°C.' });
def(LWOOD, { name: 'Wood chunks', ph: PH_POWDER, col: [208, 160, 98], v: 20, dens: 0.6, k: 0.005, cap: 1.2, hard: 0, stick: 0.45, ign: 280, flame: 850, flameF: 1150, fuel: 160, burnTo: ASH, ashP: 0.4, bag: true, desc: 'Fuel that floats on water. Wooden blocks, shafts and water wheels.' });
def(RBRICK, { name: 'Brick rubble', ph: PH_POWDER, col: [174, 88, 66], v: 26, dens: 1.7, k: 0.004, hard: 0, melt: 1750, meltTo: MAGMA, bag: true, desc: 'Broken or fired brick. 16 grains make a brick block.' });
def(CULLET, { name: 'Glass shards', ph: PH_POWDER, col: [196, 228, 236], v: 22, dens: 1.5, k: 0.006, hard: 0, melt: 1300, meltTo: MGL, alpha: 0.75, bag: true, desc: 'Broken glass. Glass blocks, or melt it back down.' });
def(SCRCU, { name: 'Copper scrap', ph: PH_POWDER, col: [192, 118, 74], v: 36, dens: 5, k: 0.035, cap: 1.5, hard: 0, stick: 0.7, melt: 1085, meltTo: MCU, bag: true, desc: 'Copper that set outside a mold. Melt it again and cast it.' });
def(SCRFE, { name: 'Iron scrap', ph: PH_POWDER, col: [124, 126, 132], v: 30, dens: 5, k: 0.03, cap: 1.5, hard: 0, stick: 0.7, melt: 1538, meltTo: MFE, bag: true, desc: 'Iron that set outside a mold. Melt it again and cast it.' });
def(SCRST, { name: 'Steel scrap', ph: PH_POWDER, col: [168, 176, 188], v: 28, dens: 5, k: 0.03, cap: 1.5, hard: 0, stick: 0.7, melt: 1450, meltTo: MST, bag: true, desc: 'Steel that set outside a mold. Melt it again and cast it.' });
def(BARCU, { name: 'Copper bar', ph: PH_ITEM, col: [214, 132, 82], v: 0, dens: 8.9, k: 0, hard: 0, melt: 1085, meltTo: MCU, bag: true, desc: 'A cast bar. Belts, screw tubes, fans and engines are made from these.' });
def(BARFE, { name: 'Iron bar', ph: PH_ITEM, col: [140, 143, 150], v: 0, dens: 7.9, k: 0, hard: 0, melt: 1538, meltTo: MFE, bag: true, desc: 'A cast bar of iron.' });
def(BARST, { name: 'Steel bar', ph: PH_ITEM, col: [184, 192, 204], v: 0, dens: 7.9, k: 0, hard: 0, melt: 1450, meltTo: MST, bag: true, desc: 'A cast bar of steel.' });
def(ACORN, { name: 'Acorn', ph: PH_POWDER, col: [150, 104, 56], v: 22, dens: 0.9, k: 0.006, hard: 0, stick: 0.6, ign: 300, flame: 700, flameF: 900, fuel: 30, burnTo: ASH, ashP: 0.3, bag: true, desc: 'A tree seed. Left on dirt or grass under open sky, it sprouts and grows into a tree.' });
def(CHARCOAL, { name: 'Charcoal', ph: PH_POWDER, col: [58, 50, 46], v: 16, dens: 0.45, k: 0.012, hard: 0, stick: 0.8, ign: 340, flame: 1300, flameF: 1950, fuel: 200, burnTo: ASH, ashP: 0.06, bag: true, desc: 'Wood baked without air. Burns nearly as hot as coal, leaves little ash, and floats.' });
def(SNOW, { name: 'Snow', ph: PH_POWDER, col: [232, 238, 246], v: 10, dens: 0.35, k: 0.004, cap: 0.5, hard: 0, stick: 0.4, melt: 0.5, meltTo: WATER, bag: true, desc: 'Melts into water above 0°C. Floats on water.' });
def(ICE, { name: 'Ice', col: [178, 214, 238], v: 12, k: 0.03, cap: 0.5, hard: 1, melt: 0.5, meltTo: WATER, alpha: 0.85, yld: SNOW, desc: 'Frozen water. Melts above 0°C; drills into snow.' });
// A metal family: vein in the rock, loose ore, cast solid, molten, scrap and bar.
function metalFamily(o) {
const { vein, ore, solid, molten, scrap, bar, name, col, mcol, melt, dens, k, hardOre, hardVein, smelt, oreCol, desc } = o;
if (vein) def(vein, { name: name + ' ore vein', col: oreCol.map(c => c - 10), v: 34, k: 0.012, hard: hardVein, yld: ore, melt: smelt, meltTo: molten, smelt: 0.6 });
if (ore) def(ore, { name: name + ' ore', ph: PH_POWDER, col: oreCol, v: 30, dens: 3.2, k: 0.012, hard: 0, melt: smelt, meltTo: molten, smelt: 0.6, bag: true, desc: `Smelts at ${smelt}°C into ${name.toLowerCase()} and slag. ${o.oreNote || ''}` });
def(solid, { name, col, v: 8, k, cap: 1.5, hard: o.hardSolid || 3, melt, meltTo: molten, yld: scrap, desc });
def(molten, { name: 'Molten ' + name.toLowerCase(), ph: PH_LIQUID, col: mcol, v: 10, dens, k, cap: 1.5, hard: 0, freeze: melt - 5, freezeTo: solid, fluid: 0.7, spread: 20, liqT: melt + 70, bag: true, desc: `Sets into ${name.toLowerCase()} below ${melt - 5}°C.` });
def(scrap, { name: name + ' scrap', ph: PH_POWDER, col: col.map(c => c - 8), v: 32, dens: 5, k, cap: 1.5, hard: 0, stick: 0.7, melt, meltTo: molten, bag: true, desc: `${name} that set outside a mold. Melt it again and cast it.` });
def(bar, { name: name + ' bar', ph: PH_ITEM, col: col.map(c => Math.min(255, c + 12)), v: 0, dens, k: 0, hard: 0, melt, meltTo: molten, bag: true, desc: `A cast bar of ${name.toLowerCase()}.` });
}
metalFamily({ vein: SNV, ore: SNORE, solid: TIN, molten: MSN, scrap: SCRSN, bar: BARSN, name: 'Tin', oreCol: [140, 156, 178], col: [196, 200, 206], mcol: [232, 234, 240], melt: 232, smelt: 1000, dens: 7, k: 0.03, hardVein: 3, hardSolid: 2,
oreNote: 'Melt tin into molten copper to make bronze.', desc: 'Soft and silvery. Melts at only 232°C. Mixed into molten copper it makes bronze.' });
metalFamily({ vein: ZNV, ore: ZNORE, solid: ZINC, molten: MZN, scrap: SCRZN, bar: BARZN, name: 'Zinc', oreCol: [126, 100, 44], col: [166, 178, 190], mcol: [214, 224, 236], melt: 420, smelt: 1000, dens: 6.6, k: 0.03, hardVein: 4,
oreNote: 'Melt zinc into molten copper to make brass.', desc: 'Bluish metal that melts at 420°C. Mixed into molten copper it makes brass.' });
metalFamily({ vein: AGV, ore: AGORE, solid: SILVER, molten: MAG, scrap: SCRAG, bar: BARAG, name: 'Silver', oreCol: [200, 204, 214], col: [214, 216, 224], mcol: [240, 236, 228], melt: 962, smelt: 962, dens: 9.3, k: 0.045, hardVein: 4,
oreNote: 'Found deep in granite. Valuable even raw.', desc: 'A precious metal. Sells high.' });
metalFamily({ vein: AUV, ore: AUORE, solid: GOLD, molten: MAU, scrap: SCRAU, bar: BARAU, name: 'Gold', oreCol: [218, 182, 62], col: [232, 190, 72], mcol: [255, 214, 120], melt: 1064, smelt: 1064, dens: 17, k: 0.045, hardVein: 5,
oreNote: 'The deepest and rarest ore. Sells for a fortune.', desc: 'The most valuable metal there is.' });
metalFamily({ solid: BRONZE, molten: MBZ, scrap: SCRBZ, bar: BARBZ, name: 'Bronze', col: [184, 126, 72], mcol: [255, 170, 90], melt: 950, dens: 8.7, k: 0.035, hardSolid: 4,
desc: 'Copper with a little tin melted in. Harder than copper and worth far more.' });
metalFamily({ solid: BRASS, molten: MBS, scrap: SCRBS, bar: BARBS, name: 'Brass', col: [212, 174, 84], mcol: [255, 196, 104], melt: 930, dens: 8.4, k: 0.035, hardSolid: 4,
desc: 'Copper with zinc melted in. Golden and valuable.' });
def(BARGL, { name: 'Glass brick', ph: PH_ITEM, col: [176, 218, 228], v: 0, dens: 2.5, k: 0, hard: 0, melt: 1300, meltTo: MGL, bag: true, desc: 'Cast from molten glass in an Ingot mold: 8 grains of glass. Sells well, and 2 make a glass wall block.' });
def(CUTST, { name: 'Cut stone', col: [168, 162, 150], v: 6, k: 0.012, hard: 4, melt: 1450, meltTo: MAGMA, yld: BARSB, desc: 'Dressed stone blocks laid in courses. Neater than rough stone, and it stands 200°C more heat before it slumps.' });
def(BARSB, { name: 'Stone brick', ph: PH_ITEM, col: [172, 166, 154], v: 0, dens: 2.6, k: 0, hard: 0, melt: 1450, meltTo: MAGMA, bag: true, desc: 'A block of stone cut square by a Stone cutter, from 8 grains of rubble. Builders buy them, and 2 make a cut stone wall block.' });
def(PIPE, { name: 'Pipe', col: [176, 112, 72], v: 4, k: 0.04, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true });
def(DOCKW, { name: 'Trade dock', col: [70, 82, 96], v: 6, k: 0.01, cap: 2, hard: 9, yld: 0, partMat: true, desc: 'The claim office\'s trade dock. Whatever drops through the grate in its floor is sold.' });
def(HULL, { name: 'Machine', col: [84, 92, 102], v: 6, k: 0.03, cap: 1.5, hard: 4, melt: 1085, meltTo: MCU, partMat: true });
// Typed property tables
const PH = new Uint8Array(NMAT), DENS = new Float32Array(NMAT), KC = new Float32Array(NMAT), ICAP = new Float32Array(NMAT),
HARD = new Uint8Array(NMAT), YLD = new Uint8Array(NMAT), MELT = new Float32Array(NMAT), MELTTO = new Uint8Array(NMAT),
SMELTP = new Float32Array(NMAT), FRZ = new Float32Array(NMAT), FRZTO = new Uint8Array(NMAT), BOIL = new Float32Array(NMAT),
BOILTO = new Uint8Array(NMAT), CONDT = new Float32Array(NMAT), CONDTO = new Uint8Array(NMAT), IGN = new Float32Array(NMAT),
FLAME = new Float32Array(NMAT), FLAMEF = new Float32Array(NMAT), FUEL = new Uint8Array(NMAT), BURNTO = new Uint8Array(NMAT),
ASHP = new Float32Array(NMAT), FLUID = new Float32Array(NMAT), SPREAD = new Uint8Array(NMAT), STICK = new Float32Array(NMAT),
ALPHA = new Float32Array(NMAT), LIQT = new Float32Array(NMAT), BAGOK = new Uint8Array(NMAT), PARTMAT = new Uint8Array(NMAT),
RELAX = new Float32Array(NMAT), COLR = new Uint8Array(NMAT), COLG = new Uint8Array(NMAT), COLB = new Uint8Array(NMAT),
CVAR = new Uint8Array(NMAT);
for (let i = 0; i < NMAT; i++) {
const d = MD[i];
PH[i] = d.ph; DENS[i] = d.dens; KC[i] = d.k; ICAP[i] = 1 / d.cap; HARD[i] = d.hard; YLD[i] = d.yld;
MELT[i] = d.melt; MELTTO[i] = d.meltTo; SMELTP[i] = d.smelt; FRZ[i] = d.freeze; FRZTO[i] = d.freezeTo;
BOIL[i] = d.boil; BOILTO[i] = d.boilTo; CONDT[i] = d.condT; CONDTO[i] = d.condTo; IGN[i] = d.ign; FLAME[i] = d.flame;
FLAMEF[i] = d.flameF; FUEL[i] = d.fuel; BURNTO[i] = d.burnTo; ASHP[i] = d.ashP; FLUID[i] = d.fluid; SPREAD[i] = d.spread;
STICK[i] = d.stick; ALPHA[i] = d.alpha; LIQT[i] = d.liqT; BAGOK[i] = d.bag ? 1 : 0; PARTMAT[i] = d.partMat ? 1 : 0;
COLR[i] = d.col[0]; COLG[i] = d.col[1]; COLB[i] = d.col[2]; CVAR[i] = d.v;
RELAX[i] = d.ph === PH_LIQUID ? 0.0006 : d.ph === PH_POWDER ? 0.0005 : d.ph === PH_GAS ? 0.003 : 0.0004;
}
// what molds cast (molten metals, and molten glass) and what they set into
const CASTMETAL = new Uint8Array(NMAT); for (const m of [MCU, MFE, MST, MSN, MZN, MAG, MAU, MBZ, MBS, MGL]) CASTMETAL[m] = 1;
// tier as a drill bit or tank (0: too soft to be a bit)
const METAL_TIER = new Int8Array(NMAT).fill(-1); METAL_TIER[COPPER] = 1; METAL_TIER[IRON] = 2; METAL_TIER[STEEL] = 3;
METAL_TIER[TIN] = 0; METAL_TIER[GLASS] = 0; METAL_TIER[ZINC] = 0; METAL_TIER[SILVER] = 0; METAL_TIER[GOLD] = 0; METAL_TIER[BRONZE] = 1; METAL_TIER[BRASS] = 1;
const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, TIN, ZINC, SILVER, GOLD, BRONZE, BRASS, GLASS]) SOLIDMETAL[m] = 1;
// ---------- Flags & parts ----------
const F_NAT = 1, F_CAST = 2, F_BURN = 4;
const P_NONE = 0, P_TU = 1, P_TD = 2, P_TL = 3, P_TR = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10;
const TDX = [0, 0, 0, -1, 1], TDY = [0, -1, 1, 0, 0];
// ---------- Tool tiers ----------
// reach: how far from the bot the drill works. Harder rock takes longer, and
// every new bit cuts faster and further.
const BIT_TIERS = [
{ name: 'Stone bit', hard: 3, speed: 0.7, reach: 16 },
{ name: 'Copper bit', hard: 4, speed: 1.0, reach: 24 },
{ name: 'Iron bit', hard: 5, speed: 1.5, reach: 34 },
{ name: 'Steel bit', hard: 6, speed: 2.2, reach: 46 },
];
const TANK_TIERS = [
{ name: 'Canvas sack', cap: 1500 },
{ name: 'Copper tank', cap: 4000 },
{ name: 'Iron tank', cap: 10000 },
{ name: 'Steel tank', cap: 25000 },
];
const REACH = 64;
+284
View File
@@ -0,0 +1,284 @@
// Molds: cavities that fill with molten metal (or glass) and are knocked out as bars.
// ---------- Molds ----------
function moldCellsAt(kind, x, y) {
const md = MOLDS[kind]; const walls = [];
for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) if (md.rows[r][c] === '#') walls.push((y + r) * W + x + c);
const cavs = md.cavs.map(comp => comp.map(([c, r]) => (y + r) * W + x + c));
return { walls, cavs };
}
function canPlaceMold(kind, bx, by) {
const md = MOLDS[kind];
if (bx < 0 || by < 0 || bx + md.bw > BW || by + md.bh > BH) return 'Out of bounds';
for (let y = by; y < by + md.bh; y++) for (let x = bx; x < bx + md.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
const { walls, cavs } = moldCellsAt(kind, bx * BS, by * BS);
for (const i of walls.concat(...cavs)) if (!cellBuildable(i)) return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there';
if (moldAt(bx * BS + 1, by * BS + 1)) return 'There is already a mold there';
return null;
}
function newMoldRecord(kind, x, y, wall) {
const md = MOLDS[kind];
return { id: (molds.length ? molds[molds.length - 1].id : 0) + 1, kind, x, y, w: md.w, h: md.h, wall, cavs: moldCellsAt(kind, x, y).cavs, ready: [], state: 'empty', metal: 0, fill: 0, flaw: '' };
}
function addMold(kind, bx, by, wallLoose, free) {
const md = MOLDS[kind];
if (!WALL_OF[wallLoose]) return 'Pick a wall material with R';
const cost = wallCost(wallLoose, md.walls);
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
const x = bx * BS, y = by * BS;
const { walls, cavs } = moldCellsAt(kind, x, y);
evictCells(walls.concat(...cavs), y);
for (const i of walls) setCell(i, WALL_OF[wallLoose], AMB[(i / W) | 0], 0);
const mo = newMoldRecord(kind, x, y, wallLoose);
molds.push(mo);
stats.placed++;
return mo;
}
// Slag and ash that reach a mold float on the metal. They are chipped off and thrown
// away when the casting is knocked out, so they never spoil it unless metal set over them.
const DROSS = new Uint8Array(NMAT); DROSS[SLAG] = 1; DROSS[MSLAG] = 1; DROSS[ASH] = 1;
// Two metals in one casting: iron with some steel in it (air got at it) casts as plain iron;
// anything else is a mix that spoils it. Returns the metal the casting counts as, or -1.
function castMix(a, b) { return a === b ? a : (a === IRON && b === STEEL) || (a === STEEL && b === IRON) ? IRON : -1; }
function cavityState(cav) {
let solid = 0, liq = 0, metal = -1, mixed = false, dirty = false, uncast = false, dross = 0;
for (const i of cav) {
const m = mat[i];
if (SOLIDMETAL[m]) {
solid++;
if (!(flags[i] & F_CAST)) uncast = true;
if (metal < 0) metal = m; else if (metal !== m) { const c = castMix(metal, m); if (c < 0) mixed = true; else metal = c; }
} else if (CASTMETAL[m]) {
liq++;
const sm = FRZTO[m];
if (metal < 0) metal = sm; else if (metal !== sm) { const c = castMix(metal, sm); if (c < 0) mixed = true; else metal = c; }
} else if (DROSS[m]) {
dross++;
if (i >= W && SOLIDMETAL[mat[i - W]]) dirty = true; // metal set on top of it: it is stuck inside the casting
} else if (m !== EMPTY && PH[m] !== PH_GAS) dirty = true;
}
const n = cav.length;
let st;
if (liq > 0) st = 'filling';
else if (solid === 0) st = 'empty';
else if (solid === n && !mixed && !uncast) st = 'ready';
else if (dirty || mixed || uncast) st = 'flawed';
else st = 'short';
const flaw = dirty ? 'Slag or grit got stuck inside the casting. Press F to knock it out as scrap and start again.' : mixed ? 'Two different metals mixed in the casting. Press F to knock it out.' : uncast ? 'That metal was not cast here. Pour molten metal in.'
: st === 'short' ? `Not full: ${solid} of ${n} grains${dross ? ', with slag floating on top' : ''}. Fire the furnace again to top it up, or press F to knock it out as scrap.` : '';
return { st, fill: (solid + liq) / n, metal, flaw };
}
// The ingot trough: ready as soon as at least one bar's worth has set and nothing is still molten.
// It is forgiving: slag, ash or ore that got in is skimmed off when you knock the ingot out.
function troughState(mo) {
let solid = 0, liq = 0, junk = 0, metal = -1, mixed = false;
for (const i of mo.cavs[0]) {
const m = mat[i];
if (SOLIDMETAL[m]) { solid++; if (metal < 0) metal = m; else if (metal !== m) { const c = castMix(metal, m); if (c < 0) mixed = true; else metal = c; } }
else if (CASTMETAL[m]) { liq++; if (metal < 0) metal = FRZTO[m]; }
else if (m !== EMPTY && PH[m] !== PH_GAS && !DROSS[m]) junk++;
}
const bars = mixed ? 0 : Math.floor(solid / GRAINS_PER_BAR), n = mo.cavs[0].length;
const st = liq ? 'filling' : bars ? 'ready' : mixed ? 'flawed' : solid ? 'short' : junk ? 'flawed' : 'empty';
const flaw = st === 'flawed' ? (mixed ? 'Two different metals mixed in the ingot. Press F to knock it out as scrap.' : 'Only junk in the trough. Press F to knock it out.')
: st === 'short' ? `Only ${solid} grains set. A bar takes ${GRAINS_PER_BAR}. Fire again to top it up, or press F to knock it out as scrap.` : '';
return { st, bars, fill: (solid + liq) / n, metal, flaw };
}
// Empties cells: metal goes to your bag (or beside you if the bag is full) as scrap, other loose
// grains (ore, coal) as they are, and slag and ash are thrown away. Molten metal is left alone.
// Returns what went to the bag, and how much dross was thrown away as got.dross.
function clearCavity(cav) {
const got = new Map();
let dross = 0;
for (const i of cav) {
const m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS || CASTMETAL[m]) continue;
if (DROSS[m]) dross++;
else {
const loose = SOLIDMETAL[m] ? (YLD[m] || SCRCU) : PH[m] === PH_SOLID ? YLD[m] : m;
if (loose) got.set(loose, (got.get(loose) || 0) + 1);
}
setCell(i, EMPTY, AMB[(i / W) | 0], 0);
}
for (const [m, n] of got) giveOrDrop(m, n, bot.x + bot.w / 2, bot.y + bot.h / 2);
got.dross = dross;
return got;
}
// Metal that set where it should not have: the open part of the mold above the cavity (overflow
// that sets as a plate over it) and the grate cells just above the mold (late drips that froze
// on the way down). Left there, it plugs the way down for the next firing. Returns the cells.
function moldPlugCells(mo) {
const { walls } = moldCellsAt(mo.kind, mo.x, mo.y), wall = new Set(walls), cav = new Set(mo.cavs.flat()), out = [];
for (let y = mo.y; y < mo.y + mo.h; y++) for (let x = mo.x; x < mo.x + mo.w; x++) {
const i = y * W + x, m = mat[i];
if (wall.has(i) || cav.has(i) || m === EMPTY || PH[m] === PH_GAS || CASTMETAL[m]) continue;
if (SOLIDMETAL[m] || PH[m] === PH_POWDER || DROSS[m]) out.push(i);
}
for (let y = Math.max(0, mo.y - 8); y < mo.y; y++) for (let x = mo.x; x < mo.x + mo.w; x++) {
const i = y * W + x;
if (part[i] === P_GRATE && SOLIDMETAL[mat[i]]) out.push(i);
}
return out;
}
// Knocking a mold out also rakes the furnace over it: slag and ash lying on the grate and in
// the charge above (up to the furnace roof) are cleared away, so the next charge sits on a
// clean grate. Unburnt coal and ore stay for the next firing. Returns how many grains it raked.
function rakeAbove(mo) {
let n = 0;
for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) {
const i = y * W + x, m = mat[i];
if (part[i] !== P_GRATE && (occ[i] || (PH[m] === PH_SOLID && !DROSS[m]))) break; // the roof, a wall or a machine
if (DROSS[m] && !(flags[i] & F_BURN)) { setCell(i, EMPTY, AMB[y], 0); n++; }
}
if (n) wakeXY(mo.x + (mo.w >> 1), mo.y - 8);
return n;
}
// After a knock-out: if the furnace over the mold is badly loaded, say what to change
function furnaceJunkHint(mo) {
let ore = 0, fuel = 0, open = 0;
for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) {
const i = y * W + x, m = mat[i];
if (part[i] === P_GRATE) continue;
if (occ[i] || PH[m] === PH_SOLID) break;
if (SMELTP[m] && PH[m] === PH_POWDER) ore++;
else if (FUEL[m] && PH[m] === PH_POWDER) fuel++;
else if (m === EMPTY || PH[m] === PH_GAS) open++;
}
if (ore > 40 && fuel < ore * 0.3) return ' A lot of ore is left and little fuel: add mostly coal next time.';
if (ore + fuel > 60 && open < (ore + fuel) * 0.5) return ' The furnace is packed too full to burn well. Leave the top third empty so air gets in.';
if (ore > 60) return ' A lot of ore did not melt: mix the next charge evenly with coal.';
return '';
}
// Knocking a mold also clears metal stuck over and above it, and rakes the furnace.
// Returns { metal: grains of metal that went to your bag, chipped: slag thrown away from the
// mold, raked: slag and ash raked out of the furnace }.
function unplugMold(mo) {
const plug = moldPlugCells(mo);
const got = plug.length ? clearCavity(plug) : new Map();
let metal = 0;
for (const [k, c] of got) if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) metal += c;
return { metal, chipped: got.dross || 0, raked: rakeAbove(mo) };
}
const grains = n => `${n} ${n === 1 ? 'grain' : 'grains'}`;
function checkMolds() {
for (const mo of molds) {
if (mo.kind === 'ingot') {
const r = troughState(mo);
mo.ready = []; if (r.st === 'ready') for (let k = 0; k < r.bars; k++) mo.ready.push(k);
if (r.st === 'ready' && mo.state !== 'ready') emit({ t: 'cast_ready', mold: mo.id, kind: mo.kind, metal: r.metal, bars: r.bars, x: mo.x + (mo.w >> 1), y: mo.y });
mo.state = r.st; mo.fill = r.fill; mo.metal = r.metal; mo.flaw = r.flaw;
continue;
}
const res = mo.cavs.map(cavityState);
mo.ready = [];
res.forEach((r, k) => { if (r.st === 'ready') mo.ready.push(k); });
let st;
if (mo.ready.length) st = 'ready';
else if (res.some(r => r.st === 'filling')) st = 'filling';
else if (res.some(r => r.st === 'flawed')) st = 'flawed';
else if (res.some(r => r.st === 'short')) st = 'short';
else st = 'empty';
mo.fill = res.reduce((a, r) => a + r.fill, 0) / res.length;
const ref = mo.ready.length ? res[mo.ready[0]] : res.find(r => r.metal >= 0) || res[0];
mo.flaw = (res.find(r => r.flaw) || {}).flaw || '';
if (st === 'ready' && mo.state !== 'ready') emit({ t: 'cast_ready', mold: mo.id, kind: mo.kind, metal: ref.metal, x: mo.x + (mo.w >> 1), y: mo.y });
mo.state = st; mo.metal = ref.metal;
}
}
function moldAt(x, y) {
for (const mo of molds) if (x >= mo.x && y >= mo.y && x < mo.x + mo.w && y < mo.y + mo.h) return mo;
return null;
}
// What a knock-out sent where, as one short sentence after the main message
function knockNote(scrap, other, chipped, raked) {
let s = '';
if (scrap) s += ` The leftover ${grains(scrap)} of scrap went into your bag${other ? ', with the grit that got in' : ''}.`;
else if (other) s += ' The grit that got in went into your bag.';
if (chipped && raked) s += ' The slag was chipped off and the grate raked clean.';
else if (chipped) s += ' The slag was chipped off.';
else if (raked) s += ' Slag and ash were raked off the grate.';
return s;
}
function tally(got) {
let scrap = 0, other = 0;
for (const [k, c] of got) { if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) scrap += c; else other += c; }
return { scrap, other, dross: got.dross || 0 };
}
function collectMold(mo) {
if (mo.state === 'filling') return 'The metal has not fully set yet.';
if (mo.state === 'empty') {
const g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo);
const scrap = g.scrap + u.metal;
if (!scrap && !g.other && !g.dross && !u.chipped && !u.raked) return 'The mold is empty.';
emit({ t: 'sfx', s: 'ui' });
return 'Knocked the mold clean.' + knockNote(scrap, g.other, g.dross + u.chipped, u.raked);
}
let msg = '';
if (mo.kind === 'ingot') {
const cav = mo.cavs[0];
let solid = 0, T = 0;
const m = mo.metal;
if (mo.state === 'ready') for (const i of cav) if (SOLIDMETAL[mat[i]]) { solid++; T += temp[i]; }
T /= Math.max(1, solid);
const bars = Math.floor(solid / GRAINS_PER_BAR);
// the ingot snaps into bars that pop up and out of the trough; take the metal for them first
let take = bars * GRAINS_PER_BAR;
for (const i of cav.slice().sort((a, b) => b - a)) if (take > 0 && SOLIDMETAL[mat[i]] && castMix(m, mat[i]) === m) { setCell(i, EMPTY, AMB[(i / W) | 0], 0); take--; }
let n = 0;
for (let k = 0; k < bars; k++) {
const e = spawnBar(BAR_OF[m], mo.x + 1 + k * 4, mo.y - 3, (k - (bars - 1) / 2) * 0.3 + (rnd() - 0.5) * 0.15, -0.55, T);
if (e) n++; else bagAdd(BAR_OF[m], 1, true);
}
// the stub too short for a bar, and anything else that got in, are cleared out: the trough is left clean
const u = unplugMold(mo), g = tally(clearCavity(cav));
stats.bars += n; if (n) stats.cast++;
mo.state = 'empty'; mo.ready = [];
emit({ t: 'sfx', s: n ? 'cast' : 'ui' });
const word = m === GLASS ? (n === 1 ? 'glass brick' : 'glass bricks') : n === 1 ? 'bar' : 'bars';
return (n ? `Knocked out ${n} ${word}. Vacuum them up.` : 'Knocked the trough clean.') + knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
}
if (mo.state === 'short' && !(mo.knockAsk != null && tick - mo.knockAsk >= 0 && tick - mo.knockAsk < 300)) {
// an unfinished casting can still be topped up, so the first press only says so
mo.knockAsk = tick;
return (mo.flaw || 'The casting is not full yet.').replace(', or press F to knock it out as scrap.', '.') + ' Press F again to knock it out as scrap.';
}
if (mo.state === 'flawed' || mo.state === 'short') {
// a spoiled or unfinished casting is knocked out as scrap, so the mold is ready for another go
const short = mo.state === 'short', g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo);
mo.state = 'empty'; mo.ready = [];
emit({ t: 'sfx', s: 'ui' });
const what = mo.kind === 'bit' ? 'bit' : 'casting';
return (short ? `The ${what} was not full, so it was knocked out as scrap.` : `The ${what} was spoiled, so it was knocked out as scrap.`)
+ knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
}
const tier = METAL_TIER[mo.metal];
const cav = mo.cavs[0];
if (tier <= 0) { msg = `${MD[mo.metal] ? MD[mo.metal].name : 'That metal'} is too ${mo.metal === GLASS ? 'brittle' : 'soft'} for a ${mo.kind === 'bit' ? 'drill bit' : 'tank'}. The casting broke into scrap, which went into your bag.`; }
else if (mo.kind === 'bit') {
if (tier > player.bitTier) { player.bitTier = tier; msg = `New drill bit: ${BIT_TIERS[tier].name}.`; emit({ t: 'upgrade', kind: 'bit', tier }); }
else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}. The casting broke into scrap, which went into your bag.`; }
} else {
if (tier > player.tankTier) { player.tankTier = tier; msg = `New bag: ${TANK_TIERS[tier].name}, holds ${TANK_TIERS[tier].cap.toLocaleString('en-US')}.`; emit({ t: 'upgrade', kind: 'tank', tier }); }
else { msg = 'Your tank is already as good. The casting broke into scrap, which went into your bag.'; }
}
// a casting you could not use breaks into scrap, which goes to your bag
if (msg.startsWith('New')) for (const i of cav) setCell(i, EMPTY, AMB[(i / W) | 0], 0);
else clearCavity(cav);
const u = unplugMold(mo);
mo.state = 'empty'; stats.cast++;
return msg + knockNote(0, 0, u.chipped, u.raked) + furnaceJunkHint(mo);
}
function removeMold(mo) {
undoNote({ t: 'mold-', kind: mo.kind, bx: mo.x / BS, by: mo.y / BS, wall: mo.wall });
const { walls } = moldCellsAt(mo.kind, mo.x, mo.y);
let metal = 0;
for (const i of walls) {
const m = mat[i];
if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) {
if (BAR_OF[m] && PH[mo.wall] === PH_ITEM) { metal++; setCell(i, EMPTY, temp[i], 0); }
else if (YLD[m] && bagAdd(YLD[m], 1, false) === 0) { setCell(i, YLD[m], temp[i], 0); droppedLast++; }
else setCell(i, EMPTY, temp[i], 0);
}
}
if (metal) giveOrDrop(BAR_OF[WALL_OF[mo.wall]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), mo.x + 8, mo.y);
molds = molds.filter(m => m !== mo);
}
+168
View File
@@ -0,0 +1,168 @@
// Trees (background layer), acorns, saplings, and waking freezing water.
// ---------- Trees (background layer) ----------
// Trunk cells drill into wood chunks; leaves just fall apart. A tree whose trunk
// no longer reaches the ground comes down: every trunk cell drops as wood chunks.
function cutTree(i, x, y, tier, amt = 1) {
const b = bgm[i];
const p = digp[i] + amt * tier.speed * 70 / (1 + HARD[b]);
if (p < 255) { digp[i] = p; return false; }
digp[i] = 0; bgm[i] = 0;
if (b === WOOD && woodRoom(i)) { setCell(i, LWOOD, temp[i], 0); discover(LWOOD, x, y, WOOD, 'dig'); }
else if (b === LEAVES && mat[i] === EMPTY && rnd() < 0.03) { setCell(i, ACORN, temp[i], 0); discover(ACORN, x, y, LEAVES, 'dig'); }
else { wakeXY(x, y); emit({ t: 'debris', x, y, m: b }); }
if (b === WOOD) for (const [dx, dy] of [[1, 0], [-1, 0], [0, -1], [0, 1], [1, -1], [-1, -1]]) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && ny >= 0 && nx < W && ny < H && bgm[ny * W + nx]) treeFall(ny * W + nx);
}
return true;
}
// A trunk cell is held up by solid ground or soil directly beneath it.
function treeHeld(i) {
if (i + W >= N) return true;
const b = mat[i + W];
return PH[b] === PH_SOLID || SOIL[b] === 1;
}
// Base cells of every tree (the lowest trunk cells). Checked now and then: a tree
// whose ground has been dug or washed away comes down.
let treeBases = [];
function findTreeBases() {
treeBases = [];
for (let i = 0; i < N - W; i++) if (bgm[i] === WOOD && bgm[i + W] !== WOOD) treeBases.push(i);
}
function treeSupportTick() {
for (let k = treeBases.length - 1; k >= 0; k--) {
const i = treeBases[k];
if (bgm[i] !== WOOD) { treeBases.splice(k, 1); continue; }
if (!treeHeld(i)) { treeBases.splice(k, 1); treeFall(i); }
}
}
// Make room for a wood chunk where a trunk cell was: air is fine; water there is
// pushed to the nearest open cell (the chunk then floats up).
function woodRoom(i) {
const m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS) return true;
if (PH[m] !== PH_LIQUID) return false;
const T = temp[i];
mat[i] = EMPTY;
depositNear(m, T, i % W, (i / W) | 0, i); // if there is nowhere at all, that bit of water is lost
return true;
}
function treeFall(start) {
if (bgm[start] !== WOOD) return;
// follow the wood (trunk and branches) to see whether any of it still stands on something
const seenT = new Set([start]), st = [start], woodCells = [];
let grounded = false, bx0 = W, bx1 = 0, by0 = H, by1 = 0;
while (st.length && woodCells.length < 2000) {
const i = st.pop(); woodCells.push(i);
const x = i % W, y = (i / W) | 0;
if (treeHeld(i)) { grounded = true; break; }
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x; if (y < by0) by0 = y; if (y > by1) by1 = y;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = ny * W + nx; if (bgm[j] === WOOD && !seenT.has(j)) { seenT.add(j); st.push(j); }
}
}
if (grounded) return;
// its crown: leaves connected to it, not straying far from its own wood
const cells = woodCells.slice();
for (const i of woodCells) st.push(i);
bx0 -= 9; bx1 += 9; by0 -= 12; by1 += 4;
while (st.length && cells.length < 4000) {
const i = st.pop(), x = i % W, y = (i / W) | 0;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx, ny = y + dy; if (nx < bx0 || ny < by0 || nx > bx1 || ny > by1 || nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
const j = ny * W + nx; if (bgm[j] === LEAVES && !seenT.has(j)) { seenT.add(j); st.push(j); cells.push(j); }
}
}
let wood = 0;
// a felled tree drops a few acorns from its crown
const crown = cells.filter(i => bgm[i] === LEAVES && mat[i] === EMPTY);
const nAcorn = Math.min(crown.length, 1 + (crown.length > 60 ? 1 : 0) + (rnd() < 0.5 ? 1 : 0));
for (let k = 0; k < nAcorn; k++) { const i = crown[(rnd() * crown.length) | 0]; if (mat[i] === EMPTY) { setCell(i, ACORN, AMB[(i / W) | 0], 0); bgm[i] = 0; discover(ACORN, i % W, (i / W) | 0, LEAVES, 'dig'); } }
for (const i of cells) {
const x = i % W, y = (i / W) | 0;
if (bgm[i] === WOOD && woodRoom(i)) { setCell(i, LWOOD, temp[i], 0); wood++; }
else if (bgm[i] && (x + y) % 3 === 0) emit({ t: 'debris', x, y, m: bgm[i] });
bgm[i] = 0; wakeXY(x, y);
}
if (wood) { discover(LWOOD, cells[0] % W, (cells[0] / W) | 0, WOOD, 'dig'); emit({ t: 'sfx', s: 'decon' }); }
}
// Acorns resting on soil under open sky sprout after a while; saplings grow
// into full trees in the background layer.
let saplings = [];
// Heat only runs where something is off ambient, so water resting in freezing air
// would never be looked at. Wake its area now and then so it can freeze.
function coldTick() {
for (let y = 0; y < H && AMB[y] < -0.5; y++) {
const yw = y * W;
for (let x = 0; x < W; x++) if (mat[yw + x] === WATER) { wakeHeatXY(x, y); x |= 31; }
}
}
function plantTick() {
for (let x = 0; x < W; x++) {
const y = roofY[x];
if (y <= 6 || y >= H - 1) continue;
const i = y * W + x;
if (mat[i] !== ACORN || (flags[i] & F_BURN)) continue;
if (!SOIL[mat[i + W]] || temp[i] < 2 || temp[i] > 45) { life[i] = 0; continue; }
let room = true;
for (let k = 1; k <= 6 && room; k++) { const j = i - k * W; if (PH[mat[j]] !== PH_EMPTY && PH[mat[j]] !== PH_GAS || bgm[j] === WOOD) room = false; }
if (!room) continue;
if (++life[i] < 14) continue;
setCell(i, EMPTY, AMB[y], 0);
saplings.push({ x, y: y + 1, age: 0, th: 8 + ((rnd() * 7) | 0), seed: (rnd() * 1e9) | 0 });
if (!stats.sprouted) { stats.sprouted = 1; emit({ t: 'sprout', x, y }); }
}
}
let treeDraw = null; // cells the sapling being drawn has set, so the next stage can redraw it
function setTreeCell(x, y, m) {
if (x < 0 || y < 1 || x >= W || y >= H) return;
const i = y * W + x;
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return;
if (m === LEAVES && bgm[i]) return;
bgm[i] = m;
if (treeDraw) treeDraw.push(i);
}
function saplingTick() {
for (let k = saplings.length - 1; k >= 0; k--) {
const sp = saplings[k], base = sp.y * W + sp.x;
// the soil it grows from must still be there
if (sp.y >= H || !(SOIL[mat[base]] || PH[mat[base]] === PH_SOLID)) { saplings.splice(k, 1); continue; }
if (sp.age > 2 && bgm[base - W] !== WOOD) { saplings.splice(k, 1); continue; } // cut down or burned while young
// if any trunk it grew is gone (cut or burned), it stops growing and is left as it is
if (sp.wood && sp.wood.some(i => bgm[i] !== WOOD)) { saplings.splice(k, 1); continue; }
sp.age++;
const s = Math.min(1, sp.age / 180);
// wipe the previous stage, then draw this one
if (sp.cells) for (const i of sp.cells) if (bgm[i] === LEAVES || (bgm[i] === WOOD && i !== base - W)) bgm[i] = 0;
treeDraw = sp.cells = [];
const th = Math.max(1, Math.round(sp.th * s)), wide = s > 0.5 ? 2 : 1;
for (let j = 1; j <= th; j++) for (let dx = 0; dx < wide; dx++) setTreeCell(sp.x + dx, sp.y - j, WOOD);
if (s > 0.2) {
// the same lumpy crown as wild trees, scaled with age
const q = s, h0 = sp.seed;
if (s >= 1) for (const side of [-1, 1]) { const by = sp.y - th + 3 + (h0 >> (side > 0 ? 3 : 5)) % 3; for (let j = 1; j <= Math.round(3 * q); j++) setTreeCell(sp.x + (side < 0 ? -j : 1 + j), by - (j >> 1), WOOD); }
const blobs = [[0.5, -th - 1, (6 + (h0 % 25) / 10) * q], [-3.5 * q, -th + 1, (4 + (h0 >> 4) % 20 / 10) * q], [4.5 * q, -th + 1, (4 + (h0 >> 8) % 20 / 10) * q], [0.5, -th - 5 * q, (4 + (h0 >> 12) % 20 / 10) * q]];
for (const [bx, by, r] of blobs) {
if (r < 1) continue;
const cx = sp.x + bx, cy = sp.y + by;
for (let yy = Math.floor(cy - r); yy <= cy + r; yy++) for (let xx = Math.floor(cx - r); xx <= cx + r; xx++) {
const ex = (xx - cx) / r, ey = (yy - cy) / (r * 0.85);
if (ex * ex + ey * ey <= 1 && hash2(xx, yy, worldSeed + 7) % 9 !== 0) setTreeCell(xx, yy, LEAVES);
}
}
}
treeDraw = null;
sp.wood = sp.cells.filter(i => bgm[i] === WOOD);
for (const i of sp.wood) if (bgm[i + W] !== WOOD && !treeBases.includes(i)) treeBases.push(i);
if (s >= 1) { saplings.splice(k, 1); stats.grown = (stats.grown || 0) + 1; }
}
}
// A flame next to a tree sets that part of it alight: it moves into the
// foreground as burning wood or leaves and burns like any other fuel.
function igniteTree(j) {
const b = bgm[j]; bgm[j] = 0;
mat[j] = b; flags[j] = F_BURN; life[j] = FUEL[b]; temp[j] = Math.max(temp[j], IGN[b] + 40);
const x = j % W, y = (j / W) | 0;
wakeXY(x, y); wakeHeatXY(x, y);
}
+140
View File
@@ -0,0 +1,140 @@
// Everything the player can build (PIECES): blocks, machines and molds, with costs and
// descriptions; filter settings; wall materials and their cost.
// ---------- Building pieces ----------
// Everything you build snaps to a grid of 4x4-grain blocks. The world itself
// stays grain-level; only built pieces snap.
const BS = 4;
const BP_WALL = 1, BP_BELT = 2, BP_TUBE = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10;
function isPipeCode(c) { return c === BP_PIPE || c === BP_PUMP || c === BP_OUTLET; }
// Filters: each filter block has a set of materials and a mode. In drop mode (inv 0)
// the chosen materials fall through and everything else rides along on top; in carry
// mode (inv 1) the chosen ones ride along and everything else falls through. The block
// variant's top bit is which way it carries (1 right, 0 left).
let filterCfg = new Map(); // block index -> { m: [materials], inv: 0|1, pass: Uint8Array(NMAT) }
function filterable(m) { return m > 0 && m < NMAT && (PH[m] === PH_POWDER || PH[m] === PH_ITEM); }
function cleanFilterCfg(c) { return { m: [...new Set(((c && c.m) || []).filter(filterable))].sort((a, b) => a - b), inv: c && c.inv ? 1 : 0 }; }
function filterRec(c) {
const k = cleanFilterCfg(c), pass = new Uint8Array(NMAT);
for (let m = 1; m < NMAT; m++) if (filterable(m)) pass[m] = (k.m.includes(m) ? 1 : 0) ^ k.inv;
return { m: k.m, inv: k.inv, pass };
}
function filterPassAt(m, j) { const c = filterCfg.get((((j / W) | 0) >> 2) * BW + ((j % W) >> 2)); return c ? c.pass[m] : 0; }
function filterInfo(b) { const c = filterCfg.get(b); return c ? { m: c.m.slice(), inv: c.inv } : { m: [], inv: 0 }; }
function sameFilter(a, b) { return a.inv === b.inv && a.m.length === b.m.length && a.m.every((m, k) => m === b.m[k]); }
// loose material -> the solid it becomes in a wall block
const WALL_OF = new Uint8Array(NMAT);
WALL_OF[LDIRT] = DIRT; WALL_OF[RSTONE] = STONE; WALL_OF[RGRAN] = GRANITE; WALL_OF[RBAS] = BASALT; WALL_OF[LCLAY] = CLAY;
WALL_OF[LWOOD] = WOOD; WALL_OF[RBRICK] = BRICK; WALL_OF[CULLET] = GLASS; WALL_OF[BARCU] = COPPER; WALL_OF[BARFE] = IRON; WALL_OF[BARST] = STEEL;
WALL_OF[BARSB] = CUTST; WALL_OF[BARGL] = GLASS;
const WALL_LIST = [RSTONE, BARSB, LCLAY, RBRICK, LDIRT, LWOOD, CULLET, BARGL, RGRAN, RBAS, BARCU, BARFE, BARST];
const BAR_OF = new Uint8Array(NMAT); BAR_OF[COPPER] = BARCU; BAR_OF[IRON] = BARFE; BAR_OF[STEEL] = BARST;
BAR_OF[GLASS] = BARGL; BAR_OF[CUTST] = BARSB; BAR_OF[TIN] = BARSN; BAR_OF[ZINC] = BARZN; BAR_OF[SILVER] = BARAG; BAR_OF[GOLD] = BARAU; BAR_OF[BRONZE] = BARBZ; BAR_OF[BRASS] = BARBS;
const GRAINS_PER_BAR = 8;
function wallCost(v, cells) { return [[v, PH[v] === PH_ITEM ? Math.ceil(cells / GRAINS_PER_BAR) : cells]]; }
// For now every machine simply runs: no power to supply. The power pieces (shaft,
// water wheel, steam engine) are hidden from the build menu but old saves keep them.
const POWER_FREE = true;
const CASH = 255; // stands for money in cost lists
const PIECES = [
{ id: 'wall', code: BP_WALL, name: 'Wall block', kind: 'block', variants: 'wall',
desc: 'A solid block. R picks what it is made of: 16 grains of rubble, lumps or chunks, or 2 bars of metal.' },
{ id: 'belt', code: BP_BELT, name: 'Belt', kind: 'block', cost: [[CASH, 12]], dirs: ['left', 'right'],
desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it.' },
{ id: 'tube', code: BP_TUBE, name: 'Screw tube', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left'],
desc: 'A turning screw that draws in loose material and pushes it along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Stack them pointing up to lift ore out of a mine, and turn the top one sideways to unload. Copper softens around 1000°C.' },
{ id: 'filter', code: BP_FILTER, name: 'Filter', kind: 'block', cost: [[CASH, 40]], dirs: ['left', 'right'], filter: true,
desc: 'A stretch of belt with a mesh in it. Put it in a line of belts and it joins them as one belt. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through.' },
{ id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE,
desc: 'Carries power between machines. Wooden, so keep it away from fire.' },
{ id: 'grate', code: BP_GRATE, name: 'Grate', kind: 'block', cost: [[LCLAY, 8]],
desc: 'Ceramic grille. Liquids and gases pass; powders and bars do not.' },
{ id: 'gate', code: BP_GATE, name: 'Gate', kind: 'block', cost: [[LCLAY, 8]],
desc: 'Ceramic slide gate. Press F on it to open or close every gate block touching it.' },
{ id: 'pipe', code: BP_PIPE, name: 'Pipe', kind: 'block', cost: [[CASH, 8]],
desc: 'Thin copper pipe for liquids. Sand, water and anything else loose fall past it, so a run of pipe never dams anything. Every pipe, pump and outlet touching another joins it in one line, and the line holds 16 grains of liquid per block. Drag to lay a run. Taking a pipe back spills what was in it.' },
{ id: 'pump', code: BP_PUMP, name: 'Pump', kind: 'block', cost: [[CASH, 70]], dirs: ['up', 'right', 'down', 'left'],
desc: 'Draws liquid in at the tail of its arrow (keep that side under water) and pushes it into the pipes on its other sides, about 60 grains a second while the line has room. It takes water and anything else cooler than 500°C, one liquid at a time per line. Press F to switch it on or off.' },
{ id: 'outlet', code: BP_OUTLET, name: 'Outlet', kind: 'block', cost: [[CASH, 15]], dirs: ['down', 'left', 'up', 'right'],
desc: 'Lets a pipe line\'s liquid out of the side its arrow points to, wherever there is room for it. Press F to open or close it.' },
{ id: 'wheel', name: 'Water wheel', kind: 'machine', bw: 3, bh: 3, cost: [[LWOOD, 48]], hidden: POWER_FREE,
desc: 'Makes power when liquid falls through it. More flow, more power.' },
{ id: 'fan', name: 'Fan', kind: 'machine', bw: 1, bh: 1, cost: [[CASH, 60]], dirs: ['right', 'down', 'left', 'up'],
desc: 'Blows gas and forces air into fires, making them far hotter: its stream reaches 28 grains and passes through grates but not walls. Its copper body melts at 1085°C, so keep it outside the furnace, a block clear of the wall, blowing in through a grate. Press F to switch it on or off.' },
{ id: 'cutter', name: 'Stone cutter', kind: 'machine', bw: 2, bh: 2, cost: [[CASH, 90]],
desc: 'A saw under a hopper. Stone rubble dropped in is cut into stone bricks, one from every 8 grains, which drop out underneath. Builders buy stone bricks, and they make neat cut stone walls that stand more heat than rough stone. Anything else falls straight through.' },
{ id: 'silo', name: 'Silo', kind: 'machine', bw: 3, bh: 6, cost: [[CASH, 180]],
desc: 'A steel bin on legs for one loose material: up to 4,000 grains go in through the hatch in its roof (drop them in from a belt or tube). Its gauge shows how full it is. Press F to open the gate in its cone and let it out underneath, about 60 grains a second; F again shuts it. The first material in decides what it holds until it is empty.' },
{ id: 'refiner', name: 'Refiner', kind: 'machine', bw: 3, bh: 3, cost: [[CASH, 120]],
desc: 'A crushing sieve. Drop stone, granite or basalt rubble, gravel, sand or dirt into its hopper: it grinds each grain, keeps the odd grain of ore it finds (clay, from dirt) and lets about half as much fine sand out of the chute underneath. Ore and anything else it cannot grind falls straight through. Keep the space under the chute clear, or a belt running under it.' },
{ id: 'engine', name: 'Steam engine', kind: 'machine', bw: 3, bh: 2, cost: [[BARCU, 10]], dirs: ['exhaust right', 'exhaust left'], hidden: POWER_FREE,
desc: 'Steam rising into the intake slots on its underside drives it. Condensate drips out the exhaust side.' },
{ id: 'dock', name: 'Trade dock', kind: 'machine', bw: 6, bh: 4, cost: [], hidden: true, fixed: true,
desc: 'Owned by the claim office. Anything that drops through the grate in its floor is sold on the spot. Build walls around it to funnel more in.' },
{ id: 'ingotmold', name: 'Ingot mold', kind: 'mold', mold: 'ingot', variants: 'wall',
desc: 'A long trough. Pour molten metal in, let it set, press F: it snaps into bars of 8 grains, up to 3.' },
{ id: 'bitmold', name: 'Bit mold', kind: 'mold', mold: 'bit', variants: 'wall',
desc: 'A drill-bit-shaped cavity. Cast a harder metal in it to upgrade your drill.' },
{ id: 'tankmold', name: 'Tank mold', kind: 'mold', mold: 'tank', variants: 'wall',
desc: 'A tank-shaped cavity. Cast metal in it to carry more.' },
];
const PIECE_BY_ID = {}, PIECE_BY_CODE = [];
PIECES.forEach((p, i) => { p.index = i; PIECE_BY_ID[p.id] = p; if (p.code) PIECE_BY_CODE[p.code] = p; if (p.kind === 'machine') { p.w = p.bw * BS; p.h = p.bh * BS; } });
// power per grain of belt / tube (a block is 16 grains)
const BELT_POWER = 0.03, TUBE_POWER = 0.125, FAN_POWER = 6;
const WHEEL_K = 0.32, ENGINE_K = 40;
const MOLDS = {
ingot: {
// one long trough: metal runs along it and sets level; knocking it out
// snaps the ingot into bars of 8 grains each
name: 'Ingot mold', rows: [
'................',
'#oooooooooooooo#',
'#oooooooooooooo#',
'################',
]
},
bit: {
name: 'Bit mold', rows: [
'#..............#',
'##............##',
'###oooooooooo###',
'####oooooooo####',
'#####oooooo#####',
'######oooo######',
'#######oo#######',
'################',
]
},
tank: {
name: 'Tank mold', rows: [
'#..............#',
'#oo##########oo#',
'#oo##########oo#',
'#oo##########oo#',
'#oo##########oo#',
'#oooooooooooooo#',
'#oooooooooooooo#',
'################',
]
},
};
for (const k in MOLDS) {
const md = MOLDS[k]; md.w = md.rows[0].length; md.h = md.rows.length; md.bw = md.w / BS; md.bh = md.h / BS; md.walls = 0; md.cav = 0;
for (const r of md.rows) for (const ch of r) { if (ch === '#') md.walls++; else if (ch === 'o') md.cav++; }
// split the cavity into separate pieces (the ingot mold has three)
const seenC = new Set(); md.cavs = [];
for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) {
if (md.rows[r][c] !== 'o' || seenC.has(r * 64 + c)) continue;
const comp = [], st = [[c, r]]; seenC.add(r * 64 + c);
while (st.length) {
const [cx, cy] = st.pop(); comp.push([cx, cy]);
for (const [nx, ny] of [[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]]) {
if (nx < 0 || ny < 0 || nx >= md.w || ny >= md.h || md.rows[ny][nx] !== 'o' || seenC.has(ny * 64 + nx)) continue;
seenC.add(ny * 64 + nx); st.push([nx, ny]);
}
}
md.cavs.push(comp);
}
}
+114
View File
@@ -0,0 +1,114 @@
// Pipes, pumps and outlets: each joined line holds one liquid as a count, not as grains.
// ---------- Pipes ----------
// Pipes, pumps and outlets that touch join into a line. A line holds one liquid at a time,
// PIPE_CAP grains per block, kept as a count (it is not simulated grain by grain inside).
// Pumps draw liquid in at their intake side; outlets let it out where there is room.
const PIPE_CAP = 16, PUMP_RATE = 1, OUTLET_RATE = 2, PUMP_MAX_T = 500;
const SIDE_V = [[0, -1], [1, 0], [0, 1], [-1, 0]]; // up, right, down, left
const PUMP_V = SIDE_V; // pump variants: the way it pushes (its intake is the opposite side)
const OUTLET_V = [[0, 1], [-1, 0], [0, -1], [1, 0]]; // outlet variants: down, left, up, right
let pipeNets = [], pipeNetAt = null, pipesDirty = true;
function sideOf(dx, dy) { for (let s = 0; s < 4; s++) if (SIDE_V[s][0] === dx && SIDE_V[s][1] === dy) return s; return -1; }
// the one side of a block that does not join the line: a pump's intake, an outlet's mouth
function pipeClosedSide(b) {
const c = blockP[b], d = blockV[b] & 3;
if (c === BP_PUMP) return sideOf(-PUMP_V[d][0], -PUMP_V[d][1]);
if (c === BP_OUTLET) return sideOf(OUTLET_V[d][0], OUTLET_V[d][1]);
return -1;
}
function pipeJoins(b, s) {
const bx = b % BW, by = (b / BW) | 0, nx = bx + SIDE_V[s][0], ny = by + SIDE_V[s][1];
if (nx < 0 || ny < 0 || nx >= BW || ny >= BH || s === pipeClosedSide(b)) return -1;
const d = ny * BW + nx;
return isPipeCode(blockP[d]) && pipeClosedSide(d) !== (s + 2) % 4 ? d : -1;
}
function rebuildPipes() {
pipesDirty = false;
const old = pipeNets, oldAt = pipeNetAt;
pipeNetAt = new Int32Array(NB).fill(-1); pipeNets = [];
for (let b0 = 0; b0 < NB; b0++) {
if (!isPipeCode(blockP[b0]) || pipeNetAt[b0] >= 0) continue;
const id = pipeNets.length, net = { blocks: [], pumps: [], outlets: [], m: 0, n: 0, T: 20, cap: 0, flowT: -999 };
pipeNets.push(net); pipeNetAt[b0] = id;
const st = [b0];
while (st.length) {
const b = st.pop(); net.blocks.push(b);
if (blockP[b] === BP_PUMP) net.pumps.push(b); else if (blockP[b] === BP_OUTLET) net.outlets.push(b);
for (let s = 0; s < 4; s++) { const d = pipeJoins(b, s); if (d >= 0 && pipeNetAt[d] < 0) { pipeNetAt[d] = id; st.push(d); } }
}
net.cap = net.blocks.length * PIPE_CAP;
}
// what the old lines held goes with their blocks into the new lines
if (oldAt) for (const o of old) {
if (!o.n) continue;
const share = new Map(); let alive = 0;
for (const b of o.blocks) { const id = pipeNetAt[b]; if (id >= 0) { share.set(id, (share.get(id) || 0) + 1); alive++; } }
let left = o.n;
const parts = [...share];
parts.forEach(([id, k], idx) => {
const amt = idx === parts.length - 1 ? left : Math.min(left, Math.round(o.n * k / alive));
left -= amt;
const net = pipeNets[id];
if (!amt) return;
if (net.n && net.m !== o.m) { if (net.n >= amt) return; net.n = 0; } // two liquids met: the smaller lot is lost
net.T = (net.T * net.n + o.T * amt) / (net.n + amt); net.m = o.m; net.n = Math.min(net.cap, net.n + amt);
});
}
}
function pipeNetOf(b) { if (pipesDirty) rebuildPipes(); const id = pipeNetAt ? pipeNetAt[b] : -1; return id >= 0 ? pipeNets[id] : null; }
// take block b's share of its line's liquid out (it is about to be removed)
function pipeTakeShare(b) {
const net = pipeNetOf(b);
if (!net || !net.n) return null;
const k = Math.min(net.n, PIPE_CAP, Math.round(net.n / net.blocks.length));
net.n -= k;
const r = { m: net.m, n: k, T: net.T };
if (!net.n) net.m = 0;
return r;
}
// the cells just outside side s of block b, lane by lane; depth grains further out
function faceCell(b, s, lane, depth) {
const bx = b % BW, by = (b / BW) | 0;
let x, y;
if (s === 0) { x = bx * BS + lane; y = by * BS - 1 - depth; }
else if (s === 2) { x = bx * BS + lane; y = by * BS + BS + depth; }
else if (s === 1) { x = bx * BS + BS + depth; y = by * BS + lane; }
else { x = bx * BS - 1 - depth; y = by * BS + lane; }
return x < 0 || y < 0 || x >= W || y >= H ? -1 : y * W + x;
}
function pumpDraw(net, b) {
const s = sideOf(-PUMP_V[blockV[b] & 3][0], -PUMP_V[blockV[b] & 3][1]);
let got = 0;
for (let k = 0; k < 4 && got < PUMP_RATE && net.n < net.cap; k++) {
const lane = (k + tick) & 3;
for (let depth = 0; depth < 3; depth++) {
const j = faceCell(b, s, lane, depth);
if (j < 0) break;
const m = mat[j];
if (PH[m] !== PH_LIQUID) { if (PH[m] === PH_SOLID && !part[j]) break; continue; }
if (temp[j] > PUMP_MAX_T || occ[j] || (net.n && m !== net.m)) break;
net.T = (net.T * net.n + temp[j]) / (net.n + 1); net.m = m; net.lm = m; net.n++;
setCell(j, EMPTY, AMB[(j / W) | 0], 0);
got++; net.flowT = tick;
break;
}
}
}
function outletPour(net, b) {
const v = OUTLET_V[blockV[b] & 3], s = sideOf(v[0], v[1]);
let put = 0;
for (let k = 0; k < 4 && put < OUTLET_RATE && net.n > 0; k++) {
const j = faceCell(b, s, (k + tick) & 3, 0);
if (j < 0 || mat[j] !== EMPTY || occ[j] || (part[j] && part[j] !== P_GRATE && part[j] !== P_GOPEN) || mach[j]) continue;
setCell(j, net.m, net.T, 0);
net.n--; put++; net.flowT = tick;
}
if (!net.n) net.m = 0;
}
function pipesTick() {
if (pipesDirty) rebuildPipes();
for (const net of pipeNets) {
for (const b of net.pumps) if (!(blockV[b] & 4)) pumpDraw(net, b);
if (net.n) for (const b of net.outlets) if (!(blockV[b] & 4)) outletPour(net, b);
}
}
+99
View File
@@ -0,0 +1,99 @@
// Processing machines: the Refiner, the Stone cutter and the Silo.
// ---------- Refiner ----------
// Grinding a grain of rock, gravel, sand or dirt turns up the odd grain of ore: [chance, what],
// tried in turn. If none turns up, half the time a grain of fine sand comes out, else nothing:
// the rest is ground to dust and washed away, so a refiner halves a pile of waste rock.
const REFINE = [];
REFINE[RSTONE] = [[0.025, CUORE], [0.02, COAL], [0.015, FEORE], [0.006, SNORE]];
REFINE[RGRAN] = [[0.02, FEORE], [0.012, ZNORE], [0.01, CUORE], [0.002, AGORE]];
REFINE[RBAS] = [[0.03, FEORE], [0.01, ZNORE], [0.003, AUORE]];
REFINE[GRAVEL] = [[0.03, FEORE], [0.02, CUORE], [0.01, SNORE], [0.003, ZNORE]];
REFINE[SAND] = [[0.01, SNORE], [0.008, FEORE], [0.0015, AUORE]];
REFINE[LDIRT] = [[0.08, LCLAY], [0.01, COAL], [0.008, CUORE], [0.003, ACORN]];
const REFINE_SAND = 0.5, REFINE_RATE = 0.5; // grains ground per tick
// a free cell under the refiner's chute, or -1 if the way out is blocked
function refinerSlot(mm) {
const y = mm.y + 12;
if (y >= H) return -1;
for (const dx of [5, 6, 4, 7]) { const j = y * W + mm.x + dx; if (mat[j] === EMPTY && !part[j] && !occ[j] && !mach[j]) return j; }
return -1;
}
function refinerTick(mm) {
mm.work = (mm.work || 0) + REFINE_RATE;
if (mm.work < 1) return;
let jobs = Math.floor(mm.work); mm.work -= jobs;
const y = mm.y + 9;
mm.blocked = false;
for (let k = 0; k < 8 && jobs > 0; k++) {
const x = mm.x + 2 + ((k + tick) & 7), i = y * W + x, m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_SOLID || stamp[i] === tickStamp) continue;
const out = refinerSlot(mm);
if (out < 0) { mm.blocked = true; break; }
jobs--; mm.busyT = tick;
const T = temp[i];
if (PH[m] === PH_POWDER && REFINE[m]) {
setCell(i, EMPTY, AMB[y], 0);
let got = 0;
for (const [chance, o] of REFINE[m]) if (rnd() < chance) { got = o; break; }
if (got) { stats.refinedOre = (stats.refinedOre || 0) + 1; discover(got, x, y, m, 'refine'); }
else if (rnd() < REFINE_SAND) got = SAND;
if (got) setCell(out, got, T, 0);
stats.refined = (stats.refined || 0) + 1;
} else {
// ore, coal, liquids: nothing to grind, so they go straight through
setCell(out, m, T, 0); setCell(i, EMPTY, AMB[y], 0);
}
}
}
// ---------- Stone cutter ----------
const CUT_PER_BRICK = 8, CUT_RATE = 0.5;
function cutterTick(mm) {
mm.work = (mm.work || 0) + CUT_RATE;
if (mm.work < 1) return;
let jobs = Math.floor(mm.work); mm.work -= jobs;
const y = mm.y + 4;
mm.blocked = false;
for (let k = 0; k < 4 && jobs > 0; k++) {
const x = mm.x + 2 + ((k + tick) & 3), i = y * W + x, m = mat[i];
if (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_SOLID || stamp[i] === tickStamp) continue;
if (m === RSTONE) {
if ((mm.cut || 0) + 1 >= CUT_PER_BRICK) {
// a brick is ready: it needs room under the machine
if (!entFreeAt(mm.x + 2, mm.y + 8)) { mm.blocked = true; break; }
mm.cut = 0;
const e = spawnBar(BARSB, mm.x + 2, mm.y + 8, (rnd() - 0.5) * 0.1, 0.05);
if (e) { e.T = temp[i]; discover(BARSB, x, y, RSTONE, 'cut'); stats.bricks = (stats.bricks || 0) + 1; }
} else mm.cut = (mm.cut || 0) + 1;
setCell(i, EMPTY, AMB[y], 0);
} else {
// anything else drops through, out of the bottom
const j = (mm.y + 8) * W + mm.x + 3 + (k & 1);
if (mm.y + 8 >= H || mat[j] !== EMPTY || part[j] || occ[j]) { mm.blocked = true; break; }
setCell(j, m, temp[i], 0); setCell(i, EMPTY, AMB[y], 0);
}
jobs--; mm.busyT = tick;
}
}
// ---------- Silo ----------
// One loose material, counted (not simulated grain by grain inside): grains in the roof hatch
// go in, and with the gate open they come out of the bottom of the cone.
const SILO_CAP = 4000, SILO_RATE = 1;
function siloTick(mm) {
for (let dy = 0; dy < 2; dy++) for (let dx = 3; dx <= 8; dx++) {
const i = (mm.y + dy) * W + mm.x + dx, m = mat[i];
if (PH[m] !== PH_POWDER || stamp[i] === tickStamp || (mm.sn > 0 && m !== mm.sm) || (mm.sn || 0) >= SILO_CAP) continue;
mm.sm = m; mm.sn = (mm.sn || 0) + 1; mm.fillT = tick;
setCell(i, EMPTY, AMB[mm.y + dy], 0);
}
if (mm.open && mm.sn > 0) {
let put = 0;
for (let k = 0; k < 4 && put < SILO_RATE && mm.sn > 0; k++) {
const x = mm.x + 4 + ((k + tick) & 3), j = (mm.y + 23) * W + x;
if (mat[j] !== EMPTY || occ[j]) continue;
setCell(j, mm.sm, AMB[mm.y + 23], 0); mm.sn--; put++; mm.pourT = tick;
}
if (!mm.sn) mm.sm = 0;
}
}
// a silo with something in it is emptied before it can be taken down
function siloFull(mm) { return mm && mm.type === 'silo' && mm.sn > 0; }
+48
View File
@@ -0,0 +1,48 @@
// Starter furnace designs and the milestone list shown in the notebook.
// ---------- Starter designs ----------
// Optional reference builds the notebook offers as blueprints. A furnace: stone
// walls, a one-block vent in the roof, a floor of clay grates (liquids drip
// through, the charge stays up) and molds right underneath.
function smelterBlueprint(kind, count, ch, wall) {
const md = MOLDS[kind], iw = count * md.bw, bw = iw + 2, bh = ch + 2 + md.bh;
const blocks = [], molds = [];
const vent = iw >> 1;
for (let x = 0; x < bw; x++) if (x !== vent) blocks.push([x, 0, BP_WALL, wall]);
for (let y = 1; y < bh; y++) { blocks.push([0, y, BP_WALL, wall]); blocks.push([bw - 1, y, BP_WALL, wall]); }
for (let x = 1; x <= iw; x++) blocks.push([x, ch + 1, BP_GRATE, 0]);
for (let k = 0; k < count; k++) molds.push([kind, 1 + k * md.bw, ch + 2, wall]);
return { v: 2, bw, bh, blocks, machines: [], molds };
}
const STARTER_DESIGNS = [
{ id: 'smelter1', name: 'Copper smelter', bp: () => smelterBlueprint('ingot', 1, 3, RSTONE),
desc: 'Stone furnace over an ingot mold. Charge it with about 50 copper ore and 50 coal, mixed (or the ore first with the coal on top), and light the coal. Makes 3 bars a firing. Press F on the mold to knock the bars out: that also rakes the slag off the grate, so it is ready to charge again. Leftover copper goes to your bag as scrap; throw it back in with the next charge.' },
{ id: 'bitfurnace', name: 'Bit casting furnace', bp: () => smelterBlueprint('bit', 1, 3, RSTONE),
desc: 'Stone furnace over a bit mold. Charge it with about 70 copper ore and 70 coal, mixed (or the coal on top), and light the coal. Press F once the bit has set. For iron, build it from brick and blow air in with a fan.' },
];
// ---------- Milestones ----------
const MILESTONES = [
{ id: 'dig', title: 'Break ground', msg: 'Broken material falls loose. The Vacuum (2) picks up only what you click on.', hint: 'Hold the left mouse button with the Drill (1): it sends pulses at the cursor that shake rock until it shatters. It falls as rubble, ore and coal.' },
{ id: 'fire', title: 'First fire', hint: 'Vacuum up coal or wood chunks, blow a pile out with right click, then hold the Igniter (4) on it. Fuel only burns where it touches open air.', reward: 'Thermal lens unlocked. Press H to see heat.' },
{ id: 'brick', title: 'Fired brick', hint: 'Clay turns into brick after a while above about 900°C, hotter than an open wood fire gets. Blow clay lumps into a coal fire, best walled in like a furnace, and vacuum up the brick rubble. Brick rubble builds brick blocks.', reward: 'Brick insulates far better than stone and survives 1750°C.' },
{ id: 'charcoal', title: 'Charcoal', hint: 'Wood that gets hot without air turns into charcoal instead of burning. Bury a burning pile of wood chunks under dirt, or heat a sealed box of wood chunks from outside.', reward: 'A fuel you can grow: nearly as hot as coal.' },
{ id: 'grow', title: 'Plant a tree', msg: 'Wood is renewable. Plant rows of acorns on dirt to farm it.', hint: 'Cutting down a tree drops acorns. Leave one on dirt or grass under open sky and it sprouts, then grows into a tree in about a minute and a half.' },
{ id: 'copper', title: 'Molten copper', msg: 'Molten copper sinks to the bottom of the charge. Get it into a mold before it sets.', hint: 'Copper ore melts at 1085°C. An open fire loses too much heat, so wall coal and ore in with blocks and leave a small vent at the top for smoke. Mix ore and coal about half and half: tick both in your bag and pick Ticked mix with Q/E, and right click blows an even mix.' },
{ id: 'bars', title: 'Copper bars', msg: 'Bars sell for far more than the ore in them, and make metal wall blocks.', hint: 'Build an Ingot mold (Build, 3) right under the furnace with a floor of clay Grates between them: molten copper drips through, while the charge and the thick slag stay up. When the bars set, press F on the mold: the bars pop out and the grate is raked clean of slag. Then vacuum up the bars. Copper that sets in the furnace melts again next firing.' },
{ id: 'bit1', title: 'Copper drill bit', hint: 'Cast copper in a Bit mold. Building the furnace right on top works. A Grate over the funnel keeps the charge out. Let it set, then press F.', reward: 'Copper bit. You can now dig iron ore.' },
{ id: 'auto', title: 'Moving parts', msg: 'Machines can now haul for you. Feed furnaces automatically, and lift ore out of your mines.', hint: 'Belts carry grains and bars resting on them. Screw tubes push loose material along (never liquids); stacked pointing up they lift it out of a mine. A Filter lets one material fall through and carries the rest along, so you can sort ore from rubble. Move 300 grains with machines.' },
{ id: 'glass', title: 'Glass', hint: 'Sand melts near 1300°C. Still air will not get a fire that hot: point a Fan into the fuel. Molten glass runs through a grate like metal, so cast it in an Ingot mold for glass bricks, which sell well.', reward: 'Flask unlocked. The Vacuum can suck up and blow out liquids.' },
{ id: 'iron', title: 'Molten iron', msg: 'Iron is hard to keep molten. A brick mold right under the hot zone works best.', hint: 'Iron ore melts at 1538°C. That needs forced air, and stone slumps at 1250°C, so build the furnace from brick.' },
{ id: 'bit2', title: 'Iron drill bit', hint: 'Cast iron in a Bit mold. A stone mold melts at iron temperatures, so build it from brick.', reward: 'Iron bit. Granite is now diggable.' },
{ id: 'steel', title: 'Steel', msg: 'Cast it in a brick Bit mold for the best drill bit there is.', hint: 'Blow air through molten iron. Aim a Fan at a pool of it and the excess carbon burns off, turning it into steel.' },
{ id: 'bit3', title: 'Steel drill bit', hint: 'Cast steel in a brick Bit mold.', reward: 'Steel bit. Basalt is now diggable.' },
{ id: 'tank', title: 'Bigger bag', hint: 'Cast any metal in a Tank mold and knock it out to carry more.' },
{ id: 'sale', title: 'First sale', msg: 'Money buys machines: belts, screw tubes and fans. Refined goods pay far more than raw.', hint: 'Blow, belt or lift goods into the trade dock near where you started. They are paid for the moment they drop through the grate in its floor.' },
{ id: 'order', title: 'First order filled', msg: 'Bigger buyers notice you as you fill more orders.', hint: 'Buyers post orders on the Market page: an amount of one good by a deadline, at well over market price. Goods dropped into the dock fill an open order first.' },
{ id: 'alloy', title: 'An alloy', msg: 'Alloys sell for far more than the metals that went into them.', hint: 'Tin ore lies a little deeper than copper, zinc deeper again. Melt tin into a pool of molten copper and it mixes into bronze; zinc makes brass.' },
{ id: 'precious', title: 'Precious metal', hint: 'Silver lies deep in the granite, gold deeper still. Their ore sells high even raw, and much higher as bars.' },
{ id: 'claim', title: 'Pay off the claim', msg: 'The land is yours.', hint: 'You owe the claim office $20,000 for this land. Pay it down from the Market page of your notebook.', reward: 'The land is yours.' },
{ id: 'rich1', title: 'Prosperous foundry', hint: 'Earn $50,000 in total.' },
{ id: 'rich2', title: 'Mining magnate', hint: 'Earn $200,000 in total.' },
{ id: 'deep', title: 'The magma sea', msg: 'Magma is a heat source that never runs out.', hint: 'Dig down through granite and basalt to the molten layer at the bottom of the world.' },
];
+112
View File
@@ -0,0 +1,112 @@
// Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
// ---------- Save / load ----------
const SAVE_VERSION = 2;
const TOFF = 64; // temperatures are saved with an offset so sub-zero snow and ice survive
function serializeWorld() {
// grains in flight from a tube or belt land where they are (rain in the air simply carries on)
for (const f of flyers) if (!f.bag && !f.rain) landFlyer(f, Math.max(0, Math.min(W - 1, Math.floor(f.x))), Math.max(0, Math.min(H - 1, Math.floor(f.y))));
flyers = flyers.filter(f => f.bag || f.rain);
const t16 = new Uint16Array(N);
for (let i = 0; i < N; i++) { const v = (temp[i] + TOFF) * 16; t16[i] = v < 0 ? 0 : v > 65535 ? 65535 : v; }
const meta = {
v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF,
saplings: saplings.map(s => [s.x, s.y, s.age, s.th, s.seed]),
economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq },
filters: [...filterCfg].map(([b, c]) => [b, c.m, c.inv]),
weather: { moist: (() => { const m = Float32Array.from(weather.moist); for (const f of flyers) if (f.rain) m[Math.min(m.length - 1, Math.max(0, (f.x / WBAND) | 0))] += 1; return Array.from(m, v => Math.round(v)); })(), raining: Array.from(weather.raining), shower: weather.shower, next: weather.next, wind: weather.wind, seen: weather.seen, base: weather.base },
bot: { x: bot.x, y: bot.y, lamp: bot.lamp },
bag: Array.from(bag), player: { ...player }, seen: Array.from(seen), ledger, stats: { ...stats },
machines: machines.filter(Boolean).map(m => m.type === 'silo' ? { type: m.type, bx: m.x / BS, by: m.y / BS, dir: m.dir, on: m.on, sm: m.sm || 0, sn: m.sn || 0, open: !!m.open } : { type: m.type, bx: m.x / BS, by: m.y / BS, dir: m.dir, on: m.on }),
molds: molds.map(m => ({ kind: m.kind, x: m.x, y: m.y, wall: m.wall })),
ents: ents.map(e => [e.m, e.ix, e.iy, Math.round(e.T)]),
flyers: flyers.filter(f => f.bag).map(f => f.m), // what you blew out goes back in the bag on load
ghostBlocks: [...ghostBlocks.entries()], ghostMachines, ghostMolds,
specks: 1,
pipes: (() => { if (pipesDirty) rebuildPipes(); return pipeNets.filter(pn => pn.n > 0).map(pn => [pn.blocks[0], pn.m, pn.n, Math.round(pn.T)]); })(),
cols: { surfY: Array.from(surfY), dirtY: Array.from(dirtY), graniteY: Array.from(graniteY), basaltY: Array.from(basaltY), magmaY: Array.from(magmaY) },
};
return { meta, buffers: { mat: mat.slice(), temp: t16, life: life.slice(), flags: flags.slice(), part: part.slice(), blockP: blockP.slice(), blockV: blockV.slice(), bgm: bgm.slice() } };
}
function deserializeWorld(meta, buffers) {
if (meta.v !== SAVE_VERSION) throw new Error('This world was saved by an older version of the game and cannot be loaded.');
alloc(meta.W, meta.H);
worldSeed = meta.seed;
if (meta.surfBase) { surfBase = meta.surfBase; underD = meta.underD; }
shallowD = meta.shallowD || 0; lapse = meta.lapse || 0.36;
surfY.set(meta.cols.surfY); dirtY.set(meta.cols.dirtY); graniteY.set(meta.cols.graniteY); basaltY.set(meta.cols.basaltY); magmaY.set(meta.cols.magmaY);
computeAmbient(); fillNoise();
mat.set(buffers.mat); life.set(buffers.life); flags.set(buffers.flags);
for (let i = 0; i < N; i++) if (mat[i] === SPRING) mat[i] = STONE; // springs are gone: older worlds keep plain rock there
blockP.set(buffers.blockP); blockV.set(buffers.blockV); filtersDirty = true;
// pipes used to be solid copper blocks; now they are thin and let things past
for (let b = 0; b < NB; b++) if (blockP[b] === BP_PIPE) for (const i of blockCellList(b % BW, (b / BW) | 0)) if (mat[i] === PIPE) { mat[i] = EMPTY; part[i] = P_PIPE; }
for (const [b, m, inv] of meta.filters || []) if (blockP[b] === BP_FILTER) filterCfg.set(b, filterRec({ m, inv }));
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) {
// the first filters kept one material in their block variant
if (!filterCfg.has(b)) filterCfg.set(b, filterRec({ m: [blockV[b] & 127], inv: 0 }));
blockV[b] &= 128;
}
if (buffers.bgm) { bgm.set(buffers.bgm); }
else {
// older saves kept trees in the foreground: move them behind
for (let i = 0; i < N; i++) {
const m = mat[i], y = (i / W) | 0;
if (m === LEAVES || (m === WOOD && (flags[i] & F_NAT) && y < surfY[i % W])) { bgm[i] = m; mat[i] = EMPTY; flags[i] = 0; }
}
}
const t16 = buffers.temp, off = meta.tOff || 0; for (let i = 0; i < N; i++) temp[i] = t16[i] / 16 - off;
saplings = (meta.saplings || []).map(([x, y, age, th, seed]) => ({ x, y, age, th, seed }));
findTreeBases();
resetEconomy();
const ec = meta.economy;
if (ec) {
money = ec.money; debt = ec.debt; earned = ec.earned || 0; shipLog = ec.log || [];
(ec.recent || []).forEach((v, k) => { if (k < NMAT) mkRecent[k] = v; });
orders = ec.orders || []; ordersDone = ec.ordersDone || 0; nextOrderT = ec.nextOrderT || tick + 3600; orderSeq = ec.orderSeq || 1;
}
// machines rebuild their hull cells; clear stale hull first
for (let i = 0; i < N; i++) if (mat[i] === HULL || mat[i] === DOCKW) mat[i] = EMPTY;
const savedPart = buffers.part;
for (let i = 0; i < N; i++) {
const p = savedPart[i];
if (p === P_WHEEL || p === P_INTAKE) continue;
part[i] = p;
if (p >= P_TU && p <= P_TR) markPow(i, true);
}
for (let i = 0; i < N; i++) { const m = mat[i]; if (m === BELTL || m === BELTR || m === SHAFT) markPow(i, true); }
for (const m of meta.machines) { const mm = addMachine(m.type, m.bx, m.by, m.dir); mm.on = m.on !== false; if (m.type === 'silo') { mm.sm = m.sm || 0; mm.sn = m.sn || 0; mm.open = !!m.open; } }
molds = [];
for (const m of meta.molds) molds.push(newMoldRecord(m.kind, m.x, m.y, m.wall));
for (const [m, x, y, T] of meta.ents || []) { const e = { m, x, y, ix: x, iy: y, vx: 0, vy: 0, T, rest: 0 }; ents.push(e); entMark(e, 1); }
if (!dock()) placeDock((W >> 1) + 34); // worlds from before the economy get their trade dock too
ghostBlocks = new Map(meta.ghostBlocks || []); ghostMachines = meta.ghostMachines || []; ghostMolds = meta.ghostMolds || [];
tick = meta.tick || 0;
updateRoof(); resetWeather();
if (meta.weather) {
const mw = meta.weather;
(mw.moist || []).forEach((v, k) => { if (k < weather.moist.length) weather.moist[k] = v; });
(mw.raining || []).forEach((v, k) => { if (k < weather.raining.length) weather.raining[k] = v ? 1 : 0; });
weather.shower = mw.shower || null; weather.next = mw.next || weather.next; weather.wind = mw.wind || weather.wind; weather.seen = !!mw.seen; weather.base = mw.base || 0;
}
bot.x = meta.bot.x; bot.y = meta.bot.y; bot.lamp = meta.bot.lamp !== false; bot.vx = bot.vy = 0;
bag = new Int32Array(NMAT); meta.bag.forEach((v, k) => { if (k < NMAT) bag[k] = v; });
for (const m of meta.flyers || []) if (m < NMAT) bag[m]++; // grains that were mid-air when saved
bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k];
Object.assign(player, meta.player);
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
ledger = meta.ledger || {};
Object.assign(stats, meta.stats || {});
awake.fill(3);
for (let c = 0; c < NCH; c++) {
const cx = c % CW, cy = (c / CW) | 0, x0 = cx << 5, y0 = cy << 5, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32);
let dev = 0;
for (let y = y0; y < y1 && dev < 2; y++) for (let x = x0; x < x1; x++) { const d = Math.abs(temp[y * W + x] - AMB[y]); if (d > dev) dev = d; if (flags[y * W + x] & F_BURN) dev = 99; }
if (dev >= 2) { hot[c] = 6; hotLvl[c] = 2; }
}
powerDirty = true; rebuildPower(); updateRoof();
if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount
if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once
rebuildPipes();
for (const [b, m, n, T] of meta.pipes || []) { const id = pipeNetAt[b]; if (id >= 0) { const pn = pipeNets[id]; pn.m = m; pn.n = Math.min(n, pn.cap); pn.T = T; } }
EV.length = 0;
}
+25
View File
@@ -0,0 +1,25 @@
// simTick: one simulation step, in order.
// ---------- Main tick ----------
function simTick() {
tick++;
tickStamp = (tickStamp % 255) + 1;
if (tick % 20 === 1) updateRoof();
weatherTick();
machinesTick();
pipesTick();
pulseTick();
entTick();
if (flyers.length) flyTick();
simMove();
heatPass();
for (let c = 0; c < NCH; c++) if (awake[c]) awake[c]--;
if (tick % 15 === 0) checkMolds();
if (tick % 30 === 0) checkLedger();
if (tick % 60 === 7) plantTick();
if (tick % 120 === 13) coldTick();
if (tick % 20 === 9 && treeBases.length) treeSupportTick();
dockTick();
if (tick % 60 === 17) { marketTick(); ordersTick(); }
if (tick % 30 === 11 && saplings.length) saplingTick();
ghostTick();
}
+52
View File
@@ -0,0 +1,52 @@
// Tool helpers: reach, brushes, and plain digging.
// ---------- Tools ----------
function inReach(x, y) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return dx * dx + dy * dy <= REACH * REACH; }
function forBrush(cx, cy, r, fn) {
const r2 = (r + 0.5) * (r + 0.5);
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > r2) continue;
const x = cx + dx, y = cy + dy;
if (x < 0 || y < 0 || x >= W || y >= H) continue;
if (!inReach(x, y)) continue;
if (fn(x, y, y * W + x) === false) return;
}
}
// Drill: breaks solids loose. Nothing goes into the bag.
// how far the drill reaches: set by the bit, and stretched or shortened by the pulse width
function drillReach(w = drillWidth) { return Math.round(BIT_TIERS[player.bitTier].reach * (PULSE_W[w] || PULSE_W[1]).reach); }
function inDrillReach(x, y, w) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2), R2 = drillReach(w); return dx * dx + dy * dy <= R2 * R2; }
// Plain digging in a circle. The game itself uses the resonance drill (drill.js); the tests use
// this to clear rock quickly.
function toolDig(cx, cy, r) {
const tier = BIT_TIERS[player.bitTier];
let dug = 0, tooHard = 0, far = 0;
forBrush(cx, cy, r, (x, y, i) => {
if (!inDrillReach(x, y)) { far++; return; }
const m = mat[i];
if (bgm[i] && (m === EMPTY || PH[m] === PH_GAS || PH[m] === PH_LIQUID)) { if (cutTree(i, x, y, tier)) dug++; return; }
if (PH[m] !== PH_SOLID) return;
if (PARTMAT[m] || part[i] || mach[i]) return;
const h = HARD[m];
if (h > tier.hard) { tooHard = m; return; }
const p = digp[i] + tier.speed * 60 / (1 + h * h * 0.6);
if (p >= 255) {
const to = YLD[m];
const b = blockP[((y / BS) | 0) * BW + ((x / BS) | 0)];
setCell(i, to, temp[i], 0);
discover(to, x, y, m, 'dig');
if (b === BP_WALL && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] = 0;
dug++;
if ((dug & 3) === 0) emit({ t: 'debris', x, y, m });
} else digp[i] = p;
});
stats.dug += dug;
let status = '';
void 0;
if (far && !dug && !tooHard) status = `Out of reach. Your ${tier.name.toLowerCase()} reaches ${drillReach()} grains from the bot.`;
if (tooHard && !dug) {
const need = BIT_TIERS.find(b => b.hard >= HARD[tooHard]);
const nb = need ? need.name.toLowerCase() : 'better bit';
status = HARD[tooHard] > 6 ? `${MD[tooHard].name} cannot be drilled.` : `${MD[tooHard].name} is too hard for your ${tier.name.toLowerCase()}. Needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
}
return { dug, status, tooHard };
}
+87
View File
@@ -0,0 +1,87 @@
// Undo (Ctrl+Z) for building, and the bot building ghosts.
// ---------- Undo ----------
// Building actions are recorded while undoRec is open (the game wraps each click, drag, cut or
// paste in one record). Ctrl+Z plays the last record backwards: what was placed is taken back
// and refunded, what was taken back is built again (paid for again), ghosts are cleared.
const UNDO_MAX = 80;
let undoStack = [], undoRec = null;
function undoNote(op) { if (undoRec) undoRec.ops.push(op); }
function undoPush(rec) { if (rec && rec.ops.length) { undoStack.push(rec); if (undoStack.length > UNDO_MAX) undoStack.shift(); } }
// label: text for the message when it is undone, or a function of the record that makes it
function undoRecord(label, fn) {
const prev = undoRec, rec = { label: '', ops: [] };
undoRec = rec;
try { return fn(); } finally { undoRec = prev; rec.label = typeof label === 'function' ? label(rec) : label; undoPush(rec); }
}
function undoLast() {
const rec = undoStack.pop();
if (!rec) return 'Nothing to undo.';
const prev = undoRec; undoRec = null;
let fails = 0, why = '';
const fail = (r) => { fails++; if (r && !why) why = r; };
for (let k = rec.ops.length - 1; k >= 0; k--) {
const o = rec.ops[k];
if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); }
else if (o.t === 'block-') { const r = placeBlock(o.code, o.code === BP_FILTER ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); }
else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (blockP[b] === BP_FILTER) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } }
else if (o.t === 'mach+') { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm && siloFull(mm)) fail('empty the silo first'); else if (mm) removeMachine(mm.id, true, false); }
else if (o.t === 'mach-') { const r = placeMachineAt(o.type, o.dir, o.bx, o.by); if (r) fail(r); else { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm) mm.on = o.on !== false; } }
else if (o.t === 'mold+') { const mo = molds.find(m => m.kind === o.kind && m.x === o.bx * BS && m.y === o.by * BS); if (mo) removeMold(mo); }
else if (o.t === 'mold-') { const r = placeMoldAt(o.kind, o.bx, o.by, o.wall); if (r) fail(r); }
else if (o.t === 'paste') {
// ghosts still waiting are cleared; anything already built from them is taken back
for (const [bx, by, code] of o.blocks) { const b = by * BW + bx; if (ghostBlocks.has(b)) { ghostBlocks.delete(b); ghostBlocked.delete(b); } else if (blockP[b] === code) removeBlock(bx, by); }
for (const g of o.machines) { const k = ghostMachines.indexOf(g); if (k >= 0) ghostMachines.splice(k, 1); else { const mm = machines.find(m => m && m.type === g.type && m.x === g.bx * BS && m.y === g.by * BS); if (mm) removeMachine(mm.id, true, false); } }
for (const g of o.molds) { const k = ghostMolds.indexOf(g); if (k >= 0) ghostMolds.splice(k, 1); else { const mo = molds.find(m => m.kind === g.kind && m.x === g.bx * BS && m.y === g.by * BS); if (mo) removeMold(mo); } }
}
}
undoRec = prev;
powerDirty = true;
const n = rec.ops.length;
return fails ? `Undid ${rec.label}, but ${fails === n ? 'none' : fails} of it could not be put back${why ? ': ' + why.replace(/\.$/, '').toLowerCase() : ''}.` : `Undid ${rec.label}.`;
}
function clearAllGhosts() { ghostBlocks.clear(); ghostMachines = []; ghostMolds = []; ghostBlocked.clear(); }
let ghostMissing = 0, ghostIter = null;
function ghostTick() {
if (!ghostBlocks.size && !ghostMachines.length && !ghostMolds.length) return;
const keepRec = undoRec; undoRec = null;
try { ghostTick0(); } finally { undoRec = keepRec; }
}
function ghostTick0() {
let budget = 2, checks = 200;
ghostMissing = 0;
if (!ghostIter) ghostIter = ghostBlocks.entries();
while (budget > 0 && checks-- > 0 && ghostBlocks.size) {
let nx = ghostIter.next();
if (nx.done) { ghostIter = ghostBlocks.entries(); nx = ghostIter.next(); if (nx.done) break; }
const [b, [code, v, e]] = nx.value;
const bx = b % BW, by = (b / BW) | 0;
if (blockP[b] === code && blockV[b] === (code === BP_FILTER ? v & 128 : v)) {
if (code === BP_FILTER && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings
ghostBlocks.delete(b); ghostBlocked.delete(b); continue;
}
if (!blockInReach(bx, by)) continue;
const err = canPlaceBlock(bx, by);
if (err) { if (err.startsWith('Needs')) ghostBlocked.add(b); continue; }
if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
placeBlock(code, v, bx, by, false, e);
ghostBlocks.delete(b); ghostBlocked.delete(b); budget--;
emit({ t: 'sfx', s: 'build' });
}
for (let k = ghostMachines.length - 1; k >= 0 && budget > 0; k--) {
const g = ghostMachines[k], P = PIECE_BY_ID[g.type];
if (!inReach(g.bx * BS + P.w / 2, g.by * BS + P.h / 2)) continue;
if (canPlaceMachine(g.type, g.bx, g.by)) continue;
if (!canAfford(P.cost, 1)) { ghostMissing++; continue; }
placeMachineAt(g.type, g.dir, g.bx, g.by); ghostMachines.splice(k, 1); budget--;
emit({ t: 'sfx', s: 'build' });
}
for (let k = ghostMolds.length - 1; k >= 0 && budget > 0; k--) {
const g = ghostMolds[k], md = MOLDS[g.kind];
if (!inReach(g.bx * BS + md.w / 2, g.by * BS + md.h / 2)) continue;
if (canPlaceMold(g.kind, g.bx, g.by)) continue;
if (!canAfford(wallCost(g.wall, md.walls), 1)) { ghostMissing++; continue; }
addMold(g.kind, g.bx, g.by, g.wall, false); ghostMolds.splice(k, 1); budget--;
emit({ t: 'sfx', s: 'build' });
}
}
+165
View File
@@ -0,0 +1,165 @@
// The vacuum, blowing grains out, grains in flight (flyers), and the flame tool.
// Material the vacuum would start on at the cursor: a bar, else the nearest loose grain.
function vacuumTargetAt(cx, cy, r) {
for (const e of entsInCircle(cx, cy, Math.max(1, r))) return e.m;
let best = 0, bd = 1e9;
forBrush(cx, cy, Math.max(1, r), (x, y, i) => {
const m = mat[i], ph = PH[m];
if (ph === PH_POWDER || (ph === PH_LIQUID && player.flask)) { const d = (x - cx) * (x - cx) + (y - cy) * (y - cy); if (d < bd) { bd = d; best = m; } }
});
return best;
}
// Vacuum: picks up loose grains, liquids (with the Flask) and bars for which pick(m) is true.
function toolVacuum(cx, cy, r, pick) {
let got = 0, full = false, budget = 48;
for (const e of entsInCircle(cx, cy, r)) {
if (!pick(e.m) || !inReach(e.ix + 2, e.iy + 1)) continue;
if (bagTotal >= bagCap()) { full = true; break; }
bagAdd(e.m, 1); removeEnt(e); got++;
emit({ t: 'suck', x: e.ix + 2, y: e.iy + 1, m: e.m });
}
if (!full) forBrush(cx, cy, r, (x, y, i) => {
const m = mat[i], ph = PH[m];
if (!(ph === PH_POWDER || (ph === PH_LIQUID && player.flask))) return;
if (!pick(m)) return;
if (part[i] >= P_TU && part[i] <= P_TR) return;
if (bagTotal >= bagCap()) { full = true; return false; }
bagAdd(m, 1); setCell(i, EMPTY, AMB[y], 0); got++;
if (got & 1) emit({ t: 'suck', x, y, m });
if (--budget <= 0) return false;
});
return { got, full, status: full ? 'Bag full. Blow something out or cast a Tank mold.' : '' };
}
// Blow: grains leave the bot's nozzle and fly to the cursor, where they drop.
// Liquids need the Flask. Bars are thrown, one at a time.
function nozzle(tx, ty) {
const cx = bot.x + bot.w / 2, cy = bot.y + bot.h / 2;
let dx = tx - cx, dy = ty - cy; const d = Math.hypot(dx, dy) || 1;
const reach = Math.min(d, REACH - 1);
dx /= d; dy /= d;
return { ox: cx + dx * 3, oy: cy + dy * 3, dx, dy, dist: Math.max(1, reach - 3) };
}
// mix: optional list of materials blown out in turn, one grain each (an even mix).
let blowRR = 0;
function toolBlow(cx, cy, r, m, mix) {
let pickM = null;
if (mix && mix.length) {
const avail = mix.filter(k => bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask));
if (!avail.length) return { placed: 0, status: 'Nothing ticked left to mix. Tick materials in your bag.' };
pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; };
m = avail[0];
}
if (!m) return { placed: 0, status: 'Pick something to blow out. Q and E cycle, or click it in your bag.' };
if (bag[m] <= 0) return { placed: 0, status: `No ${MD[m].name.toLowerCase()} left.` };
const ph = PH[m];
if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' };
const nz = nozzle(cx + 0.5, cy + 0.5);
if (ph === PH_ITEM) {
if (tick % 10 !== 0) return { placed: 0, status: '' };
// a short lob: aim so the bar lands near the cursor
const T = Math.max(10, Math.min(34, nz.dist / 2.2));
const tx = nz.ox + nz.dx * nz.dist, ty = nz.oy + nz.dy * nz.dist;
let e = null;
for (let k = 0; k < 6 && !e; k++) { const sx = nz.ox + nz.dx * k * 2 - 2, sy = nz.oy + nz.dy * k * 2 - 1; if (entFreeAt(Math.round(sx), Math.round(sy))) e = spawnBar(m, sx, sy, (tx - sx - 2) / T, (ty - sy - 1) / T - 0.5 * 0.07 * T); }
if (!e) return { placed: 0, status: 'No room to throw a bar from here.' };
bagTake(m, 1); return { placed: 1, status: '' };
}
if (flyers.length > 3000) return { placed: 0, status: '' };
// nozzle 0 is a trickle for careful work, the widest a torrent
const rate = 1 + r + ((r * r) >> 1);
const n = pickM ? rate : Math.min(bag[m], rate);
let placed = 0;
for (let k = 0; k < n; k++) {
const gm = pickM ? pickM() : m;
if (!gm) break;
// aim each grain at a spot in the brush around the cursor, on an arc that passes
// through it; past that point gravity carries it on until it hits something
const a = rnd() * Math.PI * 2, rr = Math.sqrt(rnd()) * (r + 0.5);
const tx = cx + 0.5 + Math.cos(a) * rr, ty = cy + 0.5 + Math.sin(a) * rr;
const ox = nz.ox + (rnd() - 0.5), oy = nz.oy + (rnd() - 0.5);
const dx = tx - ox, dy = ty - oy, d = Math.hypot(dx, dy) || 1;
// air drag (FLY_Q) and gravity (FLY_G) act every tick; pick the launch speed that
// reaches the target after T ticks
const T = Math.max(3, Math.min(60, Math.round(d / (2.0 + rnd() * 0.4)))), S = (1 - FLY_Q ** T) / (1 - FLY_Q), G = FLY_G * (T - S) / (1 - FLY_Q);
flyers.push({ x: ox, y: oy, vx: dx / S, vy: (dy - G) / S, age: 0, m: gm, T: bagTemp(gm), free: false, run: 0, bag: true });
bagTake(gm, 1); placed++;
}
return { placed, status: '' };
}
const FLY_G = 0.06, FLY_Q = 0.93;
// Can a flying grain of m be in cell j?
function flyOpen(m, j) {
if (occ[j] || mach[j] && mat[j] === HULL) return false;
const p = part[j];
if (p === P_GRATE && PH[m] === PH_POWDER) return false;
if (p === P_INTAKE) return false;
if (p === P_FILTER && PH[m] === PH_POWDER && !filterPassAt(m, j)) return false;
const t = mat[j];
return t === EMPTY || PH[t] === PH_GAS;
}
// Put a grain into the nearest open cell around (x, y), at or above where it stopped. It never
// jumps down: below could be the far side of a floor, a grate or a filter (into a mold, say).
function depositNear(m, T, x, y, skip) {
x = Math.max(0, Math.min(W - 1, x)); y = Math.max(0, Math.min(H - 1, y));
for (let r = 0; r <= 24; r++) {
for (let dy = -r; dy <= 0; dy++) for (let dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
const xx = x + dx, yy = y + dy;
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
const j = yy * W + xx;
if (j !== skip && flyOpen(m, j)) { setCell(j, m, T, 0); return true; }
}
}
return false;
}
// What comes out of the bag is at room temperature, except what would melt at it: snow stays frozen,
// and molten things come out hot enough to pour.
function bagTemp(m) { return PH[m] === PH_LIQUID ? Math.max(LIQT[m], 20) : MELT[m] !== 0 && MELT[m] < 20 ? MELT[m] - 6 : 20; }
function landFlyer(f, x, y) {
// settle into the nearest open cell at or above where it stopped
for (let k = 0; k < 8; k++) {
const yy = y - k; if (yy < 0) break;
const j = yy * W + x;
if (flyOpen(f.m, j)) { setCell(j, f.m, f.T, 0); return; }
}
if (!depositNear(f.m, f.T, x, y)) { if (f.rain) absorbSteam(x); else bagAdd(f.m, 1, true); } // nowhere to go: back into the bag (rain: back into the clouds)
}
function flyTick() {
for (let k = flyers.length - 1; k >= 0; k--) {
const f = flyers[k];
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(f.vx), Math.abs(f.vy))));
const sx = f.vx / steps, sy = f.vy / steps;
let px = Math.floor(f.x), py = Math.floor(f.y), done = false;
for (let s = 0; s < steps; s++) {
const nx = f.x + sx, ny = f.y + sy, cx = Math.floor(nx), cy = Math.floor(ny);
if (cx < 0 || cx >= W) { f.vx = 0; continue; } // the world's edge stops it; it drops from there
if (cy >= H) { landFlyer(f, px, H - 1); done = true; break; }
if (cy < 0) { f.x = nx; f.y = ny; continue; }
const open = flyOpen(f.m, cy * W + cx);
if (!f.free) {
// still inside whatever the bot is flying through: pass until the first open cell
// a blocked nozzle spills: the grain backs up into the nearest open space
if (open) f.free = true; else if (++f.run > 14) { if (!depositNear(f.m, f.T, cx, cy)) bagAdd(f.m, 1, true); done = true; break; }
} else if (!open) { landFlyer(f, px, py); done = true; break; }
f.x = nx; f.y = ny; px = cx; py = cy;
}
if (done) { flyers[k] = flyers[flyers.length - 1]; flyers.pop(); continue; }
f.vx *= FLY_Q; f.vy = f.vy * FLY_Q + FLY_G;
if (++f.age > 900) { landFlyer(f, Math.max(0, Math.min(W - 1, px)), Math.max(0, py)); flyers[k] = flyers[flyers.length - 1]; flyers.pop(); }
}
}
// The flame heats what it touches, and licks a few grains into a loose pile, so fuel
// buried just under ore still catches if air reaches it (through a grate, say).
// Returns { any: anything got hotter, fuel: there is fuel within the flame's reach }.
function toolIgnite(cx, cy) {
let any = false, fuel = false;
forBrush(cx, cy, 4, (x, y, i) => {
const dx = x - cx, dy = y - cy, near = dx * dx + dy * dy <= 2.5;
if (IGN[mat[i]]) fuel = true;
if (!near && !(IGN[mat[i]] && dy >= 0)) return;
const top = near ? 640 : 520;
if (temp[i] < top) { temp[i] = Math.min(top, temp[i] + (near ? 26 : 12)); any = true; }
wakeHeatXY(x, y);
});
return { any, fuel };
}
+85
View File
@@ -0,0 +1,85 @@
// Weather: clouds from steam, rain and snow, and the world water count.
// ---------- Weather ----------
// Steam that rises into the open sky is gathered into clouds above where it rose. The clouds
// drift slowly with the wind and rain it back out, or snow where the ground is below freezing.
// Water is only ever moved around, never made, so the world neither floods nor runs dry. If
// some does leave (dropped into the trade dock, say), a shower blows in from beyond the claim
// now and then with just enough to make it up.
const WBAND = 64;
let WS = 0x1b873593 | 0;
function wrnd() { WS ^= WS << 13; WS ^= WS >>> 17; WS ^= WS << 5; return (WS >>> 0) * 2.3283064365386963e-10; }
let weather = { moist: new Float32Array(1), raining: new Uint8Array(1), shower: null, next: 0, wind: 0.3, seen: false, skyY: 1 };
function resetWeather() {
let top = H; for (let x = 0; x < W; x++) if (surfY[x] < top) top = surfY[x];
weather = { moist: new Float32Array(Math.ceil(W / WBAND)), raining: new Uint8Array(Math.ceil(W / WBAND)), shower: null, next: tick + rainGap(), wind: wrnd() < 0.5 ? -0.3 : 0.3, seen: false, skyY: Math.max(1, top - 40), base: 0 };
}
function rainGap() { return 3600 * (5 + wrnd() * 7); } // 5 to 12 minutes between checks on the world's water
// All the world's water: everything that is water or will be again (ice, snow, steam, clouds,
// rain in the air, what is in your bag). weather.base is how much a world started with.
function worldWater() {
let n = 0;
for (let i = 0; i < N; i++) { const m = mat[i]; if (m === WATER || m === ICE || m === SNOW || m === STEAM) n++; }
for (let b = 0; b < weather.moist.length; b++) n += weather.moist[b];
for (const f of flyers) if (f.m === WATER || f.m === SNOW) n++;
for (const pn of pipeNets) if (pn.m === WATER) n += pn.n; // water in pipes still counts
for (const mm of machines) if (mm && mm.type === 'silo' && mm.sm === SNOW) n += mm.sn; // and snow in silos
return Math.round(n + bag[WATER] + bag[SNOW]);
}
function absorbSteam(x) { const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)); weather.moist[b] += 1; }
// Send about k drops (snowflakes where it is freezing) down from the clouds over x0..x1; returns how many fell
function spawnRain(x0, x1, k) {
if (flyers.length > 2500) return 0;
let n = Math.floor(k) + (wrnd() < k - Math.floor(k) ? 1 : 0), made = 0;
for (; n > 0; n--) {
const x = Math.floor(x0 + wrnd() * (x1 - x0));
if (x < 0 || x >= W) continue;
const y = Math.min(weather.skyY, Math.max(1, roofY[x] - 30));
const ground = Math.min(H - 1, roofY[x]), cold = AMB[ground] < 0;
flyers.push({ x: x + wrnd(), y: y + wrnd() * 6, vx: weather.wind * (cold ? 0.35 : 0.12), vy: cold ? 0.15 : 0.5, age: 0, m: cold ? SNOW : WATER, T: cold ? Math.min(AMB[ground], -2) : Math.max(4, Math.min(18, AMB[ground])), free: true, run: 0, rain: true });
made++;
}
return made;
}
// How hard it is raining over column x, 0 (dry) to 1 (a downpour)
function rainAt(x) {
let r = 0;
const sh = weather.shower;
if (sh && x >= sh.x0 - 40 && x <= sh.x1 + 40) r += sh.rate * sh.ramp * (x >= sh.x0 && x <= sh.x1 ? 1 : 0.5);
const b = Math.min(weather.moist.length - 1, Math.max(0, (x / WBAND) | 0)), mo = weather.moist[b];
if (weather.raining[b]) r += Math.min(3, 0.5 + mo / 100);
return Math.min(1, r / 2);
}
function weatherTick() {
const w = weather, n = w.moist.length;
if (!w.shower && tick >= w.next) {
// a shower from beyond the claim only when the world is short of the water it started with
const short = w.base ? w.base - worldWater() : 0;
if (short > Math.max(60, w.base * 0.03)) {
const len = Math.min(W, 160 + wrnd() * 380), x0 = wrnd() * Math.max(1, W - len);
w.shower = { x0, x1: x0 + len, rate: 0.6 + wrnd() * 1.6, until: tick + 60 * (40 + Math.floor(wrnd() * 80)), ramp: 0, left: short };
w.wind = (wrnd() - 0.5) * 0.8;
emit({ t: 'weather', kind: 'shower', x0: Math.round(x0), x1: Math.round(x0 + len) });
} else w.next = tick + rainGap();
}
if (w.shower) {
const sh = w.shower;
sh.ramp = tick < sh.until ? Math.min(1, sh.ramp + 1 / 300) : sh.ramp - 1 / 300; // fades in and out over 5 s
sh.x0 += w.wind * 0.03; sh.x1 += w.wind * 0.03;
if (sh.ramp <= 0) { w.shower = null; w.next = tick + rainGap(); }
else if (!(sh.left <= 0)) {
const made = spawnRain(sh.x0, sh.x1, Math.min(sh.left === undefined ? 1e9 : sh.left, sh.rate * sh.ramp * (sh.x1 - sh.x0) / 300));
if (sh.left !== undefined) { sh.left -= made; if (sh.left <= 0 && sh.until > tick) sh.until = tick; } // made up the shortfall: it passes
}
}
// clouds made of your steam build up, then rain it back out where they hang
for (let b = 0; b < n; b++) {
if (!w.raining[b] && w.moist[b] > 150) w.raining[b] = 1;
if (w.raining[b]) { w.moist[b] -= spawnRain(b * WBAND, (b + 1) * WBAND, Math.min(3, 0.5 + w.moist[b] / 100)); if (w.moist[b] < 12) w.raining[b] = 0; }
}
// and drift a little downwind every second; at the edge of the world they pile up instead
if (tick % 60 === 0) {
const out = new Float32Array(n), d = w.wind > 0 ? 1 : -1, f = Math.min(0.12, Math.abs(w.wind) * 0.1);
for (let b = 0; b < n; b++) { const mv = w.raining[b] ? 0 : w.moist[b] * f; out[b] += w.moist[b] - mv; if (b + d >= 0 && b + d < n) out[b + d] += mv; else out[b] += mv; }
w.moist = out;
}
}
+96
View File
@@ -0,0 +1,96 @@
// World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking.
// ---------- World state ----------
let W = 0, H = 0, N = 0, CW = 0, CH = 0, NCH = 0;
let mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ;
let surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed = 1;
let surfBase = 175, underD = 465; // surface height and underground depth: layers are fractions of underD
// shallowD: in newer worlds the top 30% of the layering (dirt, coal, copper, tin, the top of the iron) keeps
// a fixed depth in cells whatever the world size, and only the deeper layers stretch. 0 = plain fractions.
// lapse: how fast the air cools with height above the ground, per cell.
let shallowD = 0, lapse = 0.36;
let BW = 0, BH = 0, NB = 0, blockP, blockV; // block registry: piece code + variant per 4x4 block
let ents = []; // loose objects (bars)
let bgm; // background layer: trees. Liquids, powders and gases pass in front of it.
let flyers = []; // grains in flight, blown out of the bot
let ghostBlocks = new Map(), ghostMolds = [];
let awake, hot, hotLvl;
let tick = 0, tickStamp = 1;
let machines = [null], molds = [], nets = [null], pcells = new Set(), powerDirty = true;
let beltL = [], beltR = [], tubeLists = [[], [], [], [], []];
let brokenMachines = [];
let ghostMachines = [], ghostBlocked = new Set();
const EV = [];
const bot = { x: 0, y: 0, vx: 0, vy: 0, w: 5, h: 5, lamp: true, face: 1, thrust: 0 };
let bag = new Int32Array(NMAT), bagTotal = 0;
const player = { bitTier: 0, tankTier: 0, lens: false, flask: false };
let seen = new Uint8Array(NMAT);
let ledger = {};
const stats = { dug: 0, moved: 0, placed: 0, ignitions: 0, cast: 0, bars: 0 };
function emit(e) { if (EV.length < 400) EV.push(e); }
function bagCap() { return TANK_TIERS[player.tankTier].cap; }
// Things count as discovered only when they happen near the player (or in the bag).
// how: 'dig', 'pickup', 'melt', 'smelt', 'freeze', 'boil', 'ignite', 'burn', 'fire', 'blow', 'condense'
function discover(m, x, y, from, how, T) {
if (seen[m]) return;
if (x !== undefined) { const dx = x - bot.x, dy = y - bot.y; if (dx * dx + dy * dy > 110 * 110) return; }
seen[m] = 1; emit({ t: 'discover', m, from: from || 0, how: how || '', T: T || 0 });
}
function alloc(w, h) {
W = w; H = h; N = w * h;
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N);
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N);
noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N);
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map();
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
AMB = new Float32Array(h);
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
machines = [null]; molds = []; nets = [null]; pcells = new Set(); powerDirty = true;
beltL = []; beltR = []; tubeLists = [[], [], [], [], []]; brokenMachines = [];
ghostMachines = []; ghostBlocked = new Set();
tick = 0; tickStamp = 1; drillPulses = []; lastPulseTick = -99; undoStack = []; undoRec = null;
pipeNets = []; pipeNetAt = null; pipesDirty = true;
}
// 0 at the base surface height, 1 at the bottom of the world
function depthU(y) {
const d = y - surfBase;
if (!shallowD) return d / underD;
return d <= shallowD ? d / shallowD * 0.3 : 0.3 + (d - shallowD) / (underD - shallowD) * 0.7;
}
// the y at a fraction u of the underground depth (the inverse of depthU)
function depthY(u) { return Math.round(surfBase + (!shallowD ? u * underD : u <= 0.3 ? u / 0.3 * shallowD : shallowD + (u - 0.3) / 0.7 * (underD - shallowD))); }
function computeAmbient() {
let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W;
for (let y = 0; y < H; y++) {
let a = 18;
if (y > sm) a += Math.min(60, (y - sm) * 0.04);
else if (y < sm - 20) a = Math.max(-30, a - (sm - 20 - y) * lapse); // thin cold air high above the ground
const u = depthU(y);
// geothermal: warms slowly, then steeply toward the magma sea
if (u > 0.53) { const k = Math.min(1, (u - 0.53) / (0.905 - 0.53)); a += Math.pow(k, 2.2) * 1200; }
AMB[y] = a;
}
}
function fillNoise() {
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) noiseB[y * W + x] = hash2(x, y, worldSeed) & 255;
}
// ---------- Waking ----------
function wakeXY(x, y) {
const cx = x >> 5, cy = y >> 5, c = cy * CW + cx;
awake[c] = 3;
const lx = x & 31, ly = y & 31;
if (lx === 0) { if (cx > 0) awake[c - 1] = 3; } else if (lx === 31 && cx < CW - 1) awake[c + 1] = 3;
if (ly === 0) { if (cy > 0) awake[c - CW] = 3; } else if (ly === 31 && cy < CH - 1) awake[c + CW] = 3;
}
function wakeHeatXY(x, y) {
const c = (y >> 5) * CW + (x >> 5);
hot[c] = 6; hotLvl[c] = 2;
}
function wakeIdx(i) { wakeXY(i % W, (i / W) | 0); }
function wakeHeatIdx(i) { wakeHeatXY(i % W, (i / W) | 0); }
+315
View File
@@ -0,0 +1,315 @@
// 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 generateWorld(seedIn, w, h, onProgress) {
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();
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 c = nCoal(x / 13, y / 9);
if (d > 6 && fy < 0.45 && c > 0.5 && m !== CLAY) 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)) 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)) 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 && nSn(x / 8, y / 7) > 0.62) m = SNV;
else if (fy > 0.35 && fy < 0.78 && (m === STONE || m === GRANITE) && nZn(x / 22, y / 13) > 0.6) m = ZNV;
else if (fy > 0.5 && fy < 0.88 && m === GRANITE && nAg(x / 7, y / 6) > 0.66) m = AGV;
else if (fy > 0.68 && (m === GRANITE || m === BASALT) && nAu(x / 6, y / 5) > 0.7) m = AUV;
}
}
}
// 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 (onProgress && (y & 63) === 0) onProgress(y / h * 0.7);
}
clearHardSpecks(false);
// (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]; } }
}
// 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;
}
// 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();
if (onProgress) onProgress(0.75);
// settle loose material
awake.fill(3);
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 (onProgress && (k & 31) === 0) onProgress(0.75 + 0.25 * k / 260);
}
// 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;
if (onProgress) onProgress(1);
}