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> activeGuidanceCue: AudioCueKey | null constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) { this.enabled = true this.config = config this.contexts = {} this.loopTimers = {} this.activeGuidanceCue = null } 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 = {} 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 } 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') for (const effect of effects) { if (effect.type === 'session_started') { this.play('session_started') continue } if (effect.type === 'punch_feedback' && effect.tone === 'warning') { this.play('punch_feedback:warning') continue } if (effect.type === 'guidance_state_changed') { if (effect.guidanceState === 'searching') { this.startGuidanceLoop('guidance:searching') continue } if (effect.guidanceState === 'approaching') { this.startGuidanceLoop('guidance:approaching') continue } this.startGuidanceLoop('guidance:ready') 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') } } } } play(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 this.play(key) } stopGuidanceLoop(): void { if (!this.activeGuidanceCue) { return } this.clearLoopTimer(this.activeGuidanceCue) const context = this.contexts[this.activeGuidanceCue] if (context) { context.stop() if (typeof context.seek === 'function') { context.seek(0) } } 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 } 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 } }