// Procedural sound effects — pure WebAudio synthesis, no asset files. let ac = null, master = null, muted = false; export function initAudio() { if (!ac) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; ac = new AC(); master = ac.createGain(); master.gain.value = 0.35; master.connect(ac.destination); } if (ac.state === 'suspended') ac.resume(); } export function toggleMute() { muted = !muted; return muted; } function tone(freq, dur, type = 'square', vol = 0.18, slide = 0) { const t = ac.currentTime; const o = ac.createOscillator(), g = ac.createGain(); o.type = type; o.frequency.setValueAtTime(freq, t); if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t + dur); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); o.connect(g); g.connect(master); o.start(t); o.stop(t + dur + 0.02); } function noise(dur, vol = 0.25, filterFreq = 1200, q = 1) { const t = ac.currentTime; const len = Math.max(1, Math.floor(ac.sampleRate * dur)); const buf = ac.createBuffer(1, len, ac.sampleRate); const d = buf.getChannelData(0); for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); const src = ac.createBufferSource(); src.buffer = buf; const f = ac.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = filterFreq; f.Q.value = q; const g = ac.createGain(); g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); src.connect(f); f.connect(g); g.connect(master); src.start(t); } export function sfx(name) { if (!ac || muted) return; switch (name) { case 'jump': tone(300, 0.12, 'square', 0.12, 250); break; case 'airjump': tone(380, 0.1, 'square', 0.1, 280); break; case 'land': noise(0.08, 0.12, 500); break; case 'bark': tone(170, 0.07, 'sawtooth', 0.2, -60); tone(340, 0.05, 'square', 0.08, -100); break; case 'hit': noise(0.12, 0.3, 900); tone(110, 0.15, 'triangle', 0.25, -50); break; case 'block': tone(520, 0.1, 'sine', 0.15, 100); break; case 'dash': noise(0.1, 0.12, 2500); tone(600, 0.08, 'sine', 0.06, 400); break; case 'spike': tone(200, 0.2, 'sawtooth', 0.2, -140); break; case 'saw': noise(0.15, 0.25, 1800, 4); break; case 'pad': tone(180, 0.22, 'sine', 0.22, 380); break; case 'mine': tone(900 + Math.random() * 300, 0.05, 'square', 0.1); break; case 'break': tone(700, 0.07, 'square', 0.12, 300); tone(1050, 0.12, 'square', 0.1, 400); break; case 'pickup': tone(660, 0.07, 'square', 0.12); tone(990, 0.1, 'square', 0.1); break; case 'throw': noise(0.06, 0.1, 3000); break; case 'boom': noise(0.5, 0.4, 350, 0.5); tone(60, 0.4, 'sine', 0.3, -25); break; case 'shield': tone(440, 0.25, 'sine', 0.15, 220); break; case 'boost': tone(330, 0.1, 'square', 0.12, 200); tone(500, 0.12, 'square', 0.1, 300); break; case 'heal': tone(520, 0.12, 'sine', 0.12, 160); tone(780, 0.15, 'sine', 0.1, 100); break; case 'grapple': tone(800, 0.06, 'square', 0.12, -300); break; case 'whiff': tone(250, 0.08, 'sine', 0.08, -100); break; case 'blink': tone(1000, 0.12, 'sine', 0.1, 500); break; case 'jet': noise(0.07, 0.07, 700); break; case 'boing': tone(140, 0.25, 'sine', 0.22, 320); break; case 'charge': tone(120, 0.2, 'sawtooth', 0.18, 80); break; case 'thud': noise(0.1, 0.25, 400); break; case 'pound': noise(0.25, 0.35, 500); tone(70, 0.3, 'sine', 0.3, -20); break; case 'ko': tone(500, 0.5, 'sawtooth', 0.25, -420); noise(0.4, 0.3, 800); break; case 'respawn': tone(400, 0.15, 'sine', 0.12, 300); break; case 'go': tone(523, 0.12, 'square', 0.15); tone(784, 0.25, 'square', 0.15); break; case 'count': tone(440, 0.1, 'square', 0.12); break; case 'over': tone(523, 0.15, 'square', 0.15); tone(659, 0.15, 'square', 0.15); tone(784, 0.3, 'square', 0.15); break; case 'drop': tone(880, 0.1, 'sine', 0.08, -200); break; case 'fall': tone(420, 0.45, 'sawtooth', 0.2, -380); break; case 'ui': tone(600, 0.05, 'square', 0.08); break; } } // Map sim events → sfx, with a per-frame cap so explosions don't clip. const EVENT_SFX = { jump: 'jump', land: 'land', bark: 'bark', hit: 'hit', block: 'block', dash: 'dash', spike: 'spike', saw: 'saw', pad: 'pad', crack: 'mine', tile: 'break', pickup: 'pickup', throw: 'throw', boom: 'boom', shield: 'shield', boost: 'boost', heal: 'heal', grapple: 'grapple', whiff: 'whiff', blink: 'blink', jet: 'jet', boing: 'boing', charge: 'charge', thud: 'thud', pound: 'pound', ko: 'ko', respawn: 'respawn', go: 'go', over: 'over', drop: 'drop', fall: 'fall', }; export function playEvents(events) { let n = 0; for (const ev of events) { if (n >= 8) break; let name = EVENT_SFX[ev.t]; if (ev.t === 'jump' && ev.air) name = 'airjump'; if (name) { sfx(name); n++; } } }