- 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>
191 lines
13 KiB
JavaScript
191 lines
13 KiB
JavaScript
// 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);
|
||
}
|