// Cursor previews: shared grid helpers, air streaks and tool preview dispatch. // ---------- Overlay previews ---------- function blockRectPx(bx, by, bw, bh) { const q = worldToScreenPx(bx * BS, by * BS); return [q.x, q.y, bw * BS * R.zp, bh * BS * R.zp]; } function drawGrid(cbx, cby, radius) { const ctx = R.ctx, zp = R.zp, s = BS * zp; ctx.save(); ctx.lineWidth = 1; for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) { const d = Math.hypot(dx, dy) / radius; if (d > 1) continue; const [x, y] = blockRectPx(cbx + dx, cby + dy, 1, 1); ctx.strokeStyle = `rgba(236,226,210,${(0.16 * (1 - d)).toFixed(3)})`; ctx.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, s, s); } ctx.restore(); } function previewColor(code, v) { if (code === BP_WALL) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; } if (code === BP_FILTER) return '96,100,110'; return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190'; } // Air swirls into the pickup ring or spreads from the nozzle toward the cursor. function drawAirStreaks(ax, ay, p, r, blowing) { if (!SET.particles || (SET.fog && fogSeen(ax, ay) < 64)) return; const ctx = R.ctx, zp = R.zp, now = performance.now(); const cx = p.x + zp / 2, cy = p.y + zp / 2, ring = (r + 0.5) * zp; ctx.save(); ctx.lineWidth = Math.max(1, zp * 0.2); ctx.lineCap = 'round'; if (blowing) { const bx = bot.x + bot.w / 2, by = bot.y + bot.h / 2; const dx = ax + 0.5 - bx, dy = ay + 0.5 - by, d = Math.hypot(dx, dy) || 1; const o = worldToScreenPx(bx + dx / d * 3, by + dy / d * 3); const px = cx - o.x, py = cy - o.y, len = Math.hypot(px, py) || 1; const ux = px / len, uy = py / len; for (let i = 0; i < 10; i++) { const phase = (now * 0.0016 + i * 0.618) % 1; const side = ((i * 0.37) % 1 - 0.5) * 2 * ring * phase; const x = o.x + px * phase - uy * side, y = o.y + py * phase + ux * side; ctx.strokeStyle = `rgba(255,210,122,${Math.sin(phase * Math.PI) * 0.55})`; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ux * 1.6 * zp, y + uy * 1.6 * zp); ctx.stroke(); } } else { const travel = ring * 0.65 + 3 * zp; for (let i = 0; i < 12; i++) { const phase = (now * 0.0012 + i * 0.618) % 1; const dist = ring + 3 * zp - travel * phase; const angle = i * Math.PI * 2 / 12 + now * 0.0004 + phase * 1.2; const co = Math.cos(angle), si = Math.sin(angle); const x = cx + co * dist, y = cy + si * dist; const vx = -travel * co - dist * (1.2 + 1 / 3) * si; const vy = -travel * si + dist * (1.2 + 1 / 3) * co; const scale = 1.2 * zp / Math.hypot(vx, vy); ctx.strokeStyle = `rgba(92,186,166,${Math.sin(phase * Math.PI) * 0.5})`; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + vx * scale, y + vy * scale); ctx.stroke(); } } ctx.restore(); } function drawReachCircle(radius, alpha) { const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2); ctx.strokeStyle = `rgba(239,106,85,${alpha})`; ctx.setLineDash([zp * 2, zp * 2]); ctx.lineWidth = Math.max(1, zp * 0.3); ctx.beginPath(); ctx.arc(c.x, c.y, radius * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); } function drawPreviews() { const ctx = R.ctx, zp = R.zp; const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y); const p = worldToScreenPx(ax, ay); ctx.save(); const reachOk = inReach(ax, ay); const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip; if (!reachOk && t !== 'drill' && !sel) { drawReachCircle(REACH, 0.35); } if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) { drawReachCircle(drillReach(GAME.drillW), 0.45); } drawDrillPulses(); if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay); if (sel) drawSelection(sel, ax, ay); else if (pasting) drawPastePreview(); else if (t === 'vacuum' || t === 'ignite') { const r = t === 'ignite' ? 1 : GAME.brush[GAME.tool]; const reachOk = inReach(ax, ay); const blowing = t === 'vacuum' && GAME.rmb && !GAME.rmbConsumed; const nz = blowing && !reachOk ? nozzle(ax + 0.5, ay + 0.5) : null; const p = nz ? worldToScreenPx(nz.ox + nz.dx * nz.dist - 0.5, nz.oy + nz.dy * nz.dist - 0.5) : worldToScreenPx(ax, ay); ctx.strokeStyle = !reachOk && !nz ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25); if (t === 'vacuum') ctx.setLineDash([zp, zp * 0.6]); ctx.beginPath(); ctx.arc(p.x + zp / 2, p.y + zp / 2, (r + 0.5) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); if (t === 'vacuum') { // what the right button would blow, next to the cursor; what the left is sucking, while it sucks const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp; const swatches = GAME.lmb ? GAME.vacFilter === -2 ? [...GAME.blow] : GAME.vacFilter > 0 ? [GAME.vacFilter] : [] : blowingAll() ? blowAllList() : blowList(); swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); }); if (blowing || GAME.lmb) { // faint line from the nozzle: the path the grains take const c = worldToScreenPx(bot.x + bot.w / 2, bot.y + bot.h / 2); ctx.strokeStyle = blowing ? 'rgba(255,210,122,0.22)' : 'rgba(92,186,166,0.22)'; ctx.lineWidth = Math.max(1, zp * (r + 0.5) * 0.6); ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(p.x + zp / 2, p.y + zp / 2); ctx.stroke(); } if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing); } } else if (t === 'build') { const P = piece(), b = aimBlock(); drawGrid(b.bx, b.by, 9); let cells = [], col = '120,200,190'; if (P.kind === 'block') { const v = pieceV(P); col = previewColor(P.code, v); cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by)) : [[b.bx, b.by]]; const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1; if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; } const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0); const afford = canAfford(pieceCostNow(P), cells.length); for (const [bx, by] of cells) { const err = canPlaceBlock(bx, by), rb = by * BW + bx; const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P)); const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost const ok = (!err || swap) && !ghost && afford; const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillStyle = ghost ? 'rgba(90,190,230,0.22)' : ok ? (swap ? 'rgba(255,190,90,0.5)' : `rgba(${col},0.55)`) : 'rgba(239,106,85,0.35)'; ctx.fillRect(x, y, w, h); ctx.strokeStyle = ghost ? 'rgba(90,190,230,0.8)' : ok ? 'rgba(236,226,210,0.8)' : 'rgba(239,106,85,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25); if (ghost) ctx.setLineDash([zp, zp * 0.7]); ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); ctx.setLineDash([]); if (P.dirs && (P.id === 'belt' || P.id === 'tube' || P.filter || P.id === 'pump' || P.id === 'outlet')) { // a chevron on every block, pointing the way it will carry (or pump, or pour) const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx]; const cx = x + w / 2, cy = y + h / 2, k = w * 0.22, px_ = -dy, py_ = dx; ctx.strokeStyle = 'rgba(20,16,12,0.55)'; ctx.lineWidth = Math.max(2, zp * 0.9); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const chev = () => { ctx.beginPath(); ctx.moveTo(cx - dx * k * 0.6 + px_ * k, cy - dy * k * 0.6 + py_ * k); ctx.lineTo(cx + dx * k * 0.7, cy + dy * k * 0.7); ctx.lineTo(cx - dx * k * 0.6 - px_ * k, cy - dy * k * 0.6 - py_ * k); ctx.stroke(); }; chev(); ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(1.2, zp * 0.45); chev(); } } // dragging a line of belts, filters or tubes: one long arrow over it, the way it will run if (GAME.drag && dd >= 0 && cells.length > 1 && !modShift()) { const [x0, y0, w0, h0] = blockRectPx(cells[0][0], cells[0][1], 1, 1), [x1, y1, w1, h1] = blockRectPx(cells[cells.length - 1][0], cells[cells.length - 1][1], 1, 1); let sx = x0 + w0 / 2, sy = y0 + h0 / 2, ex = x1 + w1 / 2, ey = y1 + h1 / 2; const [dx, dy] = P.id === 'tube' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dd] : [[-1, 0], [1, 0]][dd]; if ((ex - sx) * dx + (ey - sy) * dy < 0) { const tx = sx, ty = sy; sx = ex; sy = ey; ex = tx; ey = ty; } const off = BS * zp * 1.0, hx = ex + dx * BS * zp * 0.55, hy = ey + dy * BS * zp * 0.55, ah = Math.max(8, BS * zp * 0.5); const ox = dy ? off : 0, oy = dx ? off : 0; const arrow = () => { ctx.beginPath(); ctx.moveTo(sx - dx * BS * zp * 0.3 + ox, sy - dy * BS * zp * 0.3 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.moveTo(hx - dx * ah - dy * ah * 0.6 + ox, hy - dy * ah - dx * ah * 0.6 + oy); ctx.lineTo(hx + ox, hy + oy); ctx.lineTo(hx - dx * ah + dy * ah * 0.6 + ox, hy - dy * ah + dx * ah * 0.6 + oy); ctx.stroke(); }; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = 'rgba(20,16,12,0.6)'; ctx.lineWidth = Math.max(4, zp * 1.1); arrow(); ctx.strokeStyle = 'rgba(255,224,160,0.98)'; ctx.lineWidth = Math.max(2, zp * 0.55); arrow(); } if (GAME.drag && cells.length > 1) { const label = `${cells.length} × ${costText(pieceCostNow(P), 1)}`; ctx.font = `600 12px ui-monospace, monospace`; ctx.fillStyle = 'rgba(14,12,11,0.85)'; const tw = ctx.measureText(label).width; ctx.fillRect(p.x + zp * 6, p.y - zp * 6 - 14, tw + 10, 18); ctx.fillStyle = '#e7dccb'; ctx.fillText(label, p.x + zp * 6 + 5, p.y - zp * 6); } } else { const { bw, bh } = pieceSize(P); const { bx, by } = stampOrigin(bw, bh); const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by); const swap = !!err && GAME.replace && /already/.test(err) && !footprintBlocker(bx, by, bw, bh); const ok = (!err || swap) && inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1); const [x, y, w, h] = blockRectPx(bx, by, bw, bh); if (swap && ok) { ctx.fillStyle = 'rgba(255,190,90,0.35)'; ctx.fillRect(x, y, w, h); } if (P.kind === 'mold') { const md = MOLDS[P.mold], wm = WALL_OF[GAME.wallMat]; for (let r = 0; r < md.h; r++) for (let c = 0; c < md.w; c++) { const ch = md.rows[r][c]; if (ch === '.') continue; ctx.fillStyle = ch === '#' ? (ok ? `rgba(${COLR[wm]},${COLG[wm]},${COLB[wm]},0.6)` : 'rgba(239,106,85,0.4)') : 'rgba(255,190,90,0.35)'; ctx.fillRect(x + c * zp, y + r * zp, zp, zp); } } else { ctx.fillStyle = ok ? 'rgba(92,186,166,0.2)' : 'rgba(239,106,85,0.2)'; if (P.id === 'wheel') { ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, w / 2, 0, Math.PI * 2); ctx.fill(); } else ctx.fillRect(x, y, w, h); if (P.id === 'engine') { ctx.fillStyle = 'rgba(255,210,122,0.6)'; ctx.fillRect(x + 4 * zp, y + 7 * zp, 4 * zp, zp); } if (P.id === 'fan') { const d = GAME.pieceDir.fan || 0, dx = [1, 0, -1, 0][d], dy = [0, 1, 0, -1][d]; ctx.strokeStyle = 'rgba(160,220,255,0.6)'; ctx.lineWidth = Math.max(1, zp * 0.4); ctx.beginPath(); ctx.moveTo(x + w / 2, y + h / 2); ctx.lineTo(x + w / 2 + dx * 28 * zp, y + h / 2 + dy * 28 * zp); ctx.stroke(); } } ctx.strokeStyle = ok ? 'rgba(92,186,166,0.95)' : 'rgba(239,106,85,0.95)'; ctx.lineWidth = Math.max(1, zp * 0.3); ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); } } // ghost machines and molds (always visible) ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3); for (const g of ghostMachines) { const P = PIECE_BY_ID[g.type]; const [x, y, w, h] = blockRectPx(g.bx, g.by, P.bw, P.bh); ctx.strokeStyle = 'rgba(90,190,230,0.7)'; ctx.strokeRect(x, y, w, h); } for (const g of ghostMolds) { const md = MOLDS[g.kind]; const [x, y, w, h] = blockRectPx(g.bx, g.by, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.7)'; ctx.strokeRect(x, y, w, h); } ctx.setLineDash([]); if (GAME.usingPad) { ctx.strokeStyle = 'rgba(255,230,200,0.9)'; ctx.lineWidth = 2; const cx = p.x + zp / 2, cy = p.y + zp / 2; ctx.beginPath(); ctx.moveTo(cx - 10, cy); ctx.lineTo(cx - 4, cy); ctx.moveTo(cx + 4, cy); ctx.lineTo(cx + 10, cy); ctx.moveTo(cx, cy - 10); ctx.lineTo(cx, cy - 4); ctx.moveTo(cx, cy + 4); ctx.lineTo(cx, cy + 10); ctx.stroke(); } ctx.restore(); if (t === 'build' && GAME.replace && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4); drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2); }