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
+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();
}
}