356 lines
8.9 KiB
TypeScript
356 lines
8.9 KiB
TypeScript
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<Record<AudioCueKey, WechatMiniprogram.InnerAudioContext>>
|
|
loopTimers: Partial<Record<AudioCueKey, number>>
|
|
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')
|
|
|
|
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 === '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')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: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
|
|
}
|
|
}
|