// Machine overlays: fans, engines, 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); renderMonoFont(); 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(); } if (mm.hot > 760) { const sz = Math.max(10, zp * 2), x = sx + sw - sz * 0.6, y = sy - sz * 0.4; ctx.save(); ctx.fillStyle = '#b84332'; ctx.beginPath(); ctx.roundRect(x, y, sz, sz, sz * 0.2); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = `700 ${sz * 0.85}px monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('!', x + sz / 2, y + sz / 2); ctx.restore(); } // 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 === '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(); } drawProcBlocks(); drawRackBlocks(); } // Crushing rollers and saw blades sit over the shared steel housings in both looks. function drawProcBlocks() { const ctx = R.ctx; for (const b of procBlocks) { const bx = b % BW, by = (b / BW) | 0; const [sx, sy, sw] = cellRect(bx * BS, by * BS, BS, BS); if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sw < 0) continue; if (SET.fog && fogSeen(bx * BS + 2, by * BS + 2) < 64) continue; const saw = blockP[b] === BP_SAW, busy = tick - (procState.get(b)?.busyT ?? -999) < 30; const angle = busy ? tick * (saw ? 0.5 : 0.25) : b * 0.7; const radius = sw * (saw ? 0.38 : 0.22), teeth = saw ? 10 : 6; ctx.save(); ctx.lineWidth = Math.max(1, sw * 0.025); if (blockP[b] === BP_PRESS) { const offset = busy ? (1 - Math.cos(tick * 0.3)) * sw * 0.12 : 0; ctx.fillStyle = '#caa488'; ctx.fillRect(sx + sw * 0.27, sy + sw * 0.76, sw * 0.46, sw * 0.16); ctx.fillStyle = '#7d8996'; ctx.fillRect(sx + sw * 0.45, sy, sw * 0.1, sw * 0.4 + offset); ctx.fillStyle = '#b9c0c8'; ctx.fillRect(sx + sw * 0.12, sy + sw * 0.4 + offset, sw * 0.76, sw * 0.12); } else for (let wheel = 0; wheel < (saw ? 1 : 2); wheel++) { const cx = sx + sw * (saw ? 0.5 : wheel ? 0.73 : 0.27), cy = sy + sw * 0.5; const a = wheel ? -angle : angle; ctx.fillStyle = saw ? '#b9c0c8' : '#8a8f96'; ctx.strokeStyle = '#444b54'; if (sw >= 12) for (let k = 0; k < teeth; k++) { const t = a + k * Math.PI * 2 / teeth, half = (saw ? 0.18 : 0.24); ctx.beginPath(); ctx.moveTo(cx + Math.cos(t - half) * radius * 0.9, cy + Math.sin(t - half) * radius * 0.9); ctx.lineTo(cx + Math.cos(t) * radius * 1.15, cy + Math.sin(t) * radius * 1.15); ctx.lineTo(cx + Math.cos(t + half) * radius * 0.9, cy + Math.sin(t + half) * radius * 0.9); ctx.closePath(); ctx.fill(); ctx.stroke(); } ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); if (saw) { ctx.beginPath(); ctx.arc(cx, cy, radius * 0.65, 0, Math.PI * 2); ctx.stroke(); } ctx.fillStyle = '#292e36'; ctx.beginPath(); ctx.arc(cx, cy, Math.max(1, radius * 0.19), 0, Math.PI * 2); ctx.fill(); } 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) { if (!R.smoothOn) { drawSiloPixel(ctx, mm, sx, sy, zp); return; } const X = u => sx + u * zp, Y = u => sy + u * zp; 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(); drawSiloWindow(ctx, mm, sx, sy, zp, false); // what it holds, stencilled on the drum if (zp >= 3) { renderMonoFont(); 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(4.5), Y(5), 5 * zp); 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(4.5), Y(16.8), 5 * zp); 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(); } function drawSiloPixel(ctx, mm, sx, sy, zp) { const col = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; const grain = (x, y, c) => { ctx.fillStyle = c; ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp); }; // Steel shaded by column (light on the left, dark at the right edge), darker rib rows, and fixed // grain noise so it never shimmers. The drum is rows 2-19, the cone 20-22 narrowing to the gate. const steel = [[78,86,94],[176,186,194],[160,170,179],[148,158,167],[140,150,159],[134,144,153], [126,136,145],[118,128,137],[108,118,127],[96,105,114],[84,93,101],[62,70,78]]; for (let y = 2; y < 23; y++) for (let x = 0; x < 12; x++) { if (y >= 20 && (x < y - 19 || x > 30 - y)) continue; const c = steel[x], h = Math.imul(mm.x + x, 374761393) ^ Math.imul(mm.y + y, 668265263); const n = (((h ^ h >>> 13) >>> 0) % 3 - 1) * 4 - (y === 6 || y === 11 || y === 15 || y === 19 ? 26 : 0) - (y >= 20 ? 10 : 0) - (x === 2 && y < 19 && !(y & 1) ? 34 : 0); // ladder rungs up the left side grain(x, y, `rgb(${c[0] + n},${c[1] + n},${c[2] + n})`); } // legs with a strut to the cone, the gate opening, and the slide gate (pulled aside when open) for (let y = 20; y < 24; y++) { grain(0, y, '#2f353b'); grain(11, y, '#2f353b'); } grain(1, 21, '#2f353b'); grain(10, 21, '#2f353b'); for (let x = 4; x < 8; x++) grain(x, 23, '#1c1f23'); if (mm.open) for (let x = 8; x < 11; x++) grain(x, 23, '#c99a3c'); else for (let x = 3; x < 9; x++) grain(x, 23, '#6b737b'); drawSiloWindow(ctx, mm, sx, sy, zp, true); // roof: a rim, shoulders, the dark hatch and its lid standing open for (let x = 0; x < 12; x++) { grain(x, 1, x >= 3 && x < 9 ? '#15181b' : '#5b646c'); if (x < 3 || x >= 9) grain(x, 0, '#7d878f'); } const lid = tick - (mm.fillT || -99) < 10 ? '#e0b43c' : '#9aa4ae'; grain(8, 0, lid); grain(9, -1, lid); if (zp >= 3) { renderMonoFont(); ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(20,24,28,0.75)'; ctx.font = `700 ${Math.max(8, Math.round(zp * 1.15))}px ${R.monoFont}`; ctx.fillText('SILO', sx + 4.5 * zp, sy + 5 * zp, 5 * zp); ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`; ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', sx + 4.5 * zp, sy + 16.8 * zp, 5 * zp); if (mm.sn) { grain(4, 18, col); grain(5, 18, col); grain(6, 18, col); } ctx.restore(); } } // The same framed, three-grain window in both looks; one grain remains visible near empty. function drawSiloWindow(ctx, mm, sx, sy, zp, pixel) { const height = mm.sn > 0 ? Math.max(1, Math.round(16 * Math.min(1, mm.sn / SILO_CAP))) : 0; ctx.fillStyle = '#c9d1d8'; ctx.fillRect(sx + 7 * zp, sy + 2 * zp, 5 * zp, 18 * zp); ctx.fillStyle = '#14181c'; ctx.fillRect(sx + 8 * zp, sy + 3 * zp, 3 * zp, 16 * zp); if (!height) return; // Lift dark fuel slightly so coal stays distinct from the empty glass. const lift = Math.max(COLR[mm.sm], COLG[mm.sm], COLB[mm.sm]) < 80 ? 32 : 0; if (pixel) { for (let y = 19 - height; y < 19; y++) for (let x = 8; x < 11; x++) { const h = Math.imul(mm.x + x, 374761393) ^ Math.imul(mm.y + y, 668265263); const n = lift + (((h ^ h >>> 13) >>> 0) % 3 - 1) * 8; ctx.fillStyle = `rgb(${COLR[mm.sm] + n},${COLG[mm.sm] + n},${COLB[mm.sm] + n})`; ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp); } } else { ctx.fillStyle = `rgb(${COLR[mm.sm] + lift},${COLG[mm.sm] + lift},${COLB[mm.sm] + lift})`; ctx.fillRect(sx + 8 * zp, sy + (19 - height) * zp, 3 * zp, height * zp); } } // Shelves show each block's fill and materials; the bottom gate belongs to the column. function drawRackBlocks() { const ctx = R.ctx; for (const [b, s] of rackState) { const x = (b % BW) * BS, y = ((b / BW) | 0) * BS; const [sx, sy, sw] = cellRect(x, y, BS, BS); if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sw < 0) continue; if (SET.fog && fogSeen(x + 2, y + 2) < 64) continue; ctx.save(); ctx.fillStyle = '#202831'; ctx.fillRect(sx + sw * 0.1, sy + sw * 0.1, sw * 0.8, sw * 0.8); ctx.fillStyle = '#a5adb5'; for (let row = 0; row < 2; row++) ctx.fillRect(sx + sw * 0.1, sy + sw * (0.47 + row * 0.4), sw * 0.8, sw * 0.05); const slots = Math.ceil(s.n / 5); let run = 0, count = 0; for (let k = 0; k < slots; k++) { while (run + 1 < s.q.length && k * 5 >= count + s.q[run][1]) count += s.q[run++][1]; const m = s.q[run][0]; ctx.fillStyle = `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`; ctx.fillRect(sx + sw * (0.14 + k % 5 * 0.145), sy + sw * (0.71 - Math.floor(k / 5) * 0.4), sw * 0.12, sw * 0.12); } if (b + BW >= NB || blockP[b + BW] !== BP_RACK) { ctx.fillStyle = s.open ? '#e0b43c' : '#c9d1d8'; ctx.fillRect(sx + sw * 0.1, sy + sw * 0.94, sw * (s.open ? 0.2 : 0.8), sw * 0.06); if (s.open) ctx.fillRect(sx + sw * 0.7, sy + sw * 0.94, sw * 0.2, sw * 0.06); } ctx.restore(); } }