335 lines
19 KiB
JavaScript
335 lines
19 KiB
JavaScript
// Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
|
|
function spawnFloater(x, y, text, colour) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, colour, life: 70 }); }
|
|
// Rain as thin falling streaks, snow as soft flakes
|
|
function drawRain() {
|
|
if (!flyers.length) return;
|
|
const ctx = R.ctx, zp = R.zp, v = viewCells();
|
|
const x0 = R.camX - 2, y0 = R.camY - 4, x1 = R.camX + v.w + 2, y1 = R.camY + v.h + 2;
|
|
ctx.save();
|
|
ctx.lineCap = 'round';
|
|
ctx.strokeStyle = 'rgba(176,204,236,0.5)'; ctx.lineWidth = Math.max(1, zp * 0.22);
|
|
ctx.beginPath();
|
|
let snow = false;
|
|
for (const f of flyers) {
|
|
if ((SET.fog && fogSeen(f.x, f.y) < 64) || !f.rain || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
|
|
if (f.m !== WATER) { snow = true; continue; }
|
|
const a = worldToScreenPx(f.x, f.y), len = Math.max(1.5, Math.hypot(f.vx, f.vy) * 3.2);
|
|
ctx.moveTo(a.x, a.y); ctx.lineTo(a.x - f.vx / Math.max(0.2, f.vy) * len * zp * 0.5, a.y - len * zp);
|
|
}
|
|
ctx.stroke();
|
|
if (snow) {
|
|
ctx.fillStyle = 'rgba(244,247,252,0.9)';
|
|
for (const f of flyers) {
|
|
if ((SET.fog && fogSeen(f.x, f.y) < 64) || !f.rain || f.m === WATER || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue;
|
|
const a = worldToScreenPx(f.x + Math.sin(f.age * 0.07 + f.x) * 0.6, f.y), r = Math.max(1, zp * 0.42);
|
|
ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
// Info overlay: a small arrow on every belt and screw conveyor block on screen, the way it moves things
|
|
function drawFlowArrows() {
|
|
const ctx = R.ctx, zp = R.zp, v = viewCells();
|
|
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
|
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
|
|
const s = BS * zp, r = Math.max(3, s * 0.22);
|
|
ctx.save();
|
|
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
|
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
|
const b = by * BW + bx, code = blockP[b];
|
|
if (code !== BP_BELT && code !== BP_SCREW && code !== BP_PUMP && code !== BP_OUTLET) continue;
|
|
const vv = blockV[b];
|
|
const [dx, dy] = code === BP_BELT ? (vv ? [1, 0] : [-1, 0]) : code === BP_PUMP ? PUMP_V[vv & 3] : code === BP_OUTLET ? OUTLET_V[vv & 3] : SCREW_DIRS[vv & 7];
|
|
const c = worldToScreenPx(bx * BS + BS / 2, by * BS + BS / 2);
|
|
ctx.fillStyle = 'rgba(14,12,11,0.55)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.35, 0, Math.PI * 2); ctx.fill();
|
|
ctx.strokeStyle = code === BP_SCREW ? '#ffc58a' : '#e7dccb'; ctx.lineWidth = Math.max(1.2, r * 0.32);
|
|
// a chevron pointing (dx, dy)
|
|
const px = -dy, py = dx;
|
|
ctx.beginPath();
|
|
ctx.moveTo(c.x - dx * r * 0.45 + px * r * 0.7, c.y - dy * r * 0.45 + py * r * 0.7);
|
|
ctx.lineTo(c.x + dx * r * 0.55, c.y + dy * r * 0.55);
|
|
ctx.lineTo(c.x - dx * r * 0.45 - px * r * 0.7, c.y - dy * r * 0.45 - py * r * 0.7);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
// "Doesn't connect": a red ring with a slash at a transport end that would join one block up or
|
|
// down (nearMiss), on built pieces, ghosts and the drag preview alike.
|
|
function drawJoinMark(bx, by, end, code, v) {
|
|
const e = pieceEnds(code, v)[end], ctx = R.ctx, r = Math.max(3, BS * R.zp * 0.24);
|
|
const c = worldToScreenPx(bx * BS + BS / 2 + e.dx * BS / 2, by * BS + BS / 2 + e.dy * BS / 2);
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(14,12,11,0.7)'; ctx.beginPath(); ctx.arc(c.x, c.y, r * 1.2, 0, Math.PI * 2); ctx.fill();
|
|
ctx.strokeStyle = '#ef6a55'; ctx.lineWidth = Math.max(1.2, r * 0.3);
|
|
ctx.beginPath(); ctx.arc(c.x, c.y, r * 0.75, 0, Math.PI * 2); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(c.x - r * 0.53, c.y + r * 0.53); ctx.lineTo(c.x + r * 0.53, c.y - r * 0.53); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
function drawJoinMarks() {
|
|
const v = viewCells();
|
|
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
|
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
|
|
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
|
const b = by * BW + bx, ghost = ghostBlocks.get(b), code = ghost ? ghost[0] : blockP[b], vv = ghost ? ghost[1] : blockV[b];
|
|
if (!code || !pieceEnds(code, vv).length) continue;
|
|
for (let end = 0; end < 2; end++) if (nearMiss(bx, by, end, ghostBlocks.size ? ghostBlocks : null)) drawJoinMark(bx, by, end, code, vv);
|
|
}
|
|
}
|
|
// Throughput (t49, info overlay): grains per second at the open outlet end of every line, and
|
|
// an orange glow on transport blocks that are loaded but not moving, where a line backs up.
|
|
function flowLoaded(b, code) {
|
|
const bx = b % BW, by = (b / BW) | 0;
|
|
let n = 0;
|
|
for (let k = 0; k < BS; k++) {
|
|
const x = bx * BS + k, i = code === BP_SCREW ? (by * BS + 1) * W + x : (beltFaceY(code, bx, by, x) - 1) * W + x;
|
|
if (i >= 0 && i < N && PH[mat[i]] === PH_POWDER) n++;
|
|
}
|
|
return n >= 3;
|
|
}
|
|
function drawFlowRates() {
|
|
const ctx = R.ctx, v = viewCells(), s = BS * R.zp;
|
|
const bx0 = Math.max(0, Math.floor(R.camX / BS)), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
|
|
const by0 = Math.max(0, Math.floor(R.camY / BS)), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
|
|
ctx.save();
|
|
ctx.font = `600 ${Math.max(10, Math.min(14, s * 0.45))}px ui-monospace, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
|
const b = by * BW + bx, code = blockP[b], ends = pieceEnds(code, blockV[b]);
|
|
if (!ends.length) continue;
|
|
const rate = flowRate[b] || 0;
|
|
if (rate < 0.5 && flowLoaded(b, code)) {
|
|
const p = worldToScreenPx(bx * BS, by * BS);
|
|
ctx.fillStyle = 'rgba(255,150,60,0.32)'; ctx.fillRect(p.x, p.y, s, s);
|
|
}
|
|
const out = ends[1].outlet ? 1 : 0;
|
|
if (endJoin(bx, by, out) >= 0) continue;
|
|
const e = ends[out], c = worldToScreenPx(bx * BS + BS / 2 + e.dx * BS * 0.95, by * BS + BS / 2 + e.dy * BS * 0.95 - (e.dy ? 0 : BS * 0.9));
|
|
const label = rate >= 10 ? `${Math.round(rate)}/s` : `${rate.toFixed(1)}/s`, tw = ctx.measureText(label).width;
|
|
ctx.fillStyle = 'rgba(14,12,11,0.8)'; ctx.fillRect(c.x - tw / 2 - 4, c.y - 9, tw + 8, 18);
|
|
ctx.fillStyle = rate > 0 ? '#e7dccb' : '#ef9a6a'; ctx.fillText(label, c.x, c.y + 1);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
// The resonance ping's echo: a faint ring spreading from the bot, the veins it found showing in
|
|
// their own lit colour inside it (with a slightly lighter rim so dark seams read against fog),
|
|
// a brief sparkle as the ring passes them, then a fade. The veins are drawn once into a small
|
|
// image, so each frame is one clipped drawImage plus a few dozen sparkles.
|
|
const PING_SPEED = 160, PING_FADE = 4500, PING_GLINTS = 60, GLINT_MS = 450;
|
|
function pingImage(p) {
|
|
const size = p.r * 2 + 1, c = document.createElement('canvas'); c.width = c.height = size;
|
|
const g = c.getContext('2d'), img = g.createImageData(size, size), d = img.data, ox = Math.floor(p.x - p.r), oy = Math.floor(p.y - p.r);
|
|
const lit = (i, k, lift) => { const m = mat[i], v = (noiseB[i] - 128) * CVAR[m] / 128 + lift; d[k] = COLR[m] + v; d[k + 1] = COLG[m] + v; d[k + 2] = COLB[m] + v; d[k + 3] = 255; };
|
|
for (const i of p.cells) { const x = i % W - ox, y = ((i / W) | 0) - oy; if (x >= 0 && y >= 0 && x < size && y < size) lit(i, (y * size + x) * 4, 0); }
|
|
for (const i of p.cells) {
|
|
const x = i % W - ox, y = ((i / W) | 0) - oy, k = (y * size + x) * 4;
|
|
if (x <= 0 || y <= 0 || x >= size - 1 || y >= size - 1) continue;
|
|
if (!d[k - 1] || !d[k + 7] || !d[k - size * 4 + 3] || !d[k + size * 4 + 3]) lit(i, k, 45); // the rim
|
|
}
|
|
g.putImageData(img, 0, 0);
|
|
const step = Math.max(1, Math.ceil(p.cells.length / PING_GLINTS));
|
|
const glints = p.cells.filter((_, k) => k % step === 0).map(i => ({ x: i % W + 0.5, y: ((i / W) | 0) + 0.5, d: Math.hypot(i % W - p.x, ((i / W) | 0) - p.y), m: mat[i] }));
|
|
return { c, ox, oy, glints };
|
|
}
|
|
function drawPing() {
|
|
const p = GAME.ping;
|
|
if (!p) return;
|
|
const ms = performance.now() - p.t0, front = ms / 1000 * PING_SPEED, done = p.r / PING_SPEED * 1000;
|
|
if (ms > done + PING_FADE) { GAME.ping = null; return; }
|
|
if (!p.img) p.img = pingImage(p);
|
|
const ctx = R.ctx, zp = R.zp, c = worldToScreenPx(p.x, p.y), o = worldToScreenPx(p.img.ox, p.img.oy);
|
|
ctx.save();
|
|
ctx.beginPath(); ctx.arc(c.x, c.y, Math.min(front, p.r + 1) * zp, 0, Math.PI * 2); ctx.clip();
|
|
ctx.globalAlpha = ms < done ? 1 : Math.max(0, 1 - (ms - done) / PING_FADE);
|
|
ctx.imageSmoothingEnabled = false;
|
|
ctx.drawImage(p.img.c, o.x, o.y, p.img.c.width * zp, p.img.c.height * zp);
|
|
ctx.restore();
|
|
ctx.save();
|
|
if (front < p.r) {
|
|
ctx.strokeStyle = `rgba(160,225,255,${0.35 * (1 - front / p.r)})`; ctx.lineWidth = Math.max(1, zp * 0.4);
|
|
ctx.beginPath(); ctx.arc(c.x, c.y, front * zp, 0, Math.PI * 2); ctx.stroke();
|
|
}
|
|
// a four-point glint where the ring has just passed, growing then shrinking
|
|
for (const gl of p.img.glints) {
|
|
const t = (ms - gl.d / PING_SPEED * 1000) / GLINT_MS;
|
|
if (t < 0 || t > 1) continue;
|
|
const s = worldToScreenPx(gl.x, gl.y), k = Math.sin(t * Math.PI) * zp * 2.2, m = gl.m;
|
|
ctx.strokeStyle = `rgba(${Math.min(255, COLR[m] + 140)},${Math.min(255, COLG[m] + 140)},${Math.min(255, COLB[m] + 140)},${0.9 * Math.sin(t * Math.PI)})`;
|
|
ctx.lineWidth = Math.max(1, zp * 0.35);
|
|
ctx.beginPath(); ctx.moveTo(s.x - k, s.y); ctx.lineTo(s.x + k, s.y); ctx.moveTo(s.x, s.y - k); ctx.lineTo(s.x, s.y + k); ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
// Outline a run of blocks: strong while it is being edited, faint when pointed at
|
|
function drawRunOutline(run, strong) {
|
|
if (!run || !run.length) return;
|
|
const ctx = R.ctx, zp = R.zp;
|
|
const x0 = (run[0] % BW) * BS, x1 = (run[run.length - 1] % BW + 1) * BS, y = ((run[0] / BW) | 0) * BS;
|
|
const a = worldToScreenPx(x0, y), b = worldToScreenPx(x1, y + BS);
|
|
ctx.save();
|
|
ctx.strokeStyle = strong ? 'rgba(255,190,90,0.95)' : 'rgba(236,226,210,0.45)'; ctx.lineWidth = Math.max(1, zp * (strong ? 0.5 : 0.25));
|
|
if (!strong) ctx.setLineDash([zp, zp * 0.7]);
|
|
ctx.strokeRect(a.x - zp * 0.3, a.y - zp * 0.3, b.x - a.x + zp * 0.6, b.y - a.y + zp * 0.6);
|
|
ctx.restore();
|
|
}
|
|
// Tags over filters: what falls through (↓) and what rides along, and which way.
|
|
// showAll tags every run of matching filters on screen; otherwise only the run under hoverB.
|
|
function drawFilterTags(showAll, hoverB) {
|
|
if (!filterCfg.size || (!showAll && hoverB < 0)) return;
|
|
const ctx = R.ctx, u = Math.max(1, R.canvas.width / window.innerWidth);
|
|
const v = viewCells();
|
|
const bx0 = Math.max(0, Math.floor(R.camX / BS) - 1), bx1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS) + 1);
|
|
const by0 = Math.max(0, Math.floor(R.camY / BS) - 1), by1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS) + 1);
|
|
renderMonoFont();
|
|
ctx.save();
|
|
ctx.font = `600 ${Math.round(11 * u)}px ${R.monoFont}`; ctx.textBaseline = 'middle';
|
|
const sw = Math.round(9 * u), gap = Math.round(3 * u), pad = Math.round(5 * u), th = Math.round(17 * u);
|
|
const runs = showAll ? [] : [filterRun(hoverB)];
|
|
if (showAll) for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
|
|
const b = by * BW + bx;
|
|
if (blockP[b] !== BP_FILTER) continue;
|
|
const run = filterRun(b);
|
|
if (run[0] === b || bx === bx0) runs.push(run);
|
|
bx = Math.max(bx, (run[run.length - 1] % BW));
|
|
}
|
|
for (const run of runs) {
|
|
if (!run.length) continue;
|
|
const b = run[0], c = filterCfg.get(b), ms = c ? c.m : [], inv = c ? c.inv : 0, right = blockV[b] & 128;
|
|
// segment contents: swatches, or a word when it is "the rest"
|
|
const segs = [
|
|
{ icon: '↓', col: '#7fd6c2', mats: inv ? null : ms, word: inv ? (ms.length ? 'rest' : 'all') : 'none' },
|
|
{ icon: right ? '→' : '←', col: '#ffd27a', mats: inv ? ms : null, word: inv ? 'none' : (ms.length ? 'rest' : 'all') },
|
|
];
|
|
let w = pad;
|
|
for (const sg of segs) {
|
|
sg.iw = ctx.measureText(sg.icon).width;
|
|
sg.show = sg.mats && sg.mats.length ? sg.mats.slice(0, 5) : null;
|
|
sg.more = sg.mats && sg.mats.length > 5 ? `+${sg.mats.length - 5}` : '';
|
|
sg.cw = sg.show ? sg.show.length * (sw + 2) - 2 + (sg.more ? gap + ctx.measureText(sg.more).width : 0) : ctx.measureText(sg.word).width;
|
|
w += sg.iw + gap + sg.cw + pad;
|
|
}
|
|
w += gap;
|
|
const x0 = (run[0] % BW) * BS, x1 = (run[run.length - 1] % BW + 1) * BS, y = ((b / BW) | 0) * BS;
|
|
const p = worldToScreenPx((x0 + x1) / 2, y);
|
|
let tx = Math.round(p.x - w / 2), ty = Math.round(p.y - th - 4 * u);
|
|
if (tx + w < 0 || tx > R.canvas.width || ty + th < 0 || ty > R.canvas.height) continue;
|
|
ctx.fillStyle = 'rgba(14,12,11,0.86)'; ctx.fillRect(tx, ty, w, th);
|
|
ctx.strokeStyle = 'rgba(236,226,210,0.22)'; ctx.lineWidth = u; ctx.strokeRect(tx + u / 2, ty + u / 2, w - u, th - u);
|
|
let cx = tx + pad;
|
|
segs.forEach((sg, k) => {
|
|
if (k) { ctx.fillStyle = 'rgba(236,226,210,0.25)'; ctx.fillRect(cx - pad / 2 - gap / 2, ty + 3 * u, u, th - 6 * u); cx += gap; }
|
|
ctx.fillStyle = sg.col; ctx.fillText(sg.icon, cx, ty + th / 2 + u * 0.5); cx += sg.iw + gap;
|
|
if (sg.show) {
|
|
for (const m of sg.show) { ctx.fillStyle = `rgb(${COLR[m]},${COLG[m]},${COLB[m]})`; ctx.fillRect(cx, ty + (th - sw) / 2, sw, sw); ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.strokeRect(cx + 0.5, ty + (th - sw) / 2 + 0.5, sw - 1, sw - 1); cx += sw + 2; }
|
|
cx -= 2;
|
|
if (sg.more) { cx += gap; ctx.fillStyle = '#e7dccb'; ctx.fillText(sg.more, cx, ty + th / 2 + u * 0.5); cx += ctx.measureText(sg.more).width; }
|
|
} else { ctx.fillStyle = 'rgba(231,220,203,0.85)'; ctx.fillText(sg.word, cx, ty + th / 2 + u * 0.5); cx += sg.cw; }
|
|
cx += pad;
|
|
});
|
|
}
|
|
ctx.restore();
|
|
}
|
|
function drawFloaters() {
|
|
const fl = R.floaters; if (!fl || !fl.length) return;
|
|
const ctx = R.ctx, zp = R.zp;
|
|
renderMonoFont();
|
|
ctx.save();
|
|
ctx.font = `700 ${Math.max(11, Math.round(zp * 3))}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
|
for (let k = fl.length - 1; k >= 0; k--) {
|
|
const f = fl[k]; f.life--; f.y -= 0.12;
|
|
if (f.life <= 0) { fl.splice(k, 1); continue; }
|
|
const s = worldToScreenPx(f.x, f.y), a = Math.min(1, f.life / 25);
|
|
ctx.fillStyle = `rgba(14,12,11,${0.6 * a})`; ctx.fillText(f.text, s.x + 1, s.y + 1);
|
|
ctx.globalAlpha = a; ctx.fillStyle = f.colour || '#ffd27a'; ctx.fillText(f.text, s.x, s.y); ctx.globalAlpha = 1;
|
|
}
|
|
ctx.restore();
|
|
}
|
|
function moldLabel(mo) { return mo.state === 'ready' && mo.kind !== 'ingot' ? '100% · F' : Math.round(mo.fill * 100) + '%'; }
|
|
function drawMolds() {
|
|
const ctx = R.ctx, zp = R.zp, fs = Math.max(9, Math.round(zp * 2));
|
|
ctx.save();
|
|
renderMonoFont();
|
|
ctx.font = `600 ${fs}px ${R.monoFont}`;
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
|
for (const mo of molds) {
|
|
const [sx, sy, sw, sh] = cellRect(mo.x, mo.y, mo.w, mo.h);
|
|
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sh < 0) continue;
|
|
const ready = mo.state === 'ready' && mo.kind !== 'ingot', bad = mo.state === 'flawed' || mo.blocked;
|
|
ctx.setLineDash([zp, zp]);
|
|
ctx.strokeStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : 'rgba(200,190,170,0.4)';
|
|
ctx.lineWidth = Math.max(1, zp * 0.3);
|
|
ctx.strokeRect(sx - zp * 0.5, sy - zp * 0.5, sw + zp, sh + zp);
|
|
ctx.setLineDash([]);
|
|
const label = moldLabel(mo), tw = ctx.measureText(label).width + fs * 0.6;
|
|
ctx.fillStyle = 'rgba(14,12,11,0.75)';
|
|
ctx.fillRect(sx + sw / 2 - tw / 2, sy - zp - fs * 1.3, tw, fs * 1.3);
|
|
ctx.fillStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : '#e7dccb';
|
|
ctx.fillText(label, sx + sw / 2, sy - zp - fs * 0.15);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
function drawBot() {
|
|
const ctx = R.ctx, zp = R.zp;
|
|
const [sx, sy, sw, sh] = cellRect(bot.x, bot.y, bot.w, bot.h);
|
|
const cx = sx + sw / 2, cy = sy + sh / 2;
|
|
const bob = Math.sin(R.frame * 0.12) * zp * 0.25;
|
|
ctx.save();
|
|
// thruster glow
|
|
if (bot.thrust > 0.1) {
|
|
const fl = (0.6 + Math.random() * 0.4) * bot.thrust;
|
|
const grd = ctx.createRadialGradient(cx, sy + sh + bob, 0, cx, sy + sh + bob, zp * 3.2 * fl);
|
|
grd.addColorStop(0, 'rgba(255,214,140,0.9)'); grd.addColorStop(1, 'rgba(255,120,40,0)');
|
|
ctx.fillStyle = grd; ctx.fillRect(cx - zp * 4, sy + sh - zp + bob, zp * 8, zp * 5);
|
|
}
|
|
// body
|
|
ctx.fillStyle = '#c8bfb2';
|
|
ctx.beginPath(); ctx.roundRect(sx + zp * 0.2, sy + zp * 0.6 + bob, sw - zp * 0.4, sh - zp * 1.4, zp * 1.4); ctx.fill();
|
|
ctx.fillStyle = '#8c8378';
|
|
ctx.fillRect(sx + zp * 0.2, sy + sh - zp * 1.6 + bob, sw - zp * 0.4, zp * 0.8);
|
|
// visor
|
|
ctx.fillStyle = '#1b1715';
|
|
const vx = cx + bot.face * zp * 0.5;
|
|
ctx.beginPath(); ctx.roundRect(vx - zp * 1.5, sy + zp * 1.4 + bob, zp * 3, zp * 1.6, zp * 0.6); ctx.fill();
|
|
ctx.fillStyle = bot.lamp ? '#ffb45a' : '#6b5a48';
|
|
ctx.fillRect(vx + bot.face * zp * 0.5 - zp * 0.45, sy + zp * 1.8 + bob, zp * 0.9, zp * 0.8);
|
|
// antenna
|
|
ctx.strokeStyle = '#8c8378'; ctx.lineWidth = Math.max(1, zp * 0.3);
|
|
ctx.beginPath(); ctx.moveTo(cx - bot.face * zp, sy + zp * 0.6 + bob); ctx.lineTo(cx - bot.face * zp * 1.6, sy - zp * 0.6 + bob); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
function spawnDebris(x, y, m) {
|
|
if (!SET.particles || R.particles.length > 400) return;
|
|
for (let k = 0; k < 2; k++) R.particles.push({ x: x + Math.random(), y: y + Math.random(), vx: (Math.random() - 0.5) * 0.5, vy: -Math.random() * 0.5, life: 20 + Math.random() * 20, r: COLR[m], g: COLG[m], b: COLB[m] });
|
|
}
|
|
function spawnSpark(x, y) {
|
|
if (!SET.particles || R.particles.length > 400) return;
|
|
R.particles.push({ x, y, vx: (Math.random() - 0.5) * 0.9, vy: -Math.random() * 0.8, life: 10 + Math.random() * 12, r: 255, g: 190 + Math.random() * 60, b: 90, spark: true });
|
|
}
|
|
// grains flying into the vacuum nozzle
|
|
function spawnSuck(x, y, m) {
|
|
if (!SET.particles || R.particles.length > 500) return;
|
|
const v = (Math.random() - 0.5) * 2 * CVAR[m];
|
|
R.particles.push({ x: x + 0.5, y: y + 0.5, vx: 0, vy: 0, life: 40, r: COLR[m] + v, g: COLG[m] + v, b: COLB[m] + v, suck: true });
|
|
}
|
|
function drawParticles() {
|
|
const ctx = R.ctx, zp = R.zp;
|
|
const ps = R.particles;
|
|
const tx = bot.x + bot.w / 2, ty = bot.y + bot.h / 2;
|
|
for (let k = ps.length - 1; k >= 0; k--) {
|
|
const p = ps[k];
|
|
if (p.suck) {
|
|
const dx = tx - p.x, dy = ty - p.y, d = Math.hypot(dx, dy);
|
|
if (d < 2.5) { ps.splice(k, 1); continue; }
|
|
const sp = Math.min(d, 0.6 + (40 - p.life) * 0.28);
|
|
p.x += dx / d * sp; p.y += dy / d * sp; p.life--;
|
|
if (p.life <= 0) { ps.splice(k, 1); continue; }
|
|
} else {
|
|
p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--;
|
|
if (p.life <= 0) { ps.splice(k, 1); continue; }
|
|
}
|
|
if (SET.fog && fogSeen(p.x, p.y) < 64) continue;
|
|
const s = worldToScreenPx(p.x, p.y);
|
|
ctx.fillStyle = `rgba(${p.r | 0},${p.g | 0},${p.b | 0},${Math.min(1, p.life / 10)})`;
|
|
const sz = p.spark ? Math.max(1, zp * 0.5) : p.suck ? zp : Math.max(1, zp * 0.7);
|
|
ctx.fillRect(s.x, s.y, sz, sz);
|
|
}
|
|
}
|