diff --git a/miniprogram/assets/sounds/guidance-approaching.wav b/miniprogram/assets/sounds/guidance-approaching.wav new file mode 100644 index 0000000..e1c5273 Binary files /dev/null and b/miniprogram/assets/sounds/guidance-approaching.wav differ diff --git a/miniprogram/assets/sounds/guidance-ready.wav b/miniprogram/assets/sounds/guidance-ready.wav new file mode 100644 index 0000000..0766aba Binary files /dev/null and b/miniprogram/assets/sounds/guidance-ready.wav differ diff --git a/miniprogram/assets/sounds/guidance-searching.wav b/miniprogram/assets/sounds/guidance-searching.wav new file mode 100644 index 0000000..4719abf Binary files /dev/null and b/miniprogram/assets/sounds/guidance-searching.wav differ 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