Add event-driven gameplay feedback framework

This commit is contained in:
2026-03-24 09:03:27 +08:00
parent 48159be900
commit 2c03d1a702
20 changed files with 1718 additions and 64 deletions

View File

@@ -1,30 +1,41 @@
import { type GameEffect } from '../core/gameResult'
type SoundKey = 'session-start' | 'start-complete' | 'control-complete' | 'finish-complete' | 'warning'
const SOUND_SRC: Record<SoundKey, string> = {
'session-start': '/assets/sounds/session-start.wav',
'start-complete': '/assets/sounds/start-complete.wav',
'control-complete': '/assets/sounds/control-complete.wav',
'finish-complete': '/assets/sounds/finish-complete.wav',
warning: '/assets/sounds/warning.wav',
}
import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from './audioConfig'
export class SoundDirector {
enabled: boolean
contexts: Partial<Record<SoundKey, WechatMiniprogram.InnerAudioContext>>
config: GameAudioConfig
contexts: Partial<Record<AudioCueKey, WechatMiniprogram.InnerAudioContext>>
loopTimers: Partial<Record<AudioCueKey, number>>
activeGuidanceCue: AudioCueKey | null
constructor() {
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
}
destroy(): void {
const keys = Object.keys(this.contexts) as SoundKey[]
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) {
@@ -34,10 +45,15 @@ export class SoundDirector {
context.destroy()
}
this.contexts = {}
this.activeGuidanceCue = null
}
destroy(): void {
this.resetContexts()
}
handleEffects(effects: GameEffect[]): void {
if (!this.enabled || !effects.length) {
if (!this.enabled || !this.config.enabled || !effects.length) {
return
}
@@ -45,55 +61,138 @@ export class SoundDirector {
for (const effect of effects) {
if (effect.type === 'session_started') {
this.play('session-start')
this.play('session_started')
continue
}
if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
this.play('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('start-complete')
this.play('control_completed:start')
continue
}
if (effect.controlKind === 'finish') {
this.play('finish-complete')
this.play('control_completed:finish')
continue
}
this.play('control-complete')
this.play('control_completed:control')
continue
}
if (effect.type === 'session_finished' && !hasFinishCompletion) {
this.play('finish-complete')
if (effect.type === 'session_finished') {
this.stopGuidanceLoop()
if (!hasFinishCompletion) {
this.play('control_completed:finish')
}
}
}
}
play(key: SoundKey): void {
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()
context.seek(0)
if (typeof context.seek === 'function') {
context.seek(0)
}
context.volume = Math.max(0, Math.min(1, this.config.masterVolume * cue.volume))
context.play()
}
getContext(key: SoundKey): WechatMiniprogram.InnerAudioContext {
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 = SOUND_SRC[key]
context.src = cue.src
context.autoplay = false
context.loop = false
context.obeyMuteSwitch = true
context.volume = 1
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
}