caro5 / client /src /game /effects /AudioManager.ts
Pedro de Carvalho
Fix vict music
2e13c62
Raw
History Blame Contribute Delete
9.96 kB
import { Howl, Howler } from 'howler'
import { binAssetUrl } from '../../binAssets'
import { debug } from '../../debug.ts'
type SoundName = 'placement' | 'hover' | 'win' | 'click'
const DEFAULT_SOUNDTRACK_TRACKS = [
'bamboo_breath_32.opus',
'bamboo_forest_32.opus',
'bamboo_green_32.opus',
'bamboo_rainfall_32.opus',
'bamboo_rattle_32.opus',
'bamboo_silence_32.opus',
]
const HF_SOUNDTRACK_BASE = 'https://huggingface.co/datasets/pedroc/caro5-game-assets/resolve/main/music/soundtrack'
const VICTORY_TRACKS = [
'firework_rookmate_48.ogg',
'imperial_checkmate2_48.ogg',
'imperial_firecrash_victory_48.ogg',
'imperial_firecrash_victory2_48.ogg',
]
const DEFEAT_TRACKS = [
'chessfall_disappointment_48.ogg',
'chessfall_glory_48.ogg',
'judgment_gong_48.ogg',
]
const HF_VICTORY_BASE = 'https://huggingface.co/datasets/pedroc/caro5-game-assets/resolve/main/music/victory'
const HF_DEFEAT_BASE = 'https://huggingface.co/datasets/pedroc/caro5-game-assets/resolve/main/music/defeat'
export class AudioManager {
private sounds = new Map<SoundName, Howl>()
private _muted = true
private _volume = 0.5
private hoverTimer: ReturnType<typeof setTimeout> | null = null
private music: Howl | null = null
private musicId: number | null = null
private tracks: string[]
private musicTrackIndex: number
private musicRequested = false
private musicStopTimer: ReturnType<typeof setTimeout> | null = null
private victoryDefeatMusic: Howl | null = null
private victoryPreloaded: Howl | null = null
private defeatPreloaded: Howl | null = null
constructor() {
debug('AudioManager', 'constructor')
this.tracks = [...DEFAULT_SOUNDTRACK_TRACKS]
this.musicTrackIndex = Math.floor(Math.random() * this.tracks.length)
this.load('placement', binAssetUrl('sounds/placement.wav'), { volume: 1.0 })
this.load('hover', binAssetUrl('sounds/hover.wav'), { volume: 0.4 })
this.load('win', binAssetUrl('sounds/win.wav'), { volume: 0.8 })
this.load('click', binAssetUrl('sounds/placement.wav'), { volume: 0.7 })
this.preloadNextVictory()
this.preloadNextDefeat()
this.applyVolume()
}
get muted(): boolean { return this._muted }
get volume(): number { return this._volume }
private load(name: SoundName, src: string, opts: { volume?: number; loop?: boolean } = {}): void {
const howl = new Howl({ src: [src], preload: true, ...opts })
this.sounds.set(name, howl)
}
private removeTrack(failedTrack: string): void {
const idx = this.tracks.indexOf(failedTrack)
if (idx === -1) return
debug('AudioManager', 'removing failed track', failedTrack)
this.tracks.splice(idx, 1)
if (this.musicTrackIndex >= idx && this.musicTrackIndex > 0) {
this.musicTrackIndex--
}
}
private createMusic(track: string): Howl {
const src = binAssetUrl(`music/soundtrack/${track}`)
const hfSrc = `${HF_SOUNDTRACK_BASE}/${track}`
const onError = () => {
debug('AudioManager', 'track error:', track)
this.removeTrack(track)
this.playNextTrack()
}
return new Howl({
src: src === hfSrc ? [src] : [src, hfSrc],
html5: true,
preload: true,
volume: 0.35,
onend: () => this.playNextTrack(),
onloaderror: onError,
onplayerror: onError,
})
}
private createOutcomeMusic(track: string, baseUrl: string, hfBase: string): Howl {
const src = binAssetUrl(`${baseUrl}/${track}`)
const hfSrc = `${hfBase}/${track}`
debug('AudioManager', 'createOutcomeMusic', { track, src, hfSrc })
return new Howl({
src: src === hfSrc ? [src] : [src, hfSrc],
preload: true,
volume: 0.5,
onload: () => {
debug('AudioManager', 'outcome music loaded:', track)
},
onloaderror: (_id: number, err: unknown) => {
debug('AudioManager', 'outcome music LOAD error:', track, err)
},
onplayerror: (_id: number, err: unknown) => {
debug('AudioManager', 'outcome music PLAY error:', track, err)
},
})
}
private preloadNextVictory(): void {
const track = VICTORY_TRACKS[Math.floor(Math.random() * VICTORY_TRACKS.length)]
debug('AudioManager', 'preloadNextVictory', { track })
this.victoryPreloaded = this.createOutcomeMusic(track, 'music/victory', HF_VICTORY_BASE)
}
private preloadNextDefeat(): void {
const track = DEFEAT_TRACKS[Math.floor(Math.random() * DEFEAT_TRACKS.length)]
debug('AudioManager', 'preloadNextDefeat', { track })
this.defeatPreloaded = this.createOutcomeMusic(track, 'music/defeat', HF_DEFEAT_BASE)
}
playVictoryMusic(): void {
debug('AudioManager', 'playVictoryMusic', {
hasPreloaded: !!this.victoryPreloaded,
hasCurrent: !!this.victoryDefeatMusic,
muted: this._muted,
howlerVolume: (Howler as any).volume(),
})
this.stopOutcomeMusic()
this.victoryDefeatMusic = this.victoryPreloaded
this.victoryPreloaded = null
if (this.victoryDefeatMusic) {
const id = this.victoryDefeatMusic.play()
debug('AudioManager', 'playVictoryMusic play() returned', { id })
} else {
debug('AudioManager', 'playVictoryMusic: no preloaded Howl')
}
this.preloadNextVictory()
}
playDefeatMusic(): void {
debug('AudioManager', 'playDefeatMusic', {
hasPreloaded: !!this.defeatPreloaded,
hasCurrent: !!this.victoryDefeatMusic,
muted: this._muted,
howlerVolume: (Howler as any).volume(),
})
this.stopOutcomeMusic()
this.victoryDefeatMusic = this.defeatPreloaded
this.defeatPreloaded = null
if (this.victoryDefeatMusic) {
const id = this.victoryDefeatMusic.play()
debug('AudioManager', 'playDefeatMusic play() returned', { id })
} else {
debug('AudioManager', 'playDefeatMusic: no preloaded Howl')
}
this.preloadNextDefeat()
}
private stopOutcomeMusic(): void {
if (this.victoryDefeatMusic) {
this.victoryDefeatMusic.unload()
this.victoryDefeatMusic = null
}
}
private playNextTrack(): void {
if (this.musicStopTimer) {
clearTimeout(this.musicStopTimer)
this.musicStopTimer = null
}
this.musicId = null
this.music?.unload()
this.music = null
if (this.tracks.length === 0) {
debug('AudioManager', 'no tracks left, stopping music')
this.musicRequested = false
return
}
this.musicTrackIndex = (this.musicTrackIndex + 1) % this.tracks.length
if (this.musicRequested && !this._muted) this.startMusic()
}
private applyVolume(): void {
const v = this._muted ? 0 : this._volume
Howler.volume(v)
}
setVolume(v: number): void {
this._volume = Math.max(0, Math.min(1, v))
this.applyVolume()
}
mute(): void {
this._muted = true
this.applyVolume()
this.pauseMusic()
}
unmute(): void {
this._muted = false
this.applyVolume()
if (this.musicRequested) this.startMusic()
}
toggleMute(): boolean {
this._muted ? this.unmute() : this.mute()
return !this._muted
}
play(name: SoundName): void {
debug('AudioManager', 'play', name)
const howl = this.sounds.get(name)
if (!howl) { debug('AudioManager', 'sound not found:', name); return }
howl.play()
}
stop(name: SoundName): void {
const howl = this.sounds.get(name)
if (!howl) return
howl.stop()
}
playHover(): void {
if (this.hoverTimer) return
this.hoverTimer = setTimeout(() => {
this.hoverTimer = null
this.play('hover')
}, 100)
}
startMusic(): void {
debug('AudioManager', 'startMusic', { muted: this._muted, hasMusic: !!this.music, musicId: this.musicId })
this.musicRequested = true
if (this.musicStopTimer) {
clearTimeout(this.musicStopTimer)
this.musicStopTimer = null
}
if (this._muted || this.musicId !== null) return
if (!this.music) {
if (this.musicTrackIndex >= this.tracks.length) {
this.musicTrackIndex = 0
}
this.music = this.createMusic(this.tracks[this.musicTrackIndex])
}
try {
this.musicId = this.music.play()
} catch {
debug('AudioManager', 'startMusic: play failed, next track')
this.playNextTrack()
}
}
pauseMusic(): void {
debug('AudioManager', 'pauseMusic', { musicId: this.musicId })
if (this.musicId === null) return
this.music?.pause(this.musicId)
this.musicId = null
}
stopMusic(): void {
debug('AudioManager', 'stopMusic')
this.musicRequested = false
if (this.musicId !== null) {
const id = this.musicId
this.music?.fade(0.35, 0, 450, id)
if (this.musicStopTimer) clearTimeout(this.musicStopTimer)
this.musicStopTimer = setTimeout(() => {
this.music?.stop(id)
this.musicStopTimer = null
}, 450)
} else {
this.music?.stop()
}
this.musicId = null
}
stopAll(): void {
debug('AudioManager', 'stopAll')
this.musicRequested = false
if (this.musicStopTimer) clearTimeout(this.musicStopTimer)
this.music?.stop()
this.music = null
this.musicId = null
this.musicStopTimer = null
this.stopOutcomeMusic()
this.victoryPreloaded?.unload()
this.victoryPreloaded = null
this.defeatPreloaded?.unload()
this.defeatPreloaded = null
this.preloadNextVictory()
this.preloadNextDefeat()
}
destroy(): void {
debug('AudioManager', 'destroy')
if (this.hoverTimer) clearTimeout(this.hoverTimer)
if (this.musicStopTimer) clearTimeout(this.musicStopTimer)
for (const howl of this.sounds.values()) howl.unload()
this.sounds.clear()
this.music?.unload()
this.music = null
this.musicId = null
this.musicRequested = false
this.musicStopTimer = null
this.stopOutcomeMusic()
this.victoryPreloaded?.unload()
this.victoryPreloaded = null
this.defeatPreloaded?.unload()
this.defeatPreloaded = null
}
}
export const audioManager = new AudioManager()