// Kenwren juice: synthesised WebAudio sounds (no audio files), confetti particles and screen shake. // Mute is remembered in localStorage (via the kit's store(), which never throws). Shake respects reduced motion. // (c) 2026 CyberMax. import { store } from './cmx.js'; let ctx = null; let muted = !!store('kenwren:mute'); export const isMuted = () => muted; export function setMuted(m) { muted = !!m; store('kenwren:mute', muted ? 1 : 0); } const calm = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } }; function ac() { if (muted) return null; try { if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state === 'suspended') ctx.resume(); return ctx; } catch { return null; } } function tone(freq, at, dur, type = 'sine', vol = 0.18, slide = 0) { const a = ac(); if (!a) return; const t = a.currentTime + at; const o = a.createOscillator(); const g = a.createGain(); o.type = type; o.frequency.setValueAtTime(freq, t); if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t + dur); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.connect(g); g.connect(a.destination); o.start(t); o.stop(t + dur + 0.02); } export const sfx = { tap() { tone(660, 0, 0.05, 'triangle', 0.08); }, ok() { tone(784, 0, 0.09, 'triangle'); tone(1175, 0.07, 0.14, 'triangle'); }, bad() { tone(196, 0, 0.22, 'sawtooth', 0.09, -90); tone(147, 0.05, 0.22, 'square', 0.05, -60); }, star(k = 0) { tone([523, 659, 784][k] || 784, 0, 0.18, 'triangle', 0.16); tone(([523, 659, 784][k] || 784) * 2, 0.04, 0.16, 'sine', 0.06); }, win() { [523, 659, 784, 1047].forEach((f, k) => tone(f, k * 0.09, 0.22, 'triangle', 0.15)); }, lose() { tone(330, 0, 0.18, 'triangle', 0.12, -60); tone(247, 0.16, 0.3, 'triangle', 0.12, -60); }, }; export function shake(el) { if (!el || calm()) return; el.classList.remove('shake'); void el.offsetWidth; el.classList.add('shake'); setTimeout(() => el.classList.remove('shake'), 420); } let cv = null; let parts = []; let raf = 0; const COLORS = ['#FFD166', '#FF6A3D', '#2FD3A0', '#B8FFF0', '#FF7AC6', '#ffffff']; /** Burst of particles from an element (or a point). `n` pieces; bad=true gives a small grey puff instead. */ export function burst(from, n = 26, bad = false) { try { if (!cv) { cv = document.createElement('canvas'); cv.className = 'fx'; cv.setAttribute('aria-hidden', 'true'); document.body.appendChild(cv); } const dpr = Math.min(2, window.devicePixelRatio || 1); if (cv.width !== innerWidth * dpr) { cv.width = innerWidth * dpr; cv.height = innerHeight * dpr; } const r = from && from.getBoundingClientRect ? from.getBoundingClientRect() : { left: innerWidth / 2, top: innerHeight / 3, width: 0, height: 0 }; const x = r.left + r.width / 2; const y = r.top + r.height / 2; const count = calm() ? Math.ceil(n / 3) : n; for (let k = 0; k < count; k++) { const a = Math.random() * Math.PI * 2; const v = (bad ? 1.5 : 3) + Math.random() * (bad ? 2 : 5); parts.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - (bad ? 0.5 : 3), life: 1, s: 4 + Math.random() * 5, c: bad ? '#9aa0ad' : COLORS[k % COLORS.length], rot: Math.random() * 6 }); } if (!raf) raf = requestAnimationFrame(step); } catch { /* never break the game */ } } function step() { const g = cv.getContext('2d'); const dpr = cv.width / innerWidth; g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, innerWidth, innerHeight); parts = parts.filter((p) => p.life > 0); for (const p of parts) { p.x += p.vx; p.y += p.vy; p.vy += 0.22; p.vx *= 0.985; p.life -= 0.022; p.rot += 0.2; g.globalAlpha = Math.max(0, p.life); g.fillStyle = p.c; g.save(); g.translate(p.x, p.y); g.rotate(p.rot); g.fillRect(-p.s / 2, -p.s / 4, p.s, p.s / 2); g.restore(); } g.globalAlpha = 1; raf = parts.length ? requestAnimationFrame(step) : 0; if (!raf) g.clearRect(0, 0, innerWidth, innerHeight); }