77 lines
2.7 KiB
TypeScript
77 lines
2.7 KiB
TypeScript
// Lightweight game audio: one looping "scene music" track plus synthesized one-shot
|
|||
|
|
// SFX. Zero dependencies. Browsers block autoplay until a user gesture, so music/SFX
|
||
|
|
// are primed on the first pointer interaction.
|
||
|
|
type Sfx = 'advance' | 'choice' | 'sting'
|
||
|
|
const MUSIC_VOLUME = 0.5
|
||
|
|
|
||
|
|
let ctx: AudioContext | null = null
|
||
|
|
function audioContext() {
|
||
|
|
if (!ctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) ctx = new AC() }
|
||
|
|
return ctx
|
||
|
|
}
|
||
|
|
|
||
|
|
const music = typeof Audio !== 'undefined' ? new Audio() : null
|
||
|
|
if (music) music.loop = true
|
||
|
|
let currentUrl: string | null = null
|
||
|
|
let muted = false
|
||
|
|
let pending = false
|
||
|
|
let fadeTimer: number | undefined
|
||
|
|
|
||
|
|
function fade(target: number, done?: () => void) {
|
||
|
|
if (!music) return
|
||
|
|
window.clearInterval(fadeTimer)
|
||
|
|
const step = (target - music.volume) / 12 || (target > music.volume ? 0.1 : -0.1)
|
||
|
|
fadeTimer = window.setInterval(() => {
|
||
|
|
if (!music) return
|
||
|
|
const next = music.volume + step
|
||
|
|
if ((step >= 0 && next >= target) || (step <= 0 && next <= target)) { music.volume = target; window.clearInterval(fadeTimer); done?.() }
|
||
|
|
else music.volume = Math.max(0, Math.min(1, next))
|
||
|
|
}, 40)
|
||
|
|
}
|
||
|
|
|
||
|
|
function startMusic() {
|
||
|
|
if (!music || !currentUrl || muted) return
|
||
|
|
const promise = music.play()
|
||
|
|
if (promise) promise.then(() => { pending = false; fade(MUSIC_VOLUME) }).catch(() => { pending = true })
|
||
|
|
}
|
||
|
|
|
||
|
|
export const audio = {
|
||
|
|
// A null url inherits whatever is already playing; a new url crossfades to it.
|
||
|
|
setMusic(url: string | null) {
|
||
|
|
if (!music || url === currentUrl) return
|
||
|
|
currentUrl = url
|
||
|
|
if (!url) { fade(0, () => music.pause()); pending = false; return }
|
||
|
|
music.src = url
|
||
|
|
music.volume = 0
|
||
|
|
startMusic()
|
||
|
|
},
|
||
|
|
toggleMute() {
|
||
|
|
muted = !muted
|
||
|
|
if (muted) fade(0)
|
||
|
|
else if (currentUrl) startMusic()
|
||
|
|
return muted
|
||
|
|
},
|
||
|
|
isMuted() { return muted },
|
||
|
|
sfx(kind: Sfx) {
|
||
|
|
if (muted) return
|
||
|
|
const context = audioContext()
|
||
|
|
if (!context || context.state !== 'running') return
|
||
|
|
const osc = context.createOscillator(), gain = context.createGain()
|
||
|
|
osc.type = 'sine'
|
||
|
|
osc.frequency.value = kind === 'choice' ? 540 : kind === 'sting' ? 300 : 400
|
||
|
|
const now = context.currentTime
|
||
|
|
gain.gain.setValueAtTime(0.0001, now)
|
||
|
|
gain.gain.exponentialRampToValueAtTime(0.07, now + 0.008)
|
||
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.12)
|
||
|
|
osc.connect(gain).connect(context.destination)
|
||
|
|
osc.start(now); osc.stop(now + 0.13)
|
||
|
|
},
|
||
|
|
// Resume the context and retry pending music on a user gesture.
|
||
|
|
resume() {
|
||
|
|
void audioContext()?.resume?.()
|
||
|
|
if (pending) startMusic()
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof window !== 'undefined') window.addEventListener('pointerdown', () => audio.resume())
|