From 2c03d1a702788ae560cefe4456f6a9603929d3c3 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Tue, 24 Mar 2026 09:03:27 +0800 Subject: [PATCH] Add event-driven gameplay feedback framework --- .../assets/sounds/guidance-approaching.wav | Bin 0 -> 11950 bytes miniprogram/assets/sounds/guidance-ready.wav | Bin 0 -> 7982 bytes .../assets/sounds/guidance-searching.wav | Bin 0 -> 10628 bytes miniprogram/engine/map/mapEngine.ts | 184 ++++++- miniprogram/engine/tile/tileStore.ts | 16 +- miniprogram/game/audio/audioConfig.ts | 156 ++++++ miniprogram/game/audio/soundDirector.ts | 155 ++++-- miniprogram/game/core/gameDefinition.ts | 2 + miniprogram/game/core/gameResult.ts | 3 +- miniprogram/game/core/gameRuntime.ts | 1 + miniprogram/game/core/gameSessionState.ts | 2 + miniprogram/game/feedback/feedbackConfig.ts | 158 ++++++ miniprogram/game/feedback/feedbackDirector.ts | 57 +++ miniprogram/game/feedback/hapticsDirector.ts | 85 ++++ miniprogram/game/feedback/uiEffectDirector.ts | 194 ++++++++ .../game/rules/classicSequentialRule.ts | 41 +- miniprogram/pages/map/map.ts | 18 + miniprogram/pages/map/map.wxml | 8 +- miniprogram/pages/map/map.wxss | 252 ++++++++++ miniprogram/utils/remoteMapConfig.ts | 450 +++++++++++++++++- 20 files changed, 1718 insertions(+), 64 deletions(-) create mode 100644 miniprogram/assets/sounds/guidance-approaching.wav create mode 100644 miniprogram/assets/sounds/guidance-ready.wav create mode 100644 miniprogram/assets/sounds/guidance-searching.wav create mode 100644 miniprogram/game/audio/audioConfig.ts create mode 100644 miniprogram/game/feedback/feedbackConfig.ts create mode 100644 miniprogram/game/feedback/feedbackDirector.ts create mode 100644 miniprogram/game/feedback/hapticsDirector.ts create mode 100644 miniprogram/game/feedback/uiEffectDirector.ts diff --git a/miniprogram/assets/sounds/guidance-approaching.wav b/miniprogram/assets/sounds/guidance-approaching.wav new file mode 100644 index 0000000000000000000000000000000000000000..e1c52733941e2209e246a094498a05f4a917a3bf GIT binary patch literal 11950 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 literal 0 HcmV?d00001 diff --git a/miniprogram/assets/sounds/guidance-ready.wav b/miniprogram/assets/sounds/guidance-ready.wav new file mode 100644 index 0000000000000000000000000000000000000000..0766abaf9f48f278d1ecb271759653e27be559e7 GIT binary patch literal 7982 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 NKt(``OjiQH{{RJWpWgrg literal 0 HcmV?d00001 diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index b97968d..562fd81 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/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 = [ '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) => 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) diff --git a/miniprogram/engine/tile/tileStore.ts b/miniprogram/engine/tile/tileStore.ts index ef052ee..8b3e039 100644 --- a/miniprogram/engine/tile/tileStore.ts +++ b/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 diff --git a/miniprogram/game/audio/audioConfig.ts b/miniprogram/game/audio/audioConfig.ts new file mode 100644 index 0000000..2fa36ae --- /dev/null +++ b/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 +} + +export interface PartialAudioCueConfig { + src?: string + volume?: number + loop?: boolean + loopGapMs?: number +} + +export interface GameAudioConfigOverrides { + enabled?: boolean + masterVolume?: number + obeyMuteSwitch?: boolean + approachDistanceMeters?: number + cues?: Partial> +} + +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, + } +} diff --git a/miniprogram/game/audio/soundDirector.ts b/miniprogram/game/audio/soundDirector.ts index 0079498..d50231c 100644 --- a/miniprogram/game/audio/soundDirector.ts +++ b/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 = { - '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> + config: GameAudioConfig + contexts: Partial> + loopTimers: Partial> + 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 } diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts index 280b8ab..14033ee 100644 --- a/miniprogram/game/core/gameDefinition.ts +++ b/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 } diff --git a/miniprogram/game/core/gameResult.ts b/miniprogram/game/core/gameResult.ts index ed5a132..a2e1ab9 100644 --- a/miniprogram/game/core/gameResult.ts +++ b/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 { diff --git a/miniprogram/game/core/gameRuntime.ts b/miniprogram/game/core/gameRuntime.ts index b75da50..b39b815 100644 --- a/miniprogram/game/core/gameRuntime.ts +++ b/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, diff --git a/miniprogram/game/core/gameSessionState.ts b/miniprogram/game/core/gameSessionState.ts index f007b74..b95f695 100644 --- a/miniprogram/game/core/gameSessionState.ts +++ b/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 } diff --git a/miniprogram/game/feedback/feedbackConfig.ts b/miniprogram/game/feedback/feedbackConfig.ts new file mode 100644 index 0000000..1372970 --- /dev/null +++ b/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 +} + +export interface GameUiEffectsConfig { + enabled: boolean + cues: Record +} + +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> +} + +export interface GameUiEffectsConfigOverrides { + enabled?: boolean + cues?: Partial> +} + +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, + } +} diff --git a/miniprogram/game/feedback/feedbackDirector.ts b/miniprogram/game/feedback/feedbackDirector.ts new file mode 100644 index 0000000..25e7d30 --- /dev/null +++ b/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() + } + } +} diff --git a/miniprogram/game/feedback/hapticsDirector.ts b/miniprogram/game/feedback/hapticsDirector.ts new file mode 100644 index 0000000..75e2f40 --- /dev/null +++ b/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') + } + } + } +} diff --git a/miniprogram/game/feedback/uiEffectDirector.ts b/miniprogram/game/feedback/uiEffectDirector.ts new file mode 100644 index 0000000..73b5e30 --- /dev/null +++ b/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() + } + } + } +} diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts index 2d59e3c..b34741e 100644 --- a/miniprogram/game/rules/classicSequentialRule.ts +++ b/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, } } diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 97d11cd..0947788 100644 --- a/miniprogram/pages/map/map.ts +++ b/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'), diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index ebc1e1c..c7f86e0 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -22,10 +22,12 @@ + + {{punchHintText}} - {{punchFeedbackText}} - + {{punchFeedbackText}} + {{contentCardTitle}} {{contentCardBody}} 点击关闭 @@ -96,7 +98,7 @@ USER - + {{punchButtonText}} diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 840007f..80bf8da 100644 --- a/miniprogram/pages/map/map.wxss +++ b/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); + } +} diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index b424dc6..ebddf7c 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/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 { + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + return {} + } + + const normalized: Record = {} + const keys = Object.keys(rawValue as Record) + for (const key of keys) { + normalized[key.toLowerCase()] = (rawValue as Record)[key] + } + return normalized +} + +function getFirstDefined(record: Record, 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 { const parsed: Record = {} const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g @@ -214,7 +360,244 @@ function parseLooseJsonObject(text: string): Record { 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 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).uiEffects !== undefined + ? (parsed as Record).uiEffects + : (parsed as Record).uieffects !== undefined + ? (parsed as Record).uieffects + : (parsed as Record).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 = {} 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