// Reveal animations, drawn by the renderer on top of / behind the rings. // Each reveal gets live ring objects so it follows fingers that still move. export const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); export const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); export function hexToRgba(hex, a) { const n = parseInt(hex.slice(1), 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } function farthestCorner(x, y, w, h) { return Math.hypot(Math.max(x, w - x), Math.max(y, h - y)); } /** * @param {string} style flood | confetti | spotlight | shockwave | calm | teams * @param {{targets:{ring:{x:number,y:number}, color:string}[], view:{w:number,h:number,R:number}, start:number, palette:string[]}} o * `view` is read every frame so the effect follows resizes (e.g. entering fullscreen). */ export function createReveal(style, o) { const make = { flood, confetti, spotlight, shockwave, calm, teams }[style] || calm; return { winnerWhite: false, drawBack() {}, drawFront() {}, shake: null, ...make(o) }; } function flood({ targets, view, start }) { const single = targets.length === 1; return { winnerWhite: true, drawBack(g, now) { const { w, h, R } = view; const k = easeInOutCubic(clamp01((now - start) / 850)); g.globalAlpha = single ? 1 : 0.8; for (const { ring, color } of targets) { const maxR = farthestCorner(ring.x, ring.y, w, h) * (single ? 1 : 0.5); g.fillStyle = color; g.beginPath(); g.arc(ring.x, ring.y, Math.max(0, maxR * k), 0, Math.PI * 2); g.fill(); } g.globalAlpha = 1; if (single && k >= 1) { // Soft vignette so the flooded screen keeps some depth. const { ring } = targets[0]; const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, farthestCorner(ring.x, ring.y, w, h)); grad.addColorStop(0, 'rgba(0,0,0,0)'); grad.addColorStop(1, 'rgba(0,0,0,0.4)'); g.fillStyle = grad; g.fillRect(0, 0, w, h); } }, }; } function confetti({ targets, view, start, palette }) { const parts = []; const per = Math.round(170 / targets.length); for (const { ring, color } of targets) { for (let i = 0; i < per; i++) { const a = Math.random() * Math.PI * 2; const speed = 250 + Math.random() * 850; parts.push({ ring, delay: i < per * 0.6 ? 0 : 0.22, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed - 300, size: 5 + Math.random() * 7, color: Math.random() < 0.45 ? color : palette[(Math.random() * palette.length) | 0], rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 14, flip: 4 + Math.random() * 8, life: 2.4 + Math.random() * 1.2, round: Math.random() < 0.3, }); } } // Freeze burst origin at reveal time. for (const p of parts) { p.x0 = p.ring.x; p.y0 = p.ring.y; } const tau = 0.75; const grav = 1100; return { drawBack(g, now) { const { w, h, R } = view; const k = clamp01((now - start) / 700); for (const { ring, color } of targets) { const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h)); grad.addColorStop(0, hexToRgba(color, 0.45 * k)); grad.addColorStop(1, hexToRgba(color, 0.08 * k)); g.fillStyle = grad; g.fillRect(0, 0, w, h); } }, drawFront(g, now) { const { w, h, R } = view; const T = (now - start) / 1000; for (const p of parts) { const t = T - p.delay; if (t <= 0 || t > p.life) continue; const e = tau * (1 - Math.exp(-t / tau)); const x = p.x0 + p.vx * e + Math.sin(t * 3 + p.rot) * 12 * t; const y = p.y0 + (p.vy + grav * tau) * e - grav * tau * t * 0.55; if (y > h + 20) continue; g.globalAlpha = clamp01((p.life - t) / 0.5); g.fillStyle = p.color; g.save(); g.translate(x, y); g.rotate(p.rot + p.vr * t); g.scale(1, Math.cos(t * p.flip)); if (p.round) { g.beginPath(); g.arc(0, 0, p.size * 0.5, 0, Math.PI * 2); g.fill(); } else g.fillRect(-p.size / 2, -p.size * 0.3, p.size, p.size * 0.6); g.restore(); } g.globalAlpha = 1; }, }; } function spotlight({ targets, view, start }) { // Darkness is drawn at half resolution: it's all soft gradients anyway. const scale = 0.5; const off = document.createElement('canvas'); const o = off.getContext('2d'); const spots = targets.map(() => ({ sx: Math.random() * view.w, sy: Math.random() * view.h * 0.6, ph: Math.random() * 6 })); return { drawBack(g, now) { const { w, h, R } = view; const k = clamp01((now - start - 900) / 500); if (!k) return; for (const { ring, color } of targets) { const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, R * 3); grad.addColorStop(0, hexToRgba(color, 0.55 * k)); grad.addColorStop(1, hexToRgba(color, 0)); g.fillStyle = grad; g.fillRect(ring.x - R * 3, ring.y - R * 3, R * 6, R * 6); } }, drawFront(g, now) { const { w, h, R } = view; const t = (now - start) / 1000; const ow = Math.ceil(w * scale); const oh = Math.ceil(h * scale); if (off.width !== ow || off.height !== oh) { off.width = ow; off.height = oh; } o.setTransform(scale, 0, 0, scale, 0, 0); o.globalCompositeOperation = 'source-over'; o.clearRect(0, 0, w, h); o.fillStyle = `rgba(0,0,0,${0.9 * clamp01(t / 0.25)})`; o.fillRect(0, 0, w, h); o.globalCompositeOperation = 'destination-out'; targets.forEach(({ ring }, i) => { const s = spots[i]; const m = easeOutCubic(clamp01(t / 1.1)); const wob = (1 - m) * 60; const x = s.sx + (ring.x - s.sx) * m + Math.sin(t * 9 + s.ph) * wob; const y = s.sy + (ring.y - s.sy) * m + Math.cos(t * 7 + s.ph) * wob; const r = Math.max(w, h) * 0.22 + (R * 2.3 - Math.max(w, h) * 0.22) * easeInOutCubic(clamp01((t - 0.3) / 0.9)); const grad = o.createRadialGradient(x, y, r * 0.7, x, y, r * 1.2); grad.addColorStop(0, 'rgba(0,0,0,1)'); grad.addColorStop(1, 'rgba(0,0,0,0)'); o.fillStyle = grad; o.fillRect(x - r * 1.2, y - r * 1.2, r * 2.4, r * 2.4); }); g.drawImage(off, 0, 0, w, h); }, }; } function shockwave({ targets, view, start }) { const delays = [0, 0.14, 0.3]; return { shake(now) { const t = (now - start) / 1000; const amp = 16 * Math.exp(-t * 7); if (amp < 0.3) return null; return [Math.sin(now * 0.09) * amp, Math.cos(now * 0.113) * amp]; }, drawBack(g, now) { const { w, h, R } = view; const t = (now - start) / 1000; for (const { ring, color } of targets) { g.fillStyle = hexToRgba(color, 0.25 * clamp01((t - 0.2) / 0.6)); g.fillRect(-20, -20, w + 40, h + 40); const maxR = farthestCorner(ring.x, ring.y, w, h); for (const d of delays) { const k = (t - d) / 1.0; if (k <= 0 || k >= 1) continue; const r = easeOutCubic(k) * maxR; g.globalAlpha = 1 - k; g.strokeStyle = color; g.lineWidth = R * 0.6 * (1 - k) + 2; g.beginPath(); g.arc(ring.x, ring.y, r, 0, Math.PI * 2); g.stroke(); g.strokeStyle = '#fff'; g.lineWidth = Math.max(1, R * 0.12 * (1 - k)); g.stroke(); } g.globalAlpha = 1; } }, drawFront(g, now) { const { w, h, R } = view; const t = (now - start) / 1000; if (t < 0.15) { g.fillStyle = `rgba(255,255,255,${0.55 * (1 - t / 0.15)})`; g.fillRect(-20, -20, w + 40, h + 40); } }, }; } /** prefers-reduced-motion: a slow, gentle colour fade. */ function calm({ targets, view, start }) { return { drawBack(g, now) { const { w, h, R } = view; const k = easeInOutCubic(clamp01((now - start) / 1200)); for (const { ring, color } of targets) { const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h) * 0.9); grad.addColorStop(0, hexToRgba(color, 0.5 * k)); grad.addColorStop(1, hexToRgba(color, 0.15 * k)); g.fillStyle = grad; g.fillRect(0, 0, w, h); } }, }; } function teams({ targets, view, start }) { return { drawBack(g, now) { const { w, h, R } = view; const k = easeOutCubic(clamp01((now - start) / 700)); for (const { ring, color } of targets) { const r = R * (1.5 + 2.5 * k); const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, r); grad.addColorStop(0, hexToRgba(color, 0.5)); grad.addColorStop(1, hexToRgba(color, 0)); g.fillStyle = grad; g.fillRect(ring.x - r, ring.y - r, r * 2, r * 2); } }, }; } /** Small expanding ring (joins, countdown beats, order steps). */ export function createRipple(x, y, color, start, maxR, dur = 600, width = 4) { return { done: (now) => now - start > dur, draw(g, now) { const k = (now - start) / dur; if (k < 0 || k > 1) return; g.globalAlpha = (1 - k) * 0.8; g.strokeStyle = color; g.lineWidth = width * (1 - k) + 0.5; g.beginPath(); g.arc(x, y, maxR * easeOutCubic(k), 0, Math.PI * 2); g.stroke(); g.globalAlpha = 1; }, }; }