soundDirector.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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. activeGuidanceCue: AudioCueKey | null
  9. constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) {
  10. this.enabled = true
  11. this.config = config
  12. this.contexts = {}
  13. this.loopTimers = {}
  14. this.activeGuidanceCue = null
  15. }
  16. configure(config: GameAudioConfig): void {
  17. this.config = config
  18. this.resetContexts()
  19. }
  20. setEnabled(enabled: boolean): void {
  21. this.enabled = enabled
  22. }
  23. resetContexts(): void {
  24. const timerKeys = Object.keys(this.loopTimers) as AudioCueKey[]
  25. for (const key of timerKeys) {
  26. const timer = this.loopTimers[key]
  27. if (timer) {
  28. clearTimeout(timer)
  29. }
  30. }
  31. this.loopTimers = {}
  32. const keys = Object.keys(this.contexts) as AudioCueKey[]
  33. for (const key of keys) {
  34. const context = this.contexts[key]
  35. if (!context) {
  36. continue
  37. }
  38. context.stop()
  39. context.destroy()
  40. }
  41. this.contexts = {}
  42. this.activeGuidanceCue = null
  43. }
  44. destroy(): void {
  45. this.resetContexts()
  46. }
  47. handleEffects(effects: GameEffect[]): void {
  48. if (!this.enabled || !this.config.enabled || !effects.length) {
  49. return
  50. }
  51. const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
  52. for (const effect of effects) {
  53. if (effect.type === 'session_started') {
  54. this.play('session_started')
  55. continue
  56. }
  57. if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
  58. this.play('punch_feedback:warning')
  59. continue
  60. }
  61. if (effect.type === 'guidance_state_changed') {
  62. if (effect.guidanceState === 'searching') {
  63. this.startGuidanceLoop('guidance:searching')
  64. continue
  65. }
  66. if (effect.guidanceState === 'approaching') {
  67. this.startGuidanceLoop('guidance:approaching')
  68. continue
  69. }
  70. this.startGuidanceLoop('guidance:ready')
  71. continue
  72. }
  73. if (effect.type === 'control_completed') {
  74. this.stopGuidanceLoop()
  75. if (effect.controlKind === 'start') {
  76. this.play('control_completed:start')
  77. continue
  78. }
  79. if (effect.controlKind === 'finish') {
  80. this.play('control_completed:finish')
  81. continue
  82. }
  83. this.play('control_completed:control')
  84. continue
  85. }
  86. if (effect.type === 'session_finished') {
  87. this.stopGuidanceLoop()
  88. if (!hasFinishCompletion) {
  89. this.play('control_completed:finish')
  90. }
  91. }
  92. }
  93. }
  94. play(key: AudioCueKey): void {
  95. const cue = this.config.cues[key]
  96. if (!cue || !cue.src) {
  97. return
  98. }
  99. this.clearLoopTimer(key)
  100. const context = this.getContext(key)
  101. context.stop()
  102. if (typeof context.seek === 'function') {
  103. context.seek(0)
  104. }
  105. context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
  106. context.play()
  107. }
  108. startGuidanceLoop(key: AudioCueKey): void {
  109. if (this.activeGuidanceCue === key) {
  110. return
  111. }
  112. this.stopGuidanceLoop()
  113. this.activeGuidanceCue = key
  114. this.play(key)
  115. }
  116. stopGuidanceLoop(): void {
  117. if (!this.activeGuidanceCue) {
  118. return
  119. }
  120. this.clearLoopTimer(this.activeGuidanceCue)
  121. const context = this.contexts[this.activeGuidanceCue]
  122. if (context) {
  123. context.stop()
  124. if (typeof context.seek === 'function') {
  125. context.seek(0)
  126. }
  127. }
  128. this.activeGuidanceCue = null
  129. }
  130. clearLoopTimer(key: AudioCueKey): void {
  131. const timer = this.loopTimers[key]
  132. if (timer) {
  133. clearTimeout(timer)
  134. delete this.loopTimers[key]
  135. }
  136. }
  137. handleCueEnded(key: AudioCueKey): void {
  138. const cue = this.config.cues[key]
  139. if (!cue.loop || this.activeGuidanceCue !== key || !this.enabled || !this.config.enabled) {
  140. return
  141. }
  142. this.clearLoopTimer(key)
  143. this.loopTimers[key] = setTimeout(() => {
  144. delete this.loopTimers[key]
  145. if (this.activeGuidanceCue === key && this.enabled && this.config.enabled) {
  146. this.play(key)
  147. }
  148. }, cue.loopGapMs) as unknown as number
  149. }
  150. getContext(key: AudioCueKey): WechatMiniprogram.InnerAudioContext {
  151. const existing = this.contexts[key]
  152. if (existing) {
  153. return existing
  154. }
  155. const cue = this.config.cues[key]
  156. const context = wx.createInnerAudioContext()
  157. context.src = cue.src
  158. context.autoplay = false
  159. context.loop = false
  160. context.obeyMuteSwitch = this.config.obeyMuteSwitch
  161. if (typeof context.onEnded === 'function') {
  162. context.onEnded(() => {
  163. this.handleCueEnded(key)
  164. })
  165. }
  166. context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
  167. this.contexts[key] = context
  168. return context
  169. }
  170. }