soundDirector.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import { type GameEffect } from '../core/gameResult'
  2. import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from './audioConfig'
  3. export class SoundDirector {
  4. enabled: boolean
  5. config: GameAudioConfig
  6. contexts: Partial<Record<AudioCueKey, WechatMiniprogram.InnerAudioContext>>
  7. loopTimers: Partial<Record<AudioCueKey, number>>
  8. backgroundLoopTimer: number
  9. activeGuidanceCue: AudioCueKey | null
  10. backgroundManager: WechatMiniprogram.BackgroundAudioManager | null
  11. appAudioMode: 'foreground' | 'background'
  12. constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) {
  13. this.enabled = true
  14. this.config = config
  15. this.contexts = {}
  16. this.loopTimers = {}
  17. this.backgroundLoopTimer = 0
  18. this.activeGuidanceCue = null
  19. this.backgroundManager = null
  20. this.appAudioMode = 'foreground'
  21. }
  22. configure(config: GameAudioConfig): void {
  23. this.config = config
  24. this.resetContexts()
  25. }
  26. setEnabled(enabled: boolean): void {
  27. this.enabled = enabled
  28. }
  29. resetContexts(): void {
  30. const timerKeys = Object.keys(this.loopTimers) as AudioCueKey[]
  31. for (const key of timerKeys) {
  32. const timer = this.loopTimers[key]
  33. if (timer) {
  34. clearTimeout(timer)
  35. }
  36. }
  37. this.loopTimers = {}
  38. this.clearBackgroundLoopTimer()
  39. const keys = Object.keys(this.contexts) as AudioCueKey[]
  40. for (const key of keys) {
  41. const context = this.contexts[key]
  42. if (!context) {
  43. continue
  44. }
  45. context.stop()
  46. context.destroy()
  47. }
  48. this.contexts = {}
  49. this.activeGuidanceCue = null
  50. this.stopBackgroundGuidance()
  51. }
  52. destroy(): void {
  53. this.resetContexts()
  54. }
  55. handleEffects(effects: GameEffect[]): void {
  56. if (!this.enabled || !this.config.enabled || !effects.length) {
  57. return
  58. }
  59. const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
  60. if (hasFinishCompletion) {
  61. this.stopGuidanceLoop()
  62. this.play('control_completed:finish')
  63. return
  64. }
  65. for (const effect of effects) {
  66. if (effect.type === 'session_started') {
  67. this.play('session_started')
  68. continue
  69. }
  70. if (effect.type === 'session_cancelled') {
  71. this.stopGuidanceLoop()
  72. this.play('control_completed:finish')
  73. continue
  74. }
  75. if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
  76. this.play('punch_feedback:warning')
  77. continue
  78. }
  79. if (effect.type === 'guidance_state_changed') {
  80. if (effect.guidanceState === 'distant') {
  81. this.startGuidanceLoop('guidance:distant')
  82. continue
  83. }
  84. if (effect.guidanceState === 'approaching') {
  85. this.startGuidanceLoop('guidance:approaching')
  86. continue
  87. }
  88. if (effect.guidanceState === 'ready') {
  89. this.startGuidanceLoop('guidance:ready')
  90. continue
  91. }
  92. this.stopGuidanceLoop()
  93. continue
  94. }
  95. if (effect.type === 'control_completed') {
  96. this.stopGuidanceLoop()
  97. if (effect.controlKind === 'start') {
  98. this.play('control_completed:start')
  99. continue
  100. }
  101. if (effect.controlKind === 'finish') {
  102. this.play('control_completed:finish')
  103. continue
  104. }
  105. this.play('control_completed:control')
  106. continue
  107. }
  108. if (effect.type === 'session_finished') {
  109. this.stopGuidanceLoop()
  110. if (!hasFinishCompletion) {
  111. this.play('control_completed:finish')
  112. }
  113. }
  114. }
  115. }
  116. setAppAudioMode(mode: 'foreground' | 'background'): void {
  117. if (this.appAudioMode === mode) {
  118. return
  119. }
  120. this.appAudioMode = mode
  121. const activeGuidanceCue = this.activeGuidanceCue
  122. if (!activeGuidanceCue) {
  123. this.stopBackgroundGuidance()
  124. return
  125. }
  126. if (mode === 'background') {
  127. this.stopForegroundCue(activeGuidanceCue)
  128. this.startBackgroundGuidance(activeGuidanceCue)
  129. return
  130. }
  131. this.stopBackgroundGuidance()
  132. this.playForeground(activeGuidanceCue)
  133. }
  134. play(key: AudioCueKey): void {
  135. if (this.appAudioMode === 'background') {
  136. const cue = this.config.cues[key]
  137. if (!cue || cue.backgroundMode !== 'guidance' || !this.isGuidanceCue(key)) {
  138. return
  139. }
  140. this.startBackgroundGuidance(key)
  141. return
  142. }
  143. this.playForeground(key)
  144. }
  145. playForeground(key: AudioCueKey): void {
  146. const cue = this.config.cues[key]
  147. if (!cue || !cue.src) {
  148. return
  149. }
  150. this.clearLoopTimer(key)
  151. const context = this.getContext(key)
  152. context.stop()
  153. if (typeof context.seek === 'function') {
  154. context.seek(0)
  155. }
  156. context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
  157. context.play()
  158. }
  159. startGuidanceLoop(key: AudioCueKey): void {
  160. if (this.activeGuidanceCue === key) {
  161. return
  162. }
  163. this.stopGuidanceLoop()
  164. this.activeGuidanceCue = key
  165. if (this.appAudioMode === 'background') {
  166. this.startBackgroundGuidance(key)
  167. return
  168. }
  169. this.playForeground(key)
  170. }
  171. stopGuidanceLoop(): void {
  172. if (!this.activeGuidanceCue) {
  173. this.stopBackgroundGuidance()
  174. return
  175. }
  176. this.clearLoopTimer(this.activeGuidanceCue)
  177. const context = this.contexts[this.activeGuidanceCue]
  178. if (context) {
  179. context.stop()
  180. if (typeof context.seek === 'function') {
  181. context.seek(0)
  182. }
  183. }
  184. this.stopBackgroundGuidance()
  185. this.activeGuidanceCue = null
  186. }
  187. clearLoopTimer(key: AudioCueKey): void {
  188. const timer = this.loopTimers[key]
  189. if (timer) {
  190. clearTimeout(timer)
  191. delete this.loopTimers[key]
  192. }
  193. }
  194. handleCueEnded(key: AudioCueKey): void {
  195. const cue = this.config.cues[key]
  196. if (!cue.loop || this.activeGuidanceCue !== key || !this.enabled || !this.config.enabled) {
  197. return
  198. }
  199. this.clearLoopTimer(key)
  200. this.loopTimers[key] = setTimeout(() => {
  201. delete this.loopTimers[key]
  202. if (this.activeGuidanceCue === key && this.enabled && this.config.enabled) {
  203. this.play(key)
  204. }
  205. }, cue.loopGapMs) as unknown as number
  206. }
  207. handleBackgroundCueEnded(): void {
  208. const key = this.activeGuidanceCue
  209. if (!key || !this.enabled || !this.config.enabled || this.appAudioMode !== 'background') {
  210. return
  211. }
  212. const cue = this.config.cues[key]
  213. if (!cue || !cue.loop) {
  214. return
  215. }
  216. this.clearBackgroundLoopTimer()
  217. this.backgroundLoopTimer = setTimeout(() => {
  218. this.backgroundLoopTimer = 0
  219. if (this.activeGuidanceCue === key && this.appAudioMode === 'background' && this.enabled && this.config.enabled) {
  220. this.playBackgroundCue(key)
  221. }
  222. }, cue.loopGapMs) as unknown as number
  223. }
  224. clearBackgroundLoopTimer(): void {
  225. if (this.backgroundLoopTimer) {
  226. clearTimeout(this.backgroundLoopTimer)
  227. this.backgroundLoopTimer = 0
  228. }
  229. }
  230. stopForegroundCue(key: AudioCueKey): void {
  231. this.clearLoopTimer(key)
  232. const context = this.contexts[key]
  233. if (!context) {
  234. return
  235. }
  236. context.stop()
  237. if (typeof context.seek === 'function') {
  238. context.seek(0)
  239. }
  240. }
  241. isGuidanceCue(key: AudioCueKey): boolean {
  242. return key === 'guidance:searching'
  243. || key === 'guidance:distant'
  244. || key === 'guidance:approaching'
  245. || key === 'guidance:ready'
  246. }
  247. startBackgroundGuidance(key: AudioCueKey): void {
  248. if (!this.enabled || !this.config.enabled || !this.config.backgroundAudioEnabled) {
  249. return
  250. }
  251. const cue = this.config.cues[key]
  252. if (!cue || cue.backgroundMode !== 'guidance' || !cue.src) {
  253. return
  254. }
  255. this.playBackgroundCue(key)
  256. }
  257. playBackgroundCue(key: AudioCueKey): void {
  258. const cue = this.config.cues[key]
  259. if (!cue || !cue.src) {
  260. return
  261. }
  262. const manager = this.getBackgroundManager()
  263. this.clearBackgroundLoopTimer()
  264. manager.stop()
  265. manager.title = 'ColorMapRun 引导音'
  266. manager.epname = 'ColorMapRun'
  267. manager.singer = 'ColorMapRun'
  268. manager.coverImgUrl = ''
  269. manager.src = cue.src
  270. manager.play()
  271. }
  272. stopBackgroundGuidance(): void {
  273. this.clearBackgroundLoopTimer()
  274. if (!this.backgroundManager) {
  275. return
  276. }
  277. this.backgroundManager.stop()
  278. }
  279. getBackgroundManager(): WechatMiniprogram.BackgroundAudioManager {
  280. if (this.backgroundManager) {
  281. return this.backgroundManager
  282. }
  283. const manager = wx.getBackgroundAudioManager()
  284. if (typeof manager.onEnded === 'function') {
  285. manager.onEnded(() => {
  286. this.handleBackgroundCueEnded()
  287. })
  288. }
  289. this.backgroundManager = manager
  290. return manager
  291. }
  292. getContext(key: AudioCueKey): WechatMiniprogram.InnerAudioContext {
  293. const existing = this.contexts[key]
  294. if (existing) {
  295. return existing
  296. }
  297. const cue = this.config.cues[key]
  298. const context = wx.createInnerAudioContext()
  299. context.src = cue.src
  300. context.autoplay = false
  301. context.loop = false
  302. context.obeyMuteSwitch = this.config.obeyMuteSwitch
  303. if (typeof context.onEnded === 'function') {
  304. context.onEnded(() => {
  305. this.handleCueEnded(key)
  306. })
  307. }
  308. context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
  309. this.contexts[key] = context
  310. return context
  311. }
  312. }