Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
128 lines
3.4 KiB
TypeScript
128 lines
3.4 KiB
TypeScript
/** 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<void>;
|
|
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<void> | null = null;
|
|
let mudo = false;
|
|
let descartado = false;
|
|
const buffers = new Map<NomeDoSom, AudioBuffer>();
|
|
const vozes: Voz[] = [];
|
|
|
|
async function baixar(ctx: AudioContext, nome: NomeDoSom): Promise<void> {
|
|
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;
|
|
},
|
|
};
|
|
}
|