Bladeren bron

Add event-driven gameplay feedback framework

zhangyan 2 weken geleden
bovenliggende
commit
2c03d1a702

BIN
miniprogram/assets/sounds/guidance-approaching.wav


BIN
miniprogram/assets/sounds/guidance-ready.wav


BIN
miniprogram/assets/sounds/guidance-searching.wav


+ 160 - 24
miniprogram/engine/map/mapEngine.ts

@@ -9,7 +9,7 @@ import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '.
 import { GameRuntime } from '../../game/core/gameRuntime'
 import { type GameEffect } from '../../game/core/gameResult'
 import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
-import { SoundDirector } from '../../game/audio/soundDirector'
+import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
 import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
 
 const RENDER_MODE = 'Single WebGL Pipeline'
@@ -128,6 +128,15 @@ export interface MapEngineViewState {
   contentCardVisible: boolean
   contentCardTitle: string
   contentCardBody: string
+  punchButtonFxClass: string
+  punchFeedbackFxClass: string
+  contentCardFxClass: string
+  mapPulseVisible: boolean
+  mapPulseLeftPx: number
+  mapPulseTopPx: number
+  mapPulseFxClass: string
+  stageFxVisible: boolean
+  stageFxClass: string
   osmReferenceEnabled: boolean
   osmReferenceText: string
 }
@@ -185,6 +194,15 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'contentCardVisible',
   'contentCardTitle',
   'contentCardBody',
+  'punchButtonFxClass',
+  'punchFeedbackFxClass',
+  'contentCardFxClass',
+  'mapPulseVisible',
+  'mapPulseLeftPx',
+  'mapPulseTopPx',
+  'mapPulseFxClass',
+  'stageFxVisible',
+  'stageFxClass',
   'osmReferenceEnabled',
   'osmReferenceText',
 ]
@@ -423,7 +441,7 @@ export class MapEngine {
   renderer: WebGLMapRenderer
   compassController: CompassHeadingController
   locationController: LocationController
-  soundDirector: SoundDirector
+  feedbackDirector: FeedbackDirector
   onData: (patch: Partial<MapEngineViewState>) => void
   state: MapEngineViewState
   previewScale: number
@@ -476,6 +494,8 @@ export class MapEngine {
   autoFinishOnLastControl: boolean
   punchFeedbackTimer: number
   contentCardTimer: number
+  mapPulseTimer: number
+  stageFxTimer: number
   hasGpsCenteredOnce: boolean
 
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
@@ -517,7 +537,28 @@ export class MapEngine {
         }, true)
       },
     })
-    this.soundDirector = new SoundDirector()
+    this.feedbackDirector = new FeedbackDirector({
+      showPunchFeedback: (text, tone, motionClass) => {
+        this.showPunchFeedback(text, tone, motionClass)
+      },
+      showContentCard: (title, body, motionClass) => {
+        this.showContentCard(title, body, motionClass)
+      },
+      setPunchButtonFxClass: (className) => {
+        this.setPunchButtonFxClass(className)
+      },
+      showMapPulse: (controlId, motionClass) => {
+        this.showMapPulse(controlId, motionClass)
+      },
+      showStageFx: (className) => {
+        this.showStageFx(className)
+      },
+      stopLocationTracking: () => {
+        if (this.locationController.listening) {
+          this.locationController.stop()
+        }
+      },
+    })
     this.minZoom = MIN_ZOOM
     this.maxZoom = MAX_ZOOM
     this.defaultZoom = DEFAULT_ZOOM
@@ -537,6 +578,8 @@ export class MapEngine {
     this.autoFinishOnLastControl = true
     this.punchFeedbackTimer = 0
     this.contentCardTimer = 0
+    this.mapPulseTimer = 0
+    this.stageFxTimer = 0
     this.hasGpsCenteredOnce = false
     this.state = {
       buildVersion: this.buildVersion,
@@ -596,6 +639,15 @@ export class MapEngine {
       contentCardVisible: false,
       contentCardTitle: '',
       contentCardBody: '',
+      punchButtonFxClass: '',
+      punchFeedbackFxClass: '',
+      contentCardFxClass: '',
+      mapPulseVisible: false,
+      mapPulseLeftPx: 0,
+      mapPulseTopPx: 0,
+      mapPulseFxClass: '',
+      stageFxVisible: false,
+      stageFxClass: '',
       osmReferenceEnabled: false,
       osmReferenceText: 'OSM参考:关',
     }
@@ -643,9 +695,11 @@ export class MapEngine {
     this.clearAutoRotateTimer()
     this.clearPunchFeedbackTimer()
     this.clearContentCardTimer()
+    this.clearMapPulseTimer()
+    this.clearStageFxTimer()
     this.compassController.destroy()
     this.locationController.destroy()
-    this.soundDirector.destroy()
+    this.feedbackDirector.destroy()
     this.renderer.destroy()
     this.mounted = false
   }
@@ -744,32 +798,124 @@ export class MapEngine {
     }
   }
 
-  showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning'): void {
+  clearMapPulseTimer(): void {
+    if (this.mapPulseTimer) {
+      clearTimeout(this.mapPulseTimer)
+      this.mapPulseTimer = 0
+    }
+  }
+
+  clearStageFxTimer(): void {
+    if (this.stageFxTimer) {
+      clearTimeout(this.stageFxTimer)
+      this.stageFxTimer = 0
+    }
+  }
+
+  getControlScreenPoint(controlId: string): { x: number; y: number } | null {
+    if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
+      return null
+    }
+
+    const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
+    if (!control) {
+      return null
+    }
+
+    const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
+    const screenPoint = worldToScreen({
+      centerWorldX: exactCenter.x,
+      centerWorldY: exactCenter.y,
+      viewportWidth: this.state.stageWidth,
+      viewportHeight: this.state.stageHeight,
+      visibleColumns: DESIRED_VISIBLE_COLUMNS,
+      rotationRad: this.getRotationRad(this.state.rotationDeg),
+    }, lonLatToWorldTile(control.point, this.state.zoom), false)
+
+    if (screenPoint.x < -80 || screenPoint.x > this.state.stageWidth + 80 || screenPoint.y < -80 || screenPoint.y > this.state.stageHeight + 80) {
+      return null
+    }
+
+    return screenPoint
+  }
+
+  setPunchButtonFxClass(className: string): void {
+    this.setState({
+      punchButtonFxClass: className,
+    }, true)
+  }
+
+  showMapPulse(controlId: string, motionClass = ''): void {
+    const screenPoint = this.getControlScreenPoint(controlId)
+    if (!screenPoint) {
+      return
+    }
+
+    this.clearMapPulseTimer()
+    this.setState({
+      mapPulseVisible: true,
+      mapPulseLeftPx: screenPoint.x,
+      mapPulseTopPx: screenPoint.y,
+      mapPulseFxClass: motionClass,
+    }, true)
+    this.mapPulseTimer = setTimeout(() => {
+      this.mapPulseTimer = 0
+      this.setState({
+        mapPulseVisible: false,
+        mapPulseFxClass: '',
+      }, true)
+    }, 820) as unknown as number
+  }
+
+  showStageFx(className: string): void {
+    if (!className) {
+      return
+    }
+
+    this.clearStageFxTimer()
+    this.setState({
+      stageFxVisible: true,
+      stageFxClass: className,
+    }, true)
+    this.stageFxTimer = setTimeout(() => {
+      this.stageFxTimer = 0
+      this.setState({
+        stageFxVisible: false,
+        stageFxClass: '',
+      }, true)
+    }, 760) as unknown as number
+  }
+
+  showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning', motionClass = ''): void {
     this.clearPunchFeedbackTimer()
     this.setState({
       punchFeedbackVisible: true,
       punchFeedbackText: text,
       punchFeedbackTone: tone,
+      punchFeedbackFxClass: motionClass,
     }, true)
     this.punchFeedbackTimer = setTimeout(() => {
       this.punchFeedbackTimer = 0
       this.setState({
         punchFeedbackVisible: false,
+        punchFeedbackFxClass: '',
       }, true)
     }, 1400) as unknown as number
   }
 
