/** Sons do jogo com Web Audio. Sem `AudioContext` no navegador, tudo vira no-op. */ export type NomeDoSom = | "pistola" | "automatica" | "faca" | "acerto" | "gemido-1" | "gemido-2" | "golpe" | "drone" | "resolvido" | "caixa"; export type Som = { /** Cria o contexto e baixa os sons; depois, retoma o contexto parado. Chamar a cada toque ou clique que começa ou volta ao jogo. */ liberar(): Promise; tocar(nome: NomeDoSom, opcoes?: { volume?: number; variar?: boolean }): void; definirMudo(mudo: boolean): void; descartar(): void; }; const NOMES: readonly NomeDoSom[] = [ "pistola", "automatica", "faca", "acerto", "gemido-1", "gemido-2", "golpe", "drone", "resolvido", "caixa", ]; const MAXIMO_DE_VOZES = 8; /** `variar` muda a velocidade em até 6% para cima ou para baixo. */ const VARIACAO = 0.06; type Voz = { fonte: AudioBufferSourceNode; ganho: GainNode }; export function criarSom(): Som { let contexto: AudioContext | null = null; let geral: GainNode | null = null; let carga: Promise | null = null; let mudo = false; let descartado = false; const buffers = new Map(); const vozes: Voz[] = []; async function baixar(ctx: AudioContext, nome: NomeDoSom): Promise { try { const resposta = await fetch(`/jogo/sons/${nome}.mp3`); if (!resposta.ok) return; const buffer = await ctx.decodeAudioData(await resposta.arrayBuffer()); buffers.set(nome, buffer); } catch { // Som que não carregou simplesmente não toca. } } function soltar(voz: Voz): void { const i = vozes.indexOf(voz); if (i >= 0) vozes.splice(i, 1); voz.ganho.disconnect(); } return { liberar() { const Contexto = globalThis.AudioContext; if (descartado || typeof Contexto !== "function") return Promise.resolve(); if (carga) { // no iOS o contexto fica `interrupted` ou `suspended` ao sair da aba if (contexto && contexto.state !== "running") contexto.resume().catch(() => {}); return carga; } const ctx = new Contexto(); contexto = ctx; geral = ctx.createGain(); geral.gain.value = mudo ? 0 : 1; geral.connect(ctx.destination); carga = Promise.all([ ctx.resume().catch(() => {}), ...NOMES.map((nome) => baixar(ctx, nome)), ]).then(() => {}); return carga; }, tocar(nome, opcoes) { const buffer = buffers.get(nome); if (!contexto || !geral || !buffer) return; if (vozes.length >= MAXIMO_DE_VOZES) { const antiga = vozes[0]; antiga.fonte.stop(); soltar(antiga); } const fonte = contexto.createBufferSource(); fonte.buffer = buffer; if (opcoes?.variar) fonte.playbackRate.value = 1 + (Math.random() * 2 - 1) * VARIACAO; const ganho = contexto.createGain(); ganho.gain.value = opcoes?.volume ?? 1; fonte.connect(ganho); ganho.connect(geral); const voz = { fonte, ganho }; vozes.push(voz); fonte.onended = () => soltar(voz); fonte.start(); }, definirMudo(valor) { mudo = valor; if (geral) geral.gain.value = mudo ? 0 : 1; }, descartar() { descartado = true; for (const voz of vozes.splice(0)) voz.fonte.stop(); buffers.clear(); contexto?.close().catch(() => {}); contexto = null; geral = null; }, }; }