With the cursor outside the reach circle, blown grains aim at the circle's edge in the cursor's direction instead of flying all the way to the cursor, and the blow preview stops there too. New test/t_nozzle.js and a play.py preview check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
13 KiB
JavaScript
193 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;
|
||
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);
|
||
}
|