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 b270c4e818
97 changed files with 11710 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
{
"shell": "shell.html",
"css": [
"styles/style.css"
],
"sim": [
"sim/base.js",
"sim/materials.js",
"sim/pieces.js",
"sim/economy.js",
"sim/progress.js",
"sim/world.js",
"sim/cells.js",
"sim/heat.js",
"sim/machines.js",
"sim/processors.js",
"sim/pipes.js",
"sim/machine-tick.js",
"sim/conveyors.js",
"sim/blocks.js",
"sim/molds.js",
"sim/items.js",
"sim/tools.js",
"sim/drill.js",
"sim/nature.js",
"sim/vacuum.js",
"sim/actions.js",
"sim/blueprints.js",
"sim/undo.js",
"sim/bot.js",
"sim/ledger.js",
"sim/weather.js",
"sim/tick.js",
"sim/worldgen.js",
"sim/save.js"
],
"client": [
"render/setup.js",
"render/light.js",
"render/world.js",
"render/machines.js",
"render/overlays.js",
"render/smooth.js",
"ui/audio.js",
"ui/state.js",
"ui/title.js",
"ui/saves.js",
"ui/session.js",
"ui/settings.js",
"ui/notebook.js",
"ui/hud.js",
"ui/build-menu.js",
"ui/selection.js",
"ui/info.js",
"ui/input.js",
"ui/actions.js",
"ui/gamepad.js",
"ui/previews.js",
"ui/events.js",
"ui/loop.js"
]
}
+79
View File
@@ -0,0 +1,79 @@
// Propagated coloured light from glowing and burning cells.
// ---------- Lighting ----------
// Light is computed on a coarse grid (2 or 4 grains per cell), written into a
// small canvas, then the GPU upscales it smoothly and multiplies it over the world.
function computeLight() {
const res = SET.lighting === 'fast' ? 4 : 2;
const M = Math.ceil(28 / res);
const lx0 = Math.floor(R.vx0 / res) - M, ly0 = Math.floor(R.vy0 / res) - M;
const lw = Math.ceil(R.bw / res) + 2 * M, lh = Math.ceil(R.bh / res) + 2 * M;
if (lw !== R.lw || lh !== R.lh || res !== R.lres) {
R.lw = lw; R.lh = lh; R.lres = res;
R.lr = new Float32Array(lw * lh); R.lg = new Float32Array(lw * lh); R.lb = new Float32Array(lw * lh); R.latt = new Float32Array(lw * lh);
R.lightC.width = lw; R.lightC.height = lh;
R.lightImg = R.lightX.createImageData(lw, lh);
R.lightPx = new Uint32Array(R.lightImg.data.buffer);
}
R.lx0 = lx0; R.ly0 = ly0;
const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt;
// per-grain transmittance: air .978, gas .96, liquid .93, solid .935 (light soaks a little way into rock)
const P = res;
const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P);
const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains
for (let ly = 0; ly < lh; ly++) {
const wy = (ly0 + ly) * res;
for (let lx = 0; lx < lw; lx++) {
const wx = (lx0 + lx) * res, k = ly * lw + lx;
if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; }
const i = wy * W + wx, m = mat[i], ph = PH[m];
la[k] = ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol;
let r = 0, g = 0, b = 0;
if (wy < roofY[wx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; }
else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; }
// emission from heat: hottest grain in the block
let T = 0;
const xe = Math.min(W, wx + res), ye = Math.min(H, wy + res);
for (let yy = wy; yy < ye; yy++) { const row = yy * W; for (let xx = wx; xx < xe; xx++) { const tt = temp[row + xx]; if (tt > T) T = tt; } }
if (T > 520) {
const gi = glowIndex(T), e = GLOW_W[gi] * 1.35;
r += GLOW_R[gi] / 255 * e; g += GLOW_G[gi] / 255 * e; b += GLOW_B[gi] / 255 * e;
}
lr[k] = r; lg[k] = g; lb[k] = b;
}
}
// bot lamp
if (bot.lamp) {
const cx = (bot.x + bot.w / 2) / res - lx0, cy = (bot.y + bot.h / 2) / res - ly0, rad = 34 / res;
for (let ly = Math.max(0, Math.floor(cy - rad)); ly < Math.min(lh, cy + rad); ly++)
for (let lx = Math.max(0, Math.floor(cx - rad)); lx < Math.min(lw, cx + rad); lx++) {
const d = Math.hypot(lx - cx, ly - cy) / rad;
if (d >= 1) continue;
const v = (1 - d) * (1 - d) * 1.05, k = ly * lw + lx;
if (lr[k] < v) lr[k] = v; if (lg[k] < v * 0.93) lg[k] = v * 0.93; if (lb[k] < v * 0.8) lb[k] = v * 0.8;
}
}
// propagate: forward sweep (from left/up) then backward sweep (from right/down)
for (let ly = 0; ly < lh; ly++) for (let lx = 0; lx < lw; lx++) {
const k = ly * lw + lx, a = la[k];
let r = lr[k], g = lg[k], b = lb[k];
if (lx > 0) { const j = k - 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
if (ly > 0) { const j = k - lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
lr[k] = r; lg[k] = g; lb[k] = b;
}
for (let ly = lh - 1; ly >= 0; ly--) for (let lx = lw - 1; lx >= 0; lx--) {
const k = ly * lw + lx, a = la[k];
let r = lr[k], g = lg[k], b = lb[k];
if (lx < lw - 1) { const j = k + 1; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
if (ly < lh - 1) { const j = k + lw; if (lr[j] * a > r) r = lr[j] * a; if (lg[j] * a > g) g = lg[j] * a; if (lb[j] * a > b) b = lb[j] * a; }
lr[k] = r; lg[k] = g; lb[k] = b;
}
// write the light canvas
const fl = SET.ambient, px = R.lightPx, n = lw * lh;
for (let k = 0; k < n; k++) {
let r = lr[k], g = lg[k], b = lb[k];
if (r < fl) r = fl; if (g < fl) g = fl; if (b < fl) b = fl;
r = r >= 1 ? 255 : (r * 255) | 0; g = g >= 1 ? 255 : (g * 255) | 0; b = b >= 1 ? 255 : (b * 255) | 0;
px[k] = 0xff000000 | (b << 16) | (g << 8) | r;
}
if (!R.smoothOn) R.lightX.putImageData(R.lightImg, 0, 0);
}
+152
View File
@@ -0,0 +1,152 @@
// Machine overlays: fans, engines, refiner, cutter, silo and the rest, drawn on top of the world.
// ---------- Overlays ----------
function cellRect(x, y, w, h) { const p = worldToScreenPx(x, y); return [p.x, p.y, w * R.zp, h * R.zp]; }
// Yellow and black hazard stripes on both top corners of a hopper
function drawHazardRims(ctx, sx, sy, sw, zp) {
for (let k = 0; k < 2; k++) {
const rx = k ? sx + sw - 2 * zp : sx;
for (let j = 0; j < 4; j++) { ctx.fillStyle = j & 1 ? '#1d1b18' : '#e0b43c'; ctx.fillRect(rx, sy + j * zp * 0.5, 2 * zp, zp * 0.5); }
}
}
function drawMachines() {
const ctx = R.ctx, zp = R.zp;
for (const mm of machines) {
if (!mm) continue;
const [sx, sy, sw, sh] = cellRect(mm.x, mm.y, mm.w, mm.h);
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sh < 0) continue;
ctx.save();
if (mm.type === 'dock') {
// hazard stripes along the hopper rim and a sign on a post
drawHazardRims(ctx, sx, sy, sw, zp);
// the grate in its floor
ctx.fillStyle = 'rgba(30,28,26,0.85)'; ctx.fillRect(sx + 2 * zp, sy + 13 * zp, 20 * zp, zp);
ctx.fillStyle = 'rgba(150,160,170,0.8)';
for (let k = 0; k < 20; k += 2) ctx.fillRect(sx + (2 + k) * zp, sy + 13 * zp, zp * 0.5, zp);
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
const fs = Math.max(9, Math.round(zp * 2.6));
ctx.font = `700 ${fs}px ${R.monoFont}`;
const label = 'TRADE DOCK', tw = ctx.measureText(label).width + fs;
const px = sx + zp * 0.5, py = sy - zp * 7;
ctx.fillStyle = '#4a4036'; ctx.fillRect(px + zp * 0.5, py + fs * 1.4, zp * 0.8, sy - py - fs * 1.4);
ctx.fillStyle = '#2b4a3f'; ctx.fillRect(px, py, tw, fs * 1.5);
ctx.strokeStyle = '#e7dccb'; ctx.lineWidth = Math.max(1, zp * 0.2); ctx.strokeRect(px + 1, py + 1, tw - 2, fs * 1.5 - 2);
ctx.fillStyle = '#efe6d8'; ctx.textBaseline = 'middle'; ctx.fillText(label, px + fs * 0.5, py + fs * 0.78);
} else if (mm.type === 'wheel') {
const cx = sx + sw / 2, cy = sy + sh / 2, rad = sw / 2 - zp * 0.3;
ctx.strokeStyle = '#8a6a44'; ctx.lineWidth = Math.max(1, zp * 0.7);
ctx.beginPath(); ctx.arc(cx, cy, rad, 0, Math.PI * 2); ctx.stroke();
ctx.lineWidth = Math.max(1, zp * 0.5);
for (let k = 0; k < 8; k++) {
const a = mm.angle + k * Math.PI / 4;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a) * rad, cy + Math.sin(a) * rad); ctx.stroke();
ctx.fillStyle = '#6e5232';
ctx.fillRect(cx + Math.cos(a) * rad - zp * 0.6, cy + Math.sin(a) * rad - zp * 0.6, zp * 1.2, zp * 1.2);
}
ctx.fillStyle = '#4a3a28'; ctx.beginPath(); ctx.arc(cx, cy, zp * 1.1, 0, Math.PI * 2); ctx.fill();
} else if (mm.type === 'fan') {
const cx = sx + sw / 2, cy = sy + sh / 2;
const net = nets[mm.net], sat = net ? net.sat : 0;
ctx.fillStyle = 'rgba(20,22,26,0.55)'; ctx.beginPath(); ctx.arc(cx, cy, sw * 0.38, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = mm.on ? (sat > 0.05 ? '#b9c4cc' : '#7d868e') : '#5a5f66';
for (let k = 0; k < 3; k++) {
const a = mm.angle + k * Math.PI * 2 / 3;
ctx.beginPath(); ctx.ellipse(cx + Math.cos(a) * sw * 0.18, cy + Math.sin(a) * sw * 0.18, sw * 0.17, sw * 0.07, a, 0, Math.PI * 2); ctx.fill();
}
// direction arrow
const dx = [1, 0, -1, 0][mm.dir], dy = [0, 1, 0, -1][mm.dir];
ctx.strokeStyle = sat > 0.05 && mm.on ? 'rgba(160,220,255,0.7)' : 'rgba(160,170,180,0.35)'; ctx.lineWidth = Math.max(1, zp * 0.35);
ctx.beginPath(); ctx.moveTo(cx + dx * sw * 0.55, cy + dy * sh * 0.55); ctx.lineTo(cx + dx * sw * 0.9, cy + dy * sh * 0.9); ctx.stroke();
} else if (mm.type === 'silo') {
drawSilo(ctx, mm, sx, sy, zp);
} else if (mm.type === 'cutter') {
// hazard rims on the hopper, and a toothed saw blade in the floor that spins while it cuts
const busy = tick - (mm.busyT || -99) < 12;
drawHazardRims(ctx, sx, sy, sw, zp);
const cx = sx + 4 * zp, cy = sy + 6.5 * zp, rr = zp * 1.4, a0 = busy ? R.frame * 0.35 : 0;
ctx.fillStyle = '#c9cfd4'; ctx.beginPath();
for (let t = 0; t < 16; t++) { const a = a0 + t * Math.PI / 8, r2 = t & 1 ? rr : rr * 0.78; ctx.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2); }
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#3a4048'; ctx.beginPath(); ctx.arc(cx, cy, rr * 0.3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = mm.blocked ? '#c4553f' : busy ? '#e0b43c' : '#4a5058';
ctx.fillRect(sx + 2 * zp, sy + 7.6 * zp, 4 * zp, zp * 0.4);
} else if (mm.type === 'refiner') {
// hopper rim, two toothed grinding rollers in the floor (they turn while it works), the chute
const busy = tick - (mm.busyT || -99) < 12;
drawHazardRims(ctx, sx, sy, sw, zp);
for (let k = 0; k < 2; k++) {
const cx = sx + (k ? 7.5 : 4.5) * zp, cy = sy + 11 * zp, rr = zp * 1.15, a0 = (busy ? R.frame * 0.25 : 0) * (k ? -1 : 1);
ctx.fillStyle = '#2c3036'; ctx.beginPath(); ctx.arc(cx, cy, rr, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#9aa4ae'; ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); for (let t = 0; t < 6; t++) { const a = a0 + t * Math.PI / 3; ctx.moveTo(cx + Math.cos(a) * rr * 0.35, cy + Math.sin(a) * rr * 0.35); ctx.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); } ctx.stroke();
}
ctx.fillStyle = mm.blocked ? '#c4553f' : busy ? '#e0b43c' : '#4a5058';
ctx.beginPath(); ctx.moveTo(sx + 4 * zp, sy + 12 * zp); ctx.lineTo(sx + 8 * zp, sy + 12 * zp); ctx.lineTo(sx + 7 * zp, sy + 12.8 * zp); ctx.lineTo(sx + 5 * zp, sy + 12.8 * zp); ctx.closePath(); ctx.fill();
} else if (mm.type === 'engine') {
ctx.fillStyle = 'rgba(16,18,22,0.4)'; ctx.fillRect(sx + zp, sy + zp, sw - 2 * zp, sh - 3 * zp);
const fx = sx + sw * (mm.dir ? 0.3 : 0.7), fy = sy + sh * 0.42, fr = sh * 0.3;
ctx.strokeStyle = '#c3a06a'; ctx.lineWidth = Math.max(1, zp * 0.5);
ctx.beginPath(); ctx.arc(fx, fy, fr, 0, Math.PI * 2); ctx.stroke();
const ax = fx + Math.cos(mm.angle) * fr * 0.8, ay = fy + Math.sin(mm.angle) * fr * 0.8;
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(sx + sw * (mm.dir ? 0.75 : 0.25), fy); ctx.stroke();
ctx.fillStyle = '#d6b27a'; ctx.fillRect(sx + sw * (mm.dir ? 0.75 : 0.25) - zp, fy - zp * 0.8, zp * 2, zp * 1.6);
if (mm.out > 0.3) { ctx.fillStyle = 'rgba(230,236,244,0.5)'; ctx.fillRect(mm.dir ? sx - zp * 1.5 : sx + sw + zp * 0.5, sy + sh - zp * (1 + (R.frame >> 3) % 2), zp, zp); }
}
ctx.restore();
}
}
// The silo: legs and braces, a cone to a slide gate, a ribbed steel drum with a ladder and a sight
// glass that shows how full it is, a roof with the loading hatch. All in grain units from its corner.
function drawSilo(ctx, mm, sx, sy, zp) {
const X = u => sx + u * zp, Y = u => sy + u * zp, full = Math.min(1, (mm.sn || 0) / SILO_CAP);
const steel = (x0, x1) => { const g = ctx.createLinearGradient(X(x0), 0, X(x1), 0); g.addColorStop(0, '#3b4249'); g.addColorStop(0.22, '#a9b3bb'); g.addColorStop(0.5, '#87919a'); g.addColorStop(1, '#353c43'); return g; };
ctx.save();
// legs, with short struts to the cone
ctx.fillStyle = '#2f353b';
ctx.fillRect(X(0.15), Y(19.5), zp * 0.75, zp * 4.5); ctx.fillRect(X(11.1), Y(19.5), zp * 0.75, zp * 4.5);
ctx.strokeStyle = '#2f353b'; ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.moveTo(X(0.8), Y(23)); ctx.lineTo(X(2.6), Y(21)); ctx.moveTo(X(11.2), Y(23)); ctx.lineTo(X(9.4), Y(21)); ctx.stroke();
// the cone down to the gate
ctx.fillStyle = steel(1, 11); ctx.beginPath(); ctx.moveTo(X(0.8), Y(19.8)); ctx.lineTo(X(11.2), Y(19.8)); ctx.lineTo(X(8.2), Y(23)); ctx.lineTo(X(3.8), Y(23)); ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = Math.max(1, zp * 0.12); ctx.stroke();
// the slide gate: across the opening when shut, pulled aside when open
ctx.fillStyle = '#1c1f23'; ctx.fillRect(X(4), Y(23), zp * 4, zp);
ctx.fillStyle = mm.open ? '#c99a3c' : '#6b737b';
if (mm.open) ctx.fillRect(X(7.6), Y(23.15), zp * 2.4, zp * 0.6); else ctx.fillRect(X(3.6), Y(23.15), zp * 4.8, zp * 0.6);
// the drum
ctx.fillStyle = steel(0.5, 11.5); ctx.fillRect(X(0.5), Y(1.8), zp * 11, zp * 18.2);
ctx.strokeStyle = 'rgba(0,0,0,0.28)'; ctx.lineWidth = Math.max(1, zp * 0.14);
for (let k = 1; k < 4; k++) { const yy = Y(1.8 + k * 4.55); ctx.beginPath(); ctx.moveTo(X(0.5), yy); ctx.lineTo(X(11.5), yy); ctx.stroke(); }
ctx.strokeStyle = 'rgba(0,0,0,0.12)';
for (let k = 1; k < 6; k++) { const xx = X(0.5 + k * 11 / 6); ctx.beginPath(); ctx.moveTo(xx, Y(1.8)); ctx.lineTo(xx, Y(20)); ctx.stroke(); }
if (zp >= 4) { ctx.fillStyle = 'rgba(255,255,255,0.35)'; for (let k = 1; k < 4; k++) for (let j = 0; j < 8; j++) { ctx.beginPath(); ctx.arc(X(1 + j * 1.43), Y(1.8 + k * 4.55) - zp * 0.25, Math.max(0.6, zp * 0.09), 0, Math.PI * 2); ctx.fill(); } }
// a ladder up the left side
ctx.strokeStyle = '#2b3036'; ctx.lineWidth = Math.max(1, zp * 0.14);
ctx.beginPath(); ctx.moveTo(X(1.3), Y(2)); ctx.lineTo(X(1.3), Y(19.8)); ctx.moveTo(X(2.3), Y(2)); ctx.lineTo(X(2.3), Y(19.8));
for (let yy = 2.6; yy < 19.8; yy += 1.1) { ctx.moveTo(X(1.3), Y(yy)); ctx.lineTo(X(2.3), Y(yy)); }
ctx.stroke();
// the sight glass: fills from the bottom in the colour of what is inside
const gx = 9.1, gy0 = 3, gy1 = 18.8, gh = gy1 - gy0;
ctx.fillStyle = '#14181c'; ctx.fillRect(X(gx), Y(gy0), zp * 1.1, zp * gh);
if (mm.sn > 0) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(gx + 0.15), Y(gy1 - gh * full), zp * 0.8, zp * gh * full); }
ctx.strokeStyle = '#c9d1d8'; ctx.lineWidth = Math.max(1, zp * 0.1);
ctx.strokeRect(X(gx), Y(gy0), zp * 1.1, zp * gh);
for (let k = 1; k < 4; k++) { const yy = Y(gy1 - gh * k / 4); ctx.beginPath(); ctx.moveTo(X(gx - 0.35), yy); ctx.lineTo(X(gx), yy); ctx.stroke(); }
// what it holds, stencilled on the drum
if (zp >= 3) {
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
const fs = Math.max(8, Math.round(zp * 1.15));
ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(20,24,28,0.75)';
ctx.fillText('SILO', X(5.6), Y(5));
ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`;
ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', X(5.6), Y(16.8));
if (mm.sn) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(4.4), Y(17.9), zp * 2.4, zp * 0.55); }
}
// roof with a rim, and the loading hatch (its lid stands open)
ctx.fillStyle = '#5b646c'; ctx.fillRect(X(0.2), Y(1.2), zp * 11.6, zp * 0.8);
ctx.fillStyle = '#7d878f'; ctx.fillRect(X(0.6), Y(0.6), zp * 2.4, zp * 0.7); ctx.fillRect(X(9), Y(0.6), zp * 2.4, zp * 0.7);
ctx.fillStyle = '#15181b'; ctx.fillRect(X(3), Y(0.9), zp * 6, zp * 1.1);
ctx.strokeStyle = tick - (mm.fillT || -99) < 10 ? '#e0b43c' : '#9aa4ae'; ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.moveTo(X(8.9), Y(0.9)); ctx.lineTo(X(10.2), Y(-1.4)); ctx.stroke();
ctx.restore();
}
+230
View File
@@ -0,0 +1,230 @@
// Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
function spawnFloater(x, y, text) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, life: 70 }); }
// Rain as thin falling streaks, snow as soft flakes
function drawRain() {
if (!flyers.length) return;
const ctx = R.ctx, zp = R.zp, v = viewCells();
const x0 = R.camX - 2, y0 = R.camY - 4, x1 = R.camX + v.w + 2, y1 = R.camY + v.h + 2;
ctx.save();
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(176,204,236,0.5)'; ctx.lineWidth = Math.max(1, zp * 0.22);
ctx.beginPath();
let snow = false;
for (const f of flyers) {
if (!f.rain || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
if (f.m !== WATER) { snow = true; continue; }
const a = worldToScreenPx(f.x, f.y), len = Math.max(1.5, Math.hypot(f.vx, f.vy) * 3.2);
ctx.moveTo(a.x, a.y); ctx.lineTo(a.x - f.vx / Math.max(0.2, f.vy) * len * zp * 0.5, a.y - len * zp);
}
ctx.stroke();
if (snow) {
ctx.fillStyle = 'rgba(244,247,252,0.9)';
for (const f of flyers) {
if (!f.rain || f.m === WATER || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
const a = worldToScreenPx(f.x + Math.sin(f.age * 0.07 + f.x) * 0.6, f.y), r = Math.max(1, zp * 0.42);
ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fill();
}
}
ctx.restore();
}
// Info overlay: a small arrow on every belt and screw tube block on screen, the way it moves things
function drawFlowArrows() {
const ctx = R.ctx, zp = R.zp, v = viewCells();
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
const s = BS * zp, r = Math.max(3, s * 0.22);
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx, code = blockP[b];
if (code !== BP_BELT && code !== BP_TUBE && code !== BP_PUMP && code !== BP_OUTLET) continue;
const vv = blockV[b];
const [dx, dy] = code === BP_BELT ? (vv ? [1, 0] : [-1, 0]) : code === BP_PUMP ? PUMP_V[vv & 3] : code === BP_OUTLET ? OUTLET_V[vv & 3] : [[0, -1], [1, 0], [0, 1], [-1, 0]][vv & 3];
const c = worldToScreenPx(bx * BS + BS / 2, by * BS + BS / 2);
ctx.fillStyle = 'rgba(14,12,11,0.55)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.35, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = code === BP_TUBE ? '#ffc58a' : '#e7dccb'; ctx.lineWidth = Math.max(1.2, r * 0.32);
// a chevron pointing (dx, dy)
const px = -dy, py = dx;
ctx.beginPath();
ctx.moveTo(c.x - dx * r * 0.45 + px * r * 0.7, c.y - dy * r * 0.45 + py * r * 0.7);
ctx.lineTo(c.x + dx * r * 0.55, c.y + dy * r * 0.55);
ctx.lineTo(c.x - dx * r * 0.45 - px * r * 0.7, c.y - dy * r * 0.45 - py * r * 0.7);
ctx.stroke();
}
ctx.restore();
}
// Outline a run of blocks: strong while it is being edited, faint when pointed at
function drawRunOutline(run, strong) {
if (!run || !run.length) return;
const ctx = R.ctx, zp = R.zp;
const x0 = (run[0] % BW) * BS, x1 = (run[run.length - 1] % BW + 1) * BS, y = ((run[0] / BW) | 0) * BS;
const a = worldToScreenPx(x0, y), b = worldToScreenPx(x1, y + BS);
ctx.save();
ctx.strokeStyle = strong ? 'rgba(255,190,90,0.95)' : 'rgba(236,226,210,0.45)'; ctx.lineWidth = Math.max(1, zp * (strong ? 0.5 : 0.25));
if (!strong) ctx.setLineDash([zp, zp * 0.7]);
ctx.strokeRect(a.x - zp * 0.3, a.y - zp * 0.3, b.x - a.x + zp * 0.6, b.y - a.y + zp * 0.6);
ctx.restore();
}
// Tags over filters: what falls through (↓) and what rides along, and which way.
// showAll tags every run of matching filters on screen; otherwise only the run under hoverB.
function drawFilterTags(showAll, hoverB) {
if (!filterCfg.size || (!showAll && hoverB < 0)) return;
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
const v = viewCells();
const bx0 = Math.max(0, Math.floor(R.camX / BS) - 1), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS) + 1);
const by0 = Math.max(0, Math.floor(R.camY / BS) - 1), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS) + 1);
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
ctx.save();
ctx.font = `600 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
const sw = Math.round(9 * u), gap = Math.round(3 * u), pad = Math.round(5 * u), th = Math.round(17 * u);
const runs = showAll ? [] : [filterRun(hoverB)];
if (showAll) for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx;
if (blockP[b] !== BP_FILTER) continue;
const run = filterRun(b);
if (run[0] === b || bx === bx0) runs.push(run);
bx = Math.max(bx, (run[run.length - 1] % BW));
}
for (const run of runs) {
if (!run.length) continue;
const b = run[0], c = filterCfg.get(b), ms = c ? c.m : [], inv = c ? c.inv : 0, right = blockV[b] & 128;
// segment contents: swatches, or a word when it is "the rest"
const segs = [
{ icon: '↓', col: '#7fd6c2', mats: inv ? null : ms, word: inv ? (ms.length ? 'rest' : 'all') : 'none' },
{ icon: right ? '→' : '←', col: '#ffd27a', mats: inv ? ms : null, word: inv ? 'none' : (ms.length ? 'rest' : 'all') },
];
let w = pad;
for (const sg of segs) {
sg.iw = ctx.measureText(sg.icon).width;
sg.show = sg.mats && sg.mats.length ? sg.mats.slice(0, 5) : null;
sg.more = sg.mats && sg.mats.length > 5 ? `+${sg.mats.length - 5}` : '';
sg.cw = sg.show ? sg.show.length * (sw + 2) - 2 + (sg.more ? gap + ctx.measureText(sg.more).width : 0) : ctx.measureText(sg.word).width;
w += sg.iw + gap + sg.cw + pad;
}
w += gap;
const x0 = (run[0] % BW) * BS, x1 = (run[run.length - 1] % BW + 1) * BS, y = ((b / BW) | 0) * BS;
const p = worldToScreenPx((x0 + x1) / 2, y);
let tx = Math.round(p.x - w / 2), ty = Math.round(p.y - th - 4 * u);
if (tx + w < 0 || tx > R.canvas.width || ty + th < 0 || ty > R.canvas.height) continue;
ctx.fillStyle = 'rgba(14,12,11,0.86)'; ctx.fillRect(tx, ty, w, th);
ctx.strokeStyle = 'rgba(236,226,210,0.22)'; ctx.lineWidth = u; ctx.strokeRect(tx + u / 2, ty + u / 2, w - u, th - u);
let cx = tx + pad;
segs.forEach((sg, k) => {
if (k) { ctx.fillStyle = 'rgba(236,226,210,0.25)'; ctx.fillRect(cx - pad / 2 - gap / 2, ty + 3 * u, u, th - 6 * u); cx += gap; }
ctx.fillStyle = sg.col; ctx.fillText(sg.icon, cx, ty + th / 2 + u * 0.5); cx += sg.iw + gap;
if (sg.show) {
for (const m of sg.show) { ctx.fillStyle = `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`; ctx.fillRect(cx, ty + (th - sw) / 2, sw, sw); ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.strokeRect(cx + 0.5, ty + (th - sw) / 2 + 0.5, sw - 1, sw - 1); cx += sw + 2; }
cx -= 2;
if (sg.more) { cx += gap; ctx.fillStyle = '#e7dccb'; ctx.fillText(sg.more, cx, ty + th / 2 + u * 0.5); cx += ctx.measureText(sg.more).width; }
} else { ctx.fillStyle = 'rgba(231,220,203,0.85)'; ctx.fillText(sg.word, cx, ty + th / 2 + u * 0.5); cx += sg.cw; }
cx += pad;
});
}
ctx.restore();
}
function drawFloaters() {
const fl = R.floaters; if (!fl || !fl.length) return;
const ctx = R.ctx, zp = R.zp;
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
ctx.save();
ctx.font = `700 ${Math.max(11, Math.round(zp * 3))}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
for (let k = fl.length - 1; k >= 0; k--) {
const f = fl[k]; f.life--; f.y -= 0.12;
if (f.life <= 0) { fl.splice(k, 1); continue; }
const s = worldToScreenPx(f.x, f.y), a = Math.min(1, f.life / 25);
ctx.fillStyle = `rgba(14,12,11,${0.6 * a})`; ctx.fillText(f.text, s.x + 1, s.y + 1);
ctx.fillStyle = `rgba(255,210,122,${a})`; ctx.fillText(f.text, s.x, s.y);
}
ctx.restore();
}
function drawMolds(hoverMold) {
const ctx = R.ctx, zp = R.zp;
ctx.save();
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
ctx.font = `600 ${Math.max(10, Math.round(zp * 3.4))}px ${R.monoFont}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
for (const mo of molds) {
const [sx, sy, sw, sh] = cellRect(mo.x, mo.y, mo.w, mo.h);
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sh < 0) continue;
const ready = mo.state === 'ready', flawed = mo.state === 'flawed';
ctx.setLineDash([zp, zp]);
ctx.strokeStyle = ready ? 'rgba(255,200,90,0.95)' : flawed ? 'rgba(230,90,70,0.8)' : 'rgba(200,190,170,0.4)';
ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(sx - zp * 0.5, sy - zp * 0.5, sw + zp, sh + zp);
ctx.setLineDash([]);
if (ready || flawed || mo === hoverMold || mo.state === 'filling' || mo.state === 'short') {
const label = ready ? `${mo.kind === 'ingot' ? (mo.ready.length === 1 ? 'Bar' : `${mo.ready.length} bars`) : mo.kind === 'bit' ? 'Bit' : 'Tank'} set · F` : flawed ? 'Flawed casting' : mo.state === 'short' ? 'Needs more metal' : mo.state === 'filling' ? `Filling ${Math.round(mo.fill * 100)}%` : MOLDS[mo.kind].name;
ctx.fillStyle = 'rgba(14,12,11,0.75)';
const tw = ctx.measureText(label).width + zp * 2;
ctx.fillRect(sx + sw / 2 - tw / 2, sy - zp * 5.2, tw, zp * 4.2);
ctx.fillStyle = ready ? '#ffcf7a' : flawed ? '#ff8a72' : '#e7dccb';
ctx.fillText(label, sx + sw / 2, sy - zp * 1.4);
}
}
ctx.restore();
}
function drawBot() {
const ctx = R.ctx, zp = R.zp;
const [sx, sy, sw, sh] = cellRect(bot.x, bot.y, bot.w, bot.h);
const cx = sx + sw / 2, cy = sy + sh / 2;
const bob = Math.sin(R.frame * 0.12) * zp * 0.25;
ctx.save();
// thruster glow
if (bot.thrust > 0.1) {
const fl = (0.6 + Math.random() * 0.4) * bot.thrust;
const grd = ctx.createRadialGradient(cx, sy + sh + bob, 0, cx, sy + sh + bob, zp * 3.2 * fl);
grd.addColorStop(0, 'rgba(255,214,140,0.9)'); grd.addColorStop(1, 'rgba(255,120,40,0)');
ctx.fillStyle = grd; ctx.fillRect(cx - zp * 4, sy + sh - zp + bob, zp * 8, zp * 5);
}
// body
ctx.fillStyle = '#c8bfb2';
ctx.beginPath(); ctx.roundRect(sx + zp * 0.2, sy + zp * 0.6 + bob, sw - zp * 0.4, sh - zp * 1.4, zp * 1.4); ctx.fill();
ctx.fillStyle = '#8c8378';
ctx.fillRect(sx + zp * 0.2, sy + sh - zp * 1.6 + bob, sw - zp * 0.4, zp * 0.8);
// visor
ctx.fillStyle = '#1b1715';
const vx = cx + bot.face * zp * 0.5;
ctx.beginPath(); ctx.roundRect(vx - zp * 1.5, sy + zp * 1.4 + bob, zp * 3, zp * 1.6, zp * 0.6); ctx.fill();
ctx.fillStyle = bot.lamp ? '#ffb45a' : '#6b5a48';
ctx.fillRect(vx + bot.face * zp * 0.5 - zp * 0.45, sy + zp * 1.8 + bob, zp * 0.9, zp * 0.8);
// antenna
ctx.strokeStyle = '#8c8378'; ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.moveTo(cx - bot.face * zp, sy + zp * 0.6 + bob); ctx.lineTo(cx - bot.face * zp * 1.6, sy - zp * 0.6 + bob); ctx.stroke();
ctx.restore();
}
function spawnDebris(x, y, m) {
if (!SET.particles || R.particles.length > 400) return;
for (let k = 0; k < 2; k++) R.particles.push({ x: x + Math.random(), y: y + Math.random(), vx: (Math.random() - 0.5) * 0.5, vy: -Math.random() * 0.5, life: 20 + Math.random() * 20, r: COLR[m], g: COLG[m], b: COLB[m] });
}
function spawnSpark(x, y) {
if (!SET.particles || R.particles.length > 400) return;
R.particles.push({ x, y, vx: (Math.random() - 0.5) * 0.9, vy: -Math.random() * 0.8, life: 10 + Math.random() * 12, r: 255, g: 190 + Math.random() * 60, b: 90, spark: true });
}
// grains flying into the vacuum nozzle
function spawnSuck(x, y, m) {
if (!SET.particles || R.particles.length > 500) return;
const v = (Math.random() - 0.5) * 2 * CVAR[m];
R.particles.push({ x: x + 0.5, y: y + 0.5, vx: 0, vy: 0, life: 40, r: COLR[m] + v, g: COLG[m] + v, b: COLB[m] + v, suck: true });
}
function drawParticles() {
const ctx = R.ctx, zp = R.zp;
const ps = R.particles;
const tx = bot.x + bot.w / 2, ty = bot.y + bot.h / 2;
for (let k = ps.length - 1; k >= 0; k--) {
const p = ps[k];
if (p.suck) {
const dx = tx - p.x, dy = ty - p.y, d = Math.hypot(dx, dy);
if (d < 2.5) { ps.splice(k, 1); continue; }
const sp = Math.min(d, 0.6 + (40 - p.life) * 0.28);
p.x += dx / d * sp; p.y += dy / d * sp; p.life--;
if (p.life <= 0) { ps.splice(k, 1); continue; }
} else {
p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--;
if (p.life <= 0) { ps.splice(k, 1); continue; }
}
const s = worldToScreenPx(p.x, p.y);
ctx.fillStyle = `rgba(${p.r | 0},${p.g | 0},${p.b | 0},${Math.min(1, p.life / 10)})`;
const sz = p.spark ? Math.max(1, zp * 0.5) : p.suck ? zp : Math.max(1, zp * 0.7);
ctx.fillRect(s.x, s.y, sz, sz);
}
}
+196
View File
@@ -0,0 +1,196 @@
/* Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow,
thermal palettes, pipes, trees, sky), canvas setup and the camera. */
const R = {
canvas: null, ctx: null, buf: null, bctx: null, img: null, px: null,
glowC: null, glowX: null, glowImg: null, glowPx: null,
bw: 0, bh: 0, zp: 3, camX: 0, camY: 0, vx0: 0, vy0: 0,
lr: null, lg: null, lb: null, latt: null, lw: 0, lh: 0, lx0: 0, ly0: 0, lres: 1,
frame: 0, lens: false, particles: [],
};
// ---------- LUTs ----------
const GLOW_W = new Float32Array(700), GLOW_R = new Float32Array(700), GLOW_G = new Float32Array(700), GLOW_B = new Float32Array(700);
(function buildGlow() {
const k = [[450, 0, 90, 10, 0], [560, 0.26, 140, 18, 0], [700, 0.46, 190, 34, 4], [850, 0.6, 224, 56, 8], [1000, 0.72, 244, 86, 16],
[1150, 0.82, 255, 118, 30], [1300, 0.89, 255, 150, 52], [1500, 0.94, 255, 192, 96], [1750, 0.97, 255, 226, 158], [2200, 1, 255, 250, 228]];
for (let n = 0; n < 700; n++) {
const T = 400 + n * 4;
let a = k[0], b = k[k.length - 1];
for (let j = 0; j < k.length - 1; j++) if (T >= k[j][0] && T <= k[j + 1][0]) { a = k[j]; b = k[j + 1]; break; }
let f = b[0] === a[0] ? 0 : (T - a[0]) / (b[0] - a[0]);
if (T < k[0][0]) { a = b = k[0]; f = 0; }
if (T > k[k.length - 1][0]) { a = b = k[k.length - 1]; f = 0; }
GLOW_W[n] = a[1] + (b[1] - a[1]) * f; GLOW_R[n] = a[2] + (b[2] - a[2]) * f; GLOW_G[n] = a[3] + (b[3] - a[3]) * f; GLOW_B[n] = a[4] + (b[4] - a[4]) * f;
}
})();
function glowIndex(T) { const n = ((T - 400) / 4) | 0; return n < 0 ? 0 : n > 699 ? 699 : n; }
const THERM = { inferno: null, ironbow: null, gray: null };
(function buildTherm() {
const pal = {
inferno: [[0, 8, 6, 28], [20, 24, 14, 64], [60, 64, 18, 110], [150, 130, 30, 108], [350, 196, 54, 78], [700, 238, 104, 32], [1100, 250, 172, 30], [1600, 252, 232, 120], [2200, 255, 255, 236]],
ironbow: [[0, 0, 0, 16], [20, 26, 0, 92], [60, 92, 0, 150], [150, 172, 20, 140], [350, 224, 70, 60], [700, 250, 140, 0], [1100, 255, 200, 40], [1600, 255, 240, 150], [2200, 255, 255, 255]],
gray: [[0, 10, 10, 10], [20, 40, 40, 40], [60, 70, 70, 70], [150, 105, 105, 105], [350, 145, 145, 145], [700, 185, 185, 185], [1100, 215, 215, 215], [1600, 240, 240, 240], [2200, 255, 255, 255]],
};
for (const name in pal) {
const k = pal[name], lut = new Uint8Array(551 * 3);
for (let n = 0; n <= 550; n++) {
const T = n * 4;
let j = 0; while (j < k.length - 2 && T > k[j + 1][0]) j++;
const a = k[j], b = k[j + 1];
const f = Math.max(0, Math.min(1, (T - a[0]) / (b[0] - a[0])));
lut[n * 3] = a[1] + (b[1] - a[1]) * f; lut[n * 3 + 1] = a[2] + (b[2] - a[2]) * f; lut[n * 3 + 2] = a[3] + (b[3] - a[3]) * f;
}
THERM[name] = lut;
}
})();
function thermIdx(T) { const n = (T / 4) | 0; return (n < 0 ? 0 : n > 550 ? 550 : n) * 3; }
// Pipes, pumps and outlets, cell by cell: a copper wall round a bore that shows the line's liquid
// (dark when empty, brighter the fuller it is, with a ripple while it flows), open toward the
// pipes it joins. Pumps are blue-grey with an impeller and a grille on the intake side;
// outlets have a dark mouth on the side they pour from.
const PC = [0, 0, 0];
function pipeCellColor(x, y, anim) {
const lx = x & 3, ly = y & 3, b = (y >> 2) * BW + (x >> 2), code = blockP[b];
const net = pipeNetOf(b), full = net && net.n > 0 ? Math.min(1, net.n / net.cap) : 0;
const flowing = net && tick - net.flowT < 20;
const mid = (lx === 1 || lx === 2), midY = (ly === 1 || ly === 2);
let bore = mid && midY;
if (!bore && code === BP_PIPE) {
if (mid && ly === 0) bore = pipeJoins(b, 0) >= 0;
else if (mid && ly === 3) bore = pipeJoins(b, 2) >= 0;
else if (midY && lx === 3) bore = pipeJoins(b, 1) >= 0;
else if (midY && lx === 0) bore = pipeJoins(b, 3) >= 0;
}
if (code === BP_PUMP || code === BP_OUTLET) {
const v = code === BP_PUMP ? [-PUMP_V[blockV[b] & 3][0], -PUMP_V[blockV[b] & 3][1]] : OUTLET_V[blockV[b] & 3];
const s = sideOf(v[0], v[1]), edge = s === 0 ? ly === 0 : s === 2 ? ly === 3 : s === 1 ? lx === 3 : lx === 0;
const off = (blockV[b] & 4) !== 0;
if (edge) {
// intake grille / outlet mouth
if (code === BP_PUMP) { const bar = (s === 0 || s === 2 ? lx : ly) & 1; PC[0] = bar ? 40 : 92; PC[1] = bar ? 44 : 100; PC[2] = bar ? 50 : 112; }
else if (mid || midY) { const lq = flowing && !off && net.lm; PC[0] = lq ? COLR[net.lm] : 22; PC[1] = lq ? COLG[net.lm] : 20; PC[2] = lq ? COLB[net.lm] : 20; }
else { PC[0] = 150; PC[1] = 96; PC[2] = 62; }
return;
}
if (code === BP_PUMP) {
if (bore) { const spin = flowing && !off ? ((anim >> 1) + lx + ly * 3) & 3 : (lx + ly) & 1; PC[0] = spin === 0 ? 196 : 70; PC[1] = spin === 0 ? 204 : 78; PC[2] = spin === 0 ? 214 : 92; }
else { PC[0] = off ? 84 : 104; PC[1] = off ? 90 : 122; PC[2] = off ? 98 : 142; if (lx === 0 || ly === 0) { PC[0] += 18; PC[1] += 18; PC[2] += 18; } }
return;
}
}
if (bore) {
// a line that empties as fast as it fills still shows its liquid running through
const lm = net ? (net.n > 0 ? net.m : flowing ? net.lm : 0) : 0;
if (lm) {
const k = 0.45 + 0.55 * full, ripple = flowing && ((x + y + (anim >> 2)) & 3) === 0 ? 22 : 0;
PC[0] = COLR[lm] * k + ripple; PC[1] = COLG[lm] * k + ripple; PC[2] = COLB[lm] * k + ripple;
} else { PC[0] = 30; PC[1] = 26; PC[2] = 24; }
return;
}
// copper wall, lit on its top and left edges
const hi = (lx === 0 || ly === 0) ? 24 : (lx === 3 || ly === 3) ? -22 : 0;
PC[0] = 170 + hi; PC[1] = 106 + hi * 0.7; PC[2] = 66 + hi * 0.5;
}
// A pipe block is thin: only a cross two grains wide (toward the pipes it joins) is pipe; the
// rest of the block shows whatever is there, water flowing past included.
function pipeBodyCell(x, y) {
const lx = x & 3, ly = y & 3, mx = lx === 1 || lx === 2, my = ly === 1 || ly === 2;
if (mx && my) return true;
const b = (y >> 2) * BW + (x >> 2);
if (mx) return ly === 0 ? pipeJoins(b, 0) >= 0 : pipeJoins(b, 2) >= 0;
if (my) return lx === 3 ? pipeJoins(b, 1) >= 0 : pipeJoins(b, 3) >= 0;
return false;
}
// a thin pipe's own cells: copper, lit on one side, with the line's liquid showing through the bore
function thinPipeColor(x, y, anim) {
const lx = x & 3, ly = y & 3, b = (y >> 2) * BW + (x >> 2), net = pipeNetOf(b);
const flowing = net && tick - net.flowT < 20, lm = net ? (net.n > 0 ? net.m : flowing ? net.lm : 0) : 0;
const vert = (lx === 1 || lx === 2) && (ly === 0 || ly === 3), first = vert ? lx === 1 : ly === 1;
let r = first ? 194 : 150, g = first ? 124 : 92, bl = first ? 80 : 56;
if (lm) {
const k = 0.4 + 0.35 * Math.min(1, net.n / net.cap), rip = flowing && ((x + y + (anim >> 2)) & 3) === 0 ? 20 : 0;
r = r * (1 - k) + COLR[lm] * k + rip; g = g * (1 - k) + COLG[lm] * k + rip; bl = bl * (1 - k) + COLB[lm] * k + rip;
}
PC[0] = r; PC[1] = g; PC[2] = bl;
}
// a block that is a belt or a filter: they join up into one belt
function beltLike(b) { if (b < 0 || b >= NB) return false; const c = blockP[b]; return c === BP_BELT || c === BP_FILTER; }
// standing trees: the trunk shows its dark bark, so it reads apart from the pale cut wood it drops
const TREE_R = new Uint8Array(NMAT), TREE_G = new Uint8Array(NMAT), TREE_B = new Uint8Array(NMAT);
for (let m = 0; m < NMAT; m++) { TREE_R[m] = COLR[m]; TREE_G[m] = COLG[m]; TREE_B[m] = COLB[m]; }
TREE_R[WOOD] = 112; TREE_G[WOOD] = 82; TREE_B[WOOD] = 60;
// background colors per layer (cave back walls)
const BG = {
dirt: [46, 33, 24], stone: [36, 36, 40], granite: [48, 38, 38], basalt: [22, 22, 26],
};
let SKY_R, SKY_G, SKY_B;
function buildSky() {
SKY_R = new Uint8Array(H); SKY_G = new Uint8Array(H); SKY_B = new Uint8Array(H);
let sm = 0; for (let x = 0; x < W; x++) sm += surfY[x]; sm /= W;
for (let y = 0; y < H; y++) {
const f = Math.min(1, y / (sm + 20));
SKY_R[y] = 88 + f * 92; SKY_G[y] = 140 + f * 70; SKY_B[y] = 198 + f * 34;
}
}
function initRender(canvas) {
R.canvas = canvas;
R.ctx = canvas.getContext('2d', { alpha: false });
R.buf = document.createElement('canvas');
R.bctx = R.buf.getContext('2d');
R.glowC = document.createElement('canvas');
R.glowX = R.glowC.getContext('2d');
R.lightC = document.createElement('canvas');
R.lightX = R.lightC.getContext('2d');
}
function resizeRender() {
const dpr = Math.min(window.devicePixelRatio || 1, 3) * SET.renderScale;
const cw = Math.max(1, Math.floor(window.innerWidth * dpr)), ch = Math.max(1, Math.floor(window.innerHeight * dpr));
if (R.canvas.width !== cw || R.canvas.height !== ch) { R.canvas.width = cw; R.canvas.height = ch; }
let zp = SET.zoom * dpr;
if (SET.integerScale) zp = Math.max(1, Math.round(zp));
R.zp = zp;
const bw = Math.ceil(cw / zp) + 2, bh = Math.ceil(ch / zp) + 2;
if (bw !== R.bw || bh !== R.bh) {
R.bw = bw; R.bh = bh;
R.buf.width = bw; R.buf.height = bh;
R.img = R.bctx.createImageData(bw, bh);
R.px = new Uint32Array(R.img.data.buffer);
// the smooth look also needs, per grain: the colour behind it, any tree, and occupancy fields
R.backPx = new Uint32Array(bw * bh); R.treePx = new Uint32Array(bw * bh); R.fieldPx = new Uint32Array(bw * bh); R.moldMask = new Uint8Array(bw * bh);
const gw = Math.ceil(bw / 2), gh = Math.ceil(bh / 2);
R.glowC.width = gw; R.glowC.height = gh;
R.glowImg = R.glowX.createImageData(gw, gh);
R.glowPx = new Uint32Array(R.glowImg.data.buffer);
R.lw = 0;
}
}
function viewCells() { return { w: R.canvas.width / R.zp, h: R.canvas.height / R.zp }; }
// lookX/lookY: how far the pointer is from screen center, in grains (camera leads toward it)
function updateCamera(dt, lookX, lookY) {
const v = viewCells();
let tx = bot.x + bot.w / 2 - v.w / 2, ty = bot.y + bot.h / 2 - v.h / 2;
if (lookX !== undefined) { tx += lookX * 0.22; ty += lookY * 0.22; }
let k = SET.cameraSmooth >= 1 ? 1 : 1 - Math.pow(SET.cameraSmooth * 0.92, dt * 60);
if (R.snapNext) { k = 1; R.snapNext = false; } // after a zoom, jump straight to the bot
R.camX += (tx - R.camX) * k; R.camY += (ty - R.camY) * k;
if (v.w >= W) R.camX = (W - v.w) / 2; else R.camX = Math.max(0, Math.min(W - v.w, R.camX));
if (v.h >= H) R.camY = (H - v.h) / 2; else R.camY = Math.max(0, Math.min(H - v.h, R.camY));
if (R.shake > 0) { R.camX += (Math.random() - 0.5) * R.shake; R.camY += (Math.random() - 0.5) * R.shake; R.shake *= 0.85; if (R.shake < 0.05) R.shake = 0; }
}
function snapCamera() {
const v = viewCells();
R.camX = bot.x + bot.w / 2 - v.w / 2; R.camY = bot.y + bot.h / 2 - v.h / 2;
R.camX = Math.max(0, Math.min(Math.max(0, W - v.w), R.camX)); R.camY = Math.max(0, Math.min(Math.max(0, H - v.h), R.camY));
}
function screenToWorld(sx, sy) {
const dpr = R.canvas.width / window.innerWidth;
return { x: R.camX + sx * dpr / R.zp, y: R.camY + sy * dpr / R.zp };
}
function worldToScreenPx(x, y) { return { x: (x - R.camX) * R.zp, y: (y - R.camY) * R.zp }; }
+430
View File
@@ -0,0 +1,430 @@
/* =====================================================================
Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld
fills (colour + class, colour behind, trees, occupancy fields, light,
glow) into rounded, outlined, softly lit shapes at screen resolution.
The 2D canvas then draws this image as its world layer, and every
overlay (machines, bot, previews, labels) goes on top as before.
===================================================================== */
const SM = { tried: false, ok: false, why: '', gl: null, canvas: null, prog: null, tex: {}, u: {}, t0: performance.now(), white: null, surf: -1, scale: 1, ms: 0, n: 0 };
const SM_VS = `#version 300 es
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const SM_FS = `#version 300 es
precision highp float;
precision highp int;
uniform sampler2D tCol, tColL, tBack, tTree, tField, tLight, tGlow;
uniform vec2 uSize, uOrigin, uLightSize, uGlowSize;
uniform vec4 uView, uLightMap;
uniform float uGlowK, uTime, uSurf, uRain;
out vec4 o;
float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vn(vec2 p) {
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2(1, 0)), f.x), mix(h21(i + vec2(0, 1)), h21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int k = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
vec4 lin(sampler2D t, vec2 q) { return textureLod(t, q / uSize, 0.0); }
// cubic B-spline from four bilinear taps: rounds a field of 0s and 1s into smooth shapes
vec4 bsp(sampler2D t, vec2 q) {
vec2 tc = q - 0.5, i = floor(tc), f = tc - i, f2 = f * f, f3 = f2 * f;
vec2 w0 = (1.0 - 3.0 * f + 3.0 * f2 - f3) / 6.0, w1 = (4.0 - 6.0 * f2 + 3.0 * f3) / 6.0;
vec2 w2 = (1.0 + 3.0 * f + 3.0 * f2 - 3.0 * f3) / 6.0, w3 = f3 / 6.0;
vec2 g0 = w0 + w1, g1 = w2 + w3;
vec2 h0 = (i - 0.5 + w1 / g0) / uSize, h1 = (i + 1.5 + w3 / g1) / uSize;
return g0.y * (g0.x * textureLod(t, h0, 0.0) + g1.x * textureLod(t, vec2(h1.x, h0.y), 0.0))
+ g1.y * (g0.x * textureLod(t, vec2(h0.x, h1.y), 0.0) + g1.x * textureLod(t, h1, 0.0));
}
vec4 cellAt(sampler2D t, ivec2 c) { return texelFetch(t, clamp(c, ivec2(0), ivec2(uSize) - 1), 0); }
int cls(vec4 c) { return int(c.a * 255.0 + 0.5); }
float isT(int n) { return (n == 1 || n == 4 || n == 6 || (n >= 7 && n <= 12)) ? 1.0 : 0.0; }
float isM(int n) { return n == 2 ? 1.0 : isT(n); } // matter: ground, built pieces or loose grains
float isF(int n) { return (n == 13 || n == 14) ? 1.0 : 0.0; }
float isBelt(int n) { return (n == 7 || n == 8) ? 1.0 : 0.0; }
float isN(int n, int k) { return n == k ? 1.0 : 0.0; }
// signed distance (in grains) to the outline of one block of belt: a band 3.7 grains tall; where
// the line of belts ends, the end is square with rounded corners. Negative inside.
float beltSD(vec2 lb, bool endL, bool endR) {
float x0 = endL ? 0.15 : -2.0, x1 = endR ? 3.85 : 6.0, r = 0.75;
vec2 c = vec2((x0 + x1) * 0.5, 2.0), hs = vec2((x1 - x0) * 0.5, 1.85);
vec2 q = abs(lb - c) - hs + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
// The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so
// the line only rounds off where the whole run ends.
float beltLineSD(ivec2 c, vec2 lb) {
int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0)));
return beltSD(lb, isBelt(nl) + isF(nl) < 0.5, isBelt(nr) + isF(nr) < 0.5);
}
// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and
// back along the bottom.
float bandRidge(vec2 w, vec2 lb, float dir) {
float run = lb.y < 2.0 ? 1.0 : -1.0;
return step(0.72, fract((w.x - dir * run * uTime * 30.0) * 0.5));
}
vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); }
void main() {
float zp = uView.z, px = 1.0 / zp;
vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y);
vec2 q = sp * px + uView.xy; // position in grains, in the window of grains uploaded
vec2 w = q + uOrigin; // position in the world
vec2 cam = uOrigin + uView.xy; // world position of the screen's top-left corner
vec2 scr = q - uView.xy; // grains from the screen's top-left corner
float shc = uView.w * px; // screen height in grains
ivec2 c = ivec2(floor(q));
vec2 qq = q - 0.5; ivec2 c0 = ivec2(floor(qq)); vec2 t = qq - floor(qq);
vec4 k0 = cellAt(tCol, c0), k1 = cellAt(tCol, c0 + ivec2(1, 0)), k2 = cellAt(tCol, c0 + ivec2(0, 1)), k3 = cellAt(tCol, c0 + ivec2(1, 1));
int n0 = cls(k0), n1 = cls(k1), n2 = cls(k2), n3 = cls(k3);
vec4 own = cellAt(tCol, c); int nc = cls(own);
vec2 s = smoothstep(0.1, 0.9, t);
vec4 wq = vec4((1.0 - s.x) * (1.0 - s.y), s.x * (1.0 - s.y), (1.0 - s.x) * s.y, s.x * s.y);
// fields, and everything that needs screen derivatives (worked out before any branching)
vec4 Fb = bsp(tField, q);
vec4 F = mix(lin(tField, q), Fb, 0.62);
// loose-grain occupancy from the four grains around (bilinear): ground and grains together make "matter"
vec4 wl = vec4((1.0 - t.x) * (1.0 - t.y), t.x * (1.0 - t.y), (1.0 - t.x) * t.y, t.x * t.y);
float pw = dot(wl, vec4(isN(n0, 2), isN(n1, 2), isN(n2, 2), isN(n3, 2)));
float M = clamp(F.r + pw, 0.0, 1.0);
float lf = clamp(mix(F.g, Fb.g, 0.6) + F.r + pw, 0.0, 1.0);
float fwT = max(fwidth(F.r), 1e-4), fwM = max(fwidth(M), 1e-4), fwA = max(fwidth(F.a), 1e-4), fwL = max(fwidth(lf), 1e-4);
vec2 gT = vec2(dFdx(mix(Fb.r, M, 0.5)), dFdy(mix(Fb.r, M, 0.5))), gL = vec2(dFdx(F.g), dFdy(F.g));
// ---- behind everything: sky with clouds and far hills, or the cave's back wall
vec4 back = lin(tBack, q);
vec3 C = back.rgb;
float sky = back.a;
if (sky > 0.01) {
vec3 S = C;
vec2 P0 = scr + cam * 0.08;
// clouds; while it rains they thicken and turn grey
float cl = smoothstep(0.56 - 0.3 * uRain, 0.78 - 0.2 * uRain, fbm(vec2(P0.x * 0.016 + uTime * 0.006, P0.y * 0.05)));
float hi = clamp((uSurf - 40.0 - w.y) / 50.0, 0.0, 1.0);
S = mix(S, mix(vec3(0.96, 0.95, 0.93), vec3(0.5, 0.52, 0.56), uRain), cl * (0.6 + 0.3 * uRain) * hi);
float k1 = 0.16; vec2 P1 = scr + cam * k1;
float base1 = shc * 0.5 + 8.0 + (uSurf - shc * 0.5) * k1;
float top1 = base1 - 36.0 * (0.25 + 0.9 * fbm(vec2(P1.x * 0.009, 1.7)));
float m1 = smoothstep(top1 - px * 1.5, top1 + px * 1.5, P1.y);
vec3 far = mix(vec3(0.50, 0.60, 0.72), S, 0.35) * (0.94 + 0.08 * fbm(P1 * 0.05));
S = mix(S, far, m1);
float k2 = 0.38; vec2 P2 = scr + cam * k2;
float base2 = shc * 0.5 + 14.0 + (uSurf - shc * 0.5) * k2;
float top2 = base2 - 20.0 * (0.2 + 0.9 * fbm(vec2(P2.x * 0.016, 7.3)));
float m2 = smoothstep(top2 - px * 1.5, top2 + px * 1.5, P2.y);
vec3 near = vec3(0.36, 0.5, 0.47) * (0.92 + 0.14 * fbm(P2 * 0.08));
near = mix(near, S, 0.42 - 0.2 * smoothstep(top2, top2 + 30.0, P2.y));
S = mix(S, near, m2);
C = mix(C, S, sky);
}
// the back wall: rough rock, and darker close to the tunnel walls (ambient occlusion)
float notSky = 1.0 - sky;
C *= mix(1.0, 1.08 + 0.4 * fbm(w * 0.21), notSky);
float near = textureLod(tField, q / uSize, 2.2).r;
C *= 1.0 - 0.34 * smoothstep(0.12, 0.85, near) * (1.0 - 0.75 * sky);
// ---- trees (behind the ground and everything on it)
if (F.a > 0.2) {
vec4 a0 = cellAt(tTree, c0), a1 = cellAt(tTree, c0 + ivec2(1, 0)), a2 = cellAt(tTree, c0 + ivec2(0, 1)), a3 = cellAt(tTree, c0 + ivec2(1, 1));
vec4 m = vec4(step(0.4, a0.a), step(0.4, a1.a), step(0.4, a2.a), step(0.4, a3.a)) * wq;
float sm = m.x + m.y + m.z + m.w;
if (sm > 1e-4) {
vec3 tc = (a0.rgb * m.x + a1.rgb * m.y + a2.rgb * m.z + a3.rgb * m.w) / sm;
float leaf = (a0.a * m.x + a1.a * m.y + a2.a * m.z + a3.a * m.w) / sm;
float lv = smoothstep(0.6, 0.9, leaf);
tc *= mix(0.86 + 0.22 * vn(vec2(w.x * 1.4, w.y * 0.25)), 0.72 + 0.5 * fbm(w * 0.42), lv);
float d = (F.a - 0.36) / fwA;
tc *= mix(0.55, 1.0, smoothstep(0.4, 1.6, d));
C = mix(C, tc, smoothstep(-0.7, 0.7, d));
}
}
// ---- smoke, steam and flame: soft, see-through puffs
if (Fb.b > 0.02) {
vec4 m = vec4(isN(n0, 5), isN(n1, 5), isN(n2, 5), isN(n3, 5)) * wq;
float sm = m.x + m.y + m.z + m.w;
vec3 gc = sm > 1e-4 ? (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm : own.rgb;
if (sm > 1e-4 || nc == 5) C = mix(C, gc, smoothstep(0.03, 0.55, Fb.b) * 0.92);
}
// ---- liquids: smooth surfaces that fill right up against the ground, with a glossy top line
float anyL = isN(n0, 3) + isN(n1, 3) + isN(n2, 3) + isN(n3, 3);
if (anyL > 0.0 && lf > 0.3) {
vec4 m = vec4(isN(n0, 3), isN(n1, 3), isN(n2, 3), isN(n3, 3)) * (wq + 0.02);
float sm = m.x + m.y + m.z + m.w;
vec3 lc = (k0.rgb * m.x + k1.rgb * m.y + k2.rgb * m.z + k3.rgb * m.w) / sm;
lc *= 0.94 + 0.1 * vn(w * 0.55 + vec2(uTime * 0.7, uTime * 0.25));
float gl = length(gL);
float topSide = gl > 1e-5 ? clamp(gL.y / gl, 0.0, 1.0) : 0.0;
float band = 1.0 - smoothstep(0.5, 0.75, F.g);
lc += vec3(0.16, 0.18, 0.2) * topSide * band;
C = mix(C, lc, smoothstep(0.5 - fwL, 0.5 + fwL, lf));
}
// ---- the ground: rounded, outlined, with a lit top edge and a shaded underside
if (F.r > 0.3) {
vec4 m = vec4(isT(n0), isT(n1), isT(n2), isT(n3)) * wq;
float sm = m.x + m.y + m.z + m.w;
vec3 blur = bsp(tColL, q).rgb, tc = own.rgb;
float bd = 1e9;
if (m.x > 0.0) { vec3 e = k0.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k0.rgb; } }
if (m.y > 0.0) { vec3 e = k1.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k1.rgb; } }
if (m.z > 0.0) { vec3 e = k2.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k2.rgb; } }
if (m.w > 0.0) { vec3 e = k3.rgb - blur; float dd = dot(e, e); if (dd < bd) { bd = dd; tc = k3.rgb; } }
if (sm < 1e-4) tc = own.rgb;
tc *= 0.84 + 0.3 * fbm(w * 0.28);
tc *= 0.95 + 0.1 * vn(w * 1.6);
float gl = length(gT);
vec2 nrm = gl > 1e-6 ? -gT / gl : vec2(0.0);
float band = 1.0 - smoothstep(0.5, 0.9, M);
float lit = dot(nrm, normalize(vec2(-0.5, -1.0)));
tc *= 1.0 + 0.42 * lit * band;
// the outline follows where ground and grains meet open space, never where rock meets gravel
float dm = (M - 0.5) / fwM;
tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm));
C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT));
}
// ---- loose grains: little round pebbles; a pocket closed in by four grains is filled
int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2));
bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5;
if (np > 0) {
float best = 1e9; vec3 gc = vec3(0.0); vec2 gd = vec2(0.0); float gr = 0.6;
for (int k = 0; k < 4; k++) {
ivec2 o4 = ivec2(k & 1, k >> 1);
int nk = k == 0 ? n0 : k == 1 ? n1 : k == 2 ? n2 : n3;
if (nk != 2) continue;
vec4 ck = k == 0 ? k0 : k == 1 ? k1 : k == 2 ? k2 : k3;
vec2 ce = vec2(c0 + o4) + 0.5;
float r = 0.6 + 0.1 * (h21(ce + uOrigin) - 0.5);
vec2 dv = (q - ce) / r;
float dd = length(dv);
if (dd < best) { best = dd; gc = ck.rgb; gd = dv; gr = r; }
}
float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best);
if (closed && F.r < 0.5) cover = 1.0;
if (cover > 0.0) {
float r2 = min(1.0, dot(gd, gd));
vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25));
float sh = np == 4 ? 0.9 + 0.08 * vn(w * 0.9) : 0.8 + 0.3 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6))));
vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh;
if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best));
C = mix(C, pc, cover);
}
}
// ---- built pieces: crisp, and wall blocks drawn as bevelled tiles with mortar lines
if (nc == 4) C = own.rgb;
else if (nc == 7 || nc == 8) {
// belt: a rubber band round two copper rollers per block; the top run moves the load
float dir = nc == 8 ? 1.0 : -1.0;
vec2 lb = fract(w * 0.25) * 4.0;
// outline: square ends with rounded corners where the line of belts (and filters) ends
float sd = beltLineSD(c, lb);
float inside = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
float edge = -sd;
vec3 bc;
if (edge < 0.8) {
bc = bandColor(lb, bandRidge(w, lb, dir));
bc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
vec2 rc = vec2(lb.x < 2.0 ? 1.0 : 3.0, 2.0), dv = lb - rc;
float rd = length(dv);
bc = vec3(0.15, 0.16, 0.18);
if (rd < 0.95) {
float ang = atan(dv.y, dv.x) - dir * uTime * 30.0 / 0.95;
float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0));
bc = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd));
bc *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd));
bc *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd));
}
}
C = mix(C, bc, inside);
}
}
else if (nc >= 9 && nc <= 12) {
// screw tube: a copper pipe; what is inside shows through, and the screw's flights turn
bool vert = nc == 9 || nc == 11;
float sp2 = nc == 9 ? -1.0 : nc == 10 ? 1.0 : nc == 11 ? 1.0 : -1.0;
float a = (vert ? fract(w.x * 0.25) : fract(w.y * 0.25)) * 4.0, along = vert ? w.y : w.x;
float wall = 0.62;
vec3 tc;
if (a < wall || a > 4.0 - wall) {
float u2 = a < wall ? a / wall : (4.0 - a) / wall; // 0 at the outside, 1 at the bore
float cyl = sin(u2 * 1.5708);
tc = vec3(0.62, 0.41, 0.27) * (0.6 + 0.55 * cyl);
if (a < wall) tc *= 1.08;
float ring = 1.0 - smoothstep(0.0, 0.3, min(fract(along * 0.25), 1.0 - fract(along * 0.25)) * 4.0);
tc *= 1.0 - 0.3 * ring;
tc *= mix(0.5, 1.0, smoothstep(0.0, max(px * 1.3, 0.1), min(a, 4.0 - a)));
} else {
bool full = own.r + own.g + own.b > 0.0;
float fl = fract((along + a * 0.9 - sp2 * uTime * 14.0) * 0.5);
float flight = smoothstep(0.82, 0.92, fl) * (1.0 - smoothstep(0.92, 1.0, fl));
vec3 bore = vec3(0.11, 0.1, 0.11) * (0.8 + 0.4 * (1.0 - abs(a - 2.0) / 1.4));
tc = full ? own.rgb * (0.9 + 0.1 * vn(w * 1.3)) : bore;
tc = mix(tc, vec3(0.6, 0.48, 0.38), flight * (full ? 0.35 : 0.8));
tc *= 1.0 - 0.25 * smoothstep(0.9, 1.3, abs(a - 2.0));
}
C = tc;
}
else if (nc == 6) {
vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb;
float e = min(min(lb.x, lb.y), min(rb.x, rb.y));
vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55));
float hl = max(1.0 - smoothstep(0.0, 0.8, lb.x), 1.0 - smoothstep(0.0, 0.8, lb.y));
float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y));
tc *= 1.0 + 0.2 * hl - 0.26 * shd;
tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e));
C = tc;
}
else if (nc == 13 || nc == 14) {
// filter: a belt whose middle is a see-through steel mesh. It has the same band as a belt, so a
// row of belts and filters reads as one belt, rounded only where the whole line ends. The
// band is perforated; its colour tag sits on the top run for what rides along, on the
// bottom run for what drops through.
float dir = nc == 14 ? 1.0 : -1.0;
vec2 lb = fract(w * 0.25) * 4.0;
vec4 tag = cellAt(tTree, c);
bool carry = tag.a > 0.16, tinted = tag.r + tag.g + tag.b > 0.0;
float sd = beltLineSD(c, lb);
float inside = 1.0 - smoothstep(-px, px, sd);
if (inside > 0.0) {
float edge = -sd;
vec3 steel = vec3(0.42, 0.44, 0.5);
// through the holes: whatever is behind (water, sky, rock), or what is passing through
vec3 behind = own.r + own.g + own.b > 0.0 ? own.rgb : C * 0.8;
vec2 cg = abs(fract(w) - 0.5);
float bw2 = max(px * 0.9, 0.09);
float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y));
vec3 fc;
if (edge < 0.8) {
float rg = bandRidge(w, lb, dir);
vec3 band = bandColor(lb, rg);
if (tinted && (carry == (lb.y < 2.0))) band = mix(band, tag.rgb * (lb.y < 2.0 ? 1.0 : 0.8), 0.75 * rg);
fc = mix(band, behind * 0.85, hole * (1.0 - rg) * 0.75);
fc *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge));
} else {
fc = mix(steel * 0.78, behind, hole);
fc *= mix(0.7, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), edge - 0.8));
}
C = mix(C, fc, inside);
}
}
// ---- light, then the glow of hot things on top
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
if (uGlowK > 0.0) {
vec2 gu = q / (2.0 * uGlowSize), d = 1.0 / uGlowSize;
vec3 g = textureLod(tGlow, gu, 0.0).rgb * 0.25;
g += (textureLod(tGlow, gu + vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu - vec2(d.x, 0), 0.0).rgb + textureLod(tGlow, gu + vec2(0, d.y), 0.0).rgb + textureLod(tGlow, gu - vec2(0, d.y), 0.0).rgb) * 0.125;
g += (textureLod(tGlow, gu + d, 0.0).rgb + textureLod(tGlow, gu - d, 0.0).rgb + textureLod(tGlow, gu + vec2(d.x, -d.y), 0.0).rgb + textureLod(tGlow, gu + vec2(-d.x, d.y), 0.0).rgb) * 0.0625;
C += g * uGlowK;
}
o = vec4(C, 1.0);
}`;
function smCompile(gl, type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(sh));
return sh;
}
// Is the smooth look possible here (WebGL2)? Set up once, on first use.
function smoothAvailable() {
if (SM.tried) return SM.ok;
SM.tried = true;
try {
const cv = document.createElement('canvas');
const gl = cv.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: true, premultipliedAlpha: false });
if (!gl) { SM.why = 'nogl'; throw new Error('WebGL2 is not available'); }
// a browser drawing without the graphics card would crawl: keep the pixel look there
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const rend = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : '';
if (/swiftshader|llvmpipe|softpipe|software/i.test(rend) && !window.__allowSoftwareGL) { SM.why = 'software'; throw new Error('software rendering: ' + rend); }
const prog = gl.createProgram();
gl.attachShader(prog, smCompile(gl, gl.VERTEX_SHADER, SM_VS));
gl.attachShader(prog, smCompile(gl, gl.FRAGMENT_SHADER, SM_FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
for (const n of ['uSize', 'uOrigin', 'uLightSize', 'uGlowSize', 'uView', 'uLightMap', 'uGlowK', 'uTime', 'uSurf', 'uRain']) SM.u[n] = gl.getUniformLocation(prog, n);
// texture unit for each sampler; linear ones are sampled between grains
const texs = [['tCol', false, false], ['tColL', true, false], ['tBack', true, false], ['tTree', false, false], ['tField', true, true], ['tLight', true, false], ['tGlow', true, false]];
texs.forEach(([name, linear, mip], unit) => {
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mip ? gl.LINEAR_MIPMAP_LINEAR : linear ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(prog, name), unit);
SM.tex[name] = { t, unit, mip, w: 0, h: 0 };
});
gl.bindVertexArray(gl.createVertexArray());
SM.canvas = cv; SM.gl = gl; SM.prog = prog; SM.ok = true;
SM.white = new Uint8Array([255, 255, 255, 255]);
cv.addEventListener('webglcontextlost', e => { e.preventDefault(); SM.ok = false; });
} catch (e) {
console.warn('Smooth look unavailable, using pixels:', e && e.message);
SM.ok = false;
}
return SM.ok;
}
function smUpload(name, w, h, data) {
const gl = SM.gl, T = SM.tex[name];
gl.activeTexture(gl.TEXTURE0 + T.unit); gl.bindTexture(gl.TEXTURE_2D, T.t);
if (T.w !== w || T.h !== h) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); T.w = w; T.h = h; }
else gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, data);
if (T.mip) gl.generateMipmap(gl.TEXTURE_2D);
}
const smBytes = a => new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
function smoothDraw(lighting, glowOn) {
const t0 = performance.now();
// the GPU pass can run below full resolution on a slow graphics card; it is scaled up smoothly
const gl = SM.gl, cv = SM.canvas, sc = SM.scale, cw = Math.max(1, Math.round(R.canvas.width * sc)), ch = Math.max(1, Math.round(R.canvas.height * sc)), u = SM.u;
if (cv.width !== cw || cv.height !== ch) { cv.width = cw; cv.height = ch; }
if (SM.surf < 0 || SM.surfSeed !== worldSeed || SM.surfW !== W) { let s = 0; for (let x = 0; x < W; x++) s += surfY[x]; SM.surf = s / W; SM.surfW = W; SM.surfSeed = worldSeed; }
gl.viewport(0, 0, cw, ch);
gl.useProgram(SM.prog);
const bw = R.bw, bh = R.bh;
smUpload('tCol', bw, bh, smBytes(R.px));
smUpload('tColL', bw, bh, smBytes(R.px));
smUpload('tBack', bw, bh, smBytes(R.backPx));
smUpload('tTree', bw, bh, smBytes(R.treePx));
smUpload('tField', bw, bh, smBytes(R.fieldPx));
if (lighting && R.lightImg) smUpload('tLight', R.lw, R.lh, smBytes(R.lightImg.data)); else smUpload('tLight', 1, 1, SM.white);
if (glowOn) smUpload('tGlow', R.glowC.width, R.glowC.height, smBytes(R.glowImg.data)); else smUpload('tGlow', 1, 1, SM.white);
gl.uniform2f(u.uSize, bw, bh);
gl.uniform2f(u.uOrigin, R.vx0, R.vy0);
gl.uniform4f(u.uView, R.camX - R.vx0, R.camY - R.vy0, R.zp * (ch / R.canvas.height), ch);
gl.uniform4f(u.uLightMap, lighting ? R.lx0 * R.lres - R.vx0 : 0, lighting ? R.ly0 * R.lres - R.vy0 : 0, R.lres || 1, lighting ? 1 : 0);
gl.uniform2f(u.uLightSize, R.lw || 1, R.lh || 1);
gl.uniform2f(u.uGlowSize, R.glowC.width || 1, R.glowC.height || 1);
gl.uniform1f(u.uGlowK, glowOn ? (SET.glow === 'high' ? 0.9 : 0.55) : 0);
gl.uniform1f(u.uTime, (performance.now() - SM.t0) / 1000);
gl.uniform1f(u.uSurf, SM.surf);
gl.uniform1f(u.uRain, R.rain || 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const ctx = R.ctx;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(cv, 0, 0, cw, ch, 0, 0, R.canvas.width, R.canvas.height);
smoothPace(performance.now() - t0);
}
// Keep the frame rate up: drop the GPU pass's resolution when it is slow, raise it again when there
// is room, and if even half resolution is too slow, give up on the smooth look for this session.
function smoothPace(ms) {
SM.ms = SM.n ? SM.ms * 0.8 + ms * 0.2 : ms;
if (++SM.n < 15) return;
SM.n = 1;
if (SM.ms > 40 && SM.scale <= 0.5) { SM.ok = false; SM.why = 'slow'; return; }
if (SM.ms > 18 && SM.scale > 0.5) SM.scale = Math.max(0.5, SM.scale - (SM.ms > 60 ? 0.5 : 0.25));
else if (SM.ms < 6 && SM.scale < 1) SM.scale = Math.min(1, SM.scale + 0.25);
}
+277
View File
@@ -0,0 +1,277 @@
// drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
// ---------- World pixels ----------
// smooth-look class of a screw tube cell by direction: 9 up, 10 right, 11 down, 12 left
const TUBE_CLASS = new Uint8Array(9); TUBE_CLASS[1] = 9; TUBE_CLASS[4] = 10; TUBE_CLASS[2] = 11; TUBE_CLASS[3] = 12;
function drawWorld() {
const bw = R.bw, bh = R.bh, px = R.px;
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
const vx0 = R.vx0, vy0 = R.vy0;
// how hard it is raining around the camera, eased so the sky greys over gently
R.rain = (R.rain || 0) * 0.98 + rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + bw / 2)))) * 0.02;
const rk = R.rain;
// Smooth look: the colours below are still worked out per grain, but each grain also gets a
// class (alpha byte) and the GPU draws rounded shapes from them (see smooth.js)
const sm = R.smoothOn = SET.look === 'smooth' && !R.lens && typeof smoothAvailable === 'function' && smoothAvailable();
const bpx = R.backPx, tpx = R.treePx, fpx = R.fieldPx, mmask = R.moldMask;
if (sm) {
mmask.fill(0);
for (const mo of molds) {
if (mo.x + 40 < vx0 || mo.x - 40 > vx0 + bw || mo.y + 40 < vy0 || mo.y - 40 > vy0 + bh) continue;
for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) { const sx = (i % W) - vx0, sy = ((i / W) | 0) - vy0; if (sx >= 0 && sy >= 0 && sx < bw && sy < bh) mmask[sy * bw + sx] = 1; }
}
}
const OUT = sm ? 0x040a0a0c : 0xff0a0a0c;
const lighting = SET.lighting !== 'off' && !R.lens;
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight();
const grain = SET.grain, incand = SET.incandescence;
const anim = R.frame;
const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
const glowOn = SET.glow !== 'off' && !lens;
const gpx = R.glowPx, gw = R.glowC.width;
if (glowOn) gpx.fill(0);
for (let by = 0; by < bh; by++) {
const y = vy0 + by;
const row = by * bw;
if (y < 0 || y >= H) { for (let bx = 0; bx < bw; bx++) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } } continue; }
const yw = y * W;
let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y];
if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; }
for (let bx = 0; bx < bw; bx++) {
const x = vx0 + bx;
if (x < 0 || x >= W) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; }
const i = yw + x, m = mat[i], nz = noiseB[i] - 128;
const T = temp[i];
let r, g, b;
if (lens) {
const ti = thermIdx(T);
if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; }
else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; }
const p = part[i];
if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; }
px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
continue;
}
// background
let br, bgc, bb, sky = false;
const tb = bgm[i];
if (tb && !sm) { const v = nz * CVAR[tb] / 128; br = TREE_R[tb] + v; bgc = TREE_G[tb] + v; bb = TREE_B[tb] + v; if (digp[i]) { const f = 1 + digp[i] / 700; br *= f; bgc *= f; bb *= f; } }
else if (y < surfY[x]) { br = skr; bgc = skg; bb = skb; sky = true; }
else {
const L = y < dirtY[x] ? BG.dirt : y < graniteY[x] ? BG.stone : y < basaltY[x] ? BG.granite : BG.basalt;
const v = sm ? 0 : nz * 0.06;
br = L[0] + v; bgc = L[1] + v; bb = L[2] + v;
}
if (sm) {
// the colour behind the grain (alpha marks open sky), and a tree in front of that if there is one
bpx[row + bx] = (sky ? 0xff000000 : 0) | ((bb & 255) << 16) | ((bgc & 255) << 8) | (br & 255);
if (tb) {
const v = nz * CVAR[tb] / 256, f = digp[i] ? 1 + digp[i] / 700 : 1;
const tr = Math.max(0, Math.min(255, (TREE_R[tb] + v) * f)), tg = Math.max(0, Math.min(255, (TREE_G[tb] + v) * f)), tbb = Math.max(0, Math.min(255, (TREE_B[tb] + v) * f));
tpx[row + bx] = ((tb === LEAVES ? 0xff : 0x80) << 24) | (tbb << 16) | (tg << 8) | tr;
} else tpx[row + bx] = 0;
}
if (m === EMPTY) { r = br; g = bgc; b = bb; }
else {
const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128;
r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v;
if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; }
else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; }
else if (m === BELTL || m === BELTR) {
const dir = m === BELTR ? -1 : 1, on = netSat(i) > 0, ly = y & 3, lx = x & 3;
if (ly === 0 || ly === 3) {
// top run moves with the load, bottom run returns the other way
const st = ((x + (ly === 0 ? dir : -dir) * (on ? anim >> 1 : 0)) >> 1) & 1;
const k = ly === 0 ? 1 : 0.72;
r = (st ? 92 : 56) * k; g = (st ? 96 : 58) * k; b = (st ? 104 : 66) * k;
} else if (lx === 1 || lx === 2) {
const spin = on ? ((anim >> 2) + (dir < 0 ? lx : 3 - lx) + ly) & 1 : (lx + ly) & 1;
r = spin ? 132 : 104; g = spin ? 120 : 94; b = spin ? 108 : 84; // copper rollers
} else { r = 46; g = 48; b = 54; }
} else if (m === PIPE) {
pipeCellColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2];
} else if (m === SHAFT) {
const lx = x & 3, ly = y & 3;
if (lx === 0 || lx === 3 || ly === 0 || ly === 3) { if (((x + y) & 3) === 0) { r -= 22; g -= 20; b -= 16; } }
else { r -= 34; g -= 30; b -= 24; }
}
else if (m === GATE) { if ((x & 1) === 0 && (y & 3) !== 0) { r -= 40; g -= 24; b -= 20; } }
else if (m === CUTST) { const lx = x & 3, ly = y & 3; if (ly === 1 || ly === 3 || lx === (ly < 2 ? 3 : 1)) { r -= 34; g -= 33; b -= 30; } else if (ly === 0 || ly === 2) { r += 10; g += 10; b += 9; } }
else if (m === WOOD) { if ((y & 3) === 2 || ((x + (y >> 2) * 3) & 7) === 0) { r -= 26; g -= 22; b -= 16; } } // planks
else if (m === LWOOD) { if (((x * 3 + y) & 3) === 0) { r -= 30; g -= 26; b -= 18; } } // stringy fibres
else if (m === HULL) { if (((x * 7 + y * 3) & 15) === 0) { r += 20; g += 20; b += 22; } }
else if (m === SPRING) { if ((nz & 7) === 0) { r = 90; g = 150; b = 220; } }
const al = ALPHA[m];
if (al < 1) { r = br + (r - br) * al; g = bgc + (g - bgc) * al; b = bb + (b - bb) * al; }
const dp = digp[i];
if (dp) {
// shaken rock pales with dust and shivers before it shatters (the cracks are drawn over it)
const k = dp / 255, lift = k * 30 + Math.sin(anim * 1.9 + x * 1.3 + y * 0.7) * 6 * k;
r += lift; g += lift; b += lift * 1.08;
}
// built wall blocks read as tiles: light top-left edge, dark bottom-right edge
if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && blockP[(y >> 2) * BW + (x >> 2)] === BP_WALL) {
const lx = x & 3, ly = y & 3;
if (lx === 0 || ly === 0) { r += 16; g += 16; b += 16; }
else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; }
}
}
// field parts drawn over contents
const p = part[i];
if (p && sm && (p <= P_TR || p === P_FILTER)) { if (m === EMPTY || PH[m] === PH_GAS) { r = 0; g = 0; b = 0; } else { r = Math.max(1, r); } }
else if (p) {
if (p <= P_TR) {
const on = netSat(i) > 0;
const vert = p === P_TU || p === P_TD;
const axis = vert ? y : x, across = vert ? x & 3 : y & 3;
const dir = (p === P_TU || p === P_TL) ? 1 : -1;
if (across === 0 || across === 3) { // copper casing on the two long sides; its load shows through
if (m === EMPTY) { r = 150; g = 96; b = 64; } else { r = r * 0.72 + 44; g = g * 0.72 + 26; b = b * 0.72 + 14; }
if ((axis & 3) === 0) { r -= 24; g -= 18; b -= 12; }
} else {
// screw flights: a diagonal stripe that walks along the tube when powered
const ch = (((axis + across + dir * (on ? anim >> 1 : 0)) & 3) === 0);
if (m === EMPTY) { r = ch ? 120 : 46; g = ch ? 112 : 46; b = ch ? 104 : 52; }
else { r = r * 0.75 + (ch ? 34 : 8); g = g * 0.75 + (ch ? 30 : 8); b = b * 0.75 + (ch ? 28 : 10); }
}
} else if (p === P_FILTER) {
const lx = x & 3, ly = y & 3, fb = (y >> 2) * BW + (x >> 2), dir = blockV[fb] & 128 ? -1 : 1;
const fc = filterCfg.get(fb), fms = fc ? fc.m : null, fn = fms ? fms.length : 0, inv = fc && fc.inv;
if (ly === 0) { // rollers on top, turning the way the load rides; in carry mode, striped with what rides
const st = ((x + dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) {
if (inv && fn && !st) { const c = fms[((x + dir * (anim >> 1)) >> 2 & 0xffff) % fn]; r = COLR[c] * 0.8; g = COLG[c] * 0.8; b = COLB[c] * 0.8; }
else { r = st ? 92 : 56; g = st ? 96 : 58; b = st ? 104 : 66; }
}
} else if (ly === 3) { // the belt's return run carries on under it, so belts and filters read as one belt
const st = ((x - dir * (anim >> 1)) >> 1) & 1;
if (m === EMPTY || st) { r = (st ? 92 : 56) * 0.72; g = (st ? 96 : 58) * 0.72; b = (st ? 104 : 66) * 0.72; }
} else if ((lx === 0 && !beltLike(fb - 1)) || (lx === 3 && !beltLike(fb + 1))) { if (m === EMPTY || ((x + y) & 1)) { r = 64; g = 66; b = 74; } } // frame only where the line ends
else if (m === EMPTY) { // mesh holes, tinted with what falls through them
if ((x + y) & 1) { r = 40; g = 42; b = 48; }
else if (inv) { r = 58; g = 74; b = 72; } // carry mode: everything else drops
else if (fn) { const c = fms[(((x >> 1) + (y >> 1)) & 0xffff) % fn]; r = COLR[c] * 0.75; g = COLG[c] * 0.75; b = COLB[c] * 0.75; }
else { r = 24; g = 25; b = 28; }
}
} else if (p === P_GRATE) {
if ((x + y) & 1) { r = 150; g = 78; b = 58; } else if (m === EMPTY) { r *= 0.6; g *= 0.6; b *= 0.6; }
} else if (p === P_GOPEN) {
if ((x & 3) === 0) { r = r * 0.6 + 50; g = g * 0.6 + 30; b = b * 0.6 + 26; }
} else if (p === P_INTAKE) {
if (m === EMPTY) { r = 30; g = 32; b = 36; }
if ((x & 1) === 0) { r += 50; g += 54; b += 60; }
} else if (p === P_PIPE) {
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
}
}
// incandescence
if (T > 450 && m !== EMPTY && incand) {
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w;
if (glowOn && w > 0.3 && ((bx | by) & 1) === 0) {
const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4;
gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s);
}
}
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
let top = 0xff000000;
if (sm) {
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
let cl = 0;
const pt = p === P_PIPE && !pipeBodyCell(x, y) ? 0 : p; // the open parts of a pipe block show what is in them
if (pt >= P_TU && pt <= P_TR) cl = TUBE_CLASS[pt];
else if (p === P_FILTER) {
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
cl = blockV[fb] & 128 ? 14 : 13;
tpx[row + bx] = ((fc && fc.inv ? 0x30 : 0x20) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
}
else if (m === BELTL || m === BELTR) cl = m === BELTL ? 7 : 8;
else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4;
else if (m !== EMPTY) {
const ph = PH[m];
if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && blockP[(y >> 2) * BW + (x >> 2)] === BP_WALL) ? 6 : 1;
else if (ph === PH_POWDER) cl = 2;
else if (ph === PH_LIQUID) cl = 3;
else cl = 5;
}
top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 || cl === 4 || cl === 6 ? 0xff : 0);
}
px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r);
}
}
// bars and other loose objects
if (!lens) for (const e of ents) {
const ex = e.ix - vx0, ey = e.iy - vy0;
if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue;
const m = e.m;
for (let dy = 0; dy < BAR_H; dy++) for (let dx = 0; dx < BAR_W; dx++) {
const sx = ex + dx, sy = ey + dy;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
let r = COLR[m], g = COLG[m], b = COLB[m];
const end = dx === 0 || dx === BAR_W - 1;
if (dy === 0) { r += end ? 10 : 34; g += end ? 8 : 28; b += end ? 6 : 24; } else { r -= end ? 34 : 18; g -= end ? 30 : 16; b -= end ? 26 : 14; }
if (e.T > 450 && incand) { const gi = glowIndex(e.T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; }
if (r < 0) r = 0; if (g < 0) g = 0; if (b < 0) b = 0;
px[sy * bw + sx] = (sm ? 0x04000000 : 0xff000000) | ((b > 255 ? 255 : b | 0) << 16) | ((g > 255 ? 255 : g | 0) << 8) | (r > 255 ? 255 : r | 0);
}
}
// grains in flight from the vacuum nozzle, a tube or a belt (rain and snow are drawn over the top, see drawRain)
if (!lens) for (const f of flyers) {
if (f.rain) continue;
const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8;
px[sy * bw + sx] = (sm ? 0x02000000 : 0xff000000) | (Math.max(0, Math.min(255, COLB[m] + v)) << 16) | (Math.max(0, Math.min(255, COLG[m] + v)) << 8) | Math.max(0, Math.min(255, COLR[m] + v));
}
// blueprint ghosts: blocks waiting to be built
if (ghostBlocks.size) {
const bx0 = vx0 >> 2, by0 = vy0 >> 2, bx1 = (vx0 + bw) >> 2, by1 = (vy0 + bh) >> 2;
const pulse = (R.frame >> 3) & 3;
for (const [b, gv] of ghostBlocks) {
const gbx = b % BW, gby = (b / BW) | 0;
if (gbx < bx0 || gbx > bx1 || gby < by0 || gby > by1) continue;
const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1];
let tr = 90, tg = 190, tb = 230;
if (blocked) { tr = 230; tg = 70; tb = 60; }
else if (code === BP_WALL && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
for (let dy = 0; dy < BS; dy++) for (let dx = 0; dx < BS; dx++) {
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue;
const k = sy * bw + sx, c = px[k];
const edge = dx === 0 || dy === 0 || dx === BS - 1 || dy === BS - 1;
const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28;
const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255;
px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a);
}
}
}
if (sm) { smoothDraw(lighting, glowOn); return; }
R.bctx.putImageData(R.img, 0, 0);
const ctx = R.ctx, zp = R.zp;
ctx.imageSmoothingEnabled = false;
const ox = -(R.camX - vx0) * zp, oy = -(R.camY - vy0) * zp;
ctx.drawImage(R.buf, 0, 0, bw, bh, Math.round(ox), Math.round(oy), bw * zp, bh * zp);
if (lighting) {
const res = R.lres;
ctx.save();
ctx.globalCompositeOperation = 'multiply';
ctx.imageSmoothingEnabled = true;
const lx = (R.lx0 * res - R.camX) * zp, ly = (R.ly0 * res - R.camY) * zp;
ctx.drawImage(R.lightC, 0, 0, R.lw, R.lh, lx, ly, R.lw * res * zp, R.lh * res * zp);
ctx.restore();
}
if (glowOn) {
R.glowX.putImageData(R.glowImg, 0, 0);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = SET.glow === 'high' ? 0.9 : 0.5;
ctx.imageSmoothingEnabled = true;
const blur = Math.max(2, zp * (SET.glow === 'high' ? 3.2 : 2.2));
try { ctx.filter = `blur(${blur.toFixed(1)}px)`; } catch (e) { /* no filter support */ }
ctx.drawImage(R.glowC, 0, 0, R.glowC.width, R.glowC.height, Math.round(ox), Math.round(oy), R.glowC.width * 2 * zp, R.glowC.height * 2 * zp);
ctx.filter = 'none';
ctx.restore();
}
}
+114
View File
@@ -0,0 +1,114 @@
<title>Deepgrain Foundry</title>
<meta name="claude:viewport-fit-sides" content="cover">
<style>
/*__CSS__*/
</style>
<canvas id="game" aria-label="Game world"></canvas>
<div id="ovframe" aria-hidden="true"><i class="c1"></i><i class="c2"></i><i class="c3"></i><i class="c4"></i></div>
<div id="hud" hidden>
<div class="corner tl">
<div id="hotbar" class="plate interactive" role="toolbar" aria-label="Tools"></div>
<div id="selcard" class="plate interactive" aria-live="polite"></div>
</div>
<div class="corner tr">
<button id="wallet" class="plate interactive" aria-label="Money and the trade dock. Opens the Market page.">
<span class="money num" id="w-money"></span>
<span class="line" id="w-claim"></span>
<span class="orders" id="w-orders"></span>
</button>
<div id="bagpanel" class="plate interactive">
<div class="head">
<span class="label">Bag</span>
<span class="num" id="bagcap"></span>
</div>
<div class="bar" id="bagbar"><i></i></div>
<div id="baglist"></div>
</div>
</div>
<div class="corner bl">
<div id="info" class="plate"></div>
</div>
<div class="corner br">
<div id="cards" aria-live="polite"></div>
</div>
<div class="corner bc">
<div id="buildbar" class="plate interactive" hidden role="toolbar" aria-label="Build menu"></div>
<div id="ovbadges" aria-live="polite"></div>
</div>
</div>
<div id="toasts" aria-live="polite"></div>
<div id="perf"></div>
<div id="title">
<div class="titlewrap">
<canvas id="titleart" width="320" height="80" aria-label="Deepgrain Foundry"></canvas>
<p class="tagline">Dig. Smelt. Cast your own tools. Every grain is simulated, and nothing turns into anything unless the physics says so.</p>
<div class="tmenu" id="tmenu">
<button class="btn primary" id="btn-continue" hidden>Continue <small id="continue-meta"></small></button>
<button class="btn" id="btn-new">New world <small>generate</small></button>
<div class="plate newworld" id="newworld" hidden>
<label>Seed <input id="nw-seed" type="text" inputmode="numeric" autocomplete="off"></label>
<label>Size
<select id="nw-size">
<option value="2048x1024">Small · 2048 × 1024</option>
<option value="3072x1280" selected>Medium · 3072 × 1280</option>
<option value="4096x1536">Large · 4096 × 1536</option>
</select>
</label>
<button class="btn primary full" id="btn-generate">Generate and start</button>
</div>
<button class="btn" id="btn-title-settings">Settings <small>display · audio · controls</small></button>
<button class="btn" id="btn-title-help">How to play <small>controls · first steps</small></button>
</div>
<p class="touchnote" id="touchnote" hidden>This prototype needs a keyboard and mouse, or a gamepad.</p>
</div>
<div class="tfoot">Prototype v0.1 · keyboard + mouse or gamepad · all graphics and sound are generated in code</div>
</div>
<div id="loading" hidden><div class="plate"><span class="label">Generating world</span><div class="bar"><i id="loadbar" style="width:0%"></i></div><span id="loadtext" class="num" style="color:var(--ash)">0%</span></div></div>
<div id="m-pause" class="modal small" hidden>
<div class="plate">
<div class="mhead"><h2>Paused</h2><span class="label num" id="pause-meta"></span></div>
<div class="mbody" style="display:flex;flex-direction:column;gap:8px">
<button class="btn primary" id="pm-resume">Resume</button>
<button class="btn" id="pm-notebook">Notebook</button>
<button class="btn" id="pm-settings">Settings</button>
<button class="btn" id="pm-save">Save now</button>
<button class="btn" id="pm-export">Export world file</button>
<button class="btn" id="pm-import">Import world file</button>
<button class="btn" id="pm-title">Save and quit to title</button>
<input type="file" id="importfile" accept=".json,application/json" hidden>
</div>
</div>
</div>
<div id="m-settings" class="modal" hidden>
<div class="plate">
<div class="mhead"><h2>Settings</h2><button class="iconbtn" data-close aria-label="Close settings">Close <kbd>Esc</kbd></button></div>
<div class="tabs" id="set-tabs" role="tablist"></div>
<div class="mbody" id="set-body"></div>
</div>
</div>
<div id="m-notebook" class="modal" hidden>
<div class="plate">
<div class="mhead"><h2>Field notebook</h2><button class="iconbtn" data-close aria-label="Close notebook">Close <kbd>N</kbd></button></div>
<div class="tabs" id="nb-tabs" role="tablist"></div>
<div class="mbody" id="nb-body"></div>
</div>
</div>
<div id="m-dialog" class="modal small" hidden>
<div class="plate">
<div class="mhead"><h2 id="dlg-title"></h2></div>
<div class="mbody" id="dlg-body"></div>
<div class="mfoot" id="dlg-foot"></div>
</div>
</div>
<script>
//__JS__
</script>
+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);
}
+340
View File
@@ -0,0 +1,340 @@
/* Foundry floor: soot-dark iron plates, molten-metal accent, copper-patina for interactive states.
Layout: full-bleed simulation canvas, HUD plates pinned to the four corners, modal plates centered. */
:root {
--soot-0: #0f0d0c;
--soot-1: #191614;
--soot-2: #231f1c;
--soot-3: #3b342f;
--soot-4: #554b44;
--ash: #a2978b;
--ash-dim: #766c62;
--bone: #efe6d8;
--melt: #ff9a3c;
--melt-hot: #ffd27a;
--melt-deep: #c9561d;
--patina: #5cbaa6;
--patina-dim: #2f6d61;
--danger: #ef6a55;
--ok: #92d27c;
--plate: rgba(24, 21, 19, 0.88);
--font-ui: system-ui, -apple-system, "Segoe UI", "Noto Sans", "DejaVu Sans", sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
--ui: 1;
color-scheme: dark;
}
[hidden] { display: none !important; }
html, body { height: 100%; }
body {
margin: 0; overflow: hidden; background: var(--soot-0); color: var(--bone);
font-family: var(--font-ui); font-size: calc(13.5px * var(--ui)); line-height: 1.4;
-webkit-user-select: none; user-select: none; touch-action: none;
}
canvas#game { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; image-rendering: pixelated; cursor: crosshair; }
canvas#game.nocursor { cursor: none; }
/* overlay modes (thermal lens, info overlay): a coloured frame with corner brackets, a light wash
for the info overlay so the world stays clear, and a named badge at the bottom of the screen */
#ovframe { position: fixed; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.2s; --ov: 92, 186, 166; }
#ovframe.on { opacity: 1; }
#ovframe.info { background: rgba(60, 150, 175, 0.05); box-shadow: inset 0 0 0 2px rgba(var(--ov), 0.5), inset 0 0 110px rgba(var(--ov), 0.16); }
#ovframe.lens { --ov: 255, 140, 64; box-shadow: inset 0 0 0 2px rgba(var(--ov), 0.6), inset 0 0 110px rgba(var(--ov), 0.24); }
#ovframe.lens.info { background: rgba(60, 150, 175, 0.05); box-shadow: inset 0 0 0 2px rgba(255, 140, 64, 0.6), inset 0 0 0 5px rgba(92, 186, 166, 0.45), inset 0 0 110px rgba(255, 140, 64, 0.2); }
#ovframe i { position: absolute; width: 30px; height: 30px; border: 3px solid rgba(var(--ov), 0.9); }
#ovframe .c1 { top: 8px; left: 8px; border-right: 0; border-bottom: 0; }
#ovframe .c2 { top: 8px; right: 8px; border-left: 0; border-bottom: 0; }
#ovframe .c3 { bottom: 8px; left: 8px; border-right: 0; border-top: 0; }
#ovframe .c4 { bottom: 8px; right: 8px; border-left: 0; border-top: 0; }
#hud .bc { bottom: max(14px, env(safe-area-inset-bottom, 0px)); left: 50%; transform: translateX(-50%); align-items: center; }
/* the build menu: category tabs, a row of piece tiles, and what the pointed-at piece does */
#buildbar { padding: 6px 8px 8px; display: flex; flex-direction: column; gap: 6px; width: min(calc(560px * var(--ui)), calc(100vw - 32px)); }
#buildbar .bb-head { display: flex; align-items: center; gap: 2px; }
#buildbar .bb-tab { font-family: var(--font-mono); font-size: 0.72em; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ash); padding: 4px 9px; border: 1px solid transparent; border-radius: 3px; background: none; cursor: pointer; }
#buildbar .bb-tab:hover { color: var(--bone); }
#buildbar .bb-tab.on { color: var(--melt-hot); border-color: var(--soot-3); background: var(--soot-2); }
#buildbar .bb-keys { margin-left: auto; font-size: 0.72em; color: var(--ash-dim); white-space: nowrap; }
#buildbar .bb-tiles { display: flex; gap: 6px; }
.bb-tile { position: relative; flex: 1 1 0; min-width: 0; max-width: calc(110px * var(--ui)); display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 5px 4px 4px; border-radius: 4px; background: var(--soot-2); border: 1px solid var(--soot-3); cursor: pointer; color: inherit; }
.bb-tile svg { width: calc(26px * var(--ui)); height: calc(26px * var(--ui)); color: var(--ash); }
.bb-tile .n { font-size: 0.8em; line-height: 1.15; text-align: center; color: var(--bone); }
.bb-tile .c { font-family: var(--font-mono); font-size: 0.7em; color: var(--ash); white-space: nowrap; }
.bb-tile .c.bad { color: var(--danger); }
.bb-tile .sw { position: absolute; top: 5px; right: 5px; width: 9px; height: 9px; border-radius: 2px; box-shadow: 0 0 0 1px rgba(0,0,0,0.5); }
.bb-tile:hover { border-color: var(--soot-4); background: #2a2521; }
.bb-tile.on { border-color: var(--melt); background: linear-gradient(180deg, #3a2a1d, #2a201a); box-shadow: 0 0 0 1px rgba(255, 154, 60, 0.35), 0 0 14px rgba(255, 154, 60, 0.22); }
.bb-tile.on svg { color: var(--melt-hot); }
#buildbar .bb-tip { font-size: 0.82em; line-height: 1.35; color: var(--ash); min-height: 1.35em; }
#buildbar .bb-tip b { color: var(--bone); font-weight: 600; }
#ovbadges { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.ovbadge { --ov: 92, 186, 166; display: inline-flex; align-items: center; gap: 8px; padding: 5px 8px 5px 11px; border-radius: 999px; background: rgba(12, 13, 16, 0.86); border: 1px solid rgba(var(--ov), 0.75); color: rgb(var(--ov)); font-weight: 650; font-size: 0.8em; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
.ovbadge.lens { --ov: 255, 160, 90; }
.ovbadge .dot { width: 8px; height: 8px; border-radius: 50%; background: rgb(var(--ov)); box-shadow: 0 0 8px rgb(var(--ov)); animation: ovpulse 1.6s ease-in-out infinite; }
.ovbadge .scale { width: 54px; height: 7px; border-radius: 4px; }
.ovbadge kbd { text-transform: none; letter-spacing: 0; }
@keyframes ovpulse { 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) { .ovbadge .dot { animation: none; } }
button, select, input { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--patina); outline-offset: 2px; }
/* ---------- plates ---------- */
.plate {
background: var(--plate); border: 1px solid var(--soot-3); border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255, 240, 220, 0.04), 0 6px 18px rgba(0, 0, 0, 0.35);
-webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
}
.label { font-family: var(--font-mono); font-size: 0.74em; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ash); }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
kbd {
font-family: var(--font-mono); font-size: 0.78em; padding: 0.05em 0.4em; border-radius: 3px;
border: 1px solid var(--soot-4); border-bottom-width: 2px; background: var(--soot-2); color: var(--bone);
}
/* ---------- HUD ---------- */
#hud { position: fixed; inset: 0; pointer-events: none; padding: max(12px, env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px)) max(12px, env(safe-area-inset-bottom, 0px)) max(16px, env(safe-area-inset-left, 0px)); }
#hud .corner { position: absolute; display: flex; flex-direction: column; gap: 8px; }
#hud .tl { top: max(12px, env(safe-area-inset-top, 0px)); left: max(16px, env(safe-area-inset-left, 0px)); }
#hud .tr { top: max(12px, env(safe-area-inset-top, 0px)); right: max(16px, env(safe-area-inset-right, 0px)); align-items: flex-end; }
#hud .bl { bottom: max(12px, env(safe-area-inset-bottom, 0px)); left: max(16px, env(safe-area-inset-left, 0px)); }
#hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); right: max(16px, env(safe-area-inset-right, 0px)); align-items: flex-end; }
#hud .interactive { pointer-events: auto; }
#hotbar { display: flex; gap: 4px; padding: 5px; }
.slot {
position: relative; width: calc(46px * var(--ui)); height: calc(46px * var(--ui)); border-radius: 3px;
background: var(--soot-2); border: 1px solid var(--soot-3); display: grid; place-items: center; cursor: pointer; padding: 0;
}
.slot svg { width: 60%; height: 60%; color: var(--ash); }
.slot .k { position: absolute; top: 2px; left: 4px; font-family: var(--font-mono); font-size: 0.7em; color: var(--ash-dim); }
.slot.on { border-color: var(--melt); background: linear-gradient(180deg, #3a2a1d, #2a201a); box-shadow: 0 0 0 1px rgba(255, 154, 60, 0.35), 0 0 14px rgba(255, 154, 60, 0.22); }
.slot.on svg { color: var(--melt-hot); }
.slot.locked { opacity: 0.4; }
#selcard { padding: 8px 10px; min-width: calc(250px * var(--ui)); max-width: calc(300px * var(--ui)); display: flex; flex-direction: column; gap: 5px; }
#selcard .row { display: flex; align-items: center; gap: 8px; }
#selcard .name { font-weight: 600; font-size: 1.05em; }
#selcard .sub { color: var(--ash); font-size: 0.88em; }
#selcard .status { color: var(--danger); font-size: 0.88em; min-height: 0; }
#selcard .status:empty { display: none; }
#selcard .status.info { color: var(--patina); }
.sw { width: 14px; height: 14px; border-radius: 2px; border: 1px solid rgba(0, 0, 0, 0.5); flex: none; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); }
.cycle { display: flex; align-items: center; gap: 6px; }
.cycle .grow { flex: 1; min-width: 0; }
.chip { font-family: var(--font-mono); font-size: 0.78em; padding: 1px 6px; border-radius: 3px; background: var(--soot-2); border: 1px solid var(--soot-3); color: var(--ash); white-space: nowrap; }
.chip.ok { color: var(--ok); border-color: #3c5a33; }
.chip.bad { color: var(--danger); border-color: #6a2e25; }
.chip.hot { color: var(--melt-hot); border-color: #6b4220; }
#bagpanel { width: calc(232px * var(--ui)); padding: 8px 10px; }
#wallet { width: calc(232px * var(--ui)); padding: 7px 10px 8px; display: flex; flex-direction: column; align-items: stretch; gap: 1px; text-align: left; cursor: pointer; color: inherit; font: inherit; }
#wallet:hover { border-color: var(--soot-4); }
#wallet .money { font-size: 1.35em; font-weight: 650; color: var(--melt-hot); letter-spacing: 0.01em; }
#wallet .line { font-size: 0.86em; color: var(--ash); display: flex; justify-content: space-between; gap: 8px; }
#wallet .line b { font-weight: 600; color: var(--bone); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
#wallet .line.paid b { color: var(--ok); }
#wallet .orders { display: flex; flex-direction: column; gap: 4px; margin-top: 5px; }
#wallet .ord { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 2px 6px; align-items: center; font-size: 0.84em; }
#wallet .ord .sw { width: 10px; height: 10px; }
#wallet .ord .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bone); }
#wallet .ord .tm { font-family: var(--font-mono); color: var(--ash); }
#wallet .ord .tm.late { color: var(--danger); }
#wallet .ord .pb { grid-column: 2 / 4; height: 3px; background: var(--soot-3); border-radius: 2px; overflow: hidden; }
#wallet .ord .pb i { display: block; height: 100%; background: var(--patina); }
#wallet .none { font-size: 0.84em; color: var(--ash-dim); }
.orderlist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.orderlist .o { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; padding: 8px 10px; background: var(--soot-1); border: 1px solid var(--soot-3); border-radius: 4px; }
.orderlist .o .t { font-weight: 600; }
.orderlist .o .pay { font-family: var(--font-mono); color: var(--melt-hot); font-weight: 650; }
.orderlist .o .d { color: var(--ash); font-size: 0.88em; }
.orderlist .o .pb { grid-column: 1 / 3; height: 4px; background: var(--soot-3); border-radius: 2px; overflow: hidden; }
.orderlist .o .pb i { display: block; height: 100%; background: var(--patina); }
.mkt-sum { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
.mkt-sum > div { background: var(--soot-1); border: 1px solid var(--soot-3); border-radius: 4px; padding: 8px 10px; }
.mkt-sum .k { color: var(--ash); font-size: 0.82em; }
.mkt-sum .v { font-family: var(--font-mono); font-size: 1.15em; font-weight: 650; }
.mkt-pay { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 6px; }
table.mt td.dip { color: var(--melt); }
.shiplog { display: flex; flex-direction: column; gap: 4px; }
.shiplog div { display: flex; justify-content: space-between; gap: 10px; padding: 5px 8px; background: var(--soot-1); border: 1px solid var(--soot-3); border-radius: 3px; font-size: 0.9em; }
.shiplog .what { color: var(--ash); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bagpanel .head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.bar { height: 5px; background: var(--soot-2); border-radius: 3px; overflow: hidden; margin: 5px 0 6px; border: 1px solid var(--soot-3); }
.bar > i { display: block; height: 100%; background: linear-gradient(90deg, var(--patina-dim), var(--patina)); }
.bar.full > i { background: linear-gradient(90deg, var(--melt-deep), var(--danger)); }
#baglist { display: grid; grid-template-columns: 1fr; gap: 1px; max-height: 42vh; overflow-y: auto; }
#baglist button {
display: flex; align-items: center; gap: 7px; padding: 2px 4px; border-radius: 3px; background: transparent; border: 1px solid transparent; cursor: pointer; text-align: left;
}
#baglist button:hover { background: var(--soot-2); }
#baglist button.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); }
#baglist .n { margin-left: auto; color: var(--ash); }
#baglist .empty { color: var(--ash-dim); font-size: 0.88em; padding: 2px 4px; }
#baglist .bagrow { display: flex; align-items: center; gap: 2px; border-radius: 3px; border: 1px solid transparent; }
#baglist .bagrow.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); }
#baglist .bagrow .pick { flex: 1; min-width: 0; }
#baglist .bagrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#baglist .tick { flex: none; width: 20px; height: 20px; padding: 0; justify-content: center; }
#baglist .tick::before { content: ''; width: 10px; height: 10px; border: 1.5px solid var(--ash-dim); border-radius: 2px; box-sizing: border-box; }
#baglist .tick:hover::before { border-color: var(--ash); }
#baglist .tick.on::before { border-color: var(--patina); background: var(--patina); box-shadow: inset 0 0 0 1.5px var(--soot-1, #15120f); }
#baglist .baghint { color: var(--ash-dim); font-size: 0.82em; padding: 4px 4px 0; }
#bagtoggle { background: none; border: none; color: var(--ash); cursor: pointer; padding: 0 2px; }
#info { padding: 7px 10px; min-width: calc(220px * var(--ui)); max-width: min(360px, 60vw); display: flex; flex-direction: column; gap: 2px; font-size: 0.92em; }
#info .t1 { font-weight: 600; }
#info .t2 { color: var(--ash); }
#info .t3 { color: var(--melt-hot); opacity: 0.85; font-size: 0.94em; }
#cards { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }
.card-pop { width: calc(290px * var(--ui)); padding: 8px 11px 10px; display: flex; flex-direction: column; gap: 3px; position: relative; overflow: hidden; animation: toastIn 0.25s ease-out; }
.card-pop .top { display: flex; align-items: center; gap: 6px; }
.card-pop .x { margin-left: auto; background: none; border: none; color: var(--ash); cursor: pointer; font-size: 1.15em; line-height: 1; padding: 0 2px; }
.card-pop .x:hover { color: var(--bone); }
.card-pop .title { font-weight: 650; color: var(--melt-hot); text-wrap: balance; }
.card-pop .p1 { color: var(--bone); font-size: 0.92em; }
.card-pop .p2 { color: var(--ash); font-size: 0.88em; }
.card-pop.milestone { border-color: var(--melt-deep); }
.card-pop .life { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--patina-dim); transform-origin: left; animation: cardLife var(--dur) linear forwards; }
.card-pop:hover .life, body.paused .card-pop .life { animation-play-state: paused; }
.card-pop.out { opacity: 0; transform: translateX(10px); transition: opacity 0.35s, transform 0.35s; }
@keyframes cardLife { from { transform: scaleX(1); } to { transform: scaleX(0); } }
.linkbtn { background: none; border: none; color: var(--patina); cursor: pointer; padding: 0; font-size: 0.88em; }
.linkbtn:hover { text-decoration: underline; }
#toasts { position: fixed; left: 50%; top: max(14px, env(safe-area-inset-top, 0px)); transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 6px; pointer-events: none; width: min(560px, calc(100vw - 32px)); }
.toast { padding: 7px 12px; font-size: 0.94em; animation: toastIn 0.25s ease-out; text-align: center; }
.toast.big { border-color: var(--melt-deep); padding: 10px 16px; }
.toast.big b { color: var(--melt-hot); display: block; font-size: 1.1em; }
.toast.out { opacity: 0; transition: opacity 0.4s; }
@keyframes toastIn { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
#perf { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(12px, env(safe-area-inset-bottom, 0px)) + 4px); font-family: var(--font-mono); font-size: 11px; color: var(--ash); pointer-events: none; text-align: right; white-space: pre; }
#hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); }
#perf:not(:empty) ~ .br { }
/* ---------- modals ---------- */
.modal { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(8, 7, 6, 0.55); padding-inline: max(16px, env(safe-area-inset-left, 0px)) max(16px, env(safe-area-inset-right, 0px)); padding-block: 16px; z-index: 20; }
.modal > .plate { width: min(760px, 100%); max-height: min(86vh, 900px); display: flex; flex-direction: column; overflow: hidden; }
.modal.small > .plate { width: min(420px, 100%); }
.mhead { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px 10px; border-bottom: 1px solid var(--soot-3); }
.mhead h2 { margin: 0; font-size: 1.15em; letter-spacing: 0.02em; text-wrap: balance; }
.mbody { padding: 12px 16px 16px; overflow-y: auto; min-height: 0; }
.mfoot { padding: 10px 16px; border-top: 1px solid var(--soot-3); display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
.tabs { display: flex; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--soot-3); overflow-x: auto; flex: none; }
.tabs button { background: none; border: none; border-bottom: 2px solid transparent; color: var(--ash); padding: 8px 10px; cursor: pointer; white-space: nowrap; }
.tabs button.on { color: var(--bone); border-bottom-color: var(--melt); }
.btn {
background: var(--soot-2); border: 1px solid var(--soot-4); border-radius: 4px; padding: 7px 14px; cursor: pointer; color: var(--bone);
}
.btn:hover { background: #2d2724; }
.btn.primary { background: linear-gradient(180deg, #e2832f, #b85520); border-color: #e59a52; color: #1a0f08; font-weight: 650; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.ghost { background: transparent; }
.btn.danger { border-color: #7a3227; color: #ffb4a6; }
.btn:disabled { opacity: 0.45; cursor: default; }
.iconbtn { background: none; border: 1px solid transparent; color: var(--ash); cursor: pointer; border-radius: 4px; padding: 4px 8px; }
.iconbtn:hover { border-color: var(--soot-3); color: var(--bone); }
/* settings rows */
.set { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 220px); gap: 6px 16px; align-items: center; padding: 7px 0; border-bottom: 1px solid rgba(59, 52, 47, 0.5); }
.set:last-child { border-bottom: none; }
.set .d { color: var(--ash); font-size: 0.86em; }
.set .ctl { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.set input[type=range] { width: 100%; accent-color: var(--melt); }
.set select { background: var(--soot-2); border: 1px solid var(--soot-4); border-radius: 4px; padding: 4px 6px; width: 100%; }
.set .v { font-family: var(--font-mono); font-size: 0.85em; color: var(--ash); min-width: 3.5em; text-align: right; }
.toggle { position: relative; width: 38px; height: 21px; border-radius: 11px; background: var(--soot-3); border: 1px solid var(--soot-4); cursor: pointer; padding: 0; flex: none; }
.toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: var(--ash); transition: transform 0.15s; }
.toggle.on { background: var(--patina-dim); border-color: var(--patina); }
.toggle.on::after { transform: translateX(17px); background: var(--bone); }
.note { color: var(--ash); font-size: 0.88em; margin: 4px 0 10px; max-width: 65ch; }
.grouphead { margin: 14px 0 2px; }
.grouphead:first-child { margin-top: 0; }
/* notebook */
.ledger { display: flex; flex-direction: column; gap: 6px; }
.ms { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 2px 10px; padding: 8px 10px; border: 1px solid var(--soot-3); border-radius: 4px; background: var(--soot-1); }
.ms .mark { grid-row: span 2; width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--soot-4); margin-top: 1px; }
.ms.done .mark { border-color: var(--ok); background: radial-gradient(circle, var(--ok) 0 4px, transparent 5px); }
.ms.next { border-color: var(--melt-deep); background: #221812; }
.ms.next .mark { border-color: var(--melt); }
.ms .h { font-weight: 600; }
.ms .p { color: var(--ash); font-size: 0.9em; }
.ms .rw { color: var(--patina); font-size: 0.88em; grid-column: 2; }
.ms.open .p .linkbtn { font-size: 1em; }
table.mt { width: 100%; border-collapse: collapse; font-size: 0.92em; }
table.mt th { text-align: left; font-family: var(--font-mono); font-size: 0.74em; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ash); font-weight: 500; padding: 4px 6px; border-bottom: 1px solid var(--soot-3); }
table.mt td { padding: 5px 6px; border-bottom: 1px solid rgba(59, 52, 47, 0.45); vertical-align: top; }
table.mt td.num { text-align: right; white-space: nowrap; }
.tablewrap { overflow-x: auto; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 8px; }
.card { border: 1px solid var(--soot-3); border-radius: 4px; padding: 8px 10px; background: var(--soot-1); display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.card .h { font-weight: 600; display: flex; justify-content: space-between; gap: 8px; }
.card .p { color: var(--ash); font-size: 0.88em; }
.keys { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 4px 18px; }
.keys div { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid rgba(59, 52, 47, 0.45); font-size: 0.92em; }
.keys div span:last-child { color: var(--ash); text-align: right; }
textarea.code { width: 100%; min-height: 90px; resize: vertical; background: var(--soot-0); color: var(--bone); border: 1px solid var(--soot-4); border-radius: 4px; padding: 8px; font-family: var(--font-mono); font-size: 0.82em; box-sizing: border-box; user-select: text; -webkit-user-select: text; }
input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-radius: 4px; padding: 6px 8px; width: 100%; box-sizing: border-box; user-select: text; -webkit-user-select: text; }
/* ---------- title ---------- */
#title { position: fixed; inset: 0; z-index: 10; display: grid; grid-template-rows: 1fr auto; overflow-y: auto; padding-inline: max(16px, env(safe-area-inset-left, 0px)) max(16px, env(safe-area-inset-right, 0px)); padding-block: 0 16px; background: radial-gradient(ellipse at 50% 120%, #3a1d0c 0%, #140e0b 45%, #0b0908 100%); }
#titleart { width: min(820px, 100%); aspect-ratio: 4 / 1; display: block; margin: 0 auto; image-rendering: pixelated; max-width: 100%; }
.titlewrap { align-self: center; display: flex; flex-direction: column; align-items: center; gap: 18px; padding-block: 24px; }
.tagline { color: var(--ash); max-width: 52ch; text-align: center; margin: -6px 0 4px; text-wrap: balance; }
.tmenu { display: flex; flex-direction: column; gap: 8px; width: min(360px, 100%); }
.tmenu .btn { padding: 10px 14px; text-align: left; display: flex; justify-content: space-between; align-items: center; }
.tmenu .btn small { color: var(--ash); font-size: 0.82em; }
.tmenu .btn.primary small { color: #3a1d0a; }
.newworld { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 10px; }
.newworld label { display: flex; flex-direction: column; gap: 4px; font-size: 0.86em; color: var(--ash); }
.newworld select, .newworld input { background: var(--soot-0); border: 1px solid var(--soot-4); border-radius: 4px; padding: 6px 8px; color: var(--bone); }
.newworld .full { grid-column: 1 / -1; }
.tfoot { text-align: center; color: var(--ash-dim); font-size: 0.82em; font-family: var(--font-mono); }
#loading { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; background: rgba(11, 9, 8, 0.86); }
#loading .plate { padding: 16px 20px; min-width: 260px; display: flex; flex-direction: column; gap: 8px; }
.touchnote { color: var(--melt-hot); font-size: 0.88em; text-align: center; max-width: 46ch; }
@media (max-width: 720px) {
.card-pop { width: min(290px, calc(100vw - 32px)); }
#bagpanel, #wallet { width: calc(190px * var(--ui)); }
#info { display: none; }
.set { grid-template-columns: minmax(0, 1fr); }
.set .ctl { justify-content: flex-start; }
.keys { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 480px) {
#selcard { min-width: 0; max-width: calc(100vw - 32px - 200px); }
.slot { width: 38px; height: 38px; }
}
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; }
}
/* filter picker */
.fpick { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; max-height: min(52vh, 420px); overflow-y: auto; padding: 2px; }
.fpick .fp { display: flex; align-items: center; gap: 8px; text-align: left; padding: 7px 9px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 13px; cursor: pointer; }
.fpick .fp:hover { background: #2d2724; }
.fpick .fp.on { border-color: #e59a52; background: #33261c; }
.fpick .fp:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
/* filter picker: mode toggle and checkboxes */
.fmode { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 10px; }
.fmode .fm { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; cursor: pointer; }
.fmode .fm b { font-weight: 650; font-size: 13px; }
.fmode .fm span { font-size: 12px; color: var(--ash); }
.fmode .fm:hover { background: #2d2724; }
.fmode .fm.on { border-color: #e59a52; background: #33261c; }
.fmode .fm:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
.fpick .fp .ck { width: 13px; height: 13px; border-radius: 3px; border: 1px solid var(--soot-4); background: var(--soot-1, #171412); flex: none; position: relative; }
.fpick .fp.on .ck { background: #e59a52; border-color: #e59a52; }
.fpick .fp.on .ck::after { content: ''; position: absolute; left: 3.5px; top: 0.5px; width: 3px; height: 7px; border: solid #1a0f08; border-width: 0 2px 2px 0; transform: rotate(45deg); }
.fp-sum { margin: 10px 0 0; min-height: 1.4em; }
@media (max-width: 520px) { .fmode { grid-template-columns: 1fr; } }
.fdir { display: flex; align-items: center; gap: 6px; margin: -2px 0 10px; }
.fdir .label { margin-right: 4px; }
.fdir .fd { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 13px; cursor: pointer; }
.fdir .fd:hover { background: #2d2724; }
.fdir .fd.on { border-color: #e59a52; background: #33261c; }
.fdir .fd:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
/* the ticked-mix choice at the bottom of the bag */
#baglist .mixrow { margin-top: 3px; border-top: 1px solid var(--soot-3); padding-top: 3px; border-radius: 0 0 3px 3px; }
#baglist .mixrow .pick { gap: 6px; }
#baglist .mixsw { display: flex; gap: 1px; flex: none; }
#baglist .mixsw .sw { width: 7px; height: 14px; border-radius: 1px; }
+244
View File
@@ -0,0 +1,244 @@
// What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame.
// Which overlays are on, shown as a coloured frame round the screen and a named badge
let ovShown = '';
function updateOverlayUI() {
const on = (GAME.running && R.lens ? 'lens ' : '') + (GAME.running && GAME.infoOverlay ? 'info' : '');
if (on + SET.thermalPalette === ovShown) return;
ovShown = on + SET.thermalPalette;
const f = $('ovframe'), b = $('ovbadges');
if (!f || !b) return;
f.className = on ? 'on ' + on : '';
let h = '';
if (R.lens) {
// a little key of the lens colours, from freezing to white hot
const tl = THERM[SET.thermalPalette] || THERM.inferno, stops = [0, 20, 150, 700, 1600].map(T => { const j = thermIdx(T); return `rgb(${tl[j]},${tl[j + 1]},${tl[j + 2]})`; });
h += `<span class="ovbadge lens"><span class="dot"></span>Thermal lens<span class="scale" style="background:linear-gradient(90deg,${stops.join(',')})" title="0°C to 1600°C"></span><kbd>H</kbd></span>`;
}
if (GAME.infoOverlay) h += `<span class="ovbadge info"><span class="dot"></span>Info overlay<kbd>I</kbd></span>`;
b.innerHTML = h;
}
function toggleLens() {
if (!player.lens) { setStatus('The thermal lens unlocks at your first fire.'); playSfx('error'); return; }
R.lens = !R.lens; playSfx('ui');
}
function doInteract() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
const fb = filterBlockAt(ax, ay);
if (fb >= 0) {
if (!inReach(ax, ay)) { setStatus('Out of reach.'); playSfx('error'); return; }
const run = filterRun(fb);
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0 }), f => {
applyFilterRun(run, f);
GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir;
setStatus(`${run.length === 1 ? 'This filter' : `These ${run.length} filters`} now ${filterSentence(f)}, ${f.dir ? 'right' : 'left'}.`, 'info');
}, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter');
GAME.editRun = run;
return;
}
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
let msg = '';
outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (!inReach(ax + dx, ay + dy)) continue;
msg = interactAt(ax + dx, ay + dy);
if (msg) break outer;
}
if (msg) { setStatus(msg, 'info'); if (!/^New (drill bit|bag)/.test(msg)) toast(msg); }
else { setStatus('Nothing to use there. F works on gates, molds, fans and filters.', 'info'); }
}
// Pick (G or middle mouse): the piece under the cursor becomes the one you build, with its
// direction, wall material and filter settings. On loose material it picks that to blow out.
function pickUnderCursor() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return;
const i = ay * W + ax, m = mat[i], bi = ((ay / BS) | 0) * BW + ((ax / BS) | 0);
let pid = null, note = '';
const ghost = ghostBlocks.get(bi);
if (mach[i] && machines[mach[i]]) { const mm = machines[mach[i]]; pid = mm.type; if (PIECE_BY_ID[pid].dirs) GAME.pieceDir[pid] = mm.dir; }
else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; note = MD[WALL_OF[mo.wall]].name.toLowerCase(); }
else if (blockP[bi] || ghost) {
const code = blockP[bi] || ghost[0], v = blockP[bi] ? blockV[bi] : ghost[1];
const P = PIECE_BY_CODE[code]; pid = P.id;
if (P.code === BP_WALL) { GAME.wallMat = v; note = MD[WALL_OF[v]].name.toLowerCase(); }
else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; note = filterSentence(GAME.filterCfg); }
else if (P.dirs) GAME.pieceDir[P.id] = v & 3;
if (P.dirs && !P.filter) note = P.dirs[GAME.pieceDir[P.id] || 0];
else if (P.filter) note = `${P.dirs[GAME.pieceDir[P.id] || 0]}, ${note}`;
}
if (pid) {
const P = PIECE_BY_ID[pid];
if (P.hidden) { setStatus(`${P.name} cannot be built.`); playSfx('error'); return; }
GAME.pieceIdx = P.index; GAME.buildCat = catOf(P.id); GAME.paste = false; selectTool(2); hudDirty = true; playSfx('ui');
setStatus(`Picked: ${P.name}${note ? ` (${note})` : ''}.`, 'info');
return;
}
const t = PH[m] === PH_POWDER || PH[m] === PH_LIQUID ? m : 0;
if (t && bag[t] > 0) { GAME.dropMat = t; GAME.matChosen = true; GAME.blowMix = false; selectTool(1); hudDirty = true; playSfx('ui'); setStatus(`Picked: ${MD[t].name} to blow out.`, 'info'); }
else setStatus('Nothing built here to pick.', 'info');
}
function aimBlock() { return blockOf(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); }
function stampOrigin(bw, bh) { const { bx, by } = aimBlock(); return { bx: bx - ((bw - 1) >> 1), by: by - ((bh - 1) >> 1) }; }
// Copy and cut: hold C or X and drag a box with the left button; letting go does it.
function selModeHeld() { return GAME.running && !anyModal() ? (keys.has('x') ? 'cut' : keys.has('c') ? 'copy' : null) : null; }
function startPaste() {
if (!GAME.clip) { setStatus('Nothing copied yet. Hold C and drag a box over something you built.', 'info'); playSfx('error'); return; }
GAME.paste = true; GAME.drag = null; hudDirty = true; playSfx('ui');
}
function stopSelectAndPaste() { GAME.selDrag = null; GAME.paste = false; hudDirty = true; playSfx('ui'); }
function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}`; }
function piecesIn(rec, tag) { return rec.ops.filter(o => o.t === tag).length; }
function doUndo() {
GAME.drag = null; GAME.selDrag = null;
const msg = undoLast();
setStatus(msg, /could not|Nothing/.test(msg) ? 'err' : 'info');
playSfx(/Nothing/.test(msg) ? 'error' : 'decon'); hudDirty = true;
}
// right-click take-backs while the button is held count as one thing to undo
function endRmbRecord() { if (GAME.rmbRec) { const r = GAME.rmbRec; GAME.rmbRec = null; const n = r.ops.length; r.label = `taking back ${plural(n, 'piece')}`; undoPush(r); } }
// Belts, filters and tubes run the way you drag them; a single click uses the direction R sets
function dragDirFor(P, a, b) {
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'tube' || P.filter)) return -1;
const dx = b.bx - a.bx, dy = b.by - a.by;
if (!dx && !dy) return -1;
if (P.id === 'tube') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
}
function blockLabel(P, rec) {
const put = piecesIn(rec, 'block+'), took = piecesIn(rec, 'block-') + piecesIn(rec, 'filter');
return took ? `replacing ${plural(took, 'piece')}` : `placing ${plural(put, P.name.toLowerCase())}`;
}
// press / release actions (one-shot)
function primaryPress(shift) {
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
const sel = selModeHeld();
if (sel) { GAME.selDrag = { x: ax, y: ay, mode: sel }; GAME.drag = null; hudDirty = true; return; }
if (GAME.paste && GAME.clip) {
const { bx, by } = stampOrigin(GAME.clip.bw, GAME.clip.bh);
undoRecord('pasting a blueprint', () => pasteGhost(GAME.clip, bx, by)); playSfx('place'); hudDirty = true;
return;
}
if (t === 'vacuum') {
// with anything ticked, the vacuum takes only the ticked materials, wherever you start;
// with nothing ticked, it takes what you start on
const g = GAME.collect.size ? 0 : grainAt(ax, ay);
GAME.vacFilter = shift ? -1 : g;
GAME.vacLocked = shift || !!g || GAME.collect.size > 0;
hudDirty = true;
} else if (t === 'build') {
const P = piece();
if (P.kind === 'block') { GAME.drag = aimBlock(); }
else {
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh;
const { bx, by } = stampOrigin(bw, bh);
if (!inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; }
const place = () => P.kind === 'mold' ? placeMoldAt(P.mold, bx, by, GAME.wallMat) : placeMachineAt(P.id, GAME.pieceDir[P.id] || 0, bx, by);
const check = () => P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
let err = '';
undoRecord(rec => piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-') ? `replacing with a ${P.name.toLowerCase()}` : `placing a ${P.name.toLowerCase()}`, () => {
err = check() || '';
// Ctrl: take down what you built in the way (refunded) and put this in its place
if (err && GAME.ctrl && /already/.test(err)) {
err = footprintBlocker(bx, by, bw, bh);
if (!err && !canAfford(pieceCostNow(P), 1)) err = needText(pieceCostNow(P), 1);
if (!err) err = clearFootprint(bx, by, bw, bh);
}
if (!err) err = place();
});
if (err) { setStatus(err + (/already/.test(err) ? `${/[.!]$/.test(err) ? '' : '.'} Hold Ctrl to replace it.` : '')); playSfx('error'); } else { playSfx('place'); hudDirty = true; }
}
}
}
function primaryRelease(shift) {
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (GAME.selDrag) {
const sd = GAME.selDrag; GAME.selDrag = null; hudDirty = true;
if (sd.mode === 'cut') {
let bp = null;
undoRecord(rec => `cutting ${plural(piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-'), 'piece')}`, () => { bp = cutRegion(sd.x, sd.y, ax, ay); });
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('decon'); setStatus(`Cut ${bp.bw}×${bp.bh} blocks into your bag. Click to paste it somewhere; Ctrl+Z puts it back.`, 'info'); }
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
} else {
const bp = copyRegion(sd.x, sd.y, ax, ay);
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks. Click to place it.`, 'info'); }
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
}
return;
}
if (t === 'build' && GAME.drag) {
const P = piece(), b = aimBlock();
const list = shift ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by);
const dd = dragDirFor(P, GAME.drag, b);
if (dd >= 0) GAME.pieceDir[P.id] = dd;
const v = pieceV(P), ex = pieceExtra(P);
// blocks past the bot's reach become ghosts it builds when it gets close, so a line can be any length
const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by));
let ghosts = 0;
const r = undoRecord(rec => blockLabel(P, rec) + (piecesIn(rec, 'paste') ? ' (and its ghosts)' : ''), () => {
const res = placeBlocks(P.code, v, near, ex, GAME.ctrl);
const g = { t: 'paste', blocks: [], machines: [], molds: [] };
for (const [bx, by] of far) {
const b = by * BW + bx;
if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, P.code, v, ex) || ghostBlocks.has(b)) continue;
ghostBlocks.set(b, ex ? [P.code, v, ex] : [P.code, v]); g.blocks.push([bx, by, P.code]); ghosts++;
}
if (ghosts) undoNote(g);
return res;
});
const hint = /already built/.test(r.status) ? `${/[.!]$/.test(r.status) ? '' : '.'} Hold Ctrl to replace it.` : '';
const gtxt = ghosts ? `${r.n ? `Placed ${plural(r.n, P.name.toLowerCase())}; ` : ''}${plural(ghosts, 'more')} out of reach ${ghosts === 1 ? 'is a ghost' : 'are ghosts'} your bot builds when it gets close.` : '';
if (r.n || ghosts) { playSfx('place'); setStatus(gtxt || (r.status ? r.status + hint : ''), 'info'); } else { setStatus((r.status || 'Nothing placed.') + hint); playSfx('error'); }
GAME.drag = null; GAME.dragDir = -1; hudDirty = true;
}
if (t === 'vacuum') { GAME.vacFilter = 0; GAME.vacLocked = false; hudDirty = true; }
}
function secondaryPress() {
const t = toolId();
GAME.rmbSeen = new Set();
if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; GAME.rmbConsumed = true; }
else if (GAME.paste) { GAME.paste = false; hudDirty = true; playSfx('ui'); GAME.rmbConsumed = true; }
else if (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; }
else { GAME.rmbConsumed = false; if (t === 'build') { endRmbRecord(); GAME.rmbRec = { label: '', ops: [] }; } }
}
// continuous actions, once per simulation tick
function toolTick() {
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (GAME.lmb && !GAME.selDrag && !GAME.paste && !selModeHeld()) {
if (t === 'drill') {
const r = toolPulse(ax, ay, GAME.drillW, GAME.drillTune);
if (r.fired) { playSfx('pulse', GAME.drillW); if (r.status) setStatus(r.status); }
} else if (t === 'vacuum') {
// started on empty space: the first loose thing you sweep over (that is not ticked) becomes the filter
// clicked on empty space: the first loose thing that comes under the cursor sets the filter, once
if (!GAME.vacLocked && !GAME.collect.size) { const g = grainAt(ax, ay); if (g) { GAME.vacFilter = g; GAME.vacLocked = true; hudDirty = true; } }
const f = GAME.vacFilter, list = GAME.collect;
if (f || list.size) {
const r = toolVacuum(ax, ay, GAME.brush[1], f < 0 ? () => true : list.size ? m => list.has(m) : m => m === f);
if (r.got) { playSfx('suck'); hudDirty = true; }
if (r.status) setStatus(r.status);
}
} else if (t === 'ignite') {
if (inReach(ax, ay)) { const r = toolIgnite(ax, ay); playSfx('spark'); if (Math.random() < 0.6) spawnSpark(ax + 0.5, ay + 0.5); if (!r.fuel) setStatus('Nothing here burns. Aim at coal or wood that touches air.'); }
else setStatus('Out of reach.');
}
}
if (GAME.rmb && !GAME.rmbConsumed) {
if (t === 'vacuum') {
ensureDropMat();
// what right click blows is only ever what is selected (and shown by the cursor): one material, or the ticked mix
const mix = GAME.blowMix && GAME.collect.size ? [...GAME.collect] : null;
const r = toolBlow(ax, ay, GAME.brush[1], GAME.dropMat, mix);
if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status);
} else if (t === 'build') {
const key = `${(ax / BS) | 0},${(ay / BS) | 0}`;
if (!GAME.rmbSeen.has(key) && inReach(ax, ay)) {
GAME.rmbSeen.add(key);
const keep = undoRec; undoRec = GAME.rmbRec;
const msg = removePieceAt(ax, ay);
undoRec = keep;
if (msg) { playSfx('decon'); setStatus(msg, 'info'); hudDirty = true; }
}
}
}
}
+209
View File
@@ -0,0 +1,209 @@
/* =====================================================================
Audio: chiptune effects and generative music that changes with depth.
Everything is synthesized with WebAudio, no samples.
===================================================================== */
const AU = {
ctx: null, master: null, music: null, sfx: null, amb: null, noise: null, pulse12: null, pulse25: null,
last: {}, started: false,
zone: 0, nextNote: 0, step: 0, bar: 0, chordIdx: 0, timer: 0, melody: null, melPos: 0, lastDeg: 0,
hum: null, humGain: null, crackleT: 0, wind: null, windGain: null,
};
function auInit() {
if (AU.ctx) { if (AU.ctx.state === 'suspended') AU.ctx.resume(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ctx = new AC();
AU.ctx = ctx;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -16; comp.ratio.value = 4;
comp.connect(ctx.destination);
AU.master = ctx.createGain(); AU.master.connect(comp);
AU.music = ctx.createGain(); AU.music.connect(AU.master);
AU.sfx = ctx.createGain(); AU.sfx.connect(AU.master);
AU.amb = ctx.createGain(); AU.amb.connect(AU.sfx);
// white noise buffer
const len = ctx.sampleRate * 2, buf = ctx.createBuffer(1, len, ctx.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
AU.noise = buf;
const pulse = duty => {
const n = 64, re = new Float32Array(n), im = new Float32Array(n);
for (let k = 1; k < n; k++) { re[k] = 0; im[k] = (2 / (k * Math.PI)) * Math.sin(k * Math.PI * duty); }
return ctx.createPeriodicWave(re, im);
};
AU.pulse12 = pulse(0.125); AU.pulse25 = pulse(0.25);
// machine hum
AU.hum = ctx.createOscillator(); AU.hum.setPeriodicWave(AU.pulse25); AU.hum.frequency.value = 55;
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 320;
AU.humGain = ctx.createGain(); AU.humGain.gain.value = 0;
AU.hum.connect(lp); lp.connect(AU.humGain); AU.humGain.connect(AU.amb); AU.hum.start();
// wind / surface air
const ws = ctx.createBufferSource(); ws.buffer = buf; ws.loop = true;
const wf = ctx.createBiquadFilter(); wf.type = 'bandpass'; wf.frequency.value = 500; wf.Q.value = 0.7;
AU.windGain = ctx.createGain(); AU.windGain.gain.value = 0;
ws.connect(wf); wf.connect(AU.windGain); AU.windGain.connect(AU.amb); ws.start();
AU.windFilter = wf;
auVolumes();
AU.nextNote = ctx.currentTime + 0.3;
AU.timer = setInterval(musicScheduler, 60);
AU.started = true;
}
function auVolumes() {
if (!AU.ctx) return;
const m = SET.muted ? 0 : SET.volMaster;
AU.master.gain.setTargetAtTime(m, AU.ctx.currentTime, 0.05);
AU.music.gain.setTargetAtTime(SET.volMusic * 0.55, AU.ctx.currentTime, 0.1);
AU.sfx.gain.setTargetAtTime(SET.volSfx, AU.ctx.currentTime, 0.05);
}
function rateOk(key, ms) {
const now = performance.now();
if (AU.last[key] && now - AU.last[key] < ms) return false;
AU.last[key] = now; return true;
}
function tone(freq, dur, opts = {}) {
const ctx = AU.ctx; if (!ctx) return;
const t = (opts.at || ctx.currentTime) + 0.005;
const o = ctx.createOscillator();
if (opts.wave === 'p12') o.setPeriodicWave(AU.pulse12); else if (opts.wave === 'p25') o.setPeriodicWave(AU.pulse25); else o.type = opts.wave || 'square';
o.frequency.setValueAtTime(freq, t);
if (opts.to) o.frequency.exponentialRampToValueAtTime(opts.to, t + dur);
if (opts.vib) { const l = ctx.createOscillator(), lg = ctx.createGain(); l.frequency.value = 5.5; lg.gain.value = freq * 0.012; l.connect(lg); lg.connect(o.frequency); l.start(t); l.stop(t + dur + 0.05); }
const g = ctx.createGain();
const v = opts.vol || 0.1, a = opts.attack || 0.005, rel = opts.release || 0.04;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(v, t + a);
g.gain.setValueAtTime(v, t + Math.max(a, dur - rel));
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(opts.bus || AU.sfx);
o.start(t); o.stop(t + dur + 0.02);
}
function noiseBurst(dur, opts = {}) {
const ctx = AU.ctx; if (!ctx) return;
const t = (opts.at || ctx.currentTime) + 0.005;
const s = ctx.createBufferSource(); s.buffer = AU.noise;
s.playbackRate.value = opts.rate || 1;
const f = ctx.createBiquadFilter(); f.type = opts.type || 'bandpass'; f.frequency.value = opts.freq || 1200; f.Q.value = opts.q || 1;
if (opts.sweep) f.frequency.exponentialRampToValueAtTime(opts.sweep, t + dur);
const g = ctx.createGain(); const v = opts.vol || 0.08;
g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(f); f.connect(g); g.connect(opts.bus || AU.sfx);
s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.02);
}
const SFX = {
dig(h) { if (!rateOk('dig', 75)) return; noiseBurst(0.06, { freq: 260 + h * 260, q: 1.6, vol: 0.09 }); if (h >= 3) tone(120 + h * 40, 0.04, { wave: 'p12', vol: 0.03 }); },
// a resonance pulse: a low thrum, higher and tighter for a narrow pulse
pulse(w) { if (!rateOk('pulse', 70)) return; const f = [150, 110, 82][w | 0] || 110; tone(f, 0.16, { wave: 'sine', to: f * 0.72, vol: 0.09, release: 0.12 }); tone(f * 3.02, 0.07, { wave: 'triangle', vol: 0.012, release: 0.06 }); },
place() { if (!rateOk('place', 70)) return; tone(520, 0.045, { wave: 'p25', to: 660, vol: 0.05 }); },
build() { if (!rateOk('build', 90)) return; tone(440, 0.05, { wave: 'p25', to: 520, vol: 0.035 }); },
decon() { if (!rateOk('decon', 80)) return; tone(520, 0.06, { wave: 'p25', to: 300, vol: 0.05 }); },
spark() { if (!rateOk('spark', 90)) return; noiseBurst(0.05, { type: 'highpass', freq: 3000, vol: 0.06 }); },
ignite() { if (!rateOk('ignite', 400)) return; noiseBurst(0.5, { type: 'lowpass', freq: 400, sweep: 2400, vol: 0.12 }); },
sizzle() { if (!rateOk('sizzle', 220)) return; noiseBurst(0.22, { type: 'highpass', freq: 4500, vol: 0.05 }); },
ui() { if (!rateOk('ui', 40)) return; tone(1100, 0.025, { wave: 'p25', vol: 0.035 }); },
error() { if (!rateOk('err', 300)) return; tone(110, 0.14, { wave: 'p12', vol: 0.07 }); tone(98, 0.14, { wave: 'p12', vol: 0.05, at: AU.ctx && AU.ctx.currentTime + 0.07 }); },
gate() { noiseBurst(0.12, { type: 'lowpass', freq: 600, vol: 0.12 }); tone(90, 0.12, { wave: 'triangle', to: 60, vol: 0.12 }); },
cast() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [523, 659, 784, 1047].forEach((f, k) => tone(f, 0.12, { wave: 'p25', vol: 0.06, at: t + k * 0.08 })); },
upgrade() {
if (!AU.ctx) return; const t = AU.ctx.currentTime;
[392, 523, 659, 784, 659, 784, 1047].forEach((f, k) => tone(f, k === 6 ? 0.5 : 0.11, { wave: k % 2 ? 'p25' : 'p12', vol: 0.07, at: t + k * 0.09, vib: k === 6 }));
[196, 262, 330].forEach((f, k) => tone(f, 0.35, { wave: 'triangle', vol: 0.08, at: t + 0.27 + k * 0.18 }));
},
milestone() {
if (!AU.ctx) return; const t = AU.ctx.currentTime;
[659, 784, 988, 1319].forEach((f, k) => tone(f, k === 3 ? 0.35 : 0.1, { wave: 'p25', vol: 0.06, at: t + k * 0.07, vib: k === 3 }));
},
suck() { if (!rateOk('suck', 110)) return; noiseBurst(0.1, { type: 'bandpass', freq: 900, sweep: 2600, q: 2.2, vol: 0.045 }); },
blow() { if (!rateOk('blow', 110)) return; noiseBurst(0.12, { type: 'bandpass', freq: 2200, sweep: 700, q: 1.6, vol: 0.05 }); },
sale() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [784, 988, 1175, 1568].forEach((f, k) => tone(f, 0.09, { wave: 'p25', vol: 0.05, at: t + k * 0.06 })); },
discover() { if (!rateOk('disc', 500)) return; const t = AU.ctx && AU.ctx.currentTime; tone(880, 0.06, { wave: 'p25', vol: 0.04, at: t }); tone(1175, 0.1, { wave: 'p25', vol: 0.04, at: t + 0.06 }); },
};
function playSfx(name, arg) { if (!AU.ctx || !SFX[name]) return; try { SFX[name](arg); } catch (e) { /* ignore */ } }
// Ambient layer driven by what is on screen.
function auAmbient(info) {
if (!AU.ctx) return;
const t = AU.ctx.currentTime;
AU.humGain.gain.setTargetAtTime(Math.min(0.05, info.power * 0.004), t, 0.4);
AU.hum.frequency.setTargetAtTime(48 + Math.min(30, info.power * 0.6), t, 0.6);
// wind on the surface; rain turns it into a hiss
const rain = info.rain || 0;
AU.windGain.gain.setTargetAtTime(info.surface ? 0.018 + 0.01 * Math.sin(t * 0.3) + rain * 0.05 : 0.004 + rain * 0.008, t, 1.2);
AU.windFilter.frequency.setTargetAtTime(info.surface ? 420 + 160 * Math.sin(t * 0.21) + rain * 1900 : 260 + rain * 400, t, 1.5);
// fire crackle
if (info.fire > 0 && performance.now() > AU.crackleT) {
const n = Math.min(4, 1 + (info.fire / 60) | 0);
for (let k = 0; k < n; k++) noiseBurst(0.02 + Math.random() * 0.03, { type: 'bandpass', freq: 900 + Math.random() * 2200, q: 2, vol: 0.02 + Math.min(0.05, info.fire * 0.0004), at: t + Math.random() * 0.15, bus: AU.amb });
AU.crackleT = performance.now() + 120 + Math.random() * Math.max(60, 700 - info.fire * 4);
}
}
// ---------- Generative music ----------
const ZONES = [
{ name: 'surface', root: 60, scale: [0, 2, 4, 5, 7, 9, 11], prog: [0, 5, 3, 4], tempo: 92, density: 0.62, hat: 0.5, lead: 0.55, oct: 0 },
{ name: 'shallows', root: 57, scale: [0, 2, 3, 5, 7, 9, 10], prog: [0, 3, 6, 0], tempo: 84, density: 0.5, hat: 0.3, lead: 0.45, oct: 0 },
{ name: 'caverns', root: 62, scale: [0, 2, 3, 5, 7, 8, 10], prog: [0, 5, 6, 4], tempo: 76, density: 0.4, hat: 0.18, lead: 0.35, oct: -12 },
{ name: 'deep', root: 52, scale: [0, 1, 3, 5, 7, 8, 10], prog: [0, 1, 0, 6], tempo: 66, density: 0.3, hat: 0.08, lead: 0.25, oct: -12 },
{ name: 'magma', root: 49, scale: [0, 1, 4, 5, 7, 8, 10], prog: [0, 1, 0, 1], tempo: 60, density: 0.24, hat: 0.04, lead: 0.2, oct: -12 },
];
function mtof(m) { return 440 * Math.pow(2, (m - 69) / 12); }
function zoneForDepth() {
const cx = Math.max(0, Math.min(W - 1, Math.floor(bot.x)));
const y = bot.y;
if (y < surfY[cx] + 12) return 0;
const u = depthU(y);
if (u < 0.3) return 1;
if (y < graniteY[cx]) return 2;
if (u < 0.86) return 3;
return 4;
}
function scaleNote(z, deg, octave) {
const n = z.scale.length;
const o = Math.floor(deg / n), d = ((deg % n) + n) % n;
return z.root + z.scale[d] + 12 * (o + octave) + z.oct;
}
function musicScheduler() {
const ctx = AU.ctx;
if (!ctx || !AU.started || SET.volMusic <= 0 || (window.GAME && !GAME.running)) { if (ctx) AU.nextNote = Math.max(AU.nextNote, ctx.currentTime + 0.1); return; }
const z = ZONES[AU.zone];
const spb = 60 / z.tempo / 4; // seconds per 16th
while (AU.nextNote < ctx.currentTime + 0.25) {
const s = AU.step % 16, t = AU.nextNote;
if (s === 0) {
// new bar: maybe change zone, advance chord
const nz = W ? zoneForDepth() : 0;
if (nz !== AU.zone) { AU.zone = nz; }
AU.chordIdx = (AU.chordIdx + 1) % 4;
if (Math.random() < ZONES[AU.zone].lead * 0.5 && !AU.melody) {
const len = 4 + ((Math.random() * 5) | 0), mel = [];
let d = AU.lastDeg;
for (let k = 0; k < len; k++) { d += [-2, -1, -1, 1, 1, 2, 0][(Math.random() * 7) | 0]; d = Math.max(-2, Math.min(9, d)); mel.push({ d, at: k * (Math.random() < 0.6 ? 2 : 4) }); }
AU.melody = mel; AU.melPos = AU.step; AU.lastDeg = d;
}
}
const zz = ZONES[AU.zone];
const chordRoot = zz.prog[AU.chordIdx];
// bass
if (s === 0 || (s === 8 && Math.random() < 0.6)) {
const deg = s === 8 && Math.random() < 0.5 ? chordRoot + 4 : chordRoot;
tone(mtof(scaleNote(zz, deg, -1)), spb * (s === 0 ? 7 : 5), { wave: 'p12', vol: 0.07, bus: AU.music, release: 0.15, at: t });
}
// arpeggio
if (s % 2 === 0 && Math.random() < zz.density) {
const pat = [0, 2, 4, 2, 0, 4, 2, 4][(s >> 1) & 7] + (Math.random() < 0.15 ? 7 : 0);
tone(mtof(scaleNote(zz, chordRoot + pat, 1)), spb * 1.6, { wave: 'triangle', vol: 0.05, bus: AU.music, release: 0.06, at: t });
}
// melody phrase
if (AU.melody) {
const rel = AU.step - AU.melPos;
for (const n of AU.melody) if (n.at === rel) tone(mtof(scaleNote(zz, n.d, 1)), spb * 2.6, { wave: 'p25', vol: 0.035, bus: AU.music, vib: true, attack: 0.02, release: 0.1, at: t });
if (rel > 40) AU.melody = null;
}
// hats
if ((s % 4 === 2) && Math.random() < zz.hat) noiseBurst(0.03, { type: 'highpass', freq: 6000, vol: 0.025, at: t, bus: AU.music });
if (s === 0 && AU.zone <= 1 && Math.random() < 0.7) tone(110, 0.12, { wave: 'triangle', to: 50, vol: 0.06, at: t, bus: AU.music });
if (AU.zone >= 3 && s === 0 && AU.chordIdx % 2 === 0) tone(mtof(scaleNote(zz, chordRoot, -2)), spb * 30, { wave: 'p12', vol: 0.03, at: t, bus: AU.music, attack: 0.6, release: 1.2 });
AU.nextNote += spb;
AU.step++;
}
}
+72
View File
@@ -0,0 +1,72 @@
// The build menu along the bottom: categories, piece tiles and their icons.
// ---------- Build menu ----------
const BUILD_CATS = [
['Structure', ['wall', 'grate', 'gate']],
['Moving', ['belt', 'filter', 'tube']],
['Liquids', ['pipe', 'pump', 'outlet']],
['Machines', ['fan', 'refiner', 'cutter', 'silo']],
['Molds', ['ingotmold', 'bitmold', 'tankmold']],
];
const BUILD_ORDER = BUILD_CATS.flatMap(c => c[1]);
const PIECE_ICONS = {
wall: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 12h18M9 5v7M15 12v7"/>',
grate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M7.5 5v14M12 5v14M16.5 5v14"/>',
gate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M9 5v14"/><path d="M12 12h6M16 9.5l2.5 2.5-2.5 2.5"/>',
belt: '<rect x="2" y="9" width="20" height="8" rx="2"/><circle cx="7" cy="13" r="2"/><circle cx="17" cy="13" r="2"/><path d="M9 5h7M14 3l2 2-2 2"/>',
filter: '<rect x="2" y="10" width="20" height="8" rx="2"/><path d="M7 10v8M12 10v8M17 10v8M2 14h20"/><path d="M12 2v5M10 5l2 2 2-2"/>',
tube: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M6.5 18l3.5-12M11.5 18l3.5-12M16.5 18l3-10"/>',
pipe: '<path d="M2 10h20M2 14h20"/><path d="M6 7.5v9M18 7.5v9"/>',
pump: '<circle cx="11" cy="14" r="6"/><path d="M11 14l3.5-2.5M11 14l-1 4.2M11 14l-4-1.2"/><path d="M15.5 9.5L20 5M16 5h4v4"/>',
outlet: '<path d="M2 7h12a4 4 0 0 1 4 4v3"/><path d="M2 11h10a2 2 0 0 1 2 2v1"/><path d="M15 18v1.5M18 20v1.5M12 20v1.5"/>',
fan: '<circle cx="12" cy="12" r="1.8"/><path d="M12 10.2c-.4-3.6 1.8-6 4-4.8 2 1.2.4 3.8-4 4.8zM13.8 12c3.6-.4 6 1.8 4.8 4-1.2 2-3.8.4-4.8-4zM12 13.8c.4 3.6-1.8 6-4 4.8-2-1.2-.4-3.8 4-4.8zM10.2 12c-3.6.4-6-1.8-4.8-4 1.2-2 3.8-.4 4.8 4z"/>',
refiner: '<path d="M3 4h18l-5 7H8z"/><circle cx="9" cy="15.5" r="2.8"/><circle cx="15" cy="15.5" r="2.8"/><path d="M12 20v2"/>',
cutter: '<path d="M5 3h14l-3 5H8z"/><circle cx="12" cy="15" r="5"/><circle cx="12" cy="15" r="1.2"/><path d="M12 9.2v1.6M17.8 15h-1.6M12 20.8v-1.6M6.2 15h1.6"/>',
silo: '<path d="M6 5.5h12v11H6z"/><path d="M6 5.5l1-2h10l1 2M6 16.5l4 3h4l4-3M7 16.5v5M17 16.5v5"/><path d="M15.5 8v6"/>',
ingotmold: '<path d="M3 9h18l-2 7H5z"/><path d="M9.2 9l.8 7M14.8 9l-.8 7"/>',
bitmold: '<path d="M3 9h10l7 3-7 3H3z"/><path d="M6.5 9v6M9.5 9v6"/>',
tankmold: '<rect x="5" y="7" width="14" height="13" rx="3"/><path d="M9 4h6v3M8.5 12h7"/>',
};
function pieceIcon(id) { return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${PIECE_ICONS[id] || ''}</svg>`; }
function catOf(id) { const c = BUILD_CATS.findIndex(([, ids]) => ids.includes(id)); return c < 0 ? 0 : c; }
let lastBuildKey = '';
function renderBuildBar() {
const bar = $('buildbar'); if (!bar) return;
const show = GAME.running && toolId() === 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld();
if (bar.hidden === show) bar.hidden = !show;
if (!show) { lastBuildKey = ''; return; }
const P = piece(), cat = GAME.buildCat != null ? GAME.buildCat : catOf(P.id), hov = GAME.bbHover;
const ids = BUILD_CATS[cat][1];
const afford = ids.map(id => canAfford(pieceCostNow(PIECE_BY_ID[id]), 1) ? 1 : 0).join('');
const key = [cat, P.id, hov, afford, GAME.wallMat, Math.round(money)].join('|');
if (key === lastBuildKey) return;
lastBuildKey = key;
const tip = PIECE_BY_ID[hov && ids.includes(hov) ? hov : P.id];
let h = '<div class="bb-head">' + BUILD_CATS.map(([name], k) => `<button class="bb-tab${k === cat ? ' on' : ''}" data-cat="${k}">${esc(name)}</button>`).join('')
+ '<span class="bb-keys"><kbd>Q</kbd>/<kbd>E</kbd> cycle · <kbd>G</kbd> pick</span></div><div class="bb-tiles">';
for (const id of ids) {
const Q = PIECE_BY_ID[id], cost = pieceCostNow(Q), ok = canAfford(cost, 1);
const sw = Q.variants === 'wall' ? `<span class="sw" style="background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>` : '';
h += `<button class="bb-tile${id === P.id ? ' on' : ''}" data-piece="${id}" aria-pressed="${id === P.id}" aria-label="${esc(Q.name)}">${sw}${pieceIcon(id)}<span class="n">${esc(Q.name)}</span><span class="c${ok ? '' : ' bad'}">${esc(costText(cost, 1) || 'free')}</span></button>`;
}
// just the first sentence here; the card at the top left has the rest for the chosen piece
const first = (tip.desc.match(/^.*?[.!?](\s|$)/) || [tip.desc])[0].trim();
h += `</div><div class="bb-tip"><b>${esc(tip.name)}.</b> ${esc(first)}</div>`;
bar.innerHTML = h;
}
function setupBuildBar() {
const bar = $('buildbar'); if (!bar) return;
bar.addEventListener('click', e => {
const t = e.target.closest('[data-cat],[data-piece]'); if (!t) return;
if (t.dataset.cat != null) {
const c = +t.dataset.cat; GAME.buildCat = c;
const ids = BUILD_CATS[c][1], keep = GAME.catPick && GAME.catPick[c];
GAME.pieceIdx = PIECE_BY_ID[ids.includes(keep) ? keep : ids[0]].index;
} else { GAME.pieceIdx = PIECE_BY_ID[t.dataset.piece].index; GAME.buildCat = catOf(t.dataset.piece); }
GAME.catPick = GAME.catPick || {}; GAME.catPick[catOf(piece().id)] = piece().id;
GAME.drag = null; hudDirty = true; lastBuildKey = ''; playSfx('ui');
});
bar.addEventListener('mouseover', e => { const t = e.target.closest('[data-piece]'); const id = t ? t.dataset.piece : null; if (id !== GAME.bbHover) { GAME.bbHover = id; } });
bar.addEventListener('mouseleave', () => { GAME.bbHover = null; });
// clicks on the menu never reach the world
bar.addEventListener('mousedown', e => e.stopPropagation());
}
+44
View File
@@ -0,0 +1,44 @@
// Events from the simulation (emit) turned into sounds, toasts and cards.
// ---------- Events from the simulation ----------
function drainEvents() {
let n = 0;
while (EV.length && n++ < 300) {
const e = EV.shift();
if (e.t === 'discover') {
discoveryCard(e); playSfx('discover');
if (!$('m-notebook').hidden) renderNotebook();
} else if (e.t === 'milestone') {
const m = MILESTONES.find(x => x.id === e.id);
if (m && !['bit1', 'bit2', 'bit3', 'tank'].includes(m.id)) { showCard({ kind: 'Milestone', title: m.title, body: [m.msg, m.reward], accent: 'milestone' }); playSfx('milestone'); }
} else if (e.t === 'cast_ready') {
const metal = MD[e.metal] ? MD[e.metal].name : 'Metal';
const body = e.kind === 'ingot' ? `${e.bars} ${e.bars === 1 ? 'bar' : 'bars'} worth has set. Press F on the mold to knock ${e.bars === 1 ? 'it' : 'them'} out.` : `The ${e.kind} casting has set. Press F on the mold to knock it out.`;
showCard({ kind: 'Casting ready', title: `${metal} ${e.kind === 'ingot' ? 'ingot' : e.kind}`, swatch: e.metal > 0 ? e.metal : 0, body: [body], accent: 'milestone' });
playSfx('cast');
} else if (e.t === 'upgrade') {
playSfx('upgrade'); if (SET.shake) R.shake = 1.4;
showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone',
body: [e.kind === 'bit' ? `Digs hardness ${BIT_TIERS[e.tier].hard} and below.` : `Holds ${TANK_TIERS[e.tier].cap.toLocaleString('en-US')} grains.`] });
hudDirty = true;
} else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m);
else if (e.t === 'suck') spawnSuck(e.x, e.y, e.m);
else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v));
else if (e.t === 'order_new') {
const o = e.o;
showCard({ kind: 'New order', title: orderName(o), swatch: o.m, body: [`Within ${o.mins} minutes, for ${fmtMoney(o.pay)}: ${o.mult}× the market price.`, 'Goods dropped into the trade dock fill it first.'] });
playSfx('discover'); hudDirty = true;
}
else if (e.t === 'order_done') {
showCard({ kind: 'Order filled', title: '+' + fmtMoney(e.pay), swatch: e.m, accent: 'milestone', body: [`${orderName(e)} delivered.`, debt > 0 ? `${fmtMoney(debt)} still owed on the claim.` : ''] });
playSfx('sale'); hudDirty = true; if (!$('m-notebook').hidden) renderNotebook();
}
else if (e.t === 'order_expired') {
showCard({ kind: 'Order expired', title: orderName(e), swatch: e.m, body: [e.got ? `You delivered ${e.got} of ${e.qty}. They were paid at market price: ${fmtMoney(e.paid)}.` : 'Nothing was delivered. No harm done.'] });
hudDirty = true;
}
else if (e.t === 'claim_paid') { showCard({ kind: 'Claim', title: 'The land is yours', accent: 'milestone', hold: 1.5, body: ['You have paid off the claim on this land.', 'Keep filling orders: bigger buyers bring bigger ones. The ledger has bigger goals too.'] }); playSfx('milestone'); }
else if (e.t === 'sprout') { showCard({ kind: 'Discovery', title: 'A sapling', swatch: ACORN, body: ['An acorn on soil under open sky has sprouted.', 'It grows into a tree in about a minute and a half. Keep the space above it clear.'] }); playSfx('discover'); }
else if (e.t === 'sfx') { if (e.s) playSfx(e.s, e.a); }
else if (e.t === 'toast') { if (e.text) toast(e.text); }
}
}
+60
View File
@@ -0,0 +1,60 @@
// Gamepad input.
// ---------- Gamepad ----------
const PAD = { prev: [], active: false, menuT: 0 };
function padPressed(gp, b) { const v = gp.buttons[b] && gp.buttons[b].pressed; const was = PAD.prev[b]; return v && !was; }
function pollPad() {
const gps = navigator.getGamepads ? navigator.getGamepads() : [];
let gp = null; for (const g of gps) if (g && g.connected) { gp = g; break; }
if (!gp) return { mx: 0, my: 0 };
const dz = SET.deadzone;
const ax = v => Math.abs(v) < dz ? 0 : (v - Math.sign(v) * dz) / (1 - dz);
const lx = ax(gp.axes[0] || 0), ly = ax(gp.axes[1] || 0), rx = ax(gp.axes[2] || 0), ry = ax(gp.axes[3] || 0);
const pressedAny = gp.buttons.some(b => b && b.pressed) || lx || ly || rx || ry;
if (pressedAny && !GAME.usingPad) { GAME.usingPad = true; canvas.classList.add('nocursor'); }
const modal = topModal();
if (modal || !GAME.running) {
const now = performance.now();
const up = padPressed(gp, 12) || (ly < -0.6 && now > PAD.menuT), down = padPressed(gp, 13) || (ly > 0.6 && now > PAD.menuT);
const left = padPressed(gp, 14) || (lx < -0.6 && now > PAD.menuT), right = padPressed(gp, 15) || (lx > 0.6 && now > PAD.menuT);
if (up || down || left || right) PAD.menuT = now + 220;
const root = modal ? $(modal) : $('title');
const focusables = [...root.querySelectorAll('button:not([disabled]), select, input')].filter(e => e.offsetParent !== null);
const cur = document.activeElement;
let idx = focusables.indexOf(cur);
if (up || down) { idx = idx < 0 ? 0 : (idx + (down ? 1 : -1) + focusables.length) % focusables.length; focusables[idx] && focusables[idx].focus(); }
if ((left || right) && cur) {
if (cur.type === 'range') { const st = parseFloat(cur.step) || 1; cur.value = Math.max(+cur.min, Math.min(+cur.max, parseFloat(cur.value) + (right ? st : -st))); cur.dispatchEvent(new Event('input')); }
else if (cur.tagName === 'SELECT') { cur.selectedIndex = Math.max(0, Math.min(cur.options.length - 1, cur.selectedIndex + (right ? 1 : -1))); cur.dispatchEvent(new Event('change')); }
else if (cur.closest('.tabs')) { const tb = [...cur.parentNode.children]; const k = tb.indexOf(cur); const n = tb[(k + (right ? 1 : -1) + tb.length) % tb.length]; n.click(); setTimeout(() => { const t2 = root.querySelector('.tabs .on'); t2 && t2.focus(); }, 0); }
}
if (padPressed(gp, 0) && cur && focusables.includes(cur)) cur.click();
if (padPressed(gp, 1) && modal) closeModal(modal);
if (padPressed(gp, 9) && modal === 'm-pause') closeModal(modal);
PAD.prev = gp.buttons.map(b => b && b.pressed);
return { mx: 0, my: 0 };
}
const mag = Math.hypot(rx, ry);
const reachNow = toolId() === 'drill' ? drillReach(GAME.drillW) : REACH;
if (mag > 0.05) { GAME.padAim.x = rx / Math.max(1, mag) * reachNow * SET.aimSpeed * Math.min(1, mag); GAME.padAim.y = ry / Math.max(1, mag) * reachNow * SET.aimSpeed * Math.min(1, mag); }
if (padPressed(gp, 4)) selectTool((GAME.tool + TOOLS.length - 1) % TOOLS.length);
if (padPressed(gp, 5)) selectTool((GAME.tool + 1) % TOOLS.length);
if (padPressed(gp, 14)) cycleItem(-1);
if (padPressed(gp, 15)) cycleItem(1);
if (padPressed(gp, 12)) setBrush(1);
if (padPressed(gp, 13)) setBrush(-1);
if (padPressed(gp, 0)) doInteract();
if (padPressed(gp, 2)) rotate();
if (padPressed(gp, 3)) toggleLens();
if (padPressed(gp, 1)) { if (GAME.drag) GAME.drag = null; else if (GAME.paste) { GAME.paste = false; hudDirty = true; } }
if (padPressed(gp, 10)) bot.lamp = !bot.lamp;
if (padPressed(gp, 11)) { SET.zoom = SET.zoom >= 10 ? 2 : SET.zoom + 2; saveSettings(); resizeRender(); R.snapNext = true; }
if (padPressed(gp, 8)) openNotebook();
if (padPressed(gp, 9)) openPause();
const rt = gp.buttons[7] && (gp.buttons[7].pressed || gp.buttons[7].value > 0.4), lt = gp.buttons[6] && (gp.buttons[6].pressed || gp.buttons[6].value > 0.4);
if (GAME.usingPad) {
if (rt && !GAME.lmb) { GAME.lmb = true; primaryPress(false); } else if (!rt && GAME.lmb) { primaryRelease(false); GAME.lmb = false; }
if (lt && !GAME.rmb) { GAME.rmb = true; secondaryPress(); } else if (!lt && GAME.rmb) { GAME.rmb = false; endRmbRecord(); }
}
PAD.prev = gp.buttons.map(b => b && b.pressed);
return { mx: lx, my: ly };
}
+40
View File
@@ -0,0 +1,40 @@
// The tool hotbar and what is selected.
// ---------- HUD ----------
function renderHotbar() {
const hb = $('hotbar'); hb.innerHTML = '';
TOOLS.forEach((t, k) => {
const b = el('button', { class: 'slot' + (k === GAME.tool ? ' on' : ''), title: `${t.name} (${t.key})`, 'aria-label': `${t.name} tool, key ${t.key}`, 'aria-pressed': k === GAME.tool });
b.innerHTML = `<span class="k">${t.key}</span>${ICONS[t.id]}`;
b.onclick = () => { selectTool(k); };
hb.appendChild(b);
});
}
function toolId() { return TOOLS[GAME.tool].id; }
// Shift does two jobs. While you are steering the bot it boosts; while you are not,
// it is the tool modifier (vacuum everything, blow a mix, fill a rectangle).
function steering() { return ['w', 'a', 's', 'd', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].some(k => keys.has(k)) || GAME.padRun > 0; }
function modShift() { return keys.has('Shift') && !steering(); }
// The loose material exactly under the cursor, or the nearest one touching it.
function grainAt(ax, ay) {
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return 0;
const i = ay * W + ax, m = mat[i];
if (occ[i]) { const e = ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H); if (e) return e.m; }
if (PH[m] === PH_POWDER || (PH[m] === PH_LIQUID && player.flask)) return m;
return vacuumTargetAt(ax, ay, 1);
}
function selectTool(k) {
if (k === GAME.tool) return;
GAME.tool = k; GAME.drag = null; hudDirty = true; renderHotbar(); playSfx('ui');
if (toolId() === 'vacuum') ensureDropMat();
}
function bagMaterials() { return BAG_ORDER.filter(m => bag[m] > 0); }
// Only auto-pick a material when the player has not chosen one. A chosen
// material stays selected even after it runs out.
function mixList() { return [...GAME.collect].filter(m => bag[m] > 0 && PH[m] !== PH_ITEM); }
function ensureDropMat() {
if (GAME.blowMix && mixList().length < 1) GAME.blowMix = false;
if (bag[GAME.dropMat] > 0) return;
if (GAME.matChosen && GAME.dropMat) return;
const l = bagMaterials(); GAME.dropMat = l.length ? l[0] : 0;
}
function piece() { return PIECES[GAME.pieceIdx]; }
+167
View File
@@ -0,0 +1,167 @@
// Discovery cards, hover info (I) and the cursor label.
// ---------- Discovery cards ----------
// Short cards in the lower right when you find or make something new. They fade
// on their own (hover holds them); everything they say is also in the notebook.
function showCard(c) {
if (!SET.cardSeconds) return;
const box = $('cards');
while (box.children.length >= 3) box.firstChild.remove();
const card = el('div', { class: 'card-pop plate interactive' + (c.accent ? ' ' + c.accent : ''), role: 'status' });
const timed = SET.cardSeconds > 0;
card.innerHTML = `<div class="top">${c.swatch ? `<span class="sw" style="background:${rgbOf(c.swatch)}"></span>` : ''}<span class="label">${esc(c.kind)}</span><button class="x" aria-label="Dismiss">×</button></div>`
+ `<div class="title">${esc(c.title)}</div>${(c.body || []).filter(Boolean).map((t, k) => `<div class="${k ? 'p2' : 'p1'}">${esc(t)}</div>`).join('')}`
+ (timed ? `<i class="life" style="--dur:${(SET.cardSeconds * (c.hold || 1)).toFixed(1)}s"></i>` : '');
const close = () => { if (card.classList.contains('out')) return; card.classList.add('out'); setTimeout(() => card.remove(), 380); };
card.querySelector('.x').onclick = () => { playSfx('ui'); close(); };
if (timed) card.querySelector('.life').addEventListener('animationend', close);
box.appendChild(card);
}
function howText(e) {
const F = e.from ? MD[e.from].name : '', f = F.toLowerCase();
switch (e.how) {
case 'dig': return e.from === WOOD ? 'Cut from a tree.' : `Broken loose from ${f}.`;
case 'melt': return `${F} melted at ${fmtT(e.T)}.`;
case 'smelt': return e.m === SLAG || e.m === MSLAG ? `Left over when ${f} smelted.` : `${F} smelted at ${fmtT(e.T)}.`;
case 'freeze': return e.from === WATER ? (e.m === SNOW ? 'Water froze into flakes in the cold air.' : 'Water froze in the cold air.') : `${F} set as it cooled below ${fmtT(e.T)}.`;
case 'boil': return `${F} boiled at ${fmtT(e.T)}.`;
case 'condense': return `${F} cooled below ${fmtT(e.T)} and condensed.`;
case 'ignite': return `${F} caught fire at ${fmtT(e.T)}.`;
case 'burn': return `What ${f} leaves behind when it burns.`;
case 'fire': return `${F} fired into brick in the heat above ${fmtT(e.T)}.`;
case 'blow': return 'Air blown through molten iron burned off its extra carbon.';
case 'alloy': return `${F} melted into molten copper and the two mixed.`;
case 'char': return `${F} got hot with no air to burn in, and charred.`;
case 'refine': return `Sifted out of ${f} by a Refiner.`;
case 'cut': return `Cut from ${f} by a Stone cutter.`;
default: return '';
}
}
function discoveryCard(e) {
const m = e.m;
if (m === FIRE) { showCard({ kind: 'Discovery', title: 'Fire', body: [howText(e), 'Fuel burns where it touches air. More air, more heat.'] }); return; }
showCard({ kind: 'New material', title: MD[m].name, swatch: m, body: [howText(e), MD[m].desc] });
}
// Everything a material does when heated, cooled, wetted or lit, in short phrases. The hover
// box and the notebook's Materials page both show this list.
function reactionsOf(m, forNotebook) {
const d = MD[m], out = [];
if (!d || m === EMPTY || d.ph === PH_ITEM && !d.melt) return out;
const nm = k => MD[k].name.toLowerCase();
if (d.ign) out.push(`burns, catching at ${fmtT(d.ign)}`);
if (m === WOOD || m === LWOOD) out.push('turns to charcoal if heated with no air');
if (m === CLAY || m === LCLAY) out.push('fires into brick above 880°C');
if (m === WATER) out.push('freezes below 0°C', 'boils at 100°C');
else if (d.boil) out.push(`boils at ${fmtT(d.boil)}`);
if (d.melt) {
if (d.smelt) out.push(`smelts at ${fmtT(d.melt)} into ${nm(d.meltTo)} and slag`);
else if (d.meltTo === WATER) out.push('melts above 0°C');
else if (d.ph === PH_ITEM) out.push(`melts at ${fmtT(d.melt)}`);
else out.push(`melts at ${fmtT(d.melt)}${d.meltTo === MAGMA ? ' into magma' : d.meltTo === MGL && m !== GLASS ? ' into glass' : d.meltTo === MSLAG && m !== SLAG ? ' into slag' : ''}`);
}
if (d.freeze) out.push(`sets into ${nm(d.freezeTo)} below ${fmtT(d.freeze)}`);
if (m === STEAM) out.push('turns back to water below 97°C under a roof', 'rises into the clouds in the open');
else if (d.condT) out.push(`turns back to ${nm(d.condTo)} below ${fmtT(d.condT)}`);
if (HOTLIQ[m]) out.push(m === MAGMA ? 'boils water, and crusts into basalt in it' : 'boils water it touches');
if (ALLOY[m]) out.push(`mixes into molten copper as ${nm(FRZTO[ALLOY[m].into])}`);
if (m === MFE) out.push('turns to steel with air blown through it');
if (m === SNOW || m === ICE) out.push('chills water, and freezes it in cold air');
if (POROUS[m] && !d.ign) out.push('lets air through to a fire');
if (d.smelt && d.ph === PH_POWDER) out.push('smothers a fire under it');
if (m === ACORN) out.push('sprouts on dirt or grass under open sky');
if ((d.ph === PH_POWDER || d.ph === PH_ITEM) && m !== SNOW) out.push(DENS[m] < 1 ? 'floats on water' : 'sinks in water');
if (m === SNOW) out.push('floats on water');
if (forNotebook && d.ph === PH_SOLID && d.yld && d.yld !== m && MD[d.yld]) out.push(`drills into ${nm(d.yld)}`);
return out;
}
// What is under the cursor: t1 is the name, t2 the details, warn a short problem
// (too hard, out of reach) for the label next to the cursor.
function hoverInfo(ax, ay) {
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true };
const i = ay * W + ax, m = mat[i];
let warn = '';
let t1 = MD[m].name, t2 = [];
const p = part[i], id = mach[i];
const b = blockP[((ay / BS) | 0) * BW + ((ax / BS) | 0)];
const bar = occ[i] ? ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H) : null;
if (bar) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); }
else if (id && machines[id]) {
const mm = machines[id], P = PIECE_BY_ID[mm.type]; t1 = P.name;
if (mm.type === 'dock') { t2.push('sells what drops through its grate', `${orders.length} open ${orders.length === 1 ? 'order' : 'orders'}`); }
if (mm.type === 'wheel' || mm.type === 'engine') t2.push(`making ${mm.out.toFixed(1)} power`);
if (mm.type === 'fan') t2.push(mm.on ? 'on' : 'off · press F');
if (mm.type === 'silo') t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
if (mm.type === 'cutter') t2.push(mm.blocked ? 'stopped: clear the space under it' : tick - (mm.busyT || -999) < 30 ? 'cutting' : 'idle: drop stone rubble in', `${(stats.bricks || 0).toLocaleString('en-US')} bricks cut`);
if (mm.type === 'refiner') t2.push(mm.blocked ? 'stopped: clear the space under its chute' : tick - (mm.busyT || -999) < 30 ? 'grinding' : 'idle: drop rubble, gravel, sand or dirt in', `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`);
} else if (p === P_FILTER) {
const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←';
t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all');
} else if (p === P_PIPE) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb);
t1 = 'Pipe' + (m !== EMPTY ? ` · ${MD[m].name} flowing past` : '');
t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`);
} else if (p) t1 = ({ 1: 'Screw tube ↑', 2: 'Screw tube ↓', 3: 'Screw tube ←', 4: 'Screw tube →', 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→');
else if (m === PIPE && isPipeCode(b)) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←'];
t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe';
t2.push(pn && pn.n ? `line holds ${pn.n.toLocaleString('en-US')} / ${pn.cap.toLocaleString('en-US')} ${MD[pn.m].name.toLowerCase()}` : `line empty (room for ${pn ? pn.cap : PIPE_CAP})`);
if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off');
if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it');
}
else if (b === BP_WALL && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} block`;
const n = nets[netId[i]];
if (n && isPow[i] && !POWER_FREE) t2.push(`network ${n.supply.toFixed(1)} supply / ${n.demand.toFixed(1)} demand`);
if (player.lens && !bar) t2.push(fmtT(temp[i]));
if (!bar && m !== EMPTY && PH[m] === PH_SOLID && !PARTMAT[m] && (flags[i] & F_NAT)) {
const ok = HARD[m] <= BIT_TIERS[player.bitTier].hard;
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`);
if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard';
}
if (!bar && PH[m] === PH_POWDER) t2.push(GAME.collect.has(m) ? 'loose · ticked' : 'loose');
if (!bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
if (flags[i] & F_BURN) t2.push('burning');
const mo = moldAt(ax, ay);
if (mo) {
t1 = MOLDS[mo.kind].name;
t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set · press F` : `${MD[mo.metal].name} casting set · press F`) : mo.state === 'flawed' || mo.state === 'short' ? mo.flaw : mo.state === 'filling' ? `filling ${Math.round(mo.fill * 100)}%` : 'empty'];
if (player.lens) t2.push(fmtT(temp[i]));
}
const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : inReach(ax, ay);
if (!reachOk) { t2.push('out of reach'); warn = 'out of reach'; }
// what it does, unless the cursor is on a machine or mold, which have their own details
const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0;
const t3 = what ? reactionsOf(what) : [];
return { t1, t2, t3, warn, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b };
}
function renderInfo() {
const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
const box = $('info');
const h = `${r.t1 ? `<span class="t1">${esc(r.t1)}</span>` : ''}${r.t2.length ? `<span class="t2">${esc(r.t2.join(' · '))}</span>` : ''}${r.t3 && r.t3.length ? `<span class="t3">${esc(r.t3.join(' · '))}</span>` : ''}`;
if (box.innerHTML !== h) box.innerHTML = h;
}
// Name of what is under the cursor, drawn right next to it
function drawCursorLabel(px, py, r) {
if (!SET.cursorLabel || (!GAME.mouse.in && !GAME.usingPad)) return;
const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!info.t1 || info.air) return;
const ctx = R.ctx, dpr = R.canvas.width / window.innerWidth;
const fs = Math.round(12 * dpr * SET.uiScale);
if (!R.uiFont) R.uiFont = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif';
const main = info.t1 + (player.lens && R.lens ? '' : '');
const sub = info.warn || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : '');
ctx.save();
ctx.font = `600 ${fs}px ${R.uiFont}`;
const w1 = ctx.measureText(main).width;
ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`;
const w2 = sub ? ctx.measureText(sub).width + fs * 0.6 : 0;
const padX = fs * 0.5, h = fs * 1.6;
let x = px + (r + 1.6) * R.zp + 6 * dpr, y = py + (r + 1.2) * R.zp + 4 * dpr;
if (x + w1 + w2 + padX * 2 > R.canvas.width) x = px - (r + 1.6) * R.zp - 6 * dpr - (w1 + w2 + padX * 2);
if (y + h > R.canvas.height) y = py - (r + 1.2) * R.zp - h;
ctx.fillStyle = 'rgba(20,17,15,0.82)';
ctx.beginPath(); ctx.roundRect(x, y, w1 + w2 + padX * 2, h, 4 * dpr); ctx.fill();
ctx.textBaseline = 'middle';
ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.fillText(main, x + padX, y + h / 2);
if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); }
ctx.restore();
}
+76
View File
@@ -0,0 +1,76 @@
// Keyboard and mouse listeners.
// ---------- Input ----------
const canvas = $('game');
function isTyping(e) { const t = e.target; return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'); }
window.addEventListener('keydown', e => {
if (isTyping(e)) { if (e.key === 'Escape') e.target.blur(); return; }
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (anyModal()) {
if (k === 'Escape') { e.preventDefault(); const t = topModal(); closeModal(t); }
else if (k === 'n' && !$('m-notebook').hidden) closeModal('m-notebook');
return;
}
if (!GAME.running) return;
if (e.key === 'Control') { GAME.ctrl = true; return; }
if (e.ctrlKey || e.metaKey) {
if (k === 's') { e.preventDefault(); saveWorld('main'); }
else if (k === 'z' && !e.shiftKey) { e.preventDefault(); doUndo(); }
return;
}
keys.add(k);
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Tab'].includes(k)) e.preventDefault();
if (e.repeat) return;
GAME.usingPad = false;
switch (k) {
case '1': case '2': case '3': case '4': selectTool(+k - 1); break;
case 'c': case 'x': hudDirty = true; break;
case 'v': startPaste(); break;
case 'g': pickUnderCursor(); break;
case 'q': cycleItem(-1); break;
case 'e': cycleItem(1); break;
case 'r': rotate(); break;
case 't': if (toolId() === 'vacuum') toggleCollectUnderCursor(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
case 'f': doInteract(); break;
case 'h': toggleLens(); break;
case 'i': GAME.infoOverlay = !GAME.infoOverlay; playSfx('ui'); toast(GAME.infoOverlay ? 'Info overlay on: what every filter does, and which way belts and tubes run. I hides it.' : 'Info overlay off.'); break;
case 'l': bot.lamp = !bot.lamp; playSfx('ui'); break;
case 'n': openNotebook(); break;
case 'b': openNotebook('Blueprints'); break;
case 'm': SET.muted = !SET.muted; saveSettings(); auVolumes(); toast(SET.muted ? 'Sound muted.' : 'Sound on.'); break;
case '[': setBrush(-1); break;
case ']': setBrush(1); break;
case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break;
case 'Escape': if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); break;
case 'Tab': $('bagpanel').hidden = !$('bagpanel').hidden; break;
}
});
window.addEventListener('keyup', e => { const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; keys.delete(k); if (e.key === 'Control') GAME.ctrl = false; if (k === 'c' || k === 'x') hudDirty = true; });
window.addEventListener('blur', () => { keys.clear(); GAME.lmb = GAME.rmb = false; GAME.ctrl = false; GAME.selDrag = null; endRmbRecord(); });
canvas.addEventListener('mousemove', e => { GAME.ctrl = e.ctrlKey; GAME.mouse.x = e.clientX; GAME.mouse.y = e.clientY; GAME.mouse.in = true; if (GAME.usingPad) { GAME.usingPad = false; canvas.classList.remove('nocursor'); } });
canvas.addEventListener('mouseleave', () => { GAME.mouse.in = false; });
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousedown', e => {
if (!GAME.running || GAME.paused) return;
auInit();
GAME.usingPad = false;
if (e.button === 0) { GAME.lmb = true; primaryPress(e.shiftKey && !steering()); }
else if (e.button === 2) { GAME.rmb = true; secondaryPress(); }
else if (e.button === 1) { e.preventDefault(); pickUnderCursor(); }
});
window.addEventListener('mouseup', e => {
if (e.button === 0) { GAME.ctrl = e.ctrlKey; if (GAME.lmb) primaryRelease(modShift()); GAME.lmb = false; }
else if (e.button === 2) { GAME.rmb = false; endRmbRecord(); }
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
if (!GAME.running || GAME.paused) return;
const d = e.deltaY > 0 ? -1 : 1;
if (e.shiftKey || e.ctrlKey) setBrush(d);
else { SET.zoom = Math.max(2, Math.min(12, SET.zoom + d)); saveSettings(); resizeRender(); R.snapNext = true; }
}, { passive: false });
window.addEventListener('resize', () => { if (R.canvas) resizeRender(); });
document.addEventListener('visibilitychange', () => {
if (!AU.ctx) return;
if (document.hidden) { if (SET.muteUnfocused) AU.ctx.suspend(); if (GAME.running) saveWorld('auto', true); }
else AU.ctx.resume();
});
+117
View File
@@ -0,0 +1,117 @@
// The frame loop and boot.
// ---------- Main loop ----------
let lastT = performance.now(), acc = 0, lastRender = 0, frameMs = 0, simMs = 0, fpsSm = 60;
function scanVisible() {
let fire = 0, power = 0;
const x0 = Math.max(0, R.vx0), y0 = Math.max(0, R.vy0), x1 = Math.min(W, R.vx0 + R.bw), y1 = Math.min(H, R.vy0 + R.bh);
for (let y = y0; y < y1; y += 2) for (let x = x0; x < x1; x += 2) { const i = y * W + x; if (mat[i] === FIRE || (flags[i] & F_BURN)) fire++; }
for (const mm of machines) if (mm && mm.x + mm.w > x0 && mm.x < x1 && mm.y + mm.h > y0 && mm.y < y1) power += mm.type === 'fan' ? (nets[mm.net] ? nets[mm.net].sat * 6 : 0) : mm.out;
for (let n = 1; n < nets.length; n++) power += nets[n].demand * nets[n].sat * 0.2;
const cx = Math.max(0, Math.min(W - 1, Math.floor(bot.x)));
return { fire: fire * 4, power, surface: bot.y < surfY[cx] + 4, rain: R.rain || 0 };
}
let ambientT = 0;
function frame(now) {
requestAnimationFrame(frame);
const dtReal = Math.min(0.25, (now - lastT) / 1000); lastT = now;
if (SET.fpsCap && now - lastRender < 1000 / SET.fpsCap - 1) return;
const dt = Math.min(0.1, (now - (lastRender || now)) / 1000) || 1 / 60; lastRender = now;
if (!GAME.running) { pollPad(); return; }
const t0 = performance.now();
const pad = pollPad();
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
if (!GAME.paused) {
acc += dtReal * SET.simSpeed;
let steps = 0;
const step = 1 / 60;
while (acc >= step && steps < SET.maxSteps) {
let ix = 0, iy = 0;
if (keys.has('a') || keys.has('ArrowLeft')) ix -= 1; if (keys.has('d') || keys.has('ArrowRight')) ix += 1;
if (keys.has('w') || keys.has('ArrowUp') || keys.has(' ')) iy -= 1; if (keys.has('s') || keys.has('ArrowDown')) iy += 1;
ix += pad.mx; iy += pad.my;
const mag = Math.hypot(ix, iy); if (mag > 1) { ix /= mag; iy /= mag; }
// Shift boosts flight with every tool; as a tool modifier it only matters when you click
const pm = Math.hypot(pad.mx, pad.my);
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
toolTick();
const s0 = performance.now(); simTick(); simMs = simMs * 0.9 + (performance.now() - s0) * 0.1;
acc -= step; steps++;
GAME.playTime += step;
}
if (steps >= SET.maxSteps) acc = 0;
if (SET.autosave && performance.now() - GAME.lastAutosave > SET.autosave * 60000) { GAME.lastAutosave = performance.now(); saveWorld('auto', true); }
} else acc = 0;
drainEvents();
let lookX = 0, lookY = 0;
if (GAME.usingPad) { lookX = GAME.padAim.x * 0.5; lookY = GAME.padAim.y * 0.5; }
else if (GAME.mouse.in && toolId() !== 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld()) { const dpr = R.canvas.width / window.innerWidth; lookX = (GAME.mouse.x - window.innerWidth / 2) * dpr / R.zp; lookY = (GAME.mouse.y - window.innerHeight / 2) * dpr / R.zp; }
// hold the camera's look-ahead still while dragging out a line, box or blueprint
// While dragging, the camera stays put unless the bot itself is flying, so the
// grid under the cursor does not slide around mid-line.
if (GAME.drag) { lookX = GAME.lookHold.x; lookY = GAME.lookHold.y; } else { GAME.lookHold.x = lookX; GAME.lookHold.y = lookY; }
if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY);
R.frame++;
drawWorld();
drawCracks();
if (!weather.seen && (R.rain || 0) > 0.12) {
weather.seen = true;
showCard({ kind: 'Weather', title: 'Rain', swatch: WATER, body: ['Steam that rises into the open sky gathers into clouds and comes back down as rain nearby. On freezing heights it falls as snow.', 'Water is never used up, only moved around. If some leaves the world (dropped into the dock, say), a shower from beyond the claim makes it up.'] });
}
if (SET.look === 'smooth' && SM.tried && !SM.ok) {
SET.look = 'pixel'; saveSettings();
toast(SM.why === 'slow' ? 'The smooth look was too slow on this computer, so the pixel look is on. You can try it again in Settings.' : SM.why === 'software' ? 'This browser is drawing without the graphics card, so the smooth look would crawl. The pixel look is on.' : 'This browser cannot draw the smooth look, so the pixel look is on.');
}
drawMachines();
drawMolds(moldAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)));
drawParticles();
const hb = GAME.mouse.in || GAME.usingPad ? filterBlockAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)) : -1;
if (GAME.editRun) drawRunOutline(GAME.editRun, true); else if (hb >= 0) drawRunOutline(filterRun(hb), false);
if (GAME.infoOverlay) drawFlowArrows();
drawFilterTags(!!GAME.infoOverlay, GAME.editRun ? GAME.editRun[0] : hb);
drawFloaters();
drawRain();
drawBot();
if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots
if (GAME.ctrl !== GAME.ctrlShown) { GAME.ctrlShown = GAME.ctrl; hudDirty = true; }
renderBuildBar();
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
if (R.frame % 4 === 0) renderInfo();
if (document.body.classList.contains('paused') !== GAME.paused) document.body.classList.toggle('paused', GAME.paused);
updateOverlayUI();
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1;
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
if (SET.showPerf && R.frame % 15 === 0) {
let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; }
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
}
}
// ---------- Boot ----------
function boot(hotData) {
document.documentElement.style.setProperty('--ui', SET.uiScale);
initRender(canvas);
setupBuildBar();
buildTitleCells(); drawTitle();
$('nw-seed').value = String(Math.floor(Math.random() * 900000) + 100000);
$('wallet').onclick = () => { playSfx('ui'); openNotebook('Market'); };
$('btn-new').onclick = () => { auInit(); playSfx('ui'); $('newworld').hidden = !$('newworld').hidden; if (!$('newworld').hidden) $('btn-generate').focus(); };
$('btn-generate').onclick = () => {
const seed = parseInt(String($('nw-seed').value).replace(/\D/g, ''), 10) || Math.floor(Math.random() * 1e6);
const [w, h] = $('nw-size').value.split('x').map(Number);
startNewWorld(seed, w, h);
};
$('btn-continue').onclick = async () => { auInit(); playSfx('ui'); const sv = await newestSave(); if (sv) startFromSave(sv); else toast('No saved world found.'); };
$('btn-title-settings').onclick = () => { auInit(); playSfx('ui'); openSettings('Display'); };
$('btn-title-help').onclick = () => { auInit(); playSfx('ui'); openNotebook('Controls'); };
const touchOnly = matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;
$('touchnote').hidden = !touchOnly;
refreshContinue().then(async () => {
if (hotData && hotData.resume) { const sv = await newestSave(); if (sv) startFromSave(sv); }
});
requestAnimationFrame(frame);
}
try { if (window.claude && window.claude.hot && window.claude.hot.snapshot) window.claude.hot.snapshot(() => { if (GAME.running) saveWorld('auto', true); return { resume: GAME.running }; }); } catch (e) { /* not in a viewer */ }
if (window.claude && window.claude.hot && window.claude.hot.ready) window.claude.hot.ready(boot); else boot((window.claude && window.claude.hot && window.claude.hot.data) || {});
+201
View File
@@ -0,0 +1,201 @@
// The notebook (N): milestones, market, wallet, blueprints, controls.
// ---------- Notebook ----------
let nbTab = 'Ledger';
function openNotebook(tab) { if (tab) nbTab = tab; renderNotebook(); openModal('m-notebook'); }
function renderNotebook() {
const tabs = $('nb-tabs'); tabs.innerHTML = '';
for (const name of ['Ledger', 'Market', 'Materials', 'Pieces', 'Blueprints', 'Controls']) {
const b = el('button', { role: 'tab', class: name === nbTab ? 'on' : '', 'aria-selected': name === nbTab }, name);
b.onclick = () => { playSfx('ui'); nbTab = name; renderNotebook(); };
tabs.appendChild(b);
}
const body = $('nb-body');
if (nbTab === 'Ledger') {
const done = MILESTONES.filter(m => ledger[m.id]).length;
let h = `<p class="note">${done} of ${MILESTONES.length} reached. There is no set order. Tips stay hidden until you ask for one. Upgrades come from what you make: your bit is as hard as the metal you cast it from.</p><div class="ledger">`;
for (const m of MILESTONES) {
const isDone = !!ledger[m.id], tip = isDone || GAME.tipsOpen.has(m.id);
h += `<div class="ms ${isDone ? 'done' : 'open'}"><span class="mark" aria-hidden="true"></span><span class="h">${esc(m.title)}</span>`;
h += tip ? `<span class="p">${esc(m.hint)}</span>` : `<span class="p"><button class="linkbtn" data-tip="${m.id}">Show tip</button></span>`;
if (m.reward) h += `<span class="rw">${isDone ? '✓ ' : ''}${esc(m.reward)}</span>`;
h += '</div>';
}
h += `</div><p class="note grouphead">Tools: <b>${esc(BIT_TIERS[player.bitTier].name)}</b> (digs hardness ${BIT_TIERS[player.bitTier].hard} and below) · <b>${esc(TANK_TIERS[player.tankTier].name)}</b> (${bagCap().toLocaleString('en-US')} grains)${player.lens ? ' · <b>Thermal lens</b>' : ''}${player.flask ? ' · <b>Flask</b>' : ''}</p>`;
body.innerHTML = h;
body.querySelectorAll('[data-tip]').forEach(b => b.onclick = () => { playSfx('ui'); GAME.tipsOpen.add(b.dataset.tip); renderNotebook(); });
} else if (nbTab === 'Market') {
renderMarketTab(body);
} else if (nbTab === 'Materials') {
let rows = '', unknown = 0;
const list = [DIRT, LDIRT, STONE, RSTONE, CLAY, LCLAY, WOOD, LWOOD, ACORN, CHARCOAL, SNOW, ICE, COALV, COAL, CUV, CUORE, FEV, FEORE, SAND, GRAVEL, GRANITE, RGRAN, BASALT, RBAS,
BRICK, RBRICK, GLASS, CULLET, COPPER, SCRCU, BARCU, IRON, SCRFE, BARFE, STEEL, SCRST, BARST, ASH, SLAG, MSLAG, WATER, STEAM, MAGMA, MCU, MFE, MST, MGL, FIRE, SMOKE, SNV, SNORE, TIN, MSN, SCRSN, BARSN, ZNV, ZNORE, ZINC, MZN, SCRZN, BARZN, AGV, AGORE, SILVER, MAG, SCRAG, BARAG, AUV, AUORE, GOLD, MAU, SCRAU, BARAU, BRONZE, MBZ, SCRBZ, BARBZ, BRASS, MBS, SCRBS, BARBS];
for (const m of list) {
if (!seen[m]) { unknown++; continue; }
const d = MD[m];
const ph = ['', 'Solid', 'Loose', 'Liquid', 'Gas', 'Object'][d.ph];
const change = reactionsOf(m, true).map((t, k) => k ? t : t[0].toUpperCase() + t.slice(1)).join(' · ');
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(d.name)}</td><td>${ph}</td><td class="num">${d.hard && d.ph === PH_SOLID ? d.hard : '–'}</td><td>${esc(change || '–')}</td><td class="p" style="color:var(--ash)">${esc(d.desc || '')}</td></tr>`;
}
body.innerHTML = `<p class="note">What you have found so far. Hardness is what your drill bit has to match. Solid rock breaks into loose rubble that you vacuum up and build with.${unknown ? ` ${unknown} more to discover.` : ''}</p><div class="tablewrap"><table class="mt"><thead><tr><th>Material</th><th>State</th><th>Hard</th><th>What it does</th><th>Notes</th></tr></thead><tbody>${rows}</tbody></table></div>`;
} else if (nbTab === 'Pieces') {
let h = '<p class="note">Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Your bot flies straight through anything you build.</p><div class="cards">';
for (const P of PIECES) {
if (P.hidden) continue;
const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R picks it)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1);
const afford = P.kind === 'mold' || P.code === BP_WALL ? true : canAfford(P.cost, 1);
const size = P.kind === 'machine' ? `${P.bw}×${P.bh}` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh}` : '1×1';
h += `<div class="card"><div class="h"><span>${esc(P.name)}</span><span class="chip ${afford ? 'ok' : ''}">${P.power ? esc(P.power + ' power') : P.id === 'wheel' || P.id === 'engine' ? 'makes power' : size + (size === '1×1' ? ' block' : ' blocks')}</span></div><div class="p">${esc(P.desc)}</div><div class="p">Cost: ${esc(cost)}</div></div>`;
}
body.innerHTML = h + '</div>';
} else if (nbTab === 'Blueprints') {
renderBlueprintTab(body);
} else {
body.innerHTML = controlsHtml();
}
}
function fmtClock(ticks) { const s = Math.max(0, Math.ceil(ticks / 60)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }
function orderName(o) { return `${o.who ? o.who + ': ' : ''}${o.qty.toLocaleString('en-US')} ${MD[o.m].name.toLowerCase()}${PH[o.m] === PH_ITEM && o.qty !== 1 ? 's' : ''}`; }
function orderLeft(o) { return Math.max(0, o.due - tick); }
const MARKET_GROUPS = [
['Raw', [COAL, LWOOD, ACORN, SAND, LCLAY, RGRAN, RBAS, CUORE, FEORE, SNORE, ZNORE]],
['Bulk (builders)', [RSTONE, GRAVEL, BARSB]],
['Refined', [CHARCOAL, RBRICK, CULLET, BARGL, SCRCU, SCRFE, SCRST, SCRSN, SCRZN, BARCU, BARSN, BARZN, BARFE, BARST]],
['Alloys', [SCRBZ, SCRBS, BARBZ, BARBS]],
['Precious', [AGORE, AUORE, SCRAG, SCRAU, BARAG, BARAU]],
];
function renderMarketTab(body) {
let h = `<div class="mkt-sum"><div><div class="k">Money</div><div class="v">${fmtMoney(money)}</div></div>`
+ `<div><div class="k">${debt > 0 ? 'Still owed on the claim' : 'The claim'}</div><div class="v" style="color:${debt > 0 ? 'var(--melt)' : 'var(--ok)'}">${debt > 0 ? fmtMoney(debt) : 'Paid off'}</div></div>`
+ `<div><div class="k">Earned in total</div><div class="v">${fmtMoney(earned)}</div></div></div>`;
if (debt > 0) h += `<div class="mkt-pay"><span class="note" style="margin:0 6px 0 0">Pay toward the claim:</span>${[1000, 5000].map(a => `<button class="btn" data-pay="${a}" ${money >= Math.min(a, debt) ? '' : 'disabled'}>${fmtMoney(Math.min(a, debt))}</button>`).join('')}<button class="btn primary" data-pay="all" ${money > 0 ? '' : 'disabled'}>All I can (${fmtMoney(Math.min(money, debt))})</button></div>`;
else h += `<p class="note">The land is yours. Keep building: the ledger has bigger goals.</p>`;
h += `<p class="note">Anything that drops through the trade dock's grate is sold on the spot, once it is cool enough to handle. Junk like dirt, rubble, ash and slag goes for nothing. Build walls around the dock to funnel more in.</p>`;
h += `<p class="label grouphead">Orders · ${esc(STANDING[standing()])}</p>`;
if (orders.length) {
h += '<div class="orderlist">' + orders.map(o => `<div class="o"><span class="t"><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(o.m)}"></span>${esc(orderName(o))}</span><span class="pay">${fmtMoney(o.pay)}</span>`
+ `<span class="d">${o.got} of ${o.qty} delivered · ${o.mult}× market · ${fmtClock(orderLeft(o))} left</span><span></span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></div>`).join('') + '</div>';
} else h += `<p class="note">No open orders. Buyers post new ones every minute or two, for goods you have found.</p>`;
h += `<p class="note">Goods dropped into the dock fill a matching order first. A missed deadline costs nothing: what you delivered is paid at market price. ${ordersDone} filled so far; bigger buyers come with more.</p>`;
h += `<p class="label grouphead">Prices</p><p class="note">Per grain, or per bar. Selling a lot of one thing lowers its price for a while; it recovers over about half an hour.</p>`;
let rows = '', unknown = 0;
for (const [g, list] of MARKET_GROUPS) {
const known = list.filter(m => seen[m]);
unknown += list.length - known.length;
if (!known.length) continue;
rows += `<tr><td colspan="3" class="label" style="padding-top:8px">${esc(g)}</td></tr>`;
for (const m of known) {
const f = priceFactor(m), now = priceNow(m);
rows += `<tr><td><span class="sw" style="display:inline-block;vertical-align:-2px;margin-right:6px;background:${rgbOf(m)}"></span>${esc(MD[m].name)}</td><td class="num">${fmtPrice(PRICE[m])}</td><td class="num ${f < 0.97 ? 'dip' : ''}">${fmtPrice(now)}${f < 0.97 ? ` (${Math.round(f * 100)}%)` : ''}</td></tr>`;
}
}
h += rows ? `<div class="tablewrap"><table class="mt"><thead><tr><th>Goods</th><th>Base</th><th>Now</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="note">Find something worth selling first.</p>';
if (unknown) h += `<p class="note">${unknown} more goods to discover.</p>`;
h += `<p class="label grouphead">Sales, minute by minute</p>`;
const recent = saleAcc.total > 0 ? [{ total: saleAcc.total, items: saleAcc.items, now: true }].concat(shipLog) : shipLog;
h += recent.length ? `<div class="shiplog">${recent.map(sl => `<div><span class="what">${sl.now ? 'This minute: ' : ''}${esc(itemsText(sl.items))}</span><b class="num">+${fmtMoney(sl.total)}</b></div>`).join('')}</div>` : '<p class="note">Nothing sold yet.</p>';
body.innerHTML = h;
body.querySelectorAll('[data-pay]').forEach(b => b.onclick = () => {
const v = b.dataset.pay === 'all' ? money : +b.dataset.pay;
const paid = payClaim(v);
if (paid > 0) { playSfx('sale'); toast(`Paid ${fmtMoney(paid)} toward the claim.`); }
renderNotebook(); hudDirty = true;
});
}
function fmtPrice(v) { return v >= 10 ? '$' + Math.round(v) : '$' + v.toFixed(2); }
function itemsText(items) {
const e = Object.entries(items).map(([m, n]) => [+m, n]).sort((a, b) => b[1] * PRICE[b[0]] - a[1] * PRICE[a[0]]);
const parts = e.slice(0, 3).map(([m, n]) => PH[m] === PH_ITEM ? `${n} ${MD[m].name.toLowerCase()}${n === 1 ? '' : 's'}` : `${n} ${MD[m].name.toLowerCase()}`);
if (e.length > 3) parts.push(`${e.length - 3} more`);
return parts.join(', ');
}
function renderWallet() {
$('w-money').textContent = fmtMoney(money);
const c = $('w-claim');
c.className = 'line' + (debt <= 0 ? ' paid' : '');
c.innerHTML = debt > 0 ? `<span>Claim owed</span><b>${fmtMoney(debt)}</b>` : `<span>Claim</span><b>paid off ✓</b>`;
const h = orders.length ? orders.map(o => {
const left = orderLeft(o);
return `<span class="ord"><span class="sw" style="background:${rgbOf(o.m)}"></span><span class="nm">${o.got}/${esc(orderName(o))}</span><span class="tm${left < 3600 ? ' late' : ''}">${fmtClock(left)}</span><span class="pb"><i style="width:${(o.got / o.qty * 100).toFixed(1)}%"></i></span></span>`;
}).join('') : '<span class="none">No orders yet</span>';
const box = $('w-orders');
if (box.innerHTML !== h) box.innerHTML = h;
}
function renderBlueprintTab(body) {
const lib = libLoad();
let h = `<p class="note">Select the Blueprint tool (5), drag over something you built to copy it, then click to place a ghost. Your bot builds ghosts from the materials in your bag when it is within reach. Right-click clears ghosts.</p>`;
h += `<p class="label grouphead">Starter designs</p><p class="note">Optional. Tested builds to learn from or copy; place one as a ghost and your bot builds it from your bag. Clear the space first: ghosts wait for open air.</p><div class="cards">`;
STARTER_DESIGNS.forEach((d, k) => {
const bp = d.bp();
h += `<div class="card"><div class="h"><span>${esc(d.name)}</span><span class="chip">${bp.bw}×${bp.bh} blocks</span></div><div class="p">${esc(d.desc)}</div><div class="p">Build cost: ${esc(costText(bpCost(bp), 1))}</div><div style="display:flex;gap:6px;flex-wrap:wrap"><button class="btn" data-starter="${k}">Place it</button></div></div>`;
});
h += '</div><p class="label grouphead">Your blueprints</p>';
h += `<div class="set"><div><div>Clipboard</div><div class="d">${GAME.clip ? `${GAME.clip.bw} × ${GAME.clip.bh} blocks, ${GAME.clip.blocks.length} pieces${GAME.clip.machines.length ? `, ${GAME.clip.machines.length} machines` : ''}${GAME.clip.molds.length ? `, ${GAME.clip.molds.length} molds` : ''}. Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}` : 'Empty'}</div></div><div class="ctl"><button class="btn" id="bp-save" ${GAME.clip ? '' : 'disabled'}>Save to library</button><button class="btn" id="bp-code" ${GAME.clip ? '' : 'disabled'}>Copy code</button></div></div>`;
h += `<div class="set"><div><div>Import a blueprint code</div><div class="d">Paste a code that starts with DGBP2.</div></div><div class="ctl"><button class="btn" id="bp-import">Paste code…</button></div></div>`;
h += `<p class="label grouphead">Library</p>`;
if (!lib.length) h += `<p class="note">No saved blueprints yet.</p>`;
else {
h += '<div class="cards">';
lib.forEach((it, k) => {
const ok = bpValid(it.bp);
h += `<div class="card"><div class="h"><span>${esc(it.name)}</span><span class="chip">${ok ? `${it.bp.bw}×${it.bp.bh}` : 'old format'}</span></div><div class="p">${ok ? esc(costText(bpCost(it.bp), 1) || 'free') : 'Made before the block grid. It cannot be placed.'}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${ok ? `<button class="btn" data-bpload="${k}">Use</button><button class="btn ghost" data-bpcode="${k}">Copy code</button>` : ''}<button class="btn ghost danger" data-bpdel="${k}">Delete</button></div></div>`;
});
h += '</div>';
}
body.innerHTML = h;
const sv = $('bp-save'); if (sv) sv.onclick = () => {
dialog('Name this blueprint', '<input class="text" id="bp-name" maxlength="40" placeholder="Copper smelter">', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Save', cls: 'primary', fn: () => { const n = ($('bp-name').value || 'Untitled').trim(); const l = libLoad(); l.push({ name: n, bp: GAME.clip }); if (libSave(l)) toast(`Saved “${n}”.`); renderNotebook(); } },
]);
setTimeout(() => $('bp-name') && $('bp-name').focus(), 30);
};
const cc = $('bp-code'); if (cc) cc.onclick = () => copyCode(bpEncode(GAME.clip));
$('bp-import').onclick = () => {
dialog('Import blueprint', '<textarea class="code" id="bp-paste" placeholder="DGBP1:…"></textarea>', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Import', cls: 'primary', fn: () => { try { GAME.clip = bpDecode($('bp-paste').value); GAME.paste = true; hudDirty = true; toast('Blueprint ready. Click to place it.'); closeAllModals(); } catch (e) { toast(e.message); return true; } } },
]);
};
body.querySelectorAll('[data-bpload]').forEach(b => b.onclick = () => { GAME.clip = libLoad()[+b.dataset.bpload].bp; GAME.paste = true; hudDirty = true; closeAllModals(); toast('Blueprint ready. Click to place it.'); });
body.querySelectorAll('[data-starter]').forEach(b => b.onclick = () => { GAME.clip = STARTER_DESIGNS[+b.dataset.starter].bp(); GAME.paste = true; hudDirty = true; closeAllModals(); toast('Click to place it. R flips it.'); });
body.querySelectorAll('[data-bpcode]').forEach(b => b.onclick = () => copyCode(bpEncode(libLoad()[+b.dataset.bpcode].bp)));
body.querySelectorAll('[data-bpdel]').forEach(b => b.onclick = () => { const l = libLoad(); l.splice(+b.dataset.bpdel, 1); libSave(l); renderNotebook(); });
}
function copyCode(code) {
const done = () => toast('Blueprint code copied.');
const fallback = () => { dialog('Blueprint code', `<p class="note">Select the text and copy it.</p><textarea class="code" id="bp-out" readonly>${esc(code)}</textarea>`, [{ label: 'Done', cls: 'primary' }]); setTimeout(() => { const t = $('bp-out'); if (t) { t.focus(); t.select(); } }, 30); };
try { navigator.clipboard.writeText(code).then(done, fallback); } catch (e) { fallback(); }
}
function controlsHtml() {
const kb = [
['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'],
['Tools', '1 – 4'], ['Cycle what you blow · piece', 'Q / E'], ['Variant: material · direction · flip', 'R'],
['Tick/untick what is under the cursor (Vacuum)', 'R or T'], ['Nozzle or brush size', '[ ] or Shift + wheel'],
['Replace what is built (Build)', 'Hold Ctrl'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and tube directions', 'I'], ['Bot lamp', 'L'],
['Pick the piece under the cursor (with its direction and settings)', 'G or middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'M'],
];
const pad = [
['Fly', 'Left stick'], ['Boost', 'Left stick pushed all the way'], ['Aim', 'Right stick'], ['Use tool · suck', 'RT'], ['Second action · blow', 'LT'], ['Tools', 'LB / RB'],
['Cycle what you blow · piece', 'D-pad ← →'], ['Brush size', 'D-pad ↑ ↓'], ['Interact', 'A'], ['Variant · tick material', 'X'], ['Thermal lens', 'Y'],
['Cancel', 'B'], ['Zoom', 'R3'], ['Lamp', 'L3'], ['Notebook', 'View / Back'], ['Menu', 'Menu / Start'],
];
const tools = [
['1 Resonance drill', 'Hold left click to send pulses from the bot toward the cursor: rock they shake hard enough shatters and falls as rubble, ore, coal or scrap, and trees come apart into wood chunks and acorns. Q and E set the pulse width: narrow bores a tunnel fast, wide clears a big cone slowly, medium is in between. R over a material tunes the drill to it, so only that shatters (great for pulling a vein out of the rock); R again untunes. Built blocks are never harmed. Better bits dig harder rock, faster and further.'],
['2 Vacuum', 'Left click sucks up what you start on; if anything is ticked in your bag (R over it, or its tick box), it takes only the ticked materials instead. Hold Shift while not flying to take everything. Right click blows the chosen material out of the bot toward the cursor; Q and E choose it, and it stays chosen. Q and E (or clicking in your bag) can also pick Ticked mix, an even mix of everything ticked, which is how you charge a furnace. The swatches next to the cursor always show what right click will blow. R ticks or unticks what is under the cursor.'],
['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps and outlets, machines (fans, the Refiner) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. Right click takes a piece back. Your bot flies through anything you build.'],
['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'],
['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'],
];
const tbl = rows => '<div class="keys">' + rows.map(([a, b]) => `<div><span>${esc(a)}</span><span>${esc(b)}</span></div>`).join('') + '</div>';
return `<p class="label grouphead">Tools</p><div class="cards">${tools.map(([a, b]) => `<div class="card"><div class="h">${esc(a)}</div><div class="p">${esc(b)}</div></div>`).join('')}</div>
<p class="label grouphead">Keyboard and mouse</p>${tbl(kb)}<p class="label grouphead">Gamepad</p>${tbl(pad)}
<p class="label grouphead">First steps</p>
<p class="note">1. Drill down a little. Stone falls as rubble; coal (dark seams), copper ore (green speckles) and clay (reddish) come loose too.<br>
2. With the Vacuum, click on coal to suck up only coal, then the copper ore, rubble and clay you want.<br>
3. Build (3) a chamber of stone wall blocks with a one-block vent in the roof, a floor of clay Grates, and an Ingot mold right under the grates. The notebook's Blueprints tab has this as a starter design.<br>
4. Tick coal and copper ore in your bag and pick Ticked mix (Q/E, or click it at the bottom of the bag). Fly inside (the bot passes through what you build) and right click to blow an even mix onto the grates: about 50 of each. (Ore first and coal on top works too; coal buried under ore will not light.) Light a coal grain on top with the Igniter.<br>
5. When the ingot sets, press F on the mold and vacuum up the bars. That also rakes the slag and ash off the grate, so the furnace is ready for the next charge. Scrap goes to your bag: throw it in with the next charge.<br>
6. Blow the bars into the trade dock: they sell the moment they drop through its grate, and bars pay far more than ore. Watch the Market page for orders that pay double. Money buys belts, screw tubes and fans; pay down the claim there too.<br>
7. From there: deeper ores (tin, zinc, silver, gold), alloys, fans for hotter fires, iron and steel.</p>
`;
}
+334
View File
@@ -0,0 +1,334 @@
// Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste.
// ---------- Overlay previews ----------
function blockRectPx(bx, by, bw, bh) { const q = worldToScreenPx(bx * BS, by * BS); return [q.x, q.y, bw * BS * R.zp, bh * BS * R.zp]; }
function drawGrid(cbx, cby, radius) {
const ctx = R.ctx, zp = R.zp, s = BS * zp;
ctx.save();
ctx.lineWidth = 1;
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
const d = Math.hypot(dx, dy) / radius; if (d > 1) continue;
const [x, y] = blockRectPx(cbx + dx, cby + dy, 1, 1);
ctx.strokeStyle = `rgba(236,226,210,${(0.16 * (1 - d)).toFixed(3)})`;
ctx.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, s, s);
}
ctx.restore();
}
function previewColor(code, v) {
if (code === BP_WALL) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; }
if (code === BP_FILTER) return '96,100,110';
return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190';
}
// Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking.
// Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines
// run on unbroken from one cell to the next and look like real fractures, not a pattern.
const CRACK_N = [makeNoise(mulberry32(0xc7ac1)), makeNoise(mulberry32(0xc7ac2)), makeNoise(mulberry32(0xc7ac3))];
const CRACK_LV = [{ s: 1 / 7, k: 0.1, w: 0.2 }, { s: 1 / 3.6, k: 0.42, w: 0.14 }, { s: 1 / 2, k: 0.72, w: 0.09 }];
function crackSeg(ctx, x, y, n, sc, zp, oy) {
const v = (cx, cy) => n(cx * sc + 0.37, cy * sc + 0.61);
const a = v(x, y), b = v(x + 1, y), c = v(x + 1, y + 1), d = v(x, y + 1);
const pts = [];
if ((a < 0) !== (b < 0)) pts.push(x + a / (a - b), y);
if ((b < 0) !== (c < 0)) pts.push(x + 1, y + b / (b - c));
if ((d < 0) !== (c < 0)) pts.push(x + d / (d - c), y + 1);
if ((a < 0) !== (d < 0)) pts.push(x, y + a / (a - d));
for (let k = 0; k + 3 < pts.length; k += 4) {
ctx.moveTo((pts[k] - R.camX) * zp, (pts[k + 1] - R.camY) * zp + oy);
ctx.lineTo((pts[k + 2] - R.camX) * zp, (pts[k + 3] - R.camY) * zp + oy);
}
}
function drawCracks() {
const ctx = R.ctx, zp = R.zp;
if (zp < 2.5) return;
const x0 = Math.max(0, Math.floor(R.camX)), y0 = Math.max(0, Math.floor(R.camY));
const x1 = Math.min(W - 1, Math.ceil(R.camX + R.canvas.width / zp)), y1 = Math.min(H - 1, Math.ceil(R.camY + R.canvas.height / zp));
const cells = [];
for (let y = y0; y <= y1; y++) { const row = y * W; for (let x = x0; x <= x1; x++) { const dp = digp[row + x]; if (dp && PH[mat[row + x]] === PH_SOLID) cells.push(x, y, dp / 255); } }
if (!cells.length) return;
ctx.save(); ctx.lineCap = 'round';
for (let l = 0; l < CRACK_LV.length; l++) {
const L = CRACK_LV[l];
// a soft lit lip just below each crack, then the dark crack itself
for (const pass of [0, 1]) {
ctx.beginPath();
for (let k = 0; k < cells.length; k += 3) if (cells[k + 2] >= L.k) crackSeg(ctx, cells[k], cells[k + 1], CRACK_N[l], L.s, zp, pass ? 0 : Math.max(1, zp * 0.12));
ctx.strokeStyle = pass ? `rgba(18,12,10,${0.78 - l * 0.12})` : 'rgba(255,240,220,0.13)';
ctx.lineWidth = Math.max(1, zp * L.w * (pass ? 1 : 1.3));
ctx.stroke();
}
}
ctx.restore();
}
// The pulses in flight: a wavefront running out from the face of the bot, fading as it goes
function pulseRGB(tune) {
if (!tune) return '150,215,255';
return `${Math.round((COLR[tune] + 255) / 2)},${Math.round((COLG[tune] + 255) / 2)},${Math.round((COLB[tune] + 255) / 2)}`;
}
// screen points across a fan (mouth at the bot, or out at distance sd along each ray)
function fanPoints(ox, oy, ang, Wd, sd, n) {
const pts = [];
for (let k = 0; k <= n; k++) { const r = pulseRay(ox, oy, ang, Wd, -1 + 2 * k / n); pts.push(worldToScreenPx(r.x + r.ux * sd, r.y + r.uy * sd)); }
return pts;
}
function drawDrillPulses() {
if (!drillPulses.length) return;
const ctx = R.ctx, zp = R.zp;
ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
for (const q of drillPulses) {
const Wd = PULSE_W[q.w], rr = Math.min(q.r, q.range), a = Math.max(0, 1 - q.r / (q.range + 3)), col = pulseRGB(q.tune);
if (rr < 3) continue;
const front = fanPoints(q.ox, q.oy, q.ang, Wd, rr, 10), back = fanPoints(q.ox, q.oy, q.ang, Wd, Math.max(2.5, rr - 3), 10);
const line = (pts) => { ctx.beginPath(); pts.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); };
// a faint band behind the front, then the bright front itself
ctx.fillStyle = `rgba(${col},${(0.08 * a).toFixed(3)})`;
line(front); for (let k = back.length - 1; k >= 0; k--) ctx.lineTo(back[k].x, back[k].y); ctx.closePath(); ctx.fill();
ctx.strokeStyle = `rgba(${col},${(0.2 * a).toFixed(3)})`; ctx.lineWidth = Math.max(3, zp * 1.3); line(front); ctx.stroke();
ctx.strokeStyle = `rgba(${col},${(0.85 * a).toFixed(3)})`; ctx.lineWidth = Math.max(1.5, zp * 0.3); line(front); ctx.stroke();
}
ctx.restore();
}
// With the drill in hand: the fan a pulse will sweep (as wide as the bot where it leaves it),
// out to where it stops, and a crosshair
function drawDrillAim(ax, ay) {
const ctx = R.ctx, zp = R.zp;
const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5);
const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x);
const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3));
const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85';
const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10);
ctx.save();
// the outline gets a dark under-stroke so it reads on bright sky as well as dark rock
const outline = () => { ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); for (const pt of far) ctx.lineTo(pt.x, pt.y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.closePath(); };
ctx.fillStyle = `rgba(${col},0.06)`; outline(); ctx.fill();
ctx.setLineDash([zp * 0.8, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.3) % (zp * 1.6);
ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke();
ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke();
ctx.setLineDash([]); ctx.lineDashOffset = 0;
// the emitter glows across the bot's face while it fires
const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY);
if (GAME.lmb && hot > 0) {
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(${col},${(0.7 * hot).toFixed(3)})`; ctx.lineWidth = Math.max(2, zp * 0.6); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(mouth[0].x, mouth[0].y); ctx.lineTo(mouth[1].x, mouth[1].y); ctx.stroke();
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, zp * 3.5);
g.addColorStop(0, `rgba(${col},${(0.35 * hot).toFixed(3)})`); g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7);
ctx.globalCompositeOperation = 'source-over';
}
ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2);
const k = zp * 0.9;
ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y);
ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke();
if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); }
ctx.restore();
}
function drawPreviews() {
const ctx = R.ctx, zp = R.zp;
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
const p = worldToScreenPx(ax, ay);
ctx.save();
const reachOk = inReach(ax, ay);
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip;
if (!reachOk && t !== 'drill' && !sel) {
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
ctx.strokeStyle = 'rgba(239,106,85,0.35)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.arc(c.x, c.y, REACH * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
}
if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) {
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
ctx.strokeStyle = 'rgba(239,106,85,0.45)'; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.arc(c.x, c.y, drillReach(GAME.drillW) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
}
drawDrillPulses();
if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay);
if (sel) drawSelection(sel, ax, ay);
else if (pasting) drawPastePreview();
else if (t === 'vacuum' || t === 'ignite') {
const r = t === 'ignite' ? 1 : GAME.brush[GAME.tool];
const reachOk = inReach(ax, ay);
const blowing = t === 'vacuum' && GAME.rmb && !GAME.rmbConsumed;
ctx.strokeStyle = !reachOk ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)';
ctx.lineWidth = Math.max(1, zp * 0.25);
if (t === 'vacuum') ctx.setLineDash([zp, zp * 0.6]);
ctx.beginPath(); ctx.arc(p.x + zp / 2, p.y + zp / 2, (r + 0.5) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
if (t === 'vacuum') {
// what the right button would blow, next to the cursor; what the left is sucking, while it sucks
const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp;
const swatches = GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : GAME.blowMix ? mixList() : GAME.dropMat && bag[GAME.dropMat] > 0 ? [GAME.dropMat] : [];
swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); });
if (blowing || (GAME.lmb && GAME.vacFilter)) {
// faint line from the nozzle: the path the grains take
const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2);
ctx.strokeStyle = blowing ? 'rgba(255,210,122,0.22)' : 'rgba(92,186,166,0.22)'; ctx.lineWidth = Math.max(1, zp * (r + 0.5) * 0.6);
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(p.x + zp / 2, p.y + zp / 2); ctx.stroke();
}
}
} else if (t === 'build') {
const P = piece(), b = aimBlock();
drawGrid(b.bx, b.by, 9);
let cells = [], col = '120,200,190';
if (P.kind === 'block') {
const v = pieceV(P);
col = previewColor(P.code, v);
cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by)) : [[b.bx, b.by]];
const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1;
if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; }
const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
const afford = canAfford(pieceCostNow(P), cells.length);
for (const [bx, by] of cells) {
const err = canPlaceBlock(bx, by), rb = by * BW + bx;
const swap = GAME.ctrl && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P));
const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost
const ok = (!err || swap) && !ghost && afford;
const [x, y, w, h] = blockRectPx(bx, by, 1, 1);
ctx.fillStyle = ghost ? 'rgba(90,190,230,0.22)' : ok ? (swap ? 'rgba(255,190,90,0.5)' : `rgba(${col},0.55)`) : 'rgba(239,106,85,0.35)';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = ghost ? 'rgba(90,190,230,0.8)' : ok ? 'rgba(236,226,210,0.8)' : 'rgba(239,106,85,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25);
if (ghost) ctx.setLineDash([zp, zp * 0.7]);
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); ctx.setLineDash([]);
if (P.dirs && (P.id === 'belt' || P.id === 'tube' || P.filter || P.id === 'pump' || P.id === 'outlet')) {
// a chevron on every block, pointing the way it will carry (or pump, or pour)
const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx];
const cx = x + w / 2, cy = y + h / 2, k = w * 0.22, px_ = -dy, py_ = dx;
ctx.strokeStyle = 'rgba(20,16,12,0.55)'; ctx.lineWidth = Math.max(2, zp * 0.9); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
const chev = () => { ctx.beginPath(); ctx.moveTo(cx - dx * k * 0.6 + px_ * k, cy - dy * k * 0.6 + py_ * k); ctx.lineTo(cx + dx * k * 0.7, cy + dy * k * 0.7); ctx.lineTo(cx - dx * k * 0.6 - px_ * k, cy - dy * k * 0.6 - py_ * k); ctx.stroke(); };
chev(); ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(1.2, zp * 0.45); chev();
}
}
// dragging a line of belts, filters or tubes: one long arrow over it, the way it will run
if (GAME.drag && dd >= 0 && cells.length > 1 && !modShift()) {
const [x0, y0, w0, h0] = blockRectPx(cells[0][0], cells[0][1], 1, 1), [x1, y1, w1, h1] = blockRectPx(cells[cells.length - 1][0], cells[cells.length - 1][1], 1, 1);
let sx = x0 + w0 / 2, sy = y0 + h0 / 2, ex = x1 + w1 / 2, ey = y1 + h1 / 2;
const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dd] : [[-1, 0], [1, 0]][dd];
if ((ex - sx) * dx + (ey - sy) * dy < 0) { const tx = sx, ty = sy; sx = ex; sy = ey; ex = tx; ey = ty; }
const off = BS * zp * 1.0, hx = ex + dx * BS * zp * 0.55, hy = ey + dy * BS * zp * 0.55, ah = Math.max(8, BS * zp * 0.5);
const ox = dy ? off : 0, oy = dx ? off : 0;
const arrow = () => { ctx.beginPath(); ctx.moveTo(sx - dx * BS * zp * 0.3 + ox, sy - dy * BS * zp * 0.3 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.moveTo(hx - dx * ah - dy * ah * 0.6 + ox, hy - dy * ah - dx * ah * 0.6 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.lineTo(hx - dx * ah + dy * ah * 0.6 + ox, hy - dy * ah + dx * ah * 0.6 + oy); ctx.stroke(); };
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(20,16,12,0.6)'; ctx.lineWidth = Math.max(4, zp * 1.1); arrow();
ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(2, zp * 0.55); arrow();
}
if (GAME.drag && cells.length > 1) {
const label = `${cells.length} × ${costText(pieceCostNow(P), 1)}`;
ctx.font = `600 12px ui-monospace, monospace`; ctx.fillStyle = 'rgba(14,12,11,0.85)';
const tw = ctx.measureText(label).width;
ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6);
}
} else {
const bw = P.kind === 'mold' ? MOLDS[P.mold].bw : P.bw, bh = P.kind === 'mold' ? MOLDS[P.mold].bh : P.bh;
const { bx, by } = stampOrigin(bw, bh);
const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
const swap = !!err && GAME.ctrl && /already/.test(err) && !footprintBlocker(bx, by, bw, bh);
const ok = (!err || swap) && inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1);
const [x, y, w, h] = blockRectPx(bx, by, bw, bh);
if (swap && ok) { ctx.fillStyle = 'rgba(255,190,90,0.35)'; ctx.fillRect(x, y, w, h); }
if (P.kind === 'mold') {
const md = MOLDS[P.mold], wm = WALL_OF[GAME.wallMat];
for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) {
const ch = md.rows[r][c]; if (ch === '.') continue;
ctx.fillStyle = ch === '#' ? (ok ? `rgba(${COLR[wm]},${COLG[wm]},${COLB[wm]},0.6)` : 'rgba(239,106,85,0.4)') : 'rgba(255,190,90,0.35)';
ctx.fillRect(x + c * zp, y + r * zp, zp, zp);
}
} else {
ctx.fillStyle = ok ? 'rgba(92,186,166,0.2)' : 'rgba(239,106,85,0.2)';
if (P.id === 'wheel') { ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, w / 2, 0, Math.PI * 2); ctx.fill(); }
else ctx.fillRect(x, y, w, h);
if (P.id === 'engine') { ctx.fillStyle = 'rgba(255,210,122,0.6)'; ctx.fillRect(x + 4 * zp, y + 7 * zp, 4 * zp, zp); }
if (P.id === 'fan') { const d = GAME.pieceDir.fan || 0, dx = [1, 0, -1, 0][d], dy = [0, 1, 0, -1][d]; ctx.strokeStyle = 'rgba(160,220,255,0.6)'; ctx.lineWidth = Math.max(1, zp * 0.4); ctx.beginPath(); ctx.moveTo(x + w / 2, y + h / 2); ctx.lineTo(x + w / 2 + dx * 28 * zp, y + h / 2 + dy * 28 * zp); ctx.stroke(); }
}
ctx.strokeStyle = ok ? 'rgba(92,186,166,0.95)' : 'rgba(239,106,85,0.95)'; ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
}
}
// ghost machines and molds (always visible)
ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
for (const g of ghostMachines) { const P = PIECE_BY_ID[g.type]; const [x, y, w, h] = blockRectPx(g.bx, g.by, P.bw, P.bh); ctx.strokeStyle = 'rgba(90,190,230,0.7)'; ctx.strokeRect(x, y, w, h); }
for (const g of ghostMolds) { const md = MOLDS[g.kind]; const [x, y, w, h] = blockRectPx(g.bx, g.by, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.7)'; ctx.strokeRect(x, y, w, h); }
ctx.setLineDash([]);
if (GAME.usingPad) {
ctx.strokeStyle = 'rgba(255,230,200,0.9)'; ctx.lineWidth = 2;
const cx = p.x + zp / 2, cy = p.y + zp / 2;
ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx - 4, cy); ctx.moveTo(cx + 4, cy); ctx.lineTo(cx + 10, cy); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy - 4); ctx.moveTo(cx, cy + 4); ctx.lineTo(cx, cy + 10); ctx.stroke();
}
ctx.restore();
if (t === 'build' && GAME.ctrl && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4);
drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2);
}
// a small labelled tag drawn on the world (screen pixels)
function drawTag(text, col, x, y, sub) {
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
ctx.save();
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
const tw = ctx.measureText(text).width;
ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`;
const sw = sub ? ctx.measureText(sub).width + 8 * u : 0;
const h = Math.round(19 * u), pad = 7 * u, w = tw + sw + pad * 2;
x = Math.max(2, Math.min(R.canvas.width - w - 2, x)); y = Math.max(2, Math.min(R.canvas.height - h - 2, y));
ctx.fillStyle = 'rgba(14,12,11,0.9)'; ctx.fillRect(x, y, w, h);
ctx.fillStyle = `rgb(${col})`; ctx.fillRect(x, y, 3 * u, h);
ctx.strokeStyle = `rgba(${col},0.8)`; ctx.lineWidth = u; ctx.strokeRect(x + u / 2, y + u / 2, w - u, h - u);
ctx.font = `700 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = `rgb(${col})`; ctx.fillText(text, x + pad, y + h / 2 + u * 0.5);
if (sub) { ctx.font = `500 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.fillStyle = 'rgba(231,220,203,0.9)'; ctx.fillText(sub, x + pad + tw + 8 * u, y + h / 2 + u * 0.5); }
ctx.restore();
}
// Copy (teal) and cut (red, hatched): the box being dragged, what it would pick up, and a tag
const SEL_COL = { copy: '92,186,166', cut: '239,106,85' };
function drawSelection(mode, ax, ay) {
const ctx = R.ctx, zp = R.zp, col = SEL_COL[mode], sd = GAME.selDrag;
const sx = sd ? sd.x : ax, sy = sd ? sd.y : ay;
const a = blockOf(Math.min(sx, ax), Math.min(sy, ay)), c = blockOf(Math.max(sx, ax), Math.max(sy, ay));
const bw = c.bx - a.bx + 1, bh = c.by - a.by + 1;
drawGrid(blockOf(ax, ay).bx, blockOf(ax, ay).by, 7);
ctx.save();
// what is inside: built blocks, and machines and molds that fit wholly inside
let n = 0;
if (sd) {
ctx.fillStyle = `rgba(${col},${mode === 'cut' ? 0.42 : 0.3})`;
for (let by = a.by; by <= c.by; by++) for (let bx = a.bx; bx <= c.bx; bx++) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) continue;
const b = by * BW + bx;
if (blockP[b] && blockIntegrity(b) >= 8) { const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillRect(x, y, w, h); n++; }
}
ctx.strokeStyle = `rgba(${col},0.95)`; ctx.lineWidth = Math.max(1, zp * 0.35);
for (const mm of machines) {
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
const P = PIECE_BY_ID[mm.type], mbx = mm.x / BS, mby = mm.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + P.bw - 1 <= c.bx && mby + P.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, P.bw, P.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
for (const mo of molds) {
const md = MOLDS[mo.kind], mbx = mo.x / BS, mby = mo.y / BS;
if (mbx >= a.bx && mby >= a.by && mbx + md.bw - 1 <= c.bx && mby + md.bh - 1 <= c.by) { const [x, y, w, h] = blockRectPx(mbx, mby, md.bw, md.bh); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); n++; }
}
}
const [x, y, w, h] = blockRectPx(a.bx, a.by, bw, bh);
ctx.fillStyle = `rgba(${col},0.08)`; ctx.fillRect(x, y, w, h);
if (mode === 'cut') {
// red hatching: this will be taken down
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
ctx.strokeStyle = `rgba(${col},0.35)`; ctx.lineWidth = Math.max(1, zp * 0.25);
const step = Math.max(8, zp * 2.5);
ctx.beginPath(); for (let k = -h; k < w; k += step) { ctx.moveTo(x + k, y + h); ctx.lineTo(x + k + h, y); } ctx.stroke();
ctx.restore();
}
ctx.strokeStyle = `rgba(${col},0.98)`; ctx.lineWidth = Math.max(1.5, zp * (mode === 'cut' ? 0.45 : 0.35));
ctx.setLineDash([zp * 1.2, zp * 0.8]); ctx.lineDashOffset = -(R.frame * 0.4) % (zp * 2);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); ctx.lineDashOffset = 0;
ctx.restore();
const label = mode === 'cut' ? '✂ CUT' : '⧉ COPY';
const sub = !sd ? 'drag a box' : n ? (mode === 'cut' ? `${plural(n, 'piece')} · let go to take down` : `${plural(n, 'piece')} · let go to copy`) : 'nothing built in here';
drawTag(label, col, x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), sub);
}
// Pasting: the clipboard follows the cursor, snapped to the grid
function drawPastePreview() {
const ctx = R.ctx, zp = R.zp, bp = GAME.clip, { bx: ox, by: oy } = stampOrigin(bp.bw, bp.bh);
drawGrid(aimBlock().bx, aimBlock().by, 7);
ctx.save();
const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh);
ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); }
for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); }
ctx.restore();
drawTag('PASTE', '92,186,166', x, y - Math.round(23 * Math.max(1, R.canvas.width / window.innerWidth)), 'click to place · R flips · Esc stops');
}
+106
View File
@@ -0,0 +1,106 @@
// Saves in IndexedDB, world files (export / import) and the blueprint library.
// ---------- Saves (IndexedDB) ----------
const DB = {
db: null,
open() {
if (this.db) return Promise.resolve(this.db);
return new Promise((res, rej) => {
try {
const r = indexedDB.open('deepgrain', 1);
r.onupgradeneeded = () => r.result.createObjectStore('saves');
r.onsuccess = () => { this.db = r.result; res(this.db); };
r.onerror = () => rej(r.error);
} catch (e) { rej(e); }
});
},
async op(mode, fn) {
const db = await this.open();
return new Promise((res, rej) => { const tx = db.transaction('saves', mode); const st = tx.objectStore('saves'); const rq = fn(st); rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); });
},
put(k, v) { return this.op('readwrite', s => s.put(v, k)); },
get(k) { return this.op('readonly', s => s.get(k)); },
del(k) { return this.op('readwrite', s => s.delete(k)); },
};
async function saveWorld(slot, quiet) {
try {
const s = serializeWorld();
await DB.put(slot, { meta: s.meta, buffers: s.buffers, savedAt: Date.now(), playTime: GAME.playTime });
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
return true;
} catch (e) {
if (!quiet) toast('Could not save. Browser storage is unavailable in this view. Use Export world file instead.');
return false;
}
}
async function newestSave() {
try {
const a = await DB.get('main'), b = await DB.get('auto');
if (a && b) return a.savedAt >= b.savedAt ? a : b;
return a || b || null;
} catch (e) { return null; }
}
function b64FromBytes(u8) { let s = ''; const CH = 0x8000; for (let i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH)); return btoa(s); }
function bytesFromB64(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; }
async function gz(u8, inflate) {
if (typeof CompressionStream === 'undefined') return null;
const st = new Blob([u8]).stream().pipeThrough(inflate ? new DecompressionStream('gzip') : new CompressionStream('gzip'));
return new Uint8Array(await new Response(st).arrayBuffer());
}
async function exportWorld() {
const s = serializeWorld();
const out = { format: 'deepgrain-world', v: 1, meta: s.meta, buffers: {} };
for (const k in s.buffers) {
const arr = s.buffers[k], bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
const z = await gz(bytes, false);
out.buffers[k] = { type: arr.constructor.name, gz: !!z, b64: b64FromBytes(z || bytes) };
}
const text = JSON.stringify(out);
let dl = null;
try { dl = window.claude && window.claude.use ? await window.claude.use('downloads') : null; } catch (e) { dl = null; }
if (!dl) {
// an ordinary browser download (the downloads hook above only exists inside claude.ai)
try {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url; a.download = `deepgrain-${worldSeed}.json`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
toast('World file saved.');
} catch (e) { toast('Saving files is not available in this view.'); }
return;
}
try { await dl.save({ filename: `deepgrain-${worldSeed}.json`, data: new Blob([text], { type: 'application/json' }) }); toast('World file saved.'); }
catch (e) { if (e && e.code !== 'declined') toast('The world file was not saved.'); }
}
async function importWorldFile(file) {
try {
const obj = JSON.parse(await file.text());
if (obj.format !== 'deepgrain-world') throw new Error('Not a Deepgrain world file.');
const bufs = {};
const T = { Uint8Array, Uint16Array, Int16Array, Float32Array, Int32Array };
for (const k in obj.buffers) {
const b = obj.buffers[k]; let bytes = bytesFromB64(b.b64);
if (b.gz) bytes = await gz(bytes, true);
const C = T[b.type] || Uint8Array;
bufs[k] = new C(bytes.buffer, bytes.byteOffset, bytes.byteLength / C.BYTES_PER_ELEMENT);
}
startFromSave({ meta: obj.meta, buffers: bufs });
toast('World imported.');
} catch (e) { toast('That file could not be loaded: ' + (e.message || 'unknown error')); }
}
// ---------- Blueprint library ----------
function libLoad() { try { return JSON.parse(localStorage.getItem('deepgrain.blueprints') || '[]'); } catch (e) { return []; } }
function libSave(list) { try { localStorage.setItem('deepgrain.blueprints', JSON.stringify(list)); return true; } catch (e) { toast('Blueprints cannot be stored in this view. Copy the blueprint code instead.'); return false; } }
function bpEncode(bp) { return 'DGBP2:' + btoa(unescape(encodeURIComponent(JSON.stringify(bp)))); }
function bpValid(bp) { return !!(bp && bp.v === 2 && Array.isArray(bp.blocks) && bp.bw > 0 && bp.bh > 0); }
function bpDecode(code) {
const s = code.trim();
if (s.startsWith('DGBP1:')) throw new Error('That blueprint is from an older version of the game, before the block grid. It cannot be used.');
if (!s.startsWith('DGBP2:')) throw new Error('Blueprint codes start with DGBP2:');
let bp;
try { bp = JSON.parse(decodeURIComponent(escape(atob(s.slice(6))))); } catch (e) { throw new Error('That code is damaged.'); }
if (!bpValid(bp)) throw new Error('That code is damaged.');
bp.machines = bp.machines || []; bp.molds = bp.molds || [];
return bp;
}
+246
View File
@@ -0,0 +1,246 @@
// The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel.
function pieceUsesWall(P) { return P.variants === 'wall'; }
function setDrillWidth(w) {
const n = Math.max(0, Math.min(PULSE_W.length - 1, w));
if (n === GAME.drillW) return;
GAME.drillW = n; drillWidth = n; hudDirty = true; playSfx('ui');
const what = ['a beam as wide as the bot: bores a tunnel fast and reaches furthest', 'a cone: the all-rounder', 'a broad fan: clears the most rock, but only close to the bot'][n];
setStatus(`${PULSE_W[n].name[0].toUpperCase() + PULSE_W[n].name.slice(1)} pulse, ${what} (reach ${drillReach(n)}).`, 'info');
}
// R with the drill: tune it to the material under the cursor, or back to everything
function tuneUnderCursor() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return;
const m = mat[ay * W + ax];
const to = PH[m] === PH_SOLID ? YLD[m] : PH[m] === PH_POWDER ? m : 0;
GAME.drillTune = !to || to === GAME.drillTune ? 0 : to;
hudDirty = true; playSfx('ui');
setStatus(GAME.drillTune ? `Tuned to ${MD[GAME.drillTune].name.toLowerCase()}: only that shatters, and the pulses pass through everything else.` : 'Untuned: the drill shatters any rock it can.', 'info');
}
function cycleItem(d) {
const t = toolId();
if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; }
if (t === 'vacuum') {
// what right click blows: each material in the bag, plus an even mix of the ticked ones
const l = bagMaterials().map(String);
if (mixList().length >= 2) l.push('mix');
if (!l.length) return;
let k = l.indexOf(GAME.blowMix ? 'mix' : String(GAME.dropMat)); k = (k + d + l.length) % l.length; if (k < 0) k = 0;
if (l[k] === 'mix') GAME.blowMix = true; else { GAME.blowMix = false; GAME.dropMat = +l[k]; }
GAME.matChosen = true;
} else if (t === 'build') {
let k = BUILD_ORDER.indexOf(piece().id); k = (k + d + BUILD_ORDER.length) % BUILD_ORDER.length;
GAME.pieceIdx = PIECE_BY_ID[BUILD_ORDER[k]].index; GAME.buildCat = catOf(BUILD_ORDER[k]);
GAME.catPick = GAME.catPick || {}; GAME.catPick[GAME.buildCat] = BUILD_ORDER[k];
GAME.drag = null;
} else return;
playSfx('ui'); hudDirty = true;
}
function cycleWall(d) {
// prefer materials you actually have, but allow choosing any
const have = WALL_LIST.filter(m => bag[m] > 0);
const list = have.length ? have.concat(WALL_LIST.filter(m => !have.includes(m))) : WALL_LIST;
let k = list.indexOf(GAME.wallMat); k = (k + d + list.length) % list.length;
GAME.wallMat = list[k];
}
function rotate() {
const t = toolId();
if (GAME.paste && GAME.clip) GAME.clip = flipBlueprint(GAME.clip);
else if (t === 'build') {
const P = piece();
if (pieceUsesWall(P)) cycleWall(1);
else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length;
else return;
} else if (t === 'vacuum') { toggleCollectUnderCursor(); return; }
else if (t === 'drill') { tuneUnderCursor(); return; }
else return;
playSfx('ui'); hudDirty = true;
}
function toggleCollect(m) {
if (!m) return;
if (GAME.collect.has(m)) GAME.collect.delete(m); else GAME.collect.add(m);
try { localStorage.setItem('deepgrain.collect', JSON.stringify([...GAME.collect])); } catch (e) { /* storage unavailable */ }
lastBagKey = ''; hudDirty = true; playSfx('ui');
}
function pickFilterUnderCursor() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
const bar = ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H);
const m = bar ? bar.m : grainAt(ax, ay);
if (!m || !filterable(m)) { setStatus('Aim at something loose to add it to the filter.', 'info'); return; }
toggleFilterMat(m);
}
function toggleFilterMat(m) {
const f = GAME.filterCfg, k = f.m.indexOf(m);
if (k >= 0) f.m.splice(k, 1); else f.m.push(m);
f.m.sort((a, b) => a - b);
hudDirty = true; lastBagKey = ''; playSfx('ui');
setStatus(`New filters ${filterSentence(f)}.`, 'info');
}
function toggleCollectUnderCursor() {
const m = grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!m) { setStatus('Aim at something loose to tick it.', 'info'); return; }
toggleCollect(m);
setStatus(`${MD[m].name} ${GAME.collect.has(m) ? 'ticked. While anything is ticked, the vacuum takes only ticked materials' : GAME.collect.size ? 'unticked' : 'unticked. Nothing is ticked, so the vacuum takes what you start on'}.`, 'info');
}
function setBrush(d) {
const t = toolId();
if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; }
if (t === 'build' || t === 'blueprint' || t === 'ignite') return;
GAME.brush[GAME.tool] = Math.max(0, Math.min(t === 'vacuum' ? 6 : 8, GAME.brush[GAME.tool] + d)); hudDirty = true;
}
// The block variant a piece is placed with: wall material, or direction (a filter's
// materials and mode travel separately, as GAME.filterCfg)
function pieceV(P) {
if (P.code === BP_WALL) return GAME.wallMat;
if (P.filter) return (GAME.pieceDir[P.id] || 0) << 7;
return GAME.pieceDir[P.id] || 0;
}
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
// What a filter can hold: loose things and bars you have found, plus anything it already holds
function filterChoices(cur) { return BAG_ORDER.filter(m => filterable(m) && (seen[m] || cur.includes(m))); }
function matList(ms) {
const n = ms.map(m => MD[m].name.toLowerCase());
return n.length <= 3 ? n.join(n.length === 2 ? ' and ' : ', ') : `${n.slice(0, 2).join(', ')} and ${n.length - 2} more`;
}
// What a filter does, in words: what falls through and what rides along
function filterParts(f) {
if (!f.m.length) return f.inv ? { drops: 'everything', rides: 'nothing' } : { drops: 'nothing', rides: 'everything' };
return f.inv ? { drops: 'everything else', rides: matList(f.m) } : { drops: matList(f.m), rides: 'everything else' };
}
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
// Change the whole run of matching filters at once
function applyFilterRun(run, f) {
for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
filtersDirty = true;
}
function openFilterPicker(cur, onDone, title) {
const sel = new Set(cur.m);
let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0;
const list = filterChoices(cur.m);
const body = `<div class="fmode" role="radiogroup" aria-label="Mode">
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
</div>
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button></div>`
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
+ `<p class="note fp-sum" id="fp-sum"></p>`;
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
const box = $('dlg-body');
function sync() {
box.querySelectorAll('[data-inv]').forEach(b => { const on = +b.dataset.inv === inv; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); });
const f = { m: [...sel].sort((a, b) => a - b), inv };
box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`;
}
box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); });
box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); });
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
sync();
}
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; }
function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; }
function pieceCostNow(P) {
if (P.kind === 'mold') return wallCost(GAME.wallMat, MOLDS[P.mold].walls);
if (P.kind === 'block' && P.code === BP_WALL) return wallCost(GAME.wallMat, BS * BS);
return P.cost;
}
function renderSelCard() {
const t = toolId(), c = $('selcard');
let h = '';
const status = performance.now() - GAME.statusT < 3200 ? GAME.status : '';
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
if (sel === 'cut') {
h = `<div class="row"><span class="chip bad">✂ Cut</span><span class="name grow">Hold X and drag a box</span></div>
<div class="sub">Let go to take down everything you built in the box (refunded to your bag) and pick it up to paste somewhere else. <kbd>Ctrl</kbd>+<kbd>Z</kbd> puts it back.</div>`;
} else if (sel === 'copy') {
h = `<div class="row"><span class="chip ok">⧉ Copy</span><span class="name grow">Hold C and drag a box</span></div>
<div class="sub">Let go to copy what you built in the box. Then click to place ghosts of it; your bot builds them from your bag.</div>`;
} else if (GAME.paste && GAME.clip) {
h = `<div class="row"><span class="chip ok">Paste</span><span class="name grow">Blueprint</span><span class="chip">${GAME.clip.bw}×${GAME.clip.bh} blocks</span></div>
<div class="sub">Click to place a ghost; your bot builds ghosts from your bag when it is close. <kbd>R</kbd> flips it. Right click or <kbd>Esc</kbd> stops pasting, <kbd>V</kbd> starts again.</div><div class="sub">Needs ${esc(costText(bpCost(GAME.clip), 1) || 'nothing')}</div>`;
if (ng) h += `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>`;
} else if (t === 'drill') {
const b = BIT_TIERS[player.bitTier];
const pw = PULSE_W[GAME.drillW], tn = GAME.drillTune;
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Built blocks are never harmed.</div>`;
} else if (t === 'vacuum') {
ensureDropMat();
const m = GAME.dropMat, f = GAME.lmb ? GAME.vacFilter : 0;
const list = [...GAME.collect];
const mixL = mixList();
const blowRow = GAME.blowMix ? `<div class="row cycle"><kbd>Q</kbd>${mixL.map(k => `<span class="sw" style="background:${rgbOf(k)}"></span>`).join('')}<span class="name grow">Ticked mix</span><span class="num chip">${mixL.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}</span><kbd>E</kbd></div>`
: m ? `<div class="row cycle"><kbd>Q</kbd><span class="sw" style="background:${rgbOf(m)}"></span><span class="name grow">${esc(MD[m].name)}</span><span class="num chip ${bag[m] ? '' : 'bad'}">${bag[m] ? bag[m].toLocaleString('en-US') : 'none left'}</span><kbd>E</kbd></div>`
: `<div class="row"><span class="name">Vacuum</span><span class="sub">bag empty</span></div>`;
const sucking = f < 0 ? 'everything' : list.length ? 'only the ticked materials' : f > 0 ? 'only ' + esc(MD[f].name.toLowerCase()) : 'what you start on';
h = `${blowRow}
<div class="sub"><b>Left</b> sucks up ${sucking}${list.length && f >= 0 ? ' (untick them all to pick by what you click on)' : ''}. Standing still, <kbd>Shift</kbd> takes everything.</div>
<div class="sub"><b>Right</b> blows ${GAME.blowMix ? 'an even mix of the ticked materials' : m ? 'only ' + esc(MD[m].name.toLowerCase()) : 'the chosen material'} out. ${mixL.length > 1 || GAME.blowMix ? '<kbd>Q</kbd>/<kbd>E</kbd> switch between single materials and the ticked mix.' : 'Tick 2+ materials to blow them as a mix.'}</div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">nozzle ${GAME.brush[1]}</span>${list.map(k => `<span class="chip"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(k)}"></span>${esc(MD[k].name)}</span>`).join('')}${list.length ? '' : '<span class="sub">Nothing ticked. <kbd>R</kbd> ticks what is under the cursor.</span>'}</div>`;
} else if (t === 'build') {
const P = piece();
const dir = P.dirs ? P.dirs[GAME.pieceDir[P.id] || 0] : null;
// while dragging a line of belts, filters or tubes, the direction is the drag's
const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs;
const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓' })[d] || '';
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(dir)} ${esc(dir)} · R</span>`;
const cost = pieceCostNow(P);
const size = P.kind === 'machine' ? `${P.bw}×${P.bh} blocks` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh} blocks` : '1 block';
const fc = GAME.filterCfg;
const sws = ms => ms.slice(0, 6).map(m => `<span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:2px;background:${rgbOf(m)}"></span>`).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
const variant = pieceUsesWall(P) ? `<span class="chip hot"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R</span>`
: P.filter ? `<span class="chip hot">${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F</span>${dirChip}`
: dir ? dirChip : '';
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">${esc(P.name)}</span>${variant}<kbd>E</kbd></div>
<div class="row">${chipCost(cost)}<span class="chip">${size}</span>${P.power ? `<span class="chip">${P.power} power</span>` : ''}${GAME.ctrl ? '<span class="chip hot">Replace · Ctrl</span>' : ''}</div>
<div class="sub">${esc(P.desc)}</div>
${P.filter ? `<div class="sub">New ones will ${esc(filterSentence(GAME.filterCfg))}. <kbd>F</kbd> sets materials and mode (or click materials in the bag, or <kbd>T</kbd> over one); <kbd>F</kbd> on a placed filter changes its whole row.</div>` : ''}
<div class="sub">${P.kind === 'block' ? 'Drag for a line (any length: what is out of reach becomes ghosts), Shift-drag for a rectangle.' : 'Click to place.'}${P.dirs && P.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses <kbd>R</kbd>.' : P.dirs ? ' <kbd>R</kbd> turns it.' : pieceUsesWall(P) ? ' <kbd>R</kbd> picks the material.' : ''}</div>
<div class="sub keys-mini"><kbd>Ctrl</kbd> replace · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo · right click take back · <kbd>G</kbd> pick · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste</div>${ng ? `<div class="sub" style="color:var(--patina)">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</div>` : ''}`;
} else if (t === 'ignite') {
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
}
h += `<div class="status${GAME.statusKind === 'info' ? ' info' : ''}">${esc(status)}</div>`;
if (c.innerHTML !== h) c.innerHTML = h;
}
let lastBagKey = '';
function renderBag() {
const cap = bagCap(), pct = Math.min(100, bagTotal / cap * 100);
$('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`;
$('bagbar').firstChild.style.width = pct + '%';
$('bagbar').classList.toggle('full', bagTotal >= cap);
const l = BAG_ORDER.filter(m => bag[m] > 0 || GAME.collect.has(m));
const t = toolId();
const key = l.map(m => m + ':' + bag[m] + (GAME.collect.has(m) ? 'c' : '')).join(',') + '|' + GAME.dropMat + '|' + GAME.blowMix + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx;
if (key === lastBagKey) return;
lastBagKey = key;
const box = $('baglist'); box.innerHTML = '';
if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Vacuum.')); return; }
for (const m of l) {
const sel = (t === 'vacuum' && (GAME.blowMix ? GAME.collect.has(m) : m === GAME.dropMat)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m));
const row = el('div', { class: 'bagrow' + (sel ? ' on' : '') });
const tick = el('button', { class: 'tick' + (GAME.collect.has(m) ? ' on' : ''), title: GAME.collect.has(m) ? 'Ticked: the vacuum takes only ticked materials, and they make up the Ticked mix' : 'Tick to vacuum only ticked materials', 'aria-label': `Tick ${MD[m].name}`, 'aria-pressed': GAME.collect.has(m) });
tick.onclick = () => toggleCollect(m);
const b = el('button', { class: 'pick', title: WALL_OF[m] && t === 'build' ? `Build with ${MD[m].name}` : `Blow out ${MD[m].name}` });
b.innerHTML = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m].toLocaleString('en-US')}</span>`;
b.onclick = () => {
if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; }
else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; }
else { GAME.dropMat = m; GAME.matChosen = true; GAME.blowMix = false; if (t !== 'vacuum') selectTool(1); }
hudDirty = true; lastBagKey = ''; playSfx('ui');
};
row.appendChild(tick); row.appendChild(b); box.appendChild(row);
}
if (t === 'vacuum' && mixList().length > 1) {
// the ticked mix is a choice of its own, like any material
const mx = mixList(), row = el('div', { class: 'bagrow mixrow' + (GAME.blowMix ? ' on' : '') });
const b = el('button', { class: 'pick', title: 'Blow an even mix of the ticked materials' });
b.innerHTML = `<span class="mixsw">${mx.slice(0, 4).map(k => `<span class="sw" style="background:${rgbOf(k)}"></span>`).join('')}</span><span class="nm">Ticked mix</span><span class="n num">${mx.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}</span>`;
b.onclick = () => { GAME.blowMix = true; GAME.matChosen = true; hudDirty = true; lastBagKey = ''; playSfx('ui'); };
row.appendChild(b); box.appendChild(row);
}
if (t === 'vacuum') box.appendChild(el('div', { class: 'baghint' }, 'Click a material to blow only that, or Ticked mix to blow an even mix of the ticked ones. Ticked ones always come along when you vacuum.'));
}
+87
View File
@@ -0,0 +1,87 @@
// Starting, loading and quitting a game; modal dialogs; the pause menu.
// ---------- Start / stop ----------
function showLoading(on, p) {
$('loading').hidden = !on;
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
}
function enterGame() {
$('title').hidden = true; $('hud').hidden = false;
GAME.running = true; GAME.paused = false;
buildSky(); resizeRender(); snapCamera();
GAME.lastAutosave = performance.now();
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
hudDirty = true; renderHotbar();
}
function startNewWorld(seed, w, h) {
auInit();
showLoading(true, 0);
setTimeout(() => {
generateWorld(seed, w, h, null);
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.dropMat = 0; GAME.matChosen = false; GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
showLoading(false);
enterGame();
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
saveWorld('auto', true);
}, 30);
}
function startFromSave(sv) {
auInit();
try { deserializeWorld(sv.meta, sv.buffers); }
catch (e) {
toast(e && e.message ? e.message : 'That save could not be loaded.', { big: true, title: 'Save not loaded' });
GAME.running = false; $('hud').hidden = true; $('title').hidden = false; if (!TITLE.raf) drawTitle();
return false;
}
GAME.playTime = sv.playTime || 0;
GAME.drag = null; GAME.matChosen = false; GAME.dropMat = 0;
enterGame();
return true;
}
async function quitToTitle() {
await saveWorld('main', true);
GAME.running = false; closeAllModals();
$('hud').hidden = true; $('title').hidden = false;
refreshContinue();
if (!TITLE.raf) drawTitle();
}
async function refreshContinue() {
const sv = await newestSave();
const b = $('btn-continue');
if (sv && sv.meta && sv.meta.v === SAVE_VERSION) {
b.hidden = false;
const d = new Date(sv.savedAt);
$('continue-meta').textContent = `seed ${sv.meta.seed} · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
} else b.hidden = true;
}
// ---------- Modals ----------
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].some(id => !$(id).hidden); }
function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); }
function closeModal(id) { $(id).hidden = true; if (id === 'm-dialog') GAME.editRun = null; if (!anyModal()) GAME.paused = false; }
function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
function topModal() { for (const id of ['m-dialog', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; }
function dialog(title, bodyHtml, buttons) {
$('dlg-title').textContent = title; $('dlg-body').innerHTML = bodyHtml;
const foot = $('dlg-foot'); foot.innerHTML = '';
for (const b of buttons) {
const btn = el('button', { class: 'btn ' + (b.cls || '') }, esc(b.label));
btn.onclick = () => { playSfx('ui'); const keep = b.fn && b.fn(); if (!keep) closeModal('m-dialog'); };
foot.appendChild(btn);
}
openModal('m-dialog');
}
document.querySelectorAll('[data-close]').forEach(b => b.addEventListener('click', () => { playSfx('ui'); closeModal(b.closest('.modal').id); }));
// pause menu
$('pm-resume').onclick = () => { playSfx('ui'); closeModal('m-pause'); };
$('pm-notebook').onclick = () => { playSfx('ui'); closeModal('m-pause'); openNotebook(); };
$('pm-settings').onclick = () => { playSfx('ui'); openSettings(); };
$('pm-save').onclick = () => { playSfx('ui'); saveWorld('main'); };
$('pm-export').onclick = () => { playSfx('ui'); exportWorld(); };
$('pm-import').onclick = () => { playSfx('ui'); $('importfile').click(); };
$('importfile').onchange = e => { const f = e.target.files[0]; if (f) { closeAllModals(); importWorldFile(f); } e.target.value = ''; };
$('pm-title').onclick = () => { playSfx('ui'); quitToTitle(); };
function openPause() {
$('pause-meta').textContent = `seed ${worldSeed} · ${W}×${H}`;
openModal('m-pause');
}
+116
View File
@@ -0,0 +1,116 @@
// The settings screen.
// ---------- Settings UI ----------
const SET_TABS = {
Display: [
{ k: 'zoom', label: 'Zoom', d: 'Screen pixels per grain. Mouse wheel also zooms.', type: 'range', min: 2, max: 12, step: 1, fmt: v => v + ' px' },
{ k: 'integerScale', label: 'Pixel-perfect scaling', d: 'Keeps every grain the same size. Turn off for smooth zoom on high-DPI screens.', type: 'toggle' },
{ k: 'renderScale', label: 'Render resolution', d: 'Lower is faster on big or high-DPI screens.', type: 'select', opts: [[0.5, '50%'], [0.75, '75%'], [1, '100%']] },
{ k: 'uiScale', label: 'Interface size', type: 'range', min: 0.8, max: 1.4, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] },
{ k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen },
{ k: 'cursorLabel', label: 'Name next to the cursor', d: 'Shows what you are pointing at right beside the cursor.', type: 'toggle' },
{ k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' },
{ k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'shake', label: 'Screen shake', type: 'toggle' },
],
Graphics: [
{ k: 'look', label: 'Look', d: 'Smooth draws rounded, outlined ground, round grains and glossy liquids on the graphics card. Pixel is the original look, one square per grain.', type: 'select', opts: [['smooth', 'Smooth'], ['pixel', 'Pixel']] },
{ k: 'lighting', label: 'Lighting', d: 'Full is colored light at grain resolution. Fast uses half resolution.', type: 'select', opts: [['off', 'Off'], ['fast', 'Fast'], ['full', 'Full']] },
{ k: 'lightEvery', label: 'Light updates', type: 'select', opts: [[1, 'Every frame'], [2, 'Every 2nd frame'], [3, 'Every 3rd frame']] },
{ k: 'ambient', label: 'Darkness underground', d: 'Lowest brightness in unlit caves.', type: 'range', min: 0, max: 0.4, step: 0.01, fmt: v => Math.round(v * 100) + '%' },
{ k: 'daylight', label: 'Daylight', type: 'range', min: 0.5, max: 1.2, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'glow', label: 'Bloom on hot material', type: 'select', opts: [['off', 'Off'], ['low', 'Low'], ['high', 'High']] },
{ k: 'incandescence', label: 'Heat glow', d: 'Hot material glows red, orange and white by temperature.', type: 'toggle' },
{ k: 'grain', label: 'Grain texture', type: 'range', min: 0, max: 1.5, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'particles', label: 'Sparks and debris', type: 'toggle' },
{ k: 'thermalPalette', label: 'Thermal lens colors', type: 'select', opts: [['inferno', 'Inferno'], ['ironbow', 'Ironbow'], ['gray', 'Grayscale']] },
{ note: 'Upscaling (DLSS, FSR), frame generation and hardware ray tracing need native graphics APIs that browsers do not expose. They are planned for the compiled build.' },
],
Simulation: [
{ k: 'simSpeed', label: 'Simulation speed', type: 'select', opts: [[0.5, '0.5×'], [1, '1×'], [2, '2×']] },
{ k: 'maxSteps', label: 'Catch-up steps per frame', d: 'How hard the game tries to keep real time when frames are slow.', type: 'range', min: 1, max: 4, step: 1, fmt: v => v },
{ note: 'The whole world keeps simulating, wherever you are. Your factories keep running when you fly away. Areas where nothing moves or changes temperature go to sleep and cost almost nothing; busy areas cost the same near you or far away.' },
],
Audio: [
{ k: 'volMaster', label: 'Master volume', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'volMusic', label: 'Music', d: 'Generated chiptune that changes with depth.', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'volSfx', label: 'Effects', type: 'range', min: 0, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'muted', label: 'Mute everything', type: 'toggle' },
{ k: 'muteUnfocused', label: 'Mute when the tab is hidden', type: 'toggle' },
],
Controls: [
{ k: 'deadzone', label: 'Gamepad stick deadzone', type: 'range', min: 0.05, max: 0.4, step: 0.01, fmt: v => Math.round(v * 100) + '%' },
{ k: 'aimSpeed', label: 'Gamepad aim reach', d: 'How far the right stick reaches from the bot.', type: 'range', min: 0.4, max: 1, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ html: () => controlsHtml() },
],
Game: [
{ k: 'cardSeconds', label: 'Discovery cards', d: 'How long a card stays when you find or make something new. Everything is also in the notebook.', type: 'select', opts: [[0, 'Off'], [5, '5 seconds'], [9, '9 seconds'], [15, '15 seconds'], [-1, 'Until I close them']] },
{ k: 'autosave', label: 'Autosave', type: 'select', opts: [[0, 'Off'], [1, 'Every minute'], [3, 'Every 3 minutes'], [5, 'Every 5 minutes'], [10, 'Every 10 minutes']] },
{ k: 'save', label: 'Save', d: 'Saves to this browser.', type: 'button', text: 'Save now', fn: () => saveWorld('main'), ingame: true },
{ k: 'export', label: 'World file', d: 'Download the world as a file you can keep or move to another browser.', type: 'button', text: 'Export', fn: exportWorld, ingame: true },
{ k: 'import', label: 'Load a world file', type: 'button', text: 'Import', fn: () => $('importfile').click() },
{ k: 'wipe', label: 'Delete saved worlds', d: 'Removes the saves stored in this browser.', type: 'button', text: 'Delete…', danger: true, fn: confirmWipe },
],
};
let setTab = 'Display';
function openSettings(tab) { if (tab) setTab = tab; renderSettings(); openModal('m-settings'); }
function renderSettings() {
const tabs = $('set-tabs'); tabs.innerHTML = '';
for (const name of Object.keys(SET_TABS)) {
const b = el('button', { role: 'tab', class: name === setTab ? 'on' : '', 'aria-selected': name === setTab }, name);
b.onclick = () => { playSfx('ui'); setTab = name; renderSettings(); };
tabs.appendChild(b);
}
const body = $('set-body'); body.innerHTML = '';
for (const it of SET_TABS[setTab]) {
if (it.note) { body.appendChild(el('p', { class: 'note' }, esc(it.note))); continue; }
if (it.html) { const d = el('div'); d.innerHTML = it.html(); body.appendChild(d); continue; }
if (it.ingame && !GAME.running) continue;
const row = el('div', { class: 'set' });
row.appendChild(el('div', null, `<div>${esc(it.label)}</div>${it.d ? `<div class="d">${esc(it.d)}</div>` : ''}`));
const ctl = el('div', { class: 'ctl' });
const id = 'set-' + it.k;
if (it.type === 'range') {
const inp = el('input', { type: 'range', id, min: it.min, max: it.max, step: it.step, 'aria-label': it.label });
inp.value = SET[it.k];
const v = el('span', { class: 'v' }, esc(it.fmt(SET[it.k])));
inp.oninput = () => { SET[it.k] = parseFloat(inp.value); v.textContent = it.fmt(SET[it.k]); applySettings(); };
ctl.appendChild(inp); ctl.appendChild(v);
} else if (it.type === 'toggle') {
const t = el('button', { class: 'toggle' + (SET[it.k] ? ' on' : ''), id, role: 'switch', 'aria-checked': !!SET[it.k], 'aria-label': it.label });
t.onclick = () => { playSfx('ui'); SET[it.k] = !SET[it.k]; t.classList.toggle('on', SET[it.k]); t.setAttribute('aria-checked', SET[it.k]); applySettings(); };
ctl.appendChild(t);
} else if (it.type === 'select') {
const s = el('select', { id, 'aria-label': it.label });
for (const [val, lab] of it.opts) { const o = el('option', { value: String(val) }, esc(lab)); if (String(SET[it.k]) === String(val)) o.selected = true; s.appendChild(o); }
s.onchange = () => { const raw = s.value; SET[it.k] = isNaN(parseFloat(raw)) ? raw : parseFloat(raw); applySettings(); };
ctl.appendChild(s);
} else if (it.type === 'button') {
const b = el('button', { class: 'btn' + (it.danger ? ' danger' : ''), id }, esc(it.text));
b.onclick = () => { playSfx('ui'); it.fn(); };
ctl.appendChild(b);
}
row.appendChild(ctl); body.appendChild(row);
}
}
function applySettings() {
// choosing Smooth again after it was too slow: try once more, at half resolution
if (SET.look === 'smooth' && SM.tried && !SM.ok && SM.why === 'slow' && SM.gl) { SM.ok = true; SM.why = ''; SM.scale = 0.5; SM.n = 0; }
saveSettings();
document.documentElement.style.setProperty('--ui', SET.uiScale);
if (R.canvas) { resizeRender(); R.snapNext = true; }
auVolumes();
if (!SET.showPerf) $('perf').textContent = '';
}
function toggleFullscreen() {
try {
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => toast('Fullscreen is not available in this view.'));
else document.exitFullscreen();
} catch (e) { toast('Fullscreen is not available in this view.'); }
}
function confirmWipe() {
dialog('Delete saved worlds?', '<p class="note">This removes the main save and the autosave from this browser. Exported world files are not affected.</p>', [
{ label: 'Cancel', cls: 'ghost' },
{ label: 'Delete saves', cls: 'danger', fn: async () => { try { await DB.del('main'); await DB.del('auto'); toast('Saves deleted.'); } catch (e) { toast('Could not reach browser storage.'); } refreshContinue(); } },
]);
}
+67
View File
@@ -0,0 +1,67 @@
/* Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers,
toasts and the status line. */
// ---------- Settings ----------
const SET_DEFAULT = {
zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7,
look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true,
deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: true, sv: 2,
};
const SET = Object.assign({}, SET_DEFAULT);
// sv: settings version. 2: the default zoom became fully zoomed in, so older saved settings move there once.
function loadSettings() {
try {
const s = JSON.parse(localStorage.getItem('deepgrain.settings') || '{}'); Object.assign(SET, s);
if (!(s.sv >= 2)) { SET.zoom = SET_DEFAULT.zoom; SET.sv = 2; if (Object.keys(s).length) saveSettings(); }
} catch (e) { /* storage unavailable */ }
}
function saveSettings() { try { localStorage.setItem('deepgrain.settings', JSON.stringify(SET)); } catch (e) { /* storage unavailable */ } }
loadSettings();
// ---------- Game state ----------
const TOOLS = [
{ id: 'drill', name: 'Drill', key: '1' },
{ id: 'vacuum', name: 'Vacuum', key: '2' },
{ id: 'build', name: 'Build', key: '3' },
{ id: 'ignite', name: 'Igniter', key: '4' },
];
const ICONS = {
drill: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M4 9h9l3 3-3 3H4z"/><path d="M16 12h5M17 9.5l3 2.5-3 2.5"/><path d="M7 9v6M10 9v6"/></svg>',
vacuum: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M3 15l6-6h4l2 2v4l-6 6z"/><path d="M15 11l5-5M17 4l3 3"/><circle cx="6" cy="19" r="0.8" fill="currentColor"/><circle cx="4" cy="21" r="0.6" fill="currentColor"/></svg>',
build: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="6" width="18" height="12" rx="1"/><path d="M3 12h18M9 6v6M15 12v6"/></svg>',
ignite: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 21c-4 0-6-2.6-6-5.6 0-3.4 3-5 3.6-8.4C12.5 9 14 11 14 13c1-.6 1.6-1.8 1.6-3 2 1.6 2.4 3.6 2.4 5.4 0 3-2 5.6-6 5.6z"/></svg>',
blueprint: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="11" height="11" rx="1" stroke-dasharray="2.5 2"/><rect x="10" y="10" width="11" height="11" rx="1"/></svg>',
};
const BAG_ORDER = [BARAU, BARAG, BARBZ, BARBS, BARST, BARFE, BARCU, BARSN, BARZN, BARGL, BARSB, AUORE, AGORE, ZNORE, SNORE, RSTONE, LCLAY, RBRICK, LDIRT, LWOOD, ACORN, COAL, CHARCOAL, CUORE, FEORE, SAND, GRAVEL, RGRAN, RBAS, CULLET,
SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS];
function loadCollect() { try { const a = JSON.parse(localStorage.getItem('deepgrain.collect') || '[]'); return new Set(a.filter(m => m > 0 && m < NMAT && BAGOK[m])); } catch (e) { return new Set(); } }
const GAME = {
running: false, paused: false, tool: 0, dropMat: 0, matChosen: false, wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {},
brush: [3, 2, 0, 0, 0], collect: loadCollect(), tipsOpen: new Set(), cards: [], vacFilter: 0, vacLocked: false, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 },
paste: false, selDrag: null, rmbRec: null, dragDir: -1, ctrlShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false },
lmb: false, rmb: false, blowMix: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 },
lastAutosave: 0, playTime: 0, lastHud: 0, worldName: '',
};
const keys = new Set();
let hudDirty = true;
// ---------- DOM helpers ----------
const $ = id => document.getElementById(id);
function el(tag, attrs, html) { const e = document.createElement(tag); if (attrs) for (const k in attrs) e.setAttribute(k, attrs[k]); if (html !== undefined) e.innerHTML = html; return e; }
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
function rgbOf(m) { return `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`; }
function fmtT(t) { return t >= 999.5 ? `${Math.round(t).toLocaleString('en-US')}°C` : `${Math.round(t)}°C`; }
// ---------- Toasts ----------
function toast(text, opts = {}) {
const box = $('toasts');
while (box.children.length > 4) box.firstChild.remove();
const t = el('div', { class: 'toast plate' + (opts.big ? ' big' : '') });
t.innerHTML = opts.title ? `<b>${esc(opts.title)}</b>${esc(text)}` : esc(text);
box.appendChild(t);
const life = opts.big ? 5200 : 2600;
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 450); }, life);
}
function setStatus(s, kind) { if (s) { GAME.status = s; GAME.statusKind = kind || 'err'; GAME.statusT = performance.now(); hudDirty = true; } }
+46
View File
@@ -0,0 +1,46 @@
// The title screen art.
// ---------- Title art ----------
const FONT5 = {
D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], G: ['01111', '10000', '10000', '10011', '10001', '10001', '01111'],
R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
};
const TITLE = { cells: [], grains: [], t: 0, raf: 0 };
function buildTitleCells() {
const lines = [['DEEPGRAIN', 6, 'hot'], ['FOUNDRY', 44, 'copper']];
for (const [word, y0, kind] of lines) {
const sc = 3, wpx = word.length * 6 * sc - sc;
const x0 = Math.round((320 - wpx) / 2);
[...word].forEach((ch, k) => {
const g = FONT5[ch];
for (let r = 0; r < 7; r++) for (let c = 0; c < 5; c++) if (g[r][c] === '1')
for (let a = 0; a < sc; a++) for (let b = 0; b < sc; b++) TITLE.cells.push({ x: x0 + (k * 6 + c) * sc + a, y: y0 + r * sc + b, kind, n: Math.random() });
});
}
}
function drawTitle() {
const c = $('titleart'); if (!c || $('title').hidden) { TITLE.raf = 0; return; }
const ctx = c.getContext('2d'); const t = (TITLE.t += 1);
const img = ctx.createImageData(320, 80), px = new Uint32Array(img.data.buffer);
px.fill(0x00000000);
for (const g of TITLE.cells) {
const w = 0.55 + 0.45 * Math.sin(t * 0.045 + g.x * 0.09 + g.n * 6);
let r, gg, b;
if (g.kind === 'hot') { const T = 1050 + w * 650 + g.n * 120; const i = glowIndex(T); r = GLOW_R[i]; gg = GLOW_G[i]; b = GLOW_B[i]; }
else { const v = (g.n - 0.5) * 30; r = 200 + v + w * 20; gg = 122 + v + w * 12; b = 76 + v; }
px[g.y * 320 + g.x] = 0xff000000 | (Math.max(0, Math.min(255, b)) << 16) | (Math.max(0, Math.min(255, gg)) << 8) | Math.max(0, Math.min(255, r));
}
// falling grains under the words
if (Math.random() < 0.6) TITLE.grains.push({ x: 60 + Math.random() * 200, y: 28, v: 0.3 + Math.random() * 0.4, hot: Math.random() < 0.6 });
for (let k = TITLE.grains.length - 1; k >= 0; k--) {
const g = TITLE.grains[k]; g.y += g.v; g.v += 0.02;
if (g.y > 79) { TITLE.grains.splice(k, 1); continue; }
if (g.y > 41 && g.y < 66 && px[(g.y | 0) * 320 + (g.x | 0)]) { TITLE.grains.splice(k, 1); continue; }
px[(g.y | 0) * 320 + (g.x | 0)] = g.hot ? 0xff3aa0ff : 0xff5a8cc8;
}
ctx.putImageData(img, 0, 0);
TITLE.raf = requestAnimationFrame(drawTitle);
}