-  showContentCard(title: string, body: string): void {
+  showContentCard(title: string, body: string, motionClass = ''): void {
     this.clearContentCardTimer()
     this.setState({
       contentCardVisible: true,
       contentCardTitle: title,
       contentCardBody: body,
+      contentCardFxClass: motionClass,
     }, true)
     this.contentCardTimer = setTimeout(() => {
       this.contentCardTimer = 0
       this.setState({
         contentCardVisible: false,
+        contentCardFxClass: '',
       }, true)
     }, 2600) as unknown as number
   }
@@ -778,28 +924,13 @@ export class MapEngine {
     this.clearContentCardTimer()
     this.setState({
       contentCardVisible: false,
+      contentCardFxClass: '',
     }, true)
   }
 
   applyGameEffects(effects: GameEffect[]): string | null {
-    this.soundDirector.handleEffects(effects)
-    const statusText = this.resolveGameStatusText(effects)
-    for (const effect of effects) {
-      if (effect.type === 'punch_feedback') {
-        this.showPunchFeedback(effect.text, effect.tone)
-      }
-
-      if (effect.type === 'control_completed') {
-        this.showPunchFeedback(`完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`, 'success')
-        this.showContentCard(effect.displayTitle, effect.displayBody)
-      }
-
-      if (effect.type === 'session_finished' && this.locationController.listening) {
-        this.locationController.stop()
-      }
-    }
-
-    return statusText
+    this.feedbackDirector.handleEffects(effects)
+    return this.resolveGameStatusText(effects)
   }
 
   handleStartGame(): void {
@@ -973,6 +1104,11 @@ export class MapEngine {
     this.punchPolicy = config.punchPolicy
     this.punchRadiusMeters = config.punchRadiusMeters
     this.autoFinishOnLastControl = config.autoFinishOnLastControl
+    this.feedbackDirector.configure({
+      audioConfig: config.audioConfig,
+      hapticsConfig: config.hapticsConfig,
+      uiEffectsConfig: config.uiEffectsConfig,
+    })
 
     const gameEffects = this.loadGameDefinitionFromCourse()
     const gameStatusText = this.applyGameEffects(gameEffects)

+ 13 - 3
miniprogram/engine/tile/tileStore.ts

@@ -19,6 +19,7 @@ export interface TileStoreEntry {
   lastUsedAt: number
   lastAttemptAt: number
   lastVisibleKey: string
+  retryable: boolean
 }
 
 export interface TileStoreStats {
@@ -174,6 +175,7 @@ export class TileStore {
         lastUsedAt: usedAt,
         lastAttemptAt: 0,
         lastVisibleKey: '',
+        retryable: true,
       }
       this.tileCache.set(url, entry)
       return entry
@@ -274,9 +276,10 @@ export class TileStore {
         return
       }
 
-      if (entry.status === 'idle' || (entry.status === 'error' && usedAt - entry.lastAttemptAt > ERROR_RETRY_DELAY_MS)) {
+      if (entry.status === 'idle' || (entry.status === 'error' && entry.retryable && usedAt - entry.lastAttemptAt > ERROR_RETRY_DELAY_MS)) {
         if (entry.status === 'error') {
           entry.status = 'idle'
+          entry.retryable = true
         }
         this.queueTile(url)
       }
@@ -288,9 +291,10 @@ export class TileStore {
         continue
       }
 
-      if (entry.status === 'idle' || (entry.status === 'error' && usedAt - entry.lastAttemptAt > ERROR_RETRY_DELAY_MS)) {
+      if (entry.status === 'idle' || (entry.status === 'error' && entry.retryable && usedAt - entry.lastAttemptAt > ERROR_RETRY_DELAY_MS)) {
         if (entry.status === 'error') {
           entry.status = 'idle'
+          entry.retryable = true
         }
         this.queueTile(tile.url)
       }
@@ -358,8 +362,9 @@ export class TileStore {
       }
     }
 
-    const markError = (message: string) => {
+    const markError = (message: string, retryable = true) => {
       entry.status = 'error'
+      entry.retryable = retryable
       finish()
       if (this.onTileError) {
         this.onTileError(`${message}: ${url}`)
@@ -425,6 +430,11 @@ export class TileStore {
           }
 
           const resolvedPath = res.filePath || filePath || res.tempFilePath
+          if (res.statusCode >= 400 && res.statusCode < 500) {
+            markError(`瓦片资源不存在(${res.statusCode})`, false)
+            return
+          }
+
           if (res.statusCode !== 200 || !resolvedPath) {
             tryRemoteImage()
             return

+ 156 - 0
miniprogram/game/audio/audioConfig.ts

@@ -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,
+  }
+}

+ 127 - 28
miniprogram/game/audio/soundDirector.ts

@@ -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
   }

+ 2 - 0
miniprogram/game/core/gameDefinition.ts

@@ -1,4 +1,5 @@
 import { type LonLatPoint } from '../../utils/projection'
+import { type GameAudioConfig } from '../audio/audioConfig'
 
 export type GameMode = 'classic-sequential'
 export type GameControlKind = 'start' | 'control' | 'finish'
@@ -28,4 +29,5 @@ export interface GameDefinition {
   punchPolicy: PunchPolicyType
   controls: GameControl[]
   autoFinishOnLastControl: boolean
+  audioConfig?: GameAudioConfig
 }

+ 2 - 1
miniprogram/game/core/gameResult.ts

@@ -1,10 +1,11 @@
-import { type GameSessionState } from './gameSessionState'
+import { type GameSessionState, type GuidanceState } from './gameSessionState'
 import { type GamePresentationState } from '../presentation/presentationState'
 
 export type GameEffect =
   | { type: 'session_started' }
   | { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
   | { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string }
+  | { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }
   | { type: 'session_finished' }
 
 export interface GameResult {

+ 1 - 0
miniprogram/game/core/gameRuntime.ts

@@ -57,6 +57,7 @@ export class GameRuntime {
         currentTargetControlId: null,
         inRangeControlId: null,
         score: 0,
+        guidanceState: 'searching',
       }
       const result: GameResult = {
         nextState: emptyState,

+ 2 - 0
miniprogram/game/core/gameSessionState.ts

@@ -1,4 +1,5 @@
 export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
+export type GuidanceState = 'searching' | 'approaching' | 'ready'
 
 export interface GameSessionState {
   status: GameSessionStatus
@@ -8,4 +9,5 @@ export interface GameSessionState {
   currentTargetControlId: string | null
   inRangeControlId: string | null
   score: number
+  guidanceState: GuidanceState
 }

+ 158 - 0
miniprogram/game/feedback/feedbackConfig.ts

@@ -0,0 +1,158 @@
+export type FeedbackCueKey =
+  | 'session_started'
+  | 'session_finished'
+  | 'control_completed:start'
+  | 'control_completed:control'
+  | 'control_completed:finish'
+  | 'punch_feedback:warning'
+  | 'guidance:searching'
+  | 'guidance:approaching'
+  | 'guidance:ready'
+
+export type HapticPattern = 'short' | 'long'
+export type UiPunchFeedbackMotion = 'none' | 'pop' | 'success' | 'warning'
+export type UiContentCardMotion = 'none' | 'pop' | 'finish'
+export type UiPunchButtonMotion = 'none' | 'ready' | 'warning'
+export type UiMapPulseMotion = 'none' | 'ready' | 'control' | 'finish'
+export type UiStageMotion = 'none' | 'finish'
+
+export interface HapticCueConfig {
+  enabled: boolean
+  pattern: HapticPattern
+}
+
+export interface UiCueConfig {
+  enabled: boolean
+  punchFeedbackMotion: UiPunchFeedbackMotion
+  contentCardMotion: UiContentCardMotion
+  punchButtonMotion: UiPunchButtonMotion
+  mapPulseMotion: UiMapPulseMotion
+  stageMotion: UiStageMotion
+  durationMs: number
+}
+
+export interface GameHapticsConfig {
+  enabled: boolean
+  cues: Record<FeedbackCueKey, HapticCueConfig>
+}
+
+export interface GameUiEffectsConfig {
+  enabled: boolean
+  cues: Record<FeedbackCueKey, UiCueConfig>
+}
+
+export interface PartialHapticCueConfig {
+  enabled?: boolean
+  pattern?: HapticPattern
+}
+
+export interface PartialUiCueConfig {
+  enabled?: boolean
+  punchFeedbackMotion?: UiPunchFeedbackMotion
+  contentCardMotion?: UiContentCardMotion
+  punchButtonMotion?: UiPunchButtonMotion
+  mapPulseMotion?: UiMapPulseMotion
+  stageMotion?: UiStageMotion
+  durationMs?: number
+}
+
+export interface GameHapticsConfigOverrides {
+  enabled?: boolean
+  cues?: Partial<Record<FeedbackCueKey, PartialHapticCueConfig>>
+}
+
+export interface GameUiEffectsConfigOverrides {
+  enabled?: boolean
+  cues?: Partial<Record<FeedbackCueKey, PartialUiCueConfig>>
+}
+
+export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = {
+  enabled: true,
+  cues: {
+    session_started: { enabled: false, pattern: 'short' },
+    session_finished: { enabled: true, pattern: 'long' },
+    'control_completed:start': { enabled: true, pattern: 'short' },
+    'control_completed:control': { enabled: true, pattern: 'short' },
+    'control_completed:finish': { enabled: true, pattern: 'long' },
+    'punch_feedback:warning': { enabled: true, pattern: 'short' },
+    'guidance:searching': { enabled: false, pattern: 'short' },
+    'guidance:approaching': { enabled: false, pattern: 'short' },
+    'guidance:ready': { enabled: true, pattern: 'short' },
+  },
+}
+
+export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = {
+  enabled: true,
+  cues: {
+    session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
+    session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
+    'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 },
+    'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 },
+    'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', durationMs: 0 },
+    'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 560 },
+    'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
+    'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
+    'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', durationMs: 900 },
+  },
+}
+
+function clampDuration(value: number, fallback: number): number {
+  return Number.isFinite(value) && value >= 0 ? value : fallback
+}
+
+function mergeHapticCue(baseCue: HapticCueConfig, override?: PartialHapticCueConfig): HapticCueConfig {
+  return {
+    enabled: override && override.enabled !== undefined ? !!override.enabled : baseCue.enabled,
+    pattern: override && override.pattern ? override.pattern : baseCue.pattern,
+  }
+}
+
+function mergeUiCue(baseCue: UiCueConfig, override?: PartialUiCueConfig): UiCueConfig {
+  return {
+    enabled: override && override.enabled !== undefined ? !!override.enabled : baseCue.enabled,
+    punchFeedbackMotion: override && override.punchFeedbackMotion ? override.punchFeedbackMotion : baseCue.punchFeedbackMotion,
+    contentCardMotion: override && override.contentCardMotion ? override.contentCardMotion : baseCue.contentCardMotion,
+    punchButtonMotion: override && override.punchButtonMotion ? override.punchButtonMotion : baseCue.punchButtonMotion,
+    mapPulseMotion: override && override.mapPulseMotion ? override.mapPulseMotion : baseCue.mapPulseMotion,
+    stageMotion: override && override.stageMotion ? override.stageMotion : baseCue.stageMotion,
+    durationMs: clampDuration(Number(override && override.durationMs), baseCue.durationMs),
+  }
+}
+
+export function mergeGameHapticsConfig(overrides?: GameHapticsConfigOverrides | null): GameHapticsConfig {
+  const cues: GameHapticsConfig['cues'] = {
+    session_started: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
+    session_finished: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
+    'control_completed:start': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
+    'control_completed:control': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
+    'control_completed:finish': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
+    'punch_feedback:warning': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
+    'guidance:searching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
+    'guidance:approaching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
+    'guidance:ready': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
+  }
+
+  return {
+    enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_HAPTICS_CONFIG.enabled,
+    cues,
+  }
+}
+
+export function mergeGameUiEffectsConfig(overrides?: GameUiEffectsConfigOverrides | null): GameUiEffectsConfig {
+  const cues: GameUiEffectsConfig['cues'] = {
+    session_started: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
+    session_finished: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
+    'control_completed:start': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
+    'control_completed:control': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
+    'control_completed:finish': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
+    'punch_feedback:warning': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
+    'guidance:searching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
+    'guidance:approaching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
+    'guidance:ready': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
+  }
+
+  return {
+    enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_UI_EFFECTS_CONFIG.enabled,
+    cues,
+  }
+}

+ 57 - 0
miniprogram/game/feedback/feedbackDirector.ts

@@ -0,0 +1,57 @@
+import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig'
+import { SoundDirector } from '../audio/soundDirector'
+import { type GameEffect } from '../core/gameResult'
+import {
+  DEFAULT_GAME_HAPTICS_CONFIG,
+  DEFAULT_GAME_UI_EFFECTS_CONFIG,
+  type GameHapticsConfig,
+  type GameUiEffectsConfig,
+} from './feedbackConfig'
+import { HapticsDirector } from './hapticsDirector'
+import { UiEffectDirector, type UiEffectHost } from './uiEffectDirector'
+
+export interface FeedbackHost extends UiEffectHost {
+  stopLocationTracking: () => void
+}
+
+export interface FeedbackConfigBundle {
+  audioConfig?: GameAudioConfig
+  hapticsConfig?: GameHapticsConfig
+  uiEffectsConfig?: GameUiEffectsConfig
+}
+
+export class FeedbackDirector {
+  soundDirector: SoundDirector
+  hapticsDirector: HapticsDirector
+  uiEffectDirector: UiEffectDirector
+  host: FeedbackHost
+
+  constructor(host: FeedbackHost, config?: FeedbackConfigBundle) {
+    this.host = host
+    this.soundDirector = new SoundDirector(config && config.audioConfig ? config.audioConfig : DEFAULT_GAME_AUDIO_CONFIG)
+    this.hapticsDirector = new HapticsDirector(config && config.hapticsConfig ? config.hapticsConfig : DEFAULT_GAME_HAPTICS_CONFIG)
+    this.uiEffectDirector = new UiEffectDirector(host, config && config.uiEffectsConfig ? config.uiEffectsConfig : DEFAULT_GAME_UI_EFFECTS_CONFIG)
+  }
+
+  configure(config: FeedbackConfigBundle): void {
+    this.soundDirector.configure(config.audioConfig || DEFAULT_GAME_AUDIO_CONFIG)
+    this.hapticsDirector.configure(config.hapticsConfig || DEFAULT_GAME_HAPTICS_CONFIG)
+    this.uiEffectDirector.configure(config.uiEffectsConfig || DEFAULT_GAME_UI_EFFECTS_CONFIG)
+  }
+
+  destroy(): void {
+    this.soundDirector.destroy()
+    this.hapticsDirector.destroy()
+    this.uiEffectDirector.destroy()
+  }
+
+  handleEffects(effects: GameEffect[]): void {
+    this.soundDirector.handleEffects(effects)
+    this.hapticsDirector.handleEffects(effects)
+    this.uiEffectDirector.handleEffects(effects)
+
+    if (effects.some((effect) => effect.type === 'session_finished')) {
+      this.host.stopLocationTracking()
+    }
+  }
+}

+ 85 - 0
miniprogram/game/feedback/hapticsDirector.ts

@@ -0,0 +1,85 @@
+import { type GameEffect } from '../core/gameResult'
+import { DEFAULT_GAME_HAPTICS_CONFIG, type FeedbackCueKey, type GameHapticsConfig } from './feedbackConfig'
+
+export class HapticsDirector {
+  enabled: boolean
+  config: GameHapticsConfig
+
+  constructor(config: GameHapticsConfig = DEFAULT_GAME_HAPTICS_CONFIG) {
+    this.enabled = true
+    this.config = config
+  }
+
+  configure(config: GameHapticsConfig): void {
+    this.config = config
+  }
+
+  setEnabled(enabled: boolean): void {
+    this.enabled = enabled
+  }
+
+  destroy(): void {}
+
+  trigger(key: FeedbackCueKey): void {
+    if (!this.enabled || !this.config.enabled) {
+      return
+    }
+
+    const cue = this.config.cues[key]
+    if (!cue || !cue.enabled) {
+      return
+    }
+
+    try {
+      if (cue.pattern === 'long') {
+        wx.vibrateLong()
+      } else {
+        wx.vibrateShort({ type: 'medium' })
+      }
+    } catch {}
+  }
+
+  handleEffects(effects: GameEffect[]): void {
+    for (const effect of effects) {
+      if (effect.type === 'session_started') {
+        this.trigger('session_started')
+        continue
+      }
+
+      if (effect.type === 'session_finished') {
+        this.trigger('session_finished')
+        continue
+      }
+
+      if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
+        this.trigger('punch_feedback:warning')
+        continue
+      }
+
+      if (effect.type === 'guidance_state_changed') {
+        if (effect.guidanceState === 'searching') {
+          this.trigger('guidance:searching')
+          continue
+        }
+        if (effect.guidanceState === 'approaching') {
+          this.trigger('guidance:approaching')
+          continue
+        }
+        this.trigger('guidance:ready')
+        continue
+      }
+
+      if (effect.type === 'control_completed') {
+        if (effect.controlKind === 'start') {
+          this.trigger('control_completed:start')
+          continue
+        }
+        if (effect.controlKind === 'finish') {
+          this.trigger('control_completed:finish')
+          continue
+        }
+        this.trigger('control_completed:control')
+      }
+    }
+  }
+}

+ 194 - 0
miniprogram/game/feedback/uiEffectDirector.ts

@@ -0,0 +1,194 @@
+import { type GameEffect } from '../core/gameResult'
+import {
+  DEFAULT_GAME_UI_EFFECTS_CONFIG,
+  type FeedbackCueKey,
+  type GameUiEffectsConfig,
+  type UiContentCardMotion,
+  type UiMapPulseMotion,
+  type UiPunchButtonMotion,
+  type UiPunchFeedbackMotion,
+  type UiStageMotion,
+} from './feedbackConfig'
+
+export interface UiEffectHost {
+  showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void
+  showContentCard: (title: string, body: string, motionClass?: string) => void
+  setPunchButtonFxClass: (className: string) => void
+  showMapPulse: (controlId: string, motionClass?: string) => void
+  showStageFx: (className: string) => void
+}
+
+export class UiEffectDirector {
+  enabled: boolean
+  config: GameUiEffectsConfig
+  host: UiEffectHost
+  punchButtonMotionTimer: number
+  punchButtonMotionToggle: boolean
+
+  constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) {
+    this.enabled = true
+    this.host = host
+    this.config = config
+    this.punchButtonMotionTimer = 0
+    this.punchButtonMotionToggle = false
+  }
+
+  configure(config: GameUiEffectsConfig): void {
+    this.config = config
+    this.clearPunchButtonMotion()
+  }
+
+  setEnabled(enabled: boolean): void {
+    this.enabled = enabled
+    if (!enabled) {
+      this.clearPunchButtonMotion()
+    }
+  }
+
+  destroy(): void {
+    this.clearPunchButtonMotion()
+  }
+
+  clearPunchButtonMotion(): void {
+    if (this.punchButtonMotionTimer) {
+      clearTimeout(this.punchButtonMotionTimer)
+      this.punchButtonMotionTimer = 0
+    }
+    this.host.setPunchButtonFxClass('')
+  }
+
+  getPunchFeedbackMotionClass(motion: UiPunchFeedbackMotion): string {
+    if (motion === 'warning') {
+      return 'game-punch-feedback--fx-warning'
+    }
+    if (motion === 'success') {
+      return 'game-punch-feedback--fx-success'
+    }
+    if (motion === 'pop') {
+      return 'game-punch-feedback--fx-pop'
+    }
+    return ''
+  }
+
+  getContentCardMotionClass(motion: UiContentCardMotion): string {
+    if (motion === 'finish') {
+      return 'game-content-card--fx-finish'
+    }
+    if (motion === 'pop') {
+      return 'game-content-card--fx-pop'
+    }
+    return ''
+  }
+
+  getMapPulseMotionClass(motion: UiMapPulseMotion): string {
+    if (motion === 'ready') {
+      return 'map-stage__map-pulse--ready'
+    }
+    if (motion === 'finish') {
+      return 'map-stage__map-pulse--finish'
+    }
+    if (motion === 'control') {
+      return 'map-stage__map-pulse--control'
+    }
+    return ''
+  }
+
+  getStageMotionClass(motion: UiStageMotion): string {
+    if (motion === 'finish') {
+      return 'map-stage__stage-fx--finish'
+    }
+    return ''
+  }
+
+  triggerPunchButtonMotion(motion: UiPunchButtonMotion, durationMs: number): void {
+    if (motion === 'none') {
+      return
+    }
+
+    this.punchButtonMotionToggle = !this.punchButtonMotionToggle
+    const variant = this.punchButtonMotionToggle ? 'a' : 'b'
+    const className = motion === 'warning'
+      ? `map-punch-button--fx-warning-${variant}`
+      : `map-punch-button--fx-ready-${variant}`
+
+    this.host.setPunchButtonFxClass(className)
+    if (this.punchButtonMotionTimer) {
+      clearTimeout(this.punchButtonMotionTimer)
+    }
+    this.punchButtonMotionTimer = setTimeout(() => {
+      this.punchButtonMotionTimer = 0
+      this.host.setPunchButtonFxClass('')
+    }, durationMs) as unknown as number
+  }
+
+  getCue(key: FeedbackCueKey) {
+    if (!this.enabled || !this.config.enabled) {
+      return null
+    }
+
+    const cue = this.config.cues[key]
+    if (!cue || !cue.enabled) {
+      return null
+    }
+
+    return cue
+  }
+
+  handleEffects(effects: GameEffect[]): void {
+    for (const effect of effects) {
+      if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
+        const cue = this.getCue('punch_feedback:warning')
+        this.host.showPunchFeedback(
+          effect.text,
+          effect.tone,
+          cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
+        )
+        if (cue) {
+          this.triggerPunchButtonMotion(cue.punchButtonMotion, cue.durationMs)
+        }
+        continue
+      }
+
+      if (effect.type === 'control_completed') {
+        const key: FeedbackCueKey = effect.controlKind === 'start'
+          ? 'control_completed:start'
+          : effect.controlKind === 'finish'
+            ? 'control_completed:finish'
+            : 'control_completed:control'
+        const cue = this.getCue(key)
+        this.host.showPunchFeedback(
+          `完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`,
+          'success',
+          cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '',
+        )
+        this.host.showContentCard(
+          effect.displayTitle,
+          effect.displayBody,
+          cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '',
+        )
+        if (cue && cue.mapPulseMotion !== 'none') {
+          this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))
+        }
+        if (cue && cue.stageMotion !== 'none') {
+          this.host.showStageFx(this.getStageMotionClass(cue.stageMotion))
+        }
+        continue
+      }
+
+      if (effect.type === 'guidance_state_changed' && effect.guidanceState === 'ready') {
+        const cue = this.getCue('guidance:ready')
+        if (cue) {
+          this.triggerPunchButtonMotion(cue.punchButtonMotion, cue.durationMs)
+          if (cue.mapPulseMotion !== 'none' && effect.controlId) {
+            this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion))
+          }
+        }
+        continue
+      }
+
+      if (effect.type === 'session_finished') {
+        this.clearPunchButtonMotion()
+      }
+    }
+  }
+}

+ 39 - 2
miniprogram/game/rules/classicSequentialRule.ts

@@ -1,4 +1,5 @@
 import { type LonLatPoint } from '../../utils/projection'
+import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig'
 import { type GameControl, type GameDefinition } from '../core/gameDefinition'
 import { type GameEvent } from '../core/gameEvent'
 import { type GameEffect, type GameResult } from '../core/gameResult'
@@ -56,6 +57,31 @@ function getTargetText(control: GameControl): string {
   return '目标圈'
 }
 
+function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
+  if (distanceMeters <= definition.punchRadiusMeters) {
+    return 'ready'
+  }
+
+  const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
+  if (distanceMeters <= approachDistanceMeters) {
+    return 'approaching'
+  }
+
+  return 'searching'
+}
+
+function getGuidanceEffects(
+  previousState: GameSessionState['guidanceState'],
+  nextState: GameSessionState['guidanceState'],
+  controlId: string | null,
+): GameEffect[] {
+  if (previousState === nextState) {
+    return []
+  }
+
+  return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }]
+}
+
 
 function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
   if (state.status === 'idle') {
@@ -207,6 +233,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
     score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
     status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
     endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
+    guidanceState: nextTarget ? 'searching' : 'searching',
   }
   const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
 
