Files
deepgrain-foundry/src/render/machines.js
T
44r0n7andClaude Opus 5.5 b74a9173eb Silo contents at a glance (t38); drill aimed past its reach stays calm (t41)
Silos show a wide fill window in the material's colour in both looks,
and the cursor label names the contents anywhere over the outline.
Aiming the drill beyond its reach keeps normal colours, marks where the
pulse stops, and drops the red circle and the out-of-reach toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:40:05 -04:00

257 lines
16 KiB
JavaScript

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