import { type GameEffect } from '../core/gameResult' import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from './audioConfig' export class SoundDirector { enabled: boolean config: GameAudioConfig contexts: Partial> loopTimers: Partial> backgroundLoopTimer: number activeGuidanceCue: AudioCueKey | null backgroundManager: WechatMiniprogram.BackgroundAudioManager | null appAudioMode: 'foreground' | 'background' constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) { this.enabled = true this.config = config this.contexts = {} this.loopTimers = {} this.backgroundLoopTimer = 0 this.activeGuidanceCue = null this.backgroundManager = null this.appAudioMode = 'foreground' } configure(config: GameAudioConfig): void { this.config = config this.resetContexts() } setEnabled(enabled: boolean): void { this.enabled = enabled } resetContexts(): void { const timerKeys = Object.keys(this.loopTimers) as AudioCueKey[] for (const key of timerKeys) { const timer = this.loopTimers[key] if (timer) { clearTimeout(timer) } } this.loopTimers = {} this.clearBackgroundLoopTimer() const keys = Object.keys(this.contexts) as AudioCueKey[] for (const key of keys) { const context = this.contexts[key] if (!context) { continue } context.stop() context.destroy() } this.contexts = {} this.activeGuidanceCue = null this.stopBackgroundGuidance() } destroy(): void { this.resetContexts() } handleEffects(effects: GameEffect[]): void { if (!this.enabled || !this.config.enabled || !effects.length) { return } const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish') if (hasFinishCompletion) { this.stopGuidanceLoop() this.play('control_completed:finish') return } for (const effect of effects) { if (effect.type === 'session_started') { this.play('session_started') continue } if (effect.type === 'session_cancelled') { this.stopGuidanceLoop() this.play('control_completed:finish') continue } if (effect.type === 'punch_feedback' && effect.tone === 'warning') { this.play('punch_feedback:warning') continue } if (effect.type === 'guidance_state_changed') { if (effect.guidanceState === 'distant') { this.startGuidanceLoop('guidance:distant') continue } if (effect.guidanceState === 'approaching') { this.startGuidanceLoop('guidance:approaching') continue } if (effect.guidanceState === 'ready') { this.startGuidanceLoop('guidance:ready') continue } this.stopGuidanceLoop() continue } if (effect.type === 'control_completed') { this.stopGuidanceLoop() if (effect.controlKind === 'start') { this.play('control_completed:start') continue } if (effect.controlKind === 'finish') { this.play('control_completed:finish') continue } this.play('control_completed:control') continue } if (effect.type === 'session_finished') { this.stopGuidanceLoop() if (!hasFinishCompletion) { this.play('control_completed:finish') } } } } setAppAudioMode(mode: 'foreground' | 'background'): void { if (this.appAudioMode === mode) { return } this.appAudioMode = mode const activeGuidanceCue = this.activeGuidanceCue if (!activeGuidanceCue) { this.stopBackgroundGuidance() return } if (mode === 'background') { this.stopForegroundCue(activeGuidanceCue) this.startBackgroundGuidance(activeGuidanceCue) return } this.stopBackgroundGuidance() this.playForeground(activeGuidanceCue) } play(key: AudioCueKey): void { if (this.appAudioMode === 'background') { const cue = this.config.cues[key] if (!cue || cue.backgroundMode !== 'guidance' || !this.isGuidanceCue(key)) { return } this.startBackgroundGuidance(key) return } this.playForeground(key) } playForeground(key: AudioCueKey): void { const cue = this.config.cues[key] if (!cue || !cue.src) { return } this.clearLoopTimer(key) const context = this.getContext(key) context.stop() if (typeof context.seek === 'function') { context.seek(0) } context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume)) context.play() } startGuidanceLoop(key: AudioCueKey): void { if (this.activeGuidanceCue === key) { return } this.stopGuidanceLoop() this.activeGuidanceCue = key if (this.appAudioMode === 'background') { this.startBackgroundGuidance(key) return } this.playForeground(key) } stopGuidanceLoop(): void { if (!this.activeGuidanceCue) { this.stopBackgroundGuidance() return } this.clearLoopTimer(this.activeGuidanceCue) const context = this.contexts[this.activeGuidanceCue] if (context) { context.stop() if (typeof context.seek === 'function') { context.seek(0) } } this.stopBackgroundGuidance() this.activeGuidanceCue = null } clearLoopTimer(key: AudioCueKey): void { const timer = this.loopTimers[key] if (timer) { clearTimeout(timer) delete this.loopTimers[key] } } handleCueEnded(key: AudioCueKey): void { const cue = this.config.cues[key] if (!cue.loop || this.activeGuidanceCue !== key || !this.enabled || !this.config.enabled) { return } this.clearLoopTimer(key) this.loopTimers[key] = setTimeout(() => { delete this.loopTimers[key] if (this.activeGuidanceCue === key && this.enabled && this.config.enabled) { this.play(key) } }, cue.loopGapMs) as unknown as number } handleBackgroundCueEnded(): void { const key = this.activeGuidanceCue if (!key || !this.enabled || !this.config.enabled || this.appAudioMode !== 'background') { return } const cue = this.config.cues[key] if (!cue || !cue.loop) { return } this.clearBackgroundLoopTimer() this.backgroundLoopTimer = setTimeout(() => { this.backgroundLoopTimer = 0 if (this.activeGuidanceCue === key && this.appAudioMode === 'background' && this.enabled && this.config.enabled) { this.playBackgroundCue(key) } }, cue.loopGapMs) as unknown as number } clearBackgroundLoopTimer(): void { if (this.backgroundLoopTimer) { clearTimeout(this.backgroundLoopTimer) this.backgroundLoopTimer = 0 } } stopForegroundCue(key: AudioCueKey): void { this.clearLoopTimer(key) const context = this.contexts[key] if (!context) { return } context.stop() if (typeof context.seek === 'function') { context.seek(0) } } isGuidanceCue(key: AudioCueKey): boolean { return key === 'guidance:searching' || key === 'guidance:distant' || key === 'guidance:approaching' || key === 'guidance:ready' } startBackgroundGuidance(key: AudioCueKey): void { if (!this.enabled || !this.config.enabled || !this.config.backgroundAudioEnabled) { return } const cue = this.config.cues[key] if (!cue || cue.backgroundMode !== 'guidance' || !cue.src) { return } this.playBackgroundCue(key) } playBackgroundCue(key: AudioCueKey): void { const cue = this.config.cues[key] if (!cue || !cue.src) { return } const manager = this.getBackgroundManager() this.clearBackgroundLoopTimer() manager.stop() manager.title = 'ColorMapRun 引导音' manager.epname = 'ColorMapRun' manager.singer = 'ColorMapRun' manager.coverImgUrl = '' manager.src = cue.src manager.play() } stopBackgroundGuidance(): void { this.clearBackgroundLoopTimer() if (!this.backgroundManager) { return } this.backgroundManager.stop() } getBackgroundManager(): WechatMiniprogram.BackgroundAudioManager { if (this.backgroundManager) { return this.backgroundManager } const manager = wx.getBackgroundAudioManager() if (typeof manager.onEnded === 'function') { manager.onEnded(() => { this.handleBackgroundCueEnded() }) } this.backgroundManager = manager return manager } getContext(key: AudioCueKey): WechatMiniprogram.InnerAudioContext { const existing = this.contexts[key] if (existing) { return existing } const cue = this.config.cues[key] const context = wx.createInnerAudioContext() context.src = cue.src context.autoplay = false context.loop = false context.obeyMuteSwitch = this.config.obeyMuteSwitch if (typeof context.onEnded === 'function') { context.onEnded(() => { this.handleCueEnded(key) }) } context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume)) this.contexts[key] = context return context } }