@@ -235,6 +262,7 @@ export class ClassicSequentialRule implements RulePlugin {
       currentTargetControlId: getInitialTargetId(definition),
       inRangeControlId: null,
       score: 0,
+      guidanceState: 'searching',
     }
   }
 
@@ -250,6 +278,7 @@ export class ClassicSequentialRule implements RulePlugin {
         startedAt: event.at,
         endedAt: null,
         inRangeControlId: null,
+        guidanceState: 'searching',
       }
       return {
         nextState,
@@ -263,6 +292,7 @@ export class ClassicSequentialRule implements RulePlugin {
         ...state,
         status: 'finished',
         endedAt: event.at,
+        guidanceState: 'searching',
       }
       return {
         nextState,
@@ -291,19 +321,26 @@ export class ClassicSequentialRule implements RulePlugin {
     if (event.type === 'gps_updated') {
       const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
       const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
+      const guidanceState = getGuidanceState(definition, distanceMeters)
       const nextState: GameSessionState = {
         ...state,
         inRangeControlId,
+        guidanceState,
       }
+      const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, currentTarget.id)
 
       if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) {
-        return applyCompletion(definition, nextState, currentTarget, event.at)
+        const completionResult = applyCompletion(definition, nextState, currentTarget, event.at)
+        return {
+          ...completionResult,
+          effects: [...guidanceEffects, ...completionResult.effects],
+        }
       }
 
       return {
         nextState,
         presentation: buildPresentation(definition, nextState),
-        effects: [],
+        effects: guidanceEffects,
       }
     }
 

+ 18 - 0
miniprogram/pages/map/map.ts

@@ -109,6 +109,15 @@ Page({
     contentCardVisible: false,
     contentCardTitle: '',
     contentCardBody: '',
+    punchButtonFxClass: '',
+    punchFeedbackFxClass: '',
+    contentCardFxClass: '',
+    mapPulseVisible: false,
+    mapPulseLeftPx: 0,
+    mapPulseTopPx: 0,
+    mapPulseFxClass: '',
+    stageFxVisible: false,
+    stageFxClass: '',
     compassTicks: buildCompassTicks(),
     compassLabels: buildCompassLabels(),
     ...buildSideButtonVisibility('left'),
@@ -146,6 +155,15 @@ Page({
       contentCardVisible: false,
       contentCardTitle: '',
       contentCardBody: '',
+      punchButtonFxClass: '',
+      punchFeedbackFxClass: '',
+      contentCardFxClass: '',
+      mapPulseVisible: false,
+      mapPulseLeftPx: 0,
+      mapPulseTopPx: 0,
+      mapPulseFxClass: '',
+      stageFxVisible: false,
+      stageFxClass: '',
       compassTicks: buildCompassTicks(),
       compassLabels: buildCompassLabels(),
       ...buildSideButtonVisibility('left'),

+ 5 - 3
miniprogram/pages/map/map.wxml

@@ -22,10 +22,12 @@
     </view>
 
     <view class="map-stage__crosshair"></view>
+    <view class="map-stage__map-pulse {{mapPulseFxClass}}" wx:if="{{mapPulseVisible}}" style="left: {{mapPulseLeftPx}}px; top: {{mapPulseTopPx}}px;"></view>
+    <view class="map-stage__stage-fx {{stageFxClass}}" wx:if="{{stageFxVisible}}"></view>
 
     <view class="game-punch-hint" wx:if="{{punchHintText}}">{{punchHintText}}</view>
-    <view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
-    <view class="game-content-card" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
+    <view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}} {{punchFeedbackFxClass}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
+    <view class="game-content-card {{contentCardFxClass}}" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
       <view class="game-content-card__title">{{contentCardTitle}}</view>
       <view class="game-content-card__body">{{contentCardBody}}</view>
       <view class="game-content-card__hint">点击关闭</view>
@@ -96,7 +98,7 @@
     <cover-view class="map-side-button"><cover-view class="map-side-button__text">USER</cover-view></cover-view>
   </cover-view>
 
-  <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
+  <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
     <cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
   </cover-view>
 

+ 252 - 0
miniprogram/pages/map/map.wxss

@@ -72,6 +72,40 @@
   transform: translateY(-50%);
 }
 
+.map-stage__map-pulse {
+  position: absolute;
+  width: 44rpx;
+  height: 44rpx;
+  margin-left: -22rpx;
+  margin-top: -22rpx;
+  border-radius: 50%;
+  pointer-events: none;
+  z-index: 6;
+}
+
+.map-stage__map-pulse--control {
+  animation: map-pulse-control 0.82s ease-out 1;
+}
+
+.map-stage__map-pulse--ready {
+  animation: map-pulse-ready 0.72s ease-out 1;
+}
+
+.map-stage__map-pulse--finish {
+  animation: map-pulse-finish 0.82s ease-out 1;
+}
+
+.map-stage__stage-fx {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  z-index: 5;
+}
+
+.map-stage__stage-fx--finish {
+  animation: stage-fx-finish 0.76s ease-out 1;
+}
+
 .map-stage__overlay {
   position: absolute;
   inset: 0;
@@ -732,6 +766,16 @@
   color: #064d46;
 }
 
+.map-punch-button--fx-ready-a,
+.map-punch-button--fx-ready-b {
+  animation: punch-button-burst 0.92s ease-out 1;
+}
+
+.map-punch-button--fx-warning-a,
+.map-punch-button--fx-warning-b {
+  animation: punch-button-warning 0.56s ease-in-out 1;
+}
+
 
 .race-panel__line {
   position: absolute;
@@ -1076,6 +1120,18 @@
   background: rgba(196, 117, 18, 0.94);
 }
 
+.game-punch-feedback--fx-pop {
+  animation: feedback-toast-pop 0.42s ease-out;
+}
+
+.game-punch-feedback--fx-success {
+  animation: feedback-toast-success 0.58s ease-out;
+}
+
+.game-punch-feedback--fx-warning {
+  animation: feedback-toast-warning 0.56s ease-out;
+}
+
 .game-content-card {
   position: absolute;
   left: 50%;
@@ -1111,6 +1167,14 @@
   color: #809284;
 }
 
+.game-content-card--fx-pop {
+  animation: content-card-pop 0.5s cubic-bezier(0.18, 0.88, 0.2, 1);
+}
+
+.game-content-card--fx-finish {
+  animation: content-card-finish 0.68s cubic-bezier(0.18, 0.88, 0.2, 1);
+}
+
 .race-panel__action-button {
   display: flex;
   align-items: center;
@@ -1162,3 +1226,191 @@
 
 
 
+
+
+@keyframes punch-button-burst {
+  0% {
+    transform: scale(1);
+    box-shadow: 0 12rpx 28rpx rgba(22, 34, 46, 0.22);
+  }
+  34% {
+    transform: scale(1.12);
+    box-shadow: 0 0 0 9rpx rgba(149, 255, 244, 0.18), 0 0 34rpx rgba(92, 255, 237, 0.58);
+  }
+  100% {
+    transform: scale(1);
+    box-shadow: 0 12rpx 28rpx rgba(22, 34, 46, 0.22);
+  }
+}
+
+@keyframes punch-button-warning {
+  0%, 100% {
+    transform: translateX(0);
+  }
+  20% {
+    transform: translateX(-6rpx) scale(1.02);
+  }
+  40% {
+    transform: translateX(6rpx) scale(1.04);
+  }
+  60% {
+    transform: translateX(-4rpx) scale(1.02);
+  }
+  80% {
+    transform: translateX(4rpx);
+  }
+}
+
+@keyframes feedback-toast-pop {
+  0% {
+    opacity: 0;
+    transform: translateX(-50%) translateY(18rpx) scale(0.88);
+  }
+  100% {
+    opacity: 1;
+    transform: translateX(-50%) translateY(0) scale(1);
+  }
+}
+
+@keyframes feedback-toast-success {
+  0% {
+    opacity: 0;
+    transform: translateX(-50%) translateY(18rpx) scale(0.88);
+  }
+  55% {
+    opacity: 1;
+    transform: translateX(-50%) translateY(-6rpx) scale(1.04);
+  }
+  100% {
+    opacity: 1;
+    transform: translateX(-50%) translateY(0) scale(1);
+  }
+}
+
+@keyframes feedback-toast-warning {
+  0% {
+    opacity: 0;
+    transform: translateX(-50%) translateY(12rpx) scale(0.92);
+  }
+  30% {
+    opacity: 1;
+    transform: translateX(calc(-50% - 6rpx)) translateY(0) scale(1.02);
+  }
+  60% {
+    transform: translateX(calc(-50% + 6rpx)) translateY(0) scale(1.02);
+  }
+  100% {
+    opacity: 1;
+    transform: translateX(-50%) translateY(0) scale(1);
+  }
+}
+
+@keyframes content-card-pop {
+  0% {
+    opacity: 0;
+    transform: translateX(-50%) translateY(30rpx) scale(0.92);
+  }
+  100% {
+    opacity: 1;
+    transform: translateX(-50%) translateY(0) scale(1);
+  }
+}
+
+@keyframes content-card-finish {
+  0% {
+    opacity: 0;
+    transform: translateX(-50%) translateY(34rpx) scale(0.9);
+    box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18);
+  }
+  45% {
+    opacity: 1;
+    transform: translateX(-50%) translateY(-6rpx) scale(1.03);
+    box-shadow: 0 0 0 6rpx rgba(255, 232, 147, 0.18), 0 22rpx 52rpx rgba(22, 48, 32, 0.2);
+  }
+  100% {
+    opacity: 1;
+    transform: translateX(-50%) translateY(0) scale(1);
+    box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18);
+  }
+}
+
+@keyframes map-pulse-control {
+  0% {
+    opacity: 0.94;
+    transform: scale(0.28);
+    border: 6rpx solid rgba(92, 255, 237, 0.98);
+    box-shadow: 0 0 0 0 rgba(92, 255, 237, 0.42);
+  }
+  70% {
+    opacity: 0.32;
+    transform: scale(3.4);
+    border: 4rpx solid rgba(92, 255, 237, 0.52);
+    box-shadow: 0 0 0 10rpx rgba(92, 255, 237, 0.08);
+  }
+  100% {
+    opacity: 0;
+    transform: scale(4.1);
+    border: 2rpx solid rgba(92, 255, 237, 0);
+    box-shadow: 0 0 0 0 rgba(92, 255, 237, 0);
+  }
+}
+
+@keyframes map-pulse-ready {
+  0% {
+    opacity: 0.92;
+    transform: scale(0.22);
+    border: 5rpx solid rgba(255, 248, 184, 0.98);
+    box-shadow: 0 0 0 0 rgba(255, 248, 184, 0.28);
+  }
+  68% {
+    opacity: 0.22;
+    transform: scale(2.4);
+    border: 3rpx solid rgba(255, 248, 184, 0.46);
+    box-shadow: 0 0 0 8rpx rgba(255, 248, 184, 0.08);
+  }
+  100% {
+    opacity: 0;
+    transform: scale(3);
+    border: 2rpx solid rgba(255, 248, 184, 0);
+    box-shadow: 0 0 0 0 rgba(255, 248, 184, 0);
+  }
+}
+
+@keyframes map-pulse-finish {
+  0% {
+    opacity: 0.98;
+    transform: scale(0.24);
+    border: 6rpx solid rgba(255, 231, 117, 1);
+    box-shadow: 0 0 0 0 rgba(255, 231, 117, 0.46);
+  }
+  48% {
+    opacity: 0.52;
+    transform: scale(3.8);
+    border: 4rpx solid rgba(255, 231, 117, 0.72);
+    box-shadow: 0 0 0 14rpx rgba(255, 231, 117, 0.14);
+  }
+  100% {
+    opacity: 0;
+    transform: scale(4.8);
+    border: 2rpx solid rgba(255, 231, 117, 0);
+    box-shadow: 0 0 0 0 rgba(255, 231, 117, 0);
+  }
+}
+
+@keyframes stage-fx-finish {
+  0% {
+    opacity: 0;
+    background: radial-gradient(circle at 50% 50%, rgba(255, 241, 168, 0.22) 0%, rgba(255, 241, 168, 0.08) 28%, rgba(255, 255, 255, 0) 62%);
+    backdrop-filter: brightness(1);
+  }
+  24% {
+    opacity: 1;
+    background: radial-gradient(circle at 50% 50%, rgba(255, 241, 168, 0.34) 0%, rgba(255, 241, 168, 0.14) 32%, rgba(255, 255, 255, 0.04) 74%);
+    backdrop-filter: brightness(1.08);
+  }
+  100% {
+    opacity: 0;
+    background: radial-gradient(circle at 50% 50%, rgba(255, 241, 168, 0) 0%, rgba(255, 241, 168, 0) 100%);
+    backdrop-filter: brightness(1);
+  }
+}

+ 447 - 3
miniprogram/utils/remoteMapConfig.ts

@@ -1,5 +1,17 @@
 import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
 import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
+import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
+import {
+  mergeGameHapticsConfig,
+  mergeGameUiEffectsConfig,
+  type FeedbackCueKey,
+  type GameHapticsConfig,
+  type GameHapticsConfigOverrides,
+  type GameUiEffectsConfig,
+  type GameUiEffectsConfigOverrides,
+  type PartialHapticCueConfig,
+  type PartialUiCueConfig,
+} from '../game/feedback/feedbackConfig'
 
 export interface TileZoomBounds {
   minX: number
@@ -33,6 +45,9 @@ export interface RemoteMapConfig {
   punchPolicy: 'enter' | 'enter-confirm'
   punchRadiusMeters: number
   autoFinishOnLastControl: boolean
+  audioConfig: GameAudioConfig
+  hapticsConfig: GameHapticsConfig
+  uiEffectsConfig: GameUiEffectsConfig
 }
 
 interface ParsedGameConfig {
@@ -44,6 +59,9 @@ interface ParsedGameConfig {
   punchPolicy: 'enter' | 'enter-confirm'
   punchRadiusMeters: number
   autoFinishOnLastControl: boolean
+  audioConfig: GameAudioConfig
+  hapticsConfig: GameHapticsConfig
+  uiEffectsConfig: GameUiEffectsConfig
   declinationDeg: number
 }
 
@@ -188,6 +206,134 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
   return rawValue === 'enter' ? 'enter' : 'enter-confirm'
 }
 
+
+function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
+  if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
+    return {}
+  }
+
+  const normalized: Record<string, unknown> = {}
+  const keys = Object.keys(rawValue as Record<string, unknown>)
+  for (const key of keys) {
+    normalized[key.toLowerCase()] = (rawValue as Record<string, unknown>)[key]
+  }
+  return normalized
+}
+
+function getFirstDefined(record: Record<string, unknown>, keys: string[]): unknown {
+  for (const key of keys) {
+    if (record[key] !== undefined) {
+      return record[key]
+    }
+  }
+  return undefined
+}
+
+function resolveAudioSrc(baseUrl: string, rawValue: unknown): string | undefined {
+  if (typeof rawValue !== 'string') {
+    return undefined
+  }
+
+  const trimmed = rawValue.trim()
+  if (!trimmed) {
+    return undefined
+  }
+
+  if (/^https?:\/\//i.test(trimmed)) {
+    return trimmed
+  }
+
+  if (trimmed.startsWith('/assets/')) {
+    return trimmed
+  }
+
+  if (trimmed.startsWith('assets/')) {
+    return `/${trimmed}`
+  }
+
+  return resolveUrl(baseUrl, trimmed)
+}
+
+function buildAudioCueOverride(rawValue: unknown, baseUrl: string): PartialAudioCueConfig | null {
+  if (typeof rawValue === 'string') {
+    const src = resolveAudioSrc(baseUrl, rawValue)
+    return src ? { src } : null
+  }
+
+  const normalized = normalizeObjectRecord(rawValue)
+  if (!Object.keys(normalized).length) {
+    return null
+  }
+
+  const src = resolveAudioSrc(baseUrl, getFirstDefined(normalized, ['src', 'url', 'path']))
+  const volumeRaw = getFirstDefined(normalized, ['volume'])
+  const loopRaw = getFirstDefined(normalized, ['loop'])
+  const loopGapRaw = getFirstDefined(normalized, ['loopgapms', 'loopgap'])
+  const cue: PartialAudioCueConfig = {}
+
+  if (src) {
+    cue.src = src
+  }
+
+  if (volumeRaw !== undefined) {
+    cue.volume = parsePositiveNumber(volumeRaw, 1)
+  }
+
+  if (loopRaw !== undefined) {
+    cue.loop = parseBoolean(loopRaw, false)
+  }
+
+  if (loopGapRaw !== undefined) {
+    cue.loopGapMs = parsePositiveNumber(loopGapRaw, 0)
+  }
+
+  return cue.src || cue.volume !== undefined || cue.loop !== undefined || cue.loopGapMs !== undefined ? cue : null
+}
+
+function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
+  const normalized = normalizeObjectRecord(rawValue)
+  if (!Object.keys(normalized).length) {
+    return mergeGameAudioConfig()
+  }
+
+  const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
+  const cueMap: Array<{ key: AudioCueKey; aliases: string[] }> = [
+    { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start', 'session_start'] },
+    { key: 'control_completed:start', aliases: ['control_completed:start', 'controlcompleted:start', 'start_completed', 'startcomplete', 'start-complete'] },
+    { key: 'control_completed:control', aliases: ['control_completed:control', 'controlcompleted:control', 'control_completed', 'controlcompleted', 'control_complete', 'controlcomplete'] },
+    { key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
+    { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
+    { key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
+    { key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
+    { key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
+  ]
+
+  const cues: GameAudioConfigOverrides['cues'] = {}
+  for (const cueDef of cueMap) {
+    const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases)
+    const cue = buildAudioCueOverride(cueRaw, baseUrl)
+    if (cue) {
+      cues[cueDef.key] = cue
+    }
+  }
+
+  return mergeGameAudioConfig({
+    enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
+    masterVolume: normalized.mastervolume !== undefined
+      ? parsePositiveNumber(normalized.mastervolume, 1)
+      : normalized.volume !== undefined
+        ? parsePositiveNumber(normalized.volume, 1)
+        : undefined,
+    obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
+    approachDistanceMeters: normalized.approachdistancemeters !== undefined
+      ? parsePositiveNumber(normalized.approachdistancemeters, 20)
+      : normalized.approachdistance !== undefined
+        ? parsePositiveNumber(normalized.approachdistance, 20)
+        : undefined,
+    cues,
+  })
+}
+
 function parseLooseJsonObject(text: string): Record<string, unknown> {
   const parsed: Record<string, unknown> = {}
   const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
@@ -214,7 +360,244 @@ function parseLooseJsonObject(text: string): Record<string, unknown> {
   return parsed
 }
 
-function parseGameConfigFromJson(text: string): ParsedGameConfig {
+
+function parseHapticPattern(rawValue: unknown): 'short' | 'long' | undefined {
+  if (rawValue === 'short' || rawValue === 'long') {
+    return rawValue
+  }
+
+  if (typeof rawValue === 'string') {
+    const normalized = rawValue.trim().toLowerCase()
+    if (normalized === 'short' || normalized === 'long') {
+      return normalized
+    }
+  }
+
+  return undefined
+}
+
+function parsePunchFeedbackMotion(rawValue: unknown): 'none' | 'pop' | 'success' | 'warning' | undefined {
+  if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'success' || rawValue === 'warning') {
+    return rawValue
+  }
+
+  if (typeof rawValue === 'string') {
+    const normalized = rawValue.trim().toLowerCase()
+    if (normalized === 'none' || normalized === 'pop' || normalized === 'success' || normalized === 'warning') {
+      return normalized
+    }
+  }
+
+  return undefined
+}
+
+function parseContentCardMotion(rawValue: unknown): 'none' | 'pop' | 'finish' | undefined {
+  if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'finish') {
+    return rawValue
+  }
+
+  if (typeof rawValue === 'string') {
+    const normalized = rawValue.trim().toLowerCase()
+    if (normalized === 'none' || normalized === 'pop' || normalized === 'finish') {
+      return normalized
+    }
+  }
+
+  return undefined
+}
+
+function parsePunchButtonMotion(rawValue: unknown): 'none' | 'ready' | 'warning' | undefined {
+  if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'warning') {
+    return rawValue
+  }
+
+  if (typeof rawValue === 'string') {
+    const normalized = rawValue.trim().toLowerCase()
+    if (normalized === 'none' || normalized === 'ready' || normalized === 'warning') {
+      return normalized
+    }
+  }
+
+  return undefined
+}
+
+function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' | 'finish' | undefined {
+  if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'control' || rawValue === 'finish') {
+    return rawValue
+  }
+
+  if (typeof rawValue === 'string') {
+    const normalized = rawValue.trim().toLowerCase()
+    if (normalized === 'none' || normalized === 'ready' || normalized === 'control' || normalized === 'finish') {
+      return normalized
+    }
+  }
+
+  return undefined
+}
+
+function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
+  if (rawValue === 'none' || rawValue === 'finish') {
+    return rawValue
+  }
+
+  if (typeof rawValue === 'string') {
+    const normalized = rawValue.trim().toLowerCase()
+    if (normalized === 'none' || normalized === 'finish') {
+      return normalized
+    }
+  }
+
+  return undefined
+}
+
+function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null {
+  if (typeof rawValue === 'boolean') {
+    return { enabled: rawValue }
+  }
+
+  const pattern = parseHapticPattern(rawValue)
+  if (pattern) {
+    return { enabled: true, pattern }
+  }
+
+  const normalized = normalizeObjectRecord(rawValue)
+  if (!Object.keys(normalized).length) {
+    return null
+  }
+
+  const cue: PartialHapticCueConfig = {}
+  if (normalized.enabled !== undefined) {
+    cue.enabled = parseBoolean(normalized.enabled, true)
+  }
+
+  const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type']))
+  if (parsedPattern) {
+    cue.pattern = parsedPattern
+  }
+
+  return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null
+}
+
+function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null {
+  const normalized = normalizeObjectRecord(rawValue)
+  if (!Object.keys(normalized).length) {
+    return null
+  }
+
+  const cue: PartialUiCueConfig = {}
+  if (normalized.enabled !== undefined) {
+    cue.enabled = parseBoolean(normalized.enabled, true)
+  }
+
+  const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion']))
+  if (punchFeedbackMotion) {
+    cue.punchFeedbackMotion = punchFeedbackMotion
+  }
+
+  const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion']))
+  if (contentCardMotion) {
+    cue.contentCardMotion = contentCardMotion
+  }
+
+  const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion']))
+  if (punchButtonMotion) {
+    cue.punchButtonMotion = punchButtonMotion
+  }
+
+  const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion']))
+  if (mapPulseMotion) {
+    cue.mapPulseMotion = mapPulseMotion
+  }
+
+  const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion']))
+  if (stageMotion) {
+    cue.stageMotion = stageMotion
+  }
+
+  const durationRaw = getFirstDefined(normalized, ['durationms', 'duration'])
+  if (durationRaw !== undefined) {
+    cue.durationMs = parsePositiveNumber(durationRaw, 0)
+  }
+
+  return cue.enabled !== undefined ||
+    cue.punchFeedbackMotion !== undefined ||
+    cue.contentCardMotion !== undefined ||
+    cue.punchButtonMotion !== undefined ||
+    cue.mapPulseMotion !== undefined ||
+    cue.stageMotion !== undefined ||
+    cue.durationMs !== undefined
+    ? cue
+    : null
+}
+
+function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
+  const normalized = normalizeObjectRecord(rawValue)
+  if (!Object.keys(normalized).length) {
+    return mergeGameHapticsConfig()
+  }
+
+  const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
+  const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
+    { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
+    { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
+    { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
+    { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
+    { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
+    { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
+    { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
+    { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
+    { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
+  ]
+
+  const cues: GameHapticsConfigOverrides['cues'] = {}
+  for (const cueDef of cueMap) {
+    const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
+    if (cue) {
+      cues[cueDef.key] = cue
+    }
+  }
+
+  return mergeGameHapticsConfig({
+    enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
+    cues,
+  })
+}
+
+function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
+  const normalized = normalizeObjectRecord(rawValue)
+  if (!Object.keys(normalized).length) {
+    return mergeGameUiEffectsConfig()
+  }
+
+  const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
+  const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
+    { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
+    { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
+    { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
+    { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
+    { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
+    { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
+    { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
+    { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
+    { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
+  ]
+
+  const cues: GameUiEffectsConfigOverrides['cues'] = {}
+  for (const cueDef of cueMap) {
+    const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
+    if (cue) {
+      cues[cueDef.key] = cue
+    }
+  }
+
+  return mergeGameUiEffectsConfig({
+    enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
+    cues,
+  })
+}
+
+function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig {
   let parsed: Record<string, unknown>
   try {
     parsed = JSON.parse(text)
@@ -238,6 +621,19 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
       normalizedGame[key.toLowerCase()] = rawGame[key]
     }
   }
+  const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio
+  const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
+  const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
+    ? rawGame.uiEffects
+    : rawGame && rawGame.uieffects !== undefined
+      ? rawGame.uieffects
+      : rawGame && rawGame.ui !== undefined
+        ? rawGame.ui
+        : (parsed as Record<string, unknown>).uiEffects !== undefined
+          ? (parsed as Record<string, unknown>).uiEffects
+          : (parsed as Record<string, unknown>).uieffects !== undefined
+            ? (parsed as Record<string, unknown>).uieffects
+            : (parsed as Record<string, unknown>).ui
 
   const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
   const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
@@ -272,11 +668,14 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
       normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
       true,
     ),
+    audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
+    hapticsConfig: parseHapticsConfig(rawHaptics),
+    uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
     declinationDeg: parseDeclinationValue(normalized.declination),
   }
 }
 
-function parseGameConfigFromYaml(text: string): ParsedGameConfig {
+function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig {
   const config: Record<string, string> = {}
   const lines = text.split(/\r?\n/)
 
@@ -317,6 +716,48 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig {
       5,
     ),
     autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
+    audioConfig: parseAudioConfig({
+      enabled: config.audioenabled,
+      masterVolume: config.audiomastervolume,
+      obeyMuteSwitch: config.audioobeymuteswitch,
+      approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
+      cues: {
+        session_started: config.audiosessionstarted,
+        'control_completed:start': config.audiostartcomplete,
+        'control_completed:control': config.audiocontrolcomplete,
+        'control_completed:finish': config.audiofinishcomplete,
+        'punch_feedback:warning': config.audiowarning,
+        'guidance:searching': config.audiosearching,
+        'guidance:approaching': config.audioapproaching,
+        'guidance:ready': config.audioready,
+      },
+    }, gameConfigUrl),
+    hapticsConfig: parseHapticsConfig({
+      enabled: config.hapticsenabled,
+      cues: {
+        session_started: config.hapticsstart,
+        session_finished: config.hapticsfinish,
+        'control_completed:start': config.hapticsstartcomplete,
+        'control_completed:control': config.hapticscontrolcomplete,
+        'control_completed:finish': config.hapticsfinishcomplete,
+        'punch_feedback:warning': config.hapticswarning,
+        'guidance:searching': config.hapticssearching,
+        'guidance:approaching': config.hapticsapproaching,
+        'guidance:ready': config.hapticsready,
+      },
+    }),
+    uiEffectsConfig: parseUiEffectsConfig({
+      enabled: config.uieffectsenabled,
+      cues: {
+        session_started: { enabled: config.uistartenabled, punchButtonMotion: config.uistartbuttonmotion },
+        session_finished: { enabled: config.uifinishenabled, contentCardMotion: config.uifinishcardmotion },
+        'control_completed:start': { enabled: config.uistartcompleteenabled, contentCardMotion: config.uistartcompletecardmotion, punchFeedbackMotion: config.uistartcompletetoastmotion },
+        'control_completed:control': { enabled: config.uicontrolcompleteenabled, contentCardMotion: config.uicontrolcompletecardmotion, punchFeedbackMotion: config.uicontrolcompletetoastmotion },
+        'control_completed:finish': { enabled: config.uifinishcompleteenabled, contentCardMotion: config.uifinishcompletecardmotion, punchFeedbackMotion: config.uifinishcompletetoastmotion },
+        'punch_feedback:warning': { enabled: config.uiwarningenabled, punchFeedbackMotion: config.uiwarningtoastmotion, punchButtonMotion: config.uiwarningbuttonmotion, durationMs: config.uiwarningdurationms },
+        'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
+      },
+    }),
     declinationDeg: parseDeclinationValue(config.declination),
   }
 }
@@ -328,7 +769,7 @@ function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig
     trimmedText.startsWith('[') ||
     /\.json(?:[?#].*)?$/i.test(gameConfigUrl)
 
-  return isJson ? parseGameConfigFromJson(trimmedText) : parseGameConfigFromYaml(trimmedText)
+  return isJson ? parseGameConfigFromJson(trimmedText, gameConfigUrl) : parseGameConfigFromYaml(trimmedText, gameConfigUrl)
 }
 
 function extractStringField(text: string, key: string): string | null {
@@ -538,6 +979,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
     punchPolicy: gameConfig.punchPolicy,
     punchRadiusMeters: gameConfig.punchRadiusMeters,
     autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
+    audioConfig: gameConfig.audioConfig,
+    hapticsConfig: gameConfig.hapticsConfig,
+    uiEffectsConfig: gameConfig.uiEffectsConfig,
   }
 }