// Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. function spawnFloater(x, y, text, colour) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, colour, 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.globalAlpha = a; ctx.fillStyle = f.colour || '#ffd27a'; ctx.fillText(f.text, s.x, s.y); ctx.globalAlpha = 1; } ctx.restore(); } function moldLabel(mo) { return mo.state === 'ready' && mo.kind !== 'ingot' ? '100% · F' : Math.round(mo.fill * 100) + '%'; } function drawMolds() { const ctx = R.ctx, zp = R.zp, fs = Math.max(9, Math.round(zp * 2)); ctx.save(); if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace'; ctx.font = `600 ${fs}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' && mo.kind !== 'ingot', bad = mo.state === 'flawed' || mo.blocked; ctx.setLineDash([zp, zp]); ctx.strokeStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : '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([]); const label = moldLabel(mo), tw = ctx.measureText(label).width + fs * 0.6; ctx.fillStyle = 'rgba(14,12,11,0.75)'; ctx.fillRect(sx + sw / 2 - tw / 2, sy - zp - fs * 1.3, tw, fs * 1.3); ctx.fillStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : '#e7dccb'; ctx.fillText(label, sx + sw / 2, sy - zp - fs * 0.15); } 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); } }