soundDirector.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  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. for (const effect of effects) {
  61. if (effect.type === 'session_started') {
  62. this.play('session_started')
  63. continue
  64. }
  65. if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
  66. this.play('punch_feedback:warning')
  67. continue
  68. }
  69. if (effect.type === 'guidance_state_changed') {
  70. if (effect.guidanceState === 'searching') {
  71. this.startGuidanceLoop('guidance:searching')
  72. continue
  73. }
  74. if (effect.guidanceState === 'approaching') {
  75. this.startGuidanceLoop('guidance:approaching')
  76. continue
  77. }
  78. this.startGuidanceLoop('guidance:ready')
  79. continue
  80. }
  81. if (effect.type === 'control_completed') {
  82. this.stopGuidanceLoop()
  83. if (effect.controlKind === 'start') {
  84. this.play('control_completed:start')
  85. continue
  86. }
  87. if (effect.controlKind === 'finish') {
  88. this.play('control_completed:finish')
  89. continue
  90. }
  91. this.play('control_completed:control')
  92. continue
  93. }
  94. if (effect.type === 'session_finished') {
  95. this.stopGuidanceLoop()
  96. if (!hasFinishCompletion) {
  97. this.play('control_completed:finish')
  98. }
  99. }
  100. }
  101. }
  102. setAppAudioMode(mode: 'foreground' | 'background'): void {
  103. if (this.appAudioMode === mode) {
  104. return
  105. }
  106. this.appAudioMode = mode
  107. const activeGuidanceCue = this.activeGuidanceCue
  108. if (!activeGuidanceCue) {
  109. this.stopBackgroundGuidance()
  110. return
  111. }
  112. if (mode === 'background') {
  113. this.stopForegroundCue(activeGuidanceCue)
  114. this.startBackgroundGuidance(activeGuidanceCue)
  115. return
  116. }
  117. this.stopBackgroundGuidance()
  118. this.playForeground(activeGuidanceCue)
  119. }
  120. play(key: AudioCueKey): void {
  121. if (this.appAudioMode === 'background') {
  122. const cue = this.config.cues[key]
  123. if (!cue || cue.backgroundMode !== 'guidance' || !this.isGuidanceCue(key)) {
  124. return
  125. }
  126. this.startBackgroundGuidance(key)
  127. return
  128. }
  129. this.playForeground(key)
  130. }
  131. playForeground(key: AudioCueKey): void {
  132. const cue = this.config.cues[key]
  133. if (!cue || !cue.src) {
  134. return
  135. }
  136. this.clearLoopTimer(key)
  137. const context = this.getContext(key)
  138. context.stop()
  139. if (typeof context.seek === 'function') {
  140. context.seek(0)
  141. }
  142. context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
  143. context.play()
  144. }
  145. startGuidanceLoop(key: AudioCueKey): void {
  146. if (this.activeGuidanceCue === key) {
  147. return
  148. }
  149. this.stopGuidanceLoop()
  150. this.activeGuidanceCue = key
  151. if (this.appAudioMode === 'background') {
  152. this.startBackgroundGuidance(key)
  153. return
  154. }
  155. this.playForeground(key)
  156. }
  157. stopGuidanceLoop(): void {
  158. if (!this.activeGuidanceCue) {
  159. this.stopBackgroundGuidance()
  160. return
  161. }
  162. this.clearLoopTimer(this.activeGuidanceCue)
  163. const context = this.contexts[this.activeGuidanceCue]
  164. if (context) {
  165. context.stop()
  166. if (typeof context.seek === 'function') {
  167. context.seek(0)
  168. }
  169. }
  170. this.stopBackgroundGuidance()
  171. this.activeGuidanceCue = null
  172. }
  173. clearLoopTimer(key: AudioCueKey): void {
  174. const timer = this.loopTimers[key]
  175. if (timer) {
  176. clearTimeout(timer)
  177. delete this.loopTimers[key]
  178. }
  179. }
  180. handleCueEnded(key: AudioCueKey): void {
  181. const cue = this.config.cues[key]
  182. if (!cue.loop || this.activeGuidanceCue !== key || !this.enabled || !this.config.enabled) {
  183. return
  184. }
  185. this.clearLoopTimer(key)
  186. this.loopTimers[key] = setTimeout(() => {
  187. delete this.loopTimers[key]
  188. if (this.activeGuidanceCue === key && this.enabled && this.config.enabled) {
  189. this.play(key)
  190. }
  191. }, cue.loopGapMs) as unknown as number
  192. }
  193. handleBackgroundCueEnded(): void {
  194. const key = this.activeGuidanceCue
  195. if (!key || !this.enabled || !this.config.enabled || this.appAudioMode !== 'background') {
  196. return
  197. }
  198. const cue = this.config.cues[key]
  199. if (!cue || !cue.loop) {
  200. return
  201. }
  202. this.clearBackgroundLoopTimer()
  203. this.backgroundLoopTimer = setTimeout(() => {
  204. this.backgroundLoopTimer = 0
  205. if (this.activeGuidanceCue === key && this.appAudioMode === 'background' && this.enabled && this.config.enabled) {
  206. this.playBackgroundCue(key)
  207. }
  208. }, cue.loopGapMs) as unknown as number
  209. }
  210. clearBackgroundLoopTimer(): void {
  211. if (this.backgroundLoopTimer) {
  212. clearTimeout(this.backgroundLoopTimer)
  213. this.backgroundLoopTimer = 0
  214. }
  215. }
  216. stopForegroundCue(key: AudioCueKey): void {
  217. this.clearLoopTimer(key)
  218. const context = this.contexts[key]
  219. if (!context) {
  220. return
  221. }
  222. context.stop()
  223. if (typeof context.seek === 'function') {
  224. context.seek(0)
  225. }
  226. }
  227. isGuidanceCue(key: AudioCueKey): boolean {
  228. return key === 'guidance:searching'
  229. || key === 'guidance:approaching'
  230. || key === 'guidance:ready'
  231. }
  232. startBackgroundGuidance(key: AudioCueKey): void {
  233. if (!this.enabled || !this.config.enabled || !this.config.backgroundAudioEnabled) {
  234. return
  235. }
  236. const cue = this.config.cues[key]
  237. if (!cue || cue.backgroundMode !== 'guidance' || !cue.src) {
  238. return
  239. }
  240. this.playBackgroundCue(key)
  241. }
  242. playBackgroundCue(key: AudioCueKey): void {
  243. const cue = this.config.cues[key]
  244. if (!cue || !cue.src) {
  245. return
  246. }
  247. const manager = this.getBackgroundManager()
  248. this.clearBackgroundLoopTimer()
  249. manager.stop()
  250. manager.title = 'ColorMapRun 引导音'
  251. manager.epname = 'ColorMapRun'
  252. manager.singer = 'ColorMapRun'
  253. manager.coverImgUrl = ''
  254. manager.src = cue.src
  255. manager.play()
  256. }
  257. stopBackgroundGuidance(): void {
  258. this.clearBackgroundLoopTimer()
  259. if (!this.backgroundManager) {
  260. return
  261. }
  262. this.backgroundManager.stop()
  263. }
  264. getBackgroundManager(): WechatMiniprogram.BackgroundAudioManager {
  265. if (this.backgroundManager) {
  266. return this.backgroundManager
  267. }
  268. const manager = wx.getBackgroundAudioManager()
  269. if (typeof manager.onEnded === 'function') {
  270. manager.onEnded(() => {
  271. this.handleBackgroundCueEnded()
  272. })
  273. }
  274. this.backgroundManager = manager
  275. return manager
  276. }
  277. getContext(key: AudioCueKey): WechatMiniprogram.InnerAudioContext {
  278. const existing = this.contexts[key]
  279. if (existing) {
  280. return existing
  281. }
  282. const cue = this.config.cues[key]
  283. const context = wx.createInnerAudioContext()
  284. context.src = cue.src
  285. context.autoplay = false
  286. context.loop = false
  287. context.obeyMuteSwitch = this.config.obeyMuteSwitch
  288. if (typeof context.onEnded === 'function') {
  289. context.onEnded(() => {
  290. this.handleCueEnded(key)
  291. })
  292. }
  293. context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
  294. this.contexts[key] = context
  295. return context
  296. }
  297. }