cybermax / kenwren /juice.js
CyberMax-tools's picture
Deploy kenwren to the hub
f6d583b verified
Raw History Blame Contribute Delete
4.11 kB
// 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);
}