240 lines
16 KiB
JavaScript
240 lines
16 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 (isWall(code)) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; }
|
||
if (isFilter(code)) return '96,100,110';
|
||
if (isBelt(code)) return '110,114,124';
|
||
return { [BP_BELT]: '110,114,124', [BP_SCREW]: '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);
|
||
}
|
||
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(), code = pieceCode(P, b);
|
||
if (GAME.previewCode !== code) { GAME.previewCode = code; hudDirty = true; }
|
||
drawGrid(b.bx, b.by, 9);
|
||
let cells = [], col = '120,200,190';
|
||
if (P.kind === 'block') {
|
||
const v = pieceV(P);
|
||
col = previewColor(code, v);
|
||
cells = GAME.drag ? (modShift() && !lineOnly(code) ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_SCREW)) : [[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);
|
||
const planned = (code === BP_SCREW && dIdx >= 4) || isSlopeBelt(code) ? new Map(cells.map(([x, y]) => [y * BW + x, [code, dIdx]])) : null;
|
||
for (const [bx, by] of cells) {
|
||
const err = canPlaceBlock(bx, by, code), rb = by * BW + bx;
|
||
const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, 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)';
|
||
previewBlock(code, x, y, w, h, false, P.id === 'screw' || P.id === 'belt' ? dIdx : -1, bx, by, planned);
|
||
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]);
|
||
previewBlock(code, x, y, w, h, true, P.id === 'screw' || P.id === 'belt' ? dIdx : -1, bx, by, planned); ctx.setLineDash([]);
|
||
if (P.dirs && (P.id === 'belt' || P.id === 'screw' || 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] = isSlopeBelt(code) ? [dIdx ? 1 : -1, beltGrade(code) * (dIdx ? 1 : -1)] : P.id === 'screw' ? SCREW_DIRS[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();
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
|
||
function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1, by = -1, planned = null) {
|
||
const ctx = R.ctx;
|
||
if (code === BP_SCREW && direction >= 4) {
|
||
const joins = bx >= 0 ? diagonalScrewJoins(bx, by, direction, planned) : 0;
|
||
const scale = w / BS, step = SET.look === "smooth" ? 0.25 : 1;
|
||
const b = bx >= 0 ? by * BW + bx : 0, ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS;
|
||
ctx.beginPath();
|
||
for (let yy = -4; yy < 8; yy += step) for (let xx = -4; xx < 8; xx += step) {
|
||
const sd = diagonalScrewShape(ox + xx + step / 2, oy + yy + step / 2, b, direction, joins);
|
||
if (sd <= 0 && (!stroke || sd > -step * 1.5)) ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
|
||
}
|
||
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
|
||
else ctx.fill();
|
||
return;
|
||
}
|
||
if (isSlopeBelt(code)) {
|
||
const b = by * BW + bx, joins = bx >= 0 ? slopeBeltConnections(bx, by, code, planned) : 0;
|
||
const scale = w / BS, step = SET.look === 'smooth' ? 0.25 : 1;
|
||
const ox = bx * BS, oy = by * BS;
|
||
ctx.beginPath();
|
||
for (let yy = -4; yy < 8; yy += step) for (let xx = -4; xx < 8; xx += step) {
|
||
const wx = ox + xx, wy = oy + yy, ix = Math.floor(wx), iy = Math.floor(wy);
|
||
if (bx >= 0 && (ix < ox || ix >= ox + BS || iy < oy || iy >= oy + BS)) {
|
||
if (ix < 0 || ix >= W || iy < 0 || iy >= H) continue;
|
||
const i = iy * W + ix, n = (iy >> 2) * BW + (ix >> 2), next = planned && planned.get(n);
|
||
if (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || (blockP[n] && !isSlopeBelt(blockP[n])) || (next && !isSlopeBelt(next[0]))) continue;
|
||
}
|
||
const sd = slopeBeltShape(wx + step / 2, wy + step / 2, b, joins, code);
|
||
if (sd <= 0 && (!stroke || sd > -step * 1.5)) ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
|
||
}
|
||
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
|
||
else ctx.fill();
|
||
// Use the same filled wall profile as placed slopes; ribs stay on the physics feet.
|
||
if (!isFilter(code) && (direction < 0 || slopeUphill(code, direction))) { // downhill: a plain belt
|
||
ctx.beginPath();
|
||
for (let yy = -9; yy < 8; yy += step) for (let xx = -5; xx < 9; xx += step)
|
||
if (slopeWallShape(ox + xx + step / 2, oy + yy + step / 2, b, code) <= 0)
|
||
ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
|
||
const alpha = ctx.globalAlpha; ctx.globalAlpha *= 0.45; ctx.fill(); ctx.globalAlpha = alpha;
|
||
ctx.beginPath();
|
||
for (let k = 0; k < BS; k++) if (((ox + k) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0) {
|
||
const fy = beltFaceY(code, 0, 0, k);
|
||
for (let h = 0; h <= CLEAT_REACH; h++)
|
||
ctx.rect(x + (k + beltGrade(code) * h) * scale, y + (fy - h - 1) * scale, scale, scale);
|
||
}
|
||
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
|
||
else ctx.fill();
|
||
}
|
||
return;
|
||
}
|
||
code = slopeShape(code);
|
||
if (!isSlope(code)) { if (stroke) ctx.strokeRect(x, y, w, h); else ctx.fillRect(x, y, w, h); return; }
|
||
const right = code === BP_SLOPE_BR || code === BP_SLOPE_TR, above = code >= BP_SLOPE_TL;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + (right ? w : 0), y + (above ? 0 : h));
|
||
ctx.lineTo(x + (right ? w : 0), y + (above ? h : 0));
|
||
ctx.lineTo(x + (right ? 0 : w), y + (above ? 0 : h)); ctx.closePath();
|
||
if (stroke) ctx.stroke(); else ctx.fill();
|
||
}
|