Files
deepgrain-foundry/src/ui/previews.js
T
44r0n7andClaude Opus 5.5 80420c3453 Refactor: merge duplicated rules, split the largest files by topic (t21)
- One helper each for wall and mold refunds, tube edges and lanes, pipe text, piece dimensions,
  font lookup, reach circles and surface averaging.
- previews, smooth, notebook, materials, worldgen and selection split into topic files
  (preview-drill, preview-selection, smooth-shaders, notebook-market, notebook-blueprints,
  material-tables, worldgen-helpers, selection-pickers, selection-panels).
- Every suite's output identical apart from timings; seeds make the same worlds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:35:12 -04:00

191 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
ctx.strokeStyle = !reachOk ? '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);
}