Add event-driven gameplay feedback framework
This commit is contained in:
156
miniprogram/game/audio/audioConfig.ts
Normal file
156
miniprogram/game/audio/audioConfig.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
export type AudioCueKey =
|
||||
| 'session_started'
|
||||
| 'control_completed:start'
|
||||
| 'control_completed:control'
|
||||
| 'control_completed:finish'
|
||||
| 'punch_feedback:warning'
|
||||
| 'guidance:searching'
|
||||
| 'guidance:approaching'
|
||||
| 'guidance:ready'
|
||||
|
||||
export interface AudioCueConfig {
|
||||
src: string
|
||||
volume: number
|
||||
loop: boolean
|
||||
loopGapMs: number
|
||||
}
|
||||
|
||||
export interface GameAudioConfig {
|
||||
enabled: boolean
|
||||
masterVolume: number
|
||||
obeyMuteSwitch: boolean
|
||||
approachDistanceMeters: number
|
||||
cues: Record<AudioCueKey, AudioCueConfig>
|
||||
}
|
||||
|
||||
export interface PartialAudioCueConfig {
|
||||
src?: string
|
||||
volume?: number
|
||||
loop?: boolean
|
||||
loopGapMs?: number
|
||||
}
|
||||
|
||||
export interface GameAudioConfigOverrides {
|
||||
enabled?: boolean
|
||||
masterVolume?: number
|
||||
obeyMuteSwitch?: boolean
|
||||
approachDistanceMeters?: number
|
||||
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
|
||||
}
|
||||
|
||||
export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
||||
enabled: true,
|
||||
masterVolume: 1,
|
||||
obeyMuteSwitch: true,
|
||||
approachDistanceMeters: 20,
|
||||
cues: {
|
||||
session_started: {
|
||||
src: '/assets/sounds/session-start.wav',
|
||||
volume: 0.78,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'control_completed:start': {
|
||||
src: '/assets/sounds/start-complete.wav',
|
||||
volume: 0.84,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'control_completed:control': {
|
||||
src: '/assets/sounds/control-complete.wav',
|
||||
volume: 0.8,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'control_completed:finish': {
|
||||
src: '/assets/sounds/finish-complete.wav',
|
||||
volume: 0.92,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'punch_feedback:warning': {
|
||||
src: '/assets/sounds/warning.wav',
|
||||
volume: 0.72,
|
||||
loop: false,
|
||||
loopGapMs: 0,
|
||||
},
|
||||
'guidance:searching': {
|
||||
src: '/assets/sounds/guidance-searching.wav',
|
||||
volume: 0.48,
|
||||
loop: true,
|
||||
loopGapMs: 1800,
|
||||
},
|
||||
'guidance:approaching': {
|
||||
src: '/assets/sounds/guidance-approaching.wav',
|
||||
volume: 0.58,
|
||||
loop: true,
|
||||
loopGapMs: 950,
|
||||
},
|
||||
'guidance:ready': {
|
||||
src: '/assets/sounds/guidance-ready.wav',
|
||||
volume: 0.68,
|
||||
loop: true,
|
||||
loopGapMs: 650,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function clampVolume(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback
|
||||
}
|
||||
|
||||
function clampDistance(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
|
||||
function clampGap(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback
|
||||
}
|
||||
|
||||
export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null): GameAudioConfig {
|
||||
const cues: GameAudioConfig['cues'] = {
|
||||
session_started: { ...DEFAULT_GAME_AUDIO_CONFIG.cues.session_started },
|
||||
'control_completed:start': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:start'] },
|
||||
'control_completed:control': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:control'] },
|
||||
'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
|
||||
'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
|
||||
'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
|
||||
'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
|
||||
'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
|
||||
}
|
||||
|
||||
if (overrides && overrides.cues) {
|
||||
const keys = Object.keys(overrides.cues) as AudioCueKey[]
|
||||
for (const key of keys) {
|
||||
const cue = overrides.cues[key]
|
||||
if (!cue) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof cue.src === 'string' && cue.src) {
|
||||
cues[key].src = cue.src
|
||||
}
|
||||
|
||||
if (cue.volume !== undefined) {
|
||||
cues[key].volume = clampVolume(Number(cue.volume), cues[key].volume)
|
||||
}
|
||||
|
||||
if (cue.loop !== undefined) {
|
||||
cues[key].loop = !!cue.loop
|
||||
}
|
||||
|
||||
if (cue.loopGapMs !== undefined) {
|
||||
cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled,
|
||||
masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume),
|
||||
obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch,
|
||||
approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
|
||||
cues,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user