Add event-driven gameplay feedback framework
This commit is contained in:
BIN
miniprogram/assets/sounds/guidance-approaching.wav
Normal file
BIN
miniprogram/assets/sounds/guidance-approaching.wav
Normal file
Binary file not shown.
BIN
miniprogram/assets/sounds/guidance-ready.wav
Normal file
BIN
miniprogram/assets/sounds/guidance-ready.wav
Normal file
Binary file not shown.
BIN
miniprogram/assets/sounds/guidance-searching.wav
Normal file
BIN
miniprogram/assets/sounds/guidance-searching.wav
Normal file
Binary file not shown.
@@ -9,7 +9,7 @@ import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '.
|
|||||||
import { GameRuntime } from '../../game/core/gameRuntime'
|
import { GameRuntime } from '../../game/core/gameRuntime'
|
||||||
import { type GameEffect } from '../../game/core/gameResult'
|
import { type GameEffect } from '../../game/core/gameResult'
|
||||||
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
|
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'
|
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
|
||||||
|
|
||||||
const RENDER_MODE = 'Single WebGL Pipeline'
|
const RENDER_MODE = 'Single WebGL Pipeline'
|
||||||
@@ -128,6 +128,15 @@ export interface MapEngineViewState {
|
|||||||
contentCardVisible: boolean
|
contentCardVisible: boolean
|
||||||
contentCardTitle: string
|
contentCardTitle: string
|
||||||
contentCardBody: string
|
contentCardBody: string
|
||||||
|
punchButtonFxClass: string
|
||||||
|
punchFeedbackFxClass: string
|
||||||
|
contentCardFxClass: string
|
||||||
|
mapPulseVisible: boolean
|
||||||
|
mapPulseLeftPx: number
|
||||||
|
mapPulseTopPx: number
|
||||||
|
mapPulseFxClass: string
|
||||||
|
stageFxVisible: boolean
|
||||||
|
stageFxClass: string
|
||||||
osmReferenceEnabled: boolean
|
osmReferenceEnabled: boolean
|
||||||
osmReferenceText: string
|
osmReferenceText: string
|
||||||
}
|
}
|
||||||
@@ -185,6 +194,15 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'contentCardVisible',
|
'contentCardVisible',
|
||||||
'contentCardTitle',
|
'contentCardTitle',
|
||||||
'contentCardBody',
|
'contentCardBody',
|
||||||
|
'punchButtonFxClass',
|
||||||
|
'punchFeedbackFxClass',
|
||||||
|
'contentCardFxClass',
|
||||||
|
'mapPulseVisible',
|
||||||
|
'mapPulseLeftPx',
|
||||||
|
'mapPulseTopPx',
|
||||||
|
'mapPulseFxClass',
|
||||||
|
'stageFxVisible',
|
||||||
|
'stageFxClass',
|
||||||
'osmReferenceEnabled',
|
'osmReferenceEnabled',
|
||||||
'osmReferenceText',
|
'osmReferenceText',
|
||||||
]
|
]
|
||||||
@@ -423,7 +441,7 @@ export class MapEngine {
|
|||||||
renderer: WebGLMapRenderer
|
renderer: WebGLMapRenderer
|
||||||
compassController: CompassHeadingController
|
compassController: CompassHeadingController
|
||||||
locationController: LocationController
|
locationController: LocationController
|
||||||
soundDirector: SoundDirector
|
feedbackDirector: FeedbackDirector
|
||||||
onData: (patch: Partial<MapEngineViewState>) => void
|
onData: (patch: Partial<MapEngineViewState>) => void
|
||||||
state: MapEngineViewState
|
state: MapEngineViewState
|
||||||
previewScale: number
|
previewScale: number
|
||||||
@@ -476,6 +494,8 @@ export class MapEngine {
|
|||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
punchFeedbackTimer: number
|
punchFeedbackTimer: number
|
||||||
contentCardTimer: number
|
contentCardTimer: number
|
||||||
|
mapPulseTimer: number
|
||||||
|
stageFxTimer: number
|
||||||
hasGpsCenteredOnce: boolean
|
hasGpsCenteredOnce: boolean
|
||||||
|
|
||||||
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
||||||
@@ -517,7 +537,28 @@ export class MapEngine {
|
|||||||
}, true)
|
}, 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.minZoom = MIN_ZOOM
|
||||||
this.maxZoom = MAX_ZOOM
|
this.maxZoom = MAX_ZOOM
|
||||||
this.defaultZoom = DEFAULT_ZOOM
|
this.defaultZoom = DEFAULT_ZOOM
|
||||||
@@ -537,6 +578,8 @@ export class MapEngine {
|
|||||||
this.autoFinishOnLastControl = true
|
this.autoFinishOnLastControl = true
|
||||||
this.punchFeedbackTimer = 0
|
this.punchFeedbackTimer = 0
|
||||||
this.contentCardTimer = 0
|
this.contentCardTimer = 0
|
||||||
|
this.mapPulseTimer = 0
|
||||||
|
this.stageFxTimer = 0
|
||||||
this.hasGpsCenteredOnce = false
|
this.hasGpsCenteredOnce = false
|
||||||
this.state = {
|
this.state = {
|
||||||
buildVersion: this.buildVersion,
|
buildVersion: this.buildVersion,
|
||||||
@@ -596,6 +639,15 @@ export class MapEngine {
|
|||||||
contentCardVisible: false,
|
contentCardVisible: false,
|
||||||
contentCardTitle: '',
|
contentCardTitle: '',
|
||||||
contentCardBody: '',
|
contentCardBody: '',
|
||||||
|
punchButtonFxClass: '',
|
||||||
|
punchFeedbackFxClass: '',
|
||||||
|
contentCardFxClass: '',
|
||||||
|
mapPulseVisible: false,
|
||||||
|
mapPulseLeftPx: 0,
|
||||||
|
mapPulseTopPx: 0,
|
||||||
|
mapPulseFxClass: '',
|
||||||
|
stageFxVisible: false,
|
||||||
|
stageFxClass: '',
|
||||||
osmReferenceEnabled: false,
|
osmReferenceEnabled: false,
|
||||||
osmReferenceText: 'OSM参考:关',
|
osmReferenceText: 'OSM参考:关',
|
||||||
}
|
}
|
||||||
@@ -643,9 +695,11 @@ export class MapEngine {
|
|||||||
this.clearAutoRotateTimer()
|
this.clearAutoRotateTimer()
|
||||||
this.clearPunchFeedbackTimer()
|
this.clearPunchFeedbackTimer()
|
||||||
this.clearContentCardTimer()
|
this.clearContentCardTimer()
|
||||||
|
this.clearMapPulseTimer()
|
||||||
|
this.clearStageFxTimer()
|
||||||
this.compassController.destroy()
|
this.compassController.destroy()
|
||||||
this.locationController.destroy()
|
this.locationController.destroy()
|
||||||
this.soundDirector.destroy()
|
this.feedbackDirector.destroy()
|
||||||
this.renderer.destroy()
|
this.renderer.destroy()
|
||||||
this.mounted = false
|
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.clearPunchFeedbackTimer()
|
||||||
this.setState({
|
this.setState({
|
||||||
punchFeedbackVisible: true,
|
punchFeedbackVisible: true,
|
||||||
punchFeedbackText: text,
|
punchFeedbackText: text,
|
||||||
punchFeedbackTone: tone,
|
punchFeedbackTone: tone,
|
||||||
|
punchFeedbackFxClass: motionClass,
|
||||||
}, true)
|
}, true)
|
||||||
this.punchFeedbackTimer = setTimeout(() => {
|
this.punchFeedbackTimer = setTimeout(() => {
|
||||||
this.punchFeedbackTimer = 0
|
this.punchFeedbackTimer = 0
|
||||||
this.setState({
|
this.setState({
|
||||||
punchFeedbackVisible: false,
|
punchFeedbackVisible: false,
|
||||||
|
punchFeedbackFxClass: '',
|
||||||
}, true)
|
}, true)
|
||||||
}, 1400) as unknown as number
|
}, 1400) as unknown as number
|
||||||
}
|
}
|
||||||
|
|
||||||
showContentCard(title: string, body: string): void {
|
showContentCard(title: string, body: string, motionClass = ''): void {
|
||||||
this.clearContentCardTimer()
|
this.clearContentCardTimer()
|
||||||
this.setState({
|
this.setState({
|
||||||
contentCardVisible: true,
|
contentCardVisible: true,
|
||||||
contentCardTitle: title,
|
contentCardTitle: title,
|
||||||
contentCardBody: body,
|
contentCardBody: body,
|
||||||
|
contentCardFxClass: motionClass,
|
||||||
}, true)
|
}, true)
|
||||||
this.contentCardTimer = setTimeout(() => {
|
this.contentCardTimer = setTimeout(() => {
|
||||||
this.contentCardTimer = 0
|
this.contentCardTimer = 0
|
||||||
this.setState({
|
this.setState({
|
||||||
contentCardVisible: false,
|
contentCardVisible: false,
|
||||||
|
contentCardFxClass: '',
|
||||||
}, true)
|
}, true)
|
||||||
}, 2600) as unknown as number
|
}, 2600) as unknown as number
|
||||||
}
|
}
|
||||||
@@ -778,28 +924,13 @@ export class MapEngine {
|
|||||||
this.clearContentCardTimer()
|
this.clearContentCardTimer()
|
||||||
this.setState({
|
this.setState({
|
||||||
contentCardVisible: false,
|
contentCardVisible: false,
|
||||||
|
contentCardFxClass: '',
|
||||||
}, true)
|
}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
applyGameEffects(effects: GameEffect[]): string | null {
|
applyGameEffects(effects: GameEffect[]): string | null {
|
||||||
this.soundDirector.handleEffects(effects)
|
this.feedbackDirector.handleEffects(effects)
|
||||||
const statusText = this.resolveGameStatusText(effects)
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleStartGame(): void {
|
handleStartGame(): void {
|
||||||
@@ -973,6 +1104,11 @@ export class MapEngine {
|
|||||||
this.punchPolicy = config.punchPolicy
|
this.punchPolicy = config.punchPolicy
|
||||||
this.punchRadiusMeters = config.punchRadiusMeters
|
this.punchRadiusMeters = config.punchRadiusMeters
|
||||||
this.autoFinishOnLastControl = config.autoFinishOnLastControl
|
this.autoFinishOnLastControl = config.autoFinishOnLastControl
|
||||||
|
this.feedbackDirector.configure({
|
||||||
|
audioConfig: config.audioConfig,
|
||||||
|
hapticsConfig: config.hapticsConfig,
|
||||||
|
uiEffectsConfig: config.uiEffectsConfig,
|
||||||
|
})
|
||||||
|
|
||||||
const gameEffects = this.loadGameDefinitionFromCourse()
|
const gameEffects = this.loadGameDefinitionFromCourse()
|
||||||
const gameStatusText = this.applyGameEffects(gameEffects)
|
const gameStatusText = this.applyGameEffects(gameEffects)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface TileStoreEntry {
|
|||||||
lastUsedAt: number
|
lastUsedAt: number
|
||||||
lastAttemptAt: number
|
lastAttemptAt: number
|
||||||
lastVisibleKey: string
|
lastVisibleKey: string
|
||||||
|
retryable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TileStoreStats {
|
export interface TileStoreStats {
|
||||||
@@ -174,6 +175,7 @@ export class TileStore {
|
|||||||
lastUsedAt: usedAt,
|
lastUsedAt: usedAt,
|
||||||
lastAttemptAt: 0,
|
lastAttemptAt: 0,
|
||||||
lastVisibleKey: '',
|
lastVisibleKey: '',
|
||||||
|
retryable: true,
|
||||||
}
|
}
|
||||||
this.tileCache.set(url, entry)
|
this.tileCache.set(url, entry)
|
||||||
return entry
|
return entry
|
||||||
@@ -274,9 +276,10 @@ export class TileStore {
|
|||||||
return
|
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') {
|
if (entry.status === 'error') {
|
||||||
entry.status = 'idle'
|
entry.status = 'idle'
|
||||||
|
entry.retryable = true
|
||||||
}
|
}
|
||||||
this.queueTile(url)
|
this.queueTile(url)
|
||||||
}
|
}
|
||||||
@@ -288,9 +291,10 @@ export class TileStore {
|
|||||||
continue
|
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') {
|
if (entry.status === 'error') {
|
||||||
entry.status = 'idle'
|
entry.status = 'idle'
|
||||||
|
entry.retryable = true
|
||||||
}
|
}
|
||||||
this.queueTile(tile.url)
|
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.status = 'error'
|
||||||
|
entry.retryable = retryable
|
||||||
finish()
|
finish()
|
||||||
if (this.onTileError) {
|
if (this.onTileError) {
|
||||||
this.onTileError(`${message}: ${url}`)
|
this.onTileError(`${message}: ${url}`)
|
||||||
@@ -425,6 +430,11 @@ export class TileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = res.filePath || filePath || res.tempFilePath
|
const resolvedPath = res.filePath || filePath || res.tempFilePath
|
||||||
|
if (res.statusCode >= 400 && res.statusCode < 500) {
|
||||||
|
markError(`瓦片资源不存在(${res.statusCode})`, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (res.statusCode !== 200 || !resolvedPath) {
|
if (res.statusCode !== 200 || !resolvedPath) {
|
||||||
tryRemoteImage()
|
tryRemoteImage()
|
||||||
return
|
return
|
||||||
|
|||||||
156
miniprogram/game/audio/audioConfig.ts
Normal file
156
miniprogram/game/audio/audioConfig.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
export type AudioCueKey =
|
||||||
|
| 'session_started'
|
||||||
|
| 'control_completed:start'
|
||||||
|
| 'control_completed:control'
|
||||||
|
| 'control_completed:finish'
|
||||||
|
| 'punch_feedback:warning'
|
||||||
|
| 'guidance:searching'
|
||||||
|
| 'guidance:approaching'
|
||||||
|
| 'guidance:ready'
|
||||||
|
|
||||||
|
export interface AudioCueConfig {
|
||||||
|
src: string
|
||||||
|
volume: number
|
||||||
|
loop: boolean
|
||||||
|
loopGapMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameAudioConfig {
|
||||||
|
enabled: boolean
|
||||||
|
masterVolume: number
|
||||||
|
obeyMuteSwitch: boolean
|
||||||
|
approachDistanceMeters: number
|
||||||
|
cues: Record<AudioCueKey, AudioCueConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartialAudioCueConfig {
|
||||||
|
src?: string
|
||||||
|
volume?: number
|
||||||
|
loop?: boolean
|
||||||
|
loopGapMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameAudioConfigOverrides {
|
||||||
|
enabled?: boolean
|
||||||
|
masterVolume?: number
|
||||||
|
obeyMuteSwitch?: boolean
|
||||||
|
approachDistanceMeters?: number
|
||||||
|
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
||||||
|
enabled: true,
|
||||||
|
masterVolume: 1,
|
||||||
|
obeyMuteSwitch: true,
|
||||||
|
approachDistanceMeters: 20,
|
||||||
|
cues: {
|
||||||
|
session_started: {
|
||||||
|
src: '/assets/sounds/session-start.wav',
|
||||||
|
volume: 0.78,
|
||||||
|
loop: false,
|
||||||
|
loopGapMs: 0,
|
||||||
|
},
|
||||||
|
'control_completed:start': {
|
||||||
|
src: '/assets/sounds/start-complete.wav',
|
||||||
|
volume: 0.84,
|
||||||
|
loop: false,
|
||||||
|
loopGapMs: 0,
|
||||||
|
},
|
||||||
|
'control_completed:control': {
|
||||||
|
src: '/assets/sounds/control-complete.wav',
|
||||||
|
volume: 0.8,
|
||||||
|
loop: false,
|
||||||
|
loopGapMs: 0,
|
||||||
|
},
|
||||||
|
'control_completed:finish': {
|
||||||
|
src: '/assets/sounds/finish-complete.wav',
|
||||||
|
volume: 0.92,
|
||||||
|
loop: false,
|
||||||
|
loopGapMs: 0,
|
||||||
|
},
|
||||||
|
'punch_feedback:warning': {
|
||||||
|
src: '/assets/sounds/warning.wav',
|
||||||
|
volume: 0.72,
|
||||||
|
loop: false,
|
||||||
|
loopGapMs: 0,
|
||||||
|
},
|
||||||
|
'guidance:searching': {
|
||||||
|
src: '/assets/sounds/guidance-searching.wav',
|
||||||
|
volume: 0.48,
|
||||||
|
loop: true,
|
||||||
|
loopGapMs: 1800,
|
||||||
|
},
|
||||||
|
'guidance:approaching': {
|
||||||
|
src: '/assets/sounds/guidance-approaching.wav',
|
||||||
|
volume: 0.58,
|
||||||
|
loop: true,
|
||||||
|
loopGapMs: 950,
|
||||||
|
},
|
||||||
|
'guidance:ready': {
|
||||||
|
src: '/assets/sounds/guidance-ready.wav',
|
||||||
|
volume: 0.68,
|
||||||
|
loop: true,
|
||||||
|
loopGapMs: 650,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampVolume(value: number, fallback: number): number {
|
||||||
|
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDistance(value: number, fallback: number): number {
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function clampGap(value: number, fallback: number): number {
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null): GameAudioConfig {
|
||||||
|
const cues: GameAudioConfig['cues'] = {
|
||||||
|
session_started: { ...DEFAULT_GAME_AUDIO_CONFIG.cues.session_started },
|
||||||
|
'control_completed:start': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:start'] },
|
||||||
|
'control_completed:control': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:control'] },
|
||||||
|
'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] },
|
||||||
|
'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] },
|
||||||
|
'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] },
|
||||||
|
'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] },
|
||||||
|
'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] },
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides && overrides.cues) {
|
||||||
|
const keys = Object.keys(overrides.cues) as AudioCueKey[]
|
||||||
|
for (const key of keys) {
|
||||||
|
const cue = overrides.cues[key]
|
||||||
|
if (!cue) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof cue.src === 'string' && cue.src) {
|
||||||
|
cues[key].src = cue.src
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cue.volume !== undefined) {
|
||||||
|
cues[key].volume = clampVolume(Number(cue.volume), cues[key].volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cue.loop !== undefined) {
|
||||||
|
cues[key].loop = !!cue.loop
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cue.loopGapMs !== undefined) {
|
||||||
|
cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled,
|
||||||
|
masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume),
|
||||||
|
obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch,
|
||||||
|
approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
|
||||||
|
cues,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,41 @@
|
|||||||
import { type GameEffect } from '../core/gameResult'
|
import { type GameEffect } from '../core/gameResult'
|
||||||
|
import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from './audioConfig'
|
||||||
type SoundKey = 'session-start' | 'start-complete' | 'control-complete' | 'finish-complete' | 'warning'
|
|
||||||
|
|
||||||
const SOUND_SRC: Record<SoundKey, string> = {
|
|
||||||
'session-start': '/assets/sounds/session-start.wav',
|
|
||||||
'start-complete': '/assets/sounds/start-complete.wav',
|
|
||||||
'control-complete': '/assets/sounds/control-complete.wav',
|
|
||||||
'finish-complete': '/assets/sounds/finish-complete.wav',
|
|
||||||
warning: '/assets/sounds/warning.wav',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SoundDirector {
|
export class SoundDirector {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
contexts: Partial<Record<SoundKey, WechatMiniprogram.InnerAudioContext>>
|
config: GameAudioConfig
|
||||||
|
contexts: Partial<Record<AudioCueKey, WechatMiniprogram.InnerAudioContext>>
|
||||||
|
loopTimers: Partial<Record<AudioCueKey, number>>
|
||||||
|
activeGuidanceCue: AudioCueKey | null
|
||||||
|
|
||||||
constructor() {
|
constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) {
|
||||||
this.enabled = true
|
this.enabled = true
|
||||||
|
this.config = config
|
||||||
this.contexts = {}
|
this.contexts = {}
|
||||||
|
this.loopTimers = {}
|
||||||
|
this.activeGuidanceCue = null
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(config: GameAudioConfig): void {
|
||||||
|
this.config = config
|
||||||
|
this.resetContexts()
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnabled(enabled: boolean): void {
|
setEnabled(enabled: boolean): void {
|
||||||
this.enabled = enabled
|
this.enabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
resetContexts(): void {
|
||||||
const keys = Object.keys(this.contexts) as SoundKey[]
|
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) {
|
for (const key of keys) {
|
||||||
const context = this.contexts[key]
|
const context = this.contexts[key]
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -34,10 +45,15 @@ export class SoundDirector {
|
|||||||
context.destroy()
|
context.destroy()
|
||||||
}
|
}
|
||||||
this.contexts = {}
|
this.contexts = {}
|
||||||
|
this.activeGuidanceCue = null
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.resetContexts()
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEffects(effects: GameEffect[]): void {
|
handleEffects(effects: GameEffect[]): void {
|
||||||
if (!this.enabled || !effects.length) {
|
if (!this.enabled || !this.config.enabled || !effects.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,55 +61,138 @@ export class SoundDirector {
|
|||||||
|
|
||||||
for (const effect of effects) {
|
for (const effect of effects) {
|
||||||
if (effect.type === 'session_started') {
|
if (effect.type === 'session_started') {
|
||||||
this.play('session-start')
|
this.play('session_started')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effect.type === 'control_completed') {
|
if (effect.type === 'control_completed') {
|
||||||
|
this.stopGuidanceLoop()
|
||||||
if (effect.controlKind === 'start') {
|
if (effect.controlKind === 'start') {
|
||||||
this.play('start-complete')
|
this.play('control_completed:start')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effect.controlKind === 'finish') {
|
if (effect.controlKind === 'finish') {
|
||||||
this.play('finish-complete')
|
this.play('control_completed:finish')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
this.play('control-complete')
|
this.play('control_completed:control')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effect.type === 'session_finished' && !hasFinishCompletion) {
|
if (effect.type === 'session_finished') {
|
||||||
this.play('finish-complete')
|
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)
|
const context = this.getContext(key)
|
||||||
context.stop()
|
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()
|
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]
|
const existing = this.contexts[key]
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cue = this.config.cues[key]
|
||||||
const context = wx.createInnerAudioContext()
|
const context = wx.createInnerAudioContext()
|
||||||
context.src = SOUND_SRC[key]
|
context.src = cue.src
|
||||||
context.autoplay = false
|
context.autoplay = false
|
||||||
context.loop = false
|
context.loop = false
|
||||||
context.obeyMuteSwitch = true
|
context.obeyMuteSwitch = this.config.obeyMuteSwitch
|
||||||
context.volume = 1
|
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
|
this.contexts[key] = context
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type LonLatPoint } from '../../utils/projection'
|
import { type LonLatPoint } from '../../utils/projection'
|
||||||
|
import { type GameAudioConfig } from '../audio/audioConfig'
|
||||||
|
|
||||||
export type GameMode = 'classic-sequential'
|
export type GameMode = 'classic-sequential'
|
||||||
export type GameControlKind = 'start' | 'control' | 'finish'
|
export type GameControlKind = 'start' | 'control' | 'finish'
|
||||||
@@ -28,4 +29,5 @@ export interface GameDefinition {
|
|||||||
punchPolicy: PunchPolicyType
|
punchPolicy: PunchPolicyType
|
||||||
controls: GameControl[]
|
controls: GameControl[]
|
||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
|
audioConfig?: GameAudioConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { type GameSessionState } from './gameSessionState'
|
import { type GameSessionState, type GuidanceState } from './gameSessionState'
|
||||||
import { type GamePresentationState } from '../presentation/presentationState'
|
import { type GamePresentationState } from '../presentation/presentationState'
|
||||||
|
|
||||||
export type GameEffect =
|
export type GameEffect =
|
||||||
| { type: 'session_started' }
|
| { type: 'session_started' }
|
||||||
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
|
| { 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: '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' }
|
| { type: 'session_finished' }
|
||||||
|
|
||||||
export interface GameResult {
|
export interface GameResult {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export class GameRuntime {
|
|||||||
currentTargetControlId: null,
|
currentTargetControlId: null,
|
||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
guidanceState: 'searching',
|
||||||
}
|
}
|
||||||
const result: GameResult = {
|
const result: GameResult = {
|
||||||
nextState: emptyState,
|
nextState: emptyState,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
||||||
|
export type GuidanceState = 'searching' | 'approaching' | 'ready'
|
||||||
|
|
||||||
export interface GameSessionState {
|
export interface GameSessionState {
|
||||||
status: GameSessionStatus
|
status: GameSessionStatus
|
||||||
@@ -8,4 +9,5 @@ export interface GameSessionState {
|
|||||||
currentTargetControlId: string | null
|
currentTargetControlId: string | null
|
||||||
inRangeControlId: string | null
|
inRangeControlId: string | null
|
||||||
score: number
|
score: number
|
||||||
|
guidanceState: GuidanceState
|
||||||
}
|
}
|
||||||
|
|||||||
158
miniprogram/game/feedback/feedbackConfig.ts
Normal file
158
miniprogram/game/feedback/feedbackConfig.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
export type FeedbackCueKey =
|
||||||
|
| 'session_started'
|
||||||
|
| 'session_finished'
|
||||||
|
| 'control_completed:start'
|
||||||
|
| 'control_completed:control'
|
||||||
|
| 'control_completed:finish'
|
||||||
|
| 'punch_feedback:warning'
|
||||||
|
| 'guidance:searching'
|
||||||
|
| 'guidance:approaching'
|
||||||
|
| 'guidance:ready'
|
||||||
|
|
||||||
|
export type HapticPattern = 'short' | 'long'
|
||||||
|
export type UiPunchFeedbackMotion = 'none' | 'pop' | 'success' | 'warning'
|
||||||
|
export type UiContentCardMotion = 'none' | 'pop' | 'finish'
|
||||||
|
export type UiPunchButtonMotion = 'none' | 'ready' | 'warning'
|
||||||
|
export type UiMapPulseMotion = 'none' | 'ready' | 'control' | 'finish'
|
||||||
|
export type UiStageMotion = 'none' | 'finish'
|
||||||
|
|
||||||
|
export interface HapticCueConfig {
|
||||||
|
enabled: boolean
|
||||||
|
pattern: HapticPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiCueConfig {
|
||||||
|
enabled: boolean
|
||||||
|
punchFeedbackMotion: UiPunchFeedbackMotion
|
||||||
|
contentCardMotion: UiContentCardMotion
|
||||||
|
punchButtonMotion: UiPunchButtonMotion
|
||||||
|
mapPulseMotion: UiMapPulseMotion
|
||||||
|
stageMotion: UiStageMotion
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameHapticsConfig {
|
||||||
|
enabled: boolean
|
||||||
|
cues: Record<FeedbackCueKey, HapticCueConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameUiEffectsConfig {
|
||||||
|
enabled: boolean
|
||||||
|
cues: Record<FeedbackCueKey, UiCueConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartialHapticCueConfig {
|
||||||
|
enabled?: boolean
|
||||||
|
pattern?: HapticPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartialUiCueConfig {
|
||||||
|
enabled?: boolean
|
||||||
|
punchFeedbackMotion?: UiPunchFeedbackMotion
|
||||||
|
contentCardMotion?: UiContentCardMotion
|
||||||
|
punchButtonMotion?: UiPunchButtonMotion
|
||||||
|
mapPulseMotion?: UiMapPulseMotion
|
||||||
|
stageMotion?: UiStageMotion
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameHapticsConfigOverrides {
|
||||||
|
enabled?: boolean
|
||||||
|
cues?: Partial<Record<FeedbackCueKey, PartialHapticCueConfig>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameUiEffectsConfigOverrides {
|
||||||
|
enabled?: boolean
|
||||||
|
cues?: Partial<Record<FeedbackCueKey, PartialUiCueConfig>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = {
|
||||||
|
enabled: true,
|
||||||
|
cues: {
|
||||||
|
session_started: { enabled: false, pattern: 'short' },
|
||||||
|
session_finished: { enabled: true, pattern: 'long' },
|
||||||
|
'control_completed:start': { enabled: true, pattern: 'short' },
|
||||||
|
'control_completed:control': { enabled: true, pattern: 'short' },
|
||||||
|
'control_completed:finish': { enabled: true, pattern: 'long' },
|
||||||
|
'punch_feedback:warning': { enabled: true, pattern: 'short' },
|
||||||
|
'guidance:searching': { enabled: false, pattern: 'short' },
|
||||||
|
'guidance:approaching': { enabled: false, pattern: 'short' },
|
||||||
|
'guidance:ready': { enabled: true, pattern: 'short' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = {
|
||||||
|
enabled: true,
|
||||||
|
cues: {
|
||||||
|
session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
|
||||||
|
session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
|
||||||
|
'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 },
|
||||||
|
'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 },
|
||||||
|
'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', durationMs: 0 },
|
||||||
|
'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 560 },
|
||||||
|
'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
|
||||||
|
'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
|
||||||
|
'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', durationMs: 900 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDuration(value: number, fallback: number): number {
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeHapticCue(baseCue: HapticCueConfig, override?: PartialHapticCueConfig): HapticCueConfig {
|
||||||
|
return {
|
||||||
|
enabled: override && override.enabled !== undefined ? !!override.enabled : baseCue.enabled,
|
||||||
|
pattern: override && override.pattern ? override.pattern : baseCue.pattern,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUiCue(baseCue: UiCueConfig, override?: PartialUiCueConfig): UiCueConfig {
|
||||||
|
return {
|
||||||
|
enabled: override && override.enabled !== undefined ? !!override.enabled : baseCue.enabled,
|
||||||
|
punchFeedbackMotion: override && override.punchFeedbackMotion ? override.punchFeedbackMotion : baseCue.punchFeedbackMotion,
|
||||||
|
contentCardMotion: override && override.contentCardMotion ? override.contentCardMotion : baseCue.contentCardMotion,
|
||||||
|
punchButtonMotion: override && override.punchButtonMotion ? override.punchButtonMotion : baseCue.punchButtonMotion,
|
||||||
|
mapPulseMotion: override && override.mapPulseMotion ? override.mapPulseMotion : baseCue.mapPulseMotion,
|
||||||
|
stageMotion: override && override.stageMotion ? override.stageMotion : baseCue.stageMotion,
|
||||||
|
durationMs: clampDuration(Number(override && override.durationMs), baseCue.durationMs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeGameHapticsConfig(overrides?: GameHapticsConfigOverrides | null): GameHapticsConfig {
|
||||||
|
const cues: GameHapticsConfig['cues'] = {
|
||||||
|
session_started: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
|
||||||
|
session_finished: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
|
||||||
|
'control_completed:start': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
|
||||||
|
'control_completed:control': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
|
||||||
|
'control_completed:finish': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
|
||||||
|
'punch_feedback:warning': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
|
||||||
|
'guidance:searching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
|
||||||
|
'guidance:approaching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
|
||||||
|
'guidance:ready': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_HAPTICS_CONFIG.enabled,
|
||||||
|
cues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeGameUiEffectsConfig(overrides?: GameUiEffectsConfigOverrides | null): GameUiEffectsConfig {
|
||||||
|
const cues: GameUiEffectsConfig['cues'] = {
|
||||||
|
session_started: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined),
|
||||||
|
session_finished: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined),
|
||||||
|
'control_completed:start': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined),
|
||||||
|
'control_completed:control': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined),
|
||||||
|
'control_completed:finish': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined),
|
||||||
|
'punch_feedback:warning': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined),
|
||||||
|
'guidance:searching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined),
|
||||||
|
'guidance:approaching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined),
|
||||||
|
'guidance:ready': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_UI_EFFECTS_CONFIG.enabled,
|
||||||
|
cues,
|
||||||
|
}
|
||||||
|
}
|
||||||
57
miniprogram/game/feedback/feedbackDirector.ts
Normal file
57
miniprogram/game/feedback/feedbackDirector.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig'
|
||||||
|
import { SoundDirector } from '../audio/soundDirector'
|
||||||
|
import { type GameEffect } from '../core/gameResult'
|
||||||
|
import {
|
||||||
|
DEFAULT_GAME_HAPTICS_CONFIG,
|
||||||
|
DEFAULT_GAME_UI_EFFECTS_CONFIG,
|
||||||
|
type GameHapticsConfig,
|
||||||
|
type GameUiEffectsConfig,
|
||||||
|
} from './feedbackConfig'
|
||||||
|
import { HapticsDirector } from './hapticsDirector'
|
||||||
|
import { UiEffectDirector, type UiEffectHost } from './uiEffectDirector'
|
||||||
|
|
||||||
|
export interface FeedbackHost extends UiEffectHost {
|
||||||
|
stopLocationTracking: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackConfigBundle {
|
||||||
|
audioConfig?: GameAudioConfig
|
||||||
|
hapticsConfig?: GameHapticsConfig
|
||||||
|
uiEffectsConfig?: GameUiEffectsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FeedbackDirector {
|
||||||
|
soundDirector: SoundDirector
|
||||||
|
hapticsDirector: HapticsDirector
|
||||||
|
uiEffectDirector: UiEffectDirector
|
||||||
|
host: FeedbackHost
|
||||||
|
|
||||||
|
constructor(host: FeedbackHost, config?: FeedbackConfigBundle) {
|
||||||
|
this.host = host
|
||||||
|
this.soundDirector = new SoundDirector(config && config.audioConfig ? config.audioConfig : DEFAULT_GAME_AUDIO_CONFIG)
|
||||||
|
this.hapticsDirector = new HapticsDirector(config && config.hapticsConfig ? config.hapticsConfig : DEFAULT_GAME_HAPTICS_CONFIG)
|
||||||
|
this.uiEffectDirector = new UiEffectDirector(host, config && config.uiEffectsConfig ? config.uiEffectsConfig : DEFAULT_GAME_UI_EFFECTS_CONFIG)
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(config: FeedbackConfigBundle): void {
|
||||||
|
this.soundDirector.configure(config.audioConfig || DEFAULT_GAME_AUDIO_CONFIG)
|
||||||
|
this.hapticsDirector.configure(config.hapticsConfig || DEFAULT_GAME_HAPTICS_CONFIG)
|
||||||
|
this.uiEffectDirector.configure(config.uiEffectsConfig || DEFAULT_GAME_UI_EFFECTS_CONFIG)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.soundDirector.destroy()
|
||||||
|
this.hapticsDirector.destroy()
|
||||||
|
this.uiEffectDirector.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEffects(effects: GameEffect[]): void {
|
||||||
|
this.soundDirector.handleEffects(effects)
|
||||||
|
this.hapticsDirector.handleEffects(effects)
|
||||||
|
this.uiEffectDirector.handleEffects(effects)
|
||||||
|
|
||||||
|
if (effects.some((effect) => effect.type === 'session_finished')) {
|
||||||
|
this.host.stopLocationTracking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
miniprogram/game/feedback/hapticsDirector.ts
Normal file
85
miniprogram/game/feedback/hapticsDirector.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { type GameEffect } from '../core/gameResult'
|
||||||
|
import { DEFAULT_GAME_HAPTICS_CONFIG, type FeedbackCueKey, type GameHapticsConfig } from './feedbackConfig'
|
||||||
|
|
||||||
|
export class HapticsDirector {
|
||||||
|
enabled: boolean
|
||||||
|
config: GameHapticsConfig
|
||||||
|
|
||||||
|
constructor(config: GameHapticsConfig = DEFAULT_GAME_HAPTICS_CONFIG) {
|
||||||
|
this.enabled = true
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(config: GameHapticsConfig): void {
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this.enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {}
|
||||||
|
|
||||||
|
trigger(key: FeedbackCueKey): void {
|
||||||
|
if (!this.enabled || !this.config.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cue = this.config.cues[key]
|
||||||
|
if (!cue || !cue.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cue.pattern === 'long') {
|
||||||
|
wx.vibrateLong()
|
||||||
|
} else {
|
||||||
|
wx.vibrateShort({ type: 'medium' })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEffects(effects: GameEffect[]): void {
|
||||||
|
for (const effect of effects) {
|
||||||
|
if (effect.type === 'session_started') {
|
||||||
|
this.trigger('session_started')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effect.type === 'session_finished') {
|
||||||
|
this.trigger('session_finished')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
|
||||||
|
this.trigger('punch_feedback:warning')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effect.type === 'guidance_state_changed') {
|
||||||
|
if (effect.guidanceState === 'searching') {
|
||||||
|
this.trigger('guidance:searching')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (effect.guidanceState === 'approaching') {
|
||||||
|
this.trigger('guidance:approaching')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.trigger('guidance:ready')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effect.type === 'control_completed') {
|
||||||
|
if (effect.controlKind === 'start') {
|
||||||
|
this.trigger('control_completed:start')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (effect.controlKind === 'finish') {
|
||||||
|
this.trigger('control_completed:finish')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
this.trigger('control_completed:control')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
miniprogram/game/feedback/uiEffectDirector.ts
Normal file
194
miniprogram/game/feedback/uiEffectDirector.ts
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type LonLatPoint } from '../../utils/projection'
|
import { type LonLatPoint } from '../../utils/projection'
|
||||||
|
import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig'
|
||||||
import { type GameControl, type GameDefinition } from '../core/gameDefinition'
|
import { type GameControl, type GameDefinition } from '../core/gameDefinition'
|
||||||
import { type GameEvent } from '../core/gameEvent'
|
import { type GameEvent } from '../core/gameEvent'
|
||||||
import { type GameEffect, type GameResult } from '../core/gameResult'
|
import { type GameEffect, type GameResult } from '../core/gameResult'
|
||||||
@@ -56,6 +57,31 @@ function getTargetText(control: GameControl): string {
|
|||||||
return '目标圈'
|
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 {
|
function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
|
||||||
if (state.status === 'idle') {
|
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,
|
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
|
||||||
status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
|
status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
|
||||||
endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
|
endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
|
||||||
|
guidanceState: nextTarget ? 'searching' : 'searching',
|
||||||
}
|
}
|
||||||
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
||||||
|
|
||||||
@@ -235,6 +262,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
currentTargetControlId: getInitialTargetId(definition),
|
currentTargetControlId: getInitialTargetId(definition),
|
||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
guidanceState: 'searching',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +278,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
startedAt: event.at,
|
startedAt: event.at,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
|
guidanceState: 'searching',
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
nextState,
|
nextState,
|
||||||
@@ -263,6 +292,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
...state,
|
...state,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
endedAt: event.at,
|
endedAt: event.at,
|
||||||
|
guidanceState: 'searching',
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
nextState,
|
nextState,
|
||||||
@@ -291,19 +321,26 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
if (event.type === 'gps_updated') {
|
if (event.type === 'gps_updated') {
|
||||||
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
|
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
|
||||||
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
|
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
|
||||||
|
const guidanceState = getGuidanceState(definition, distanceMeters)
|
||||||
const nextState: GameSessionState = {
|
const nextState: GameSessionState = {
|
||||||
...state,
|
...state,
|
||||||
inRangeControlId,
|
inRangeControlId,
|
||||||
|
guidanceState,
|
||||||
}
|
}
|
||||||
|
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, currentTarget.id)
|
||||||
|
|
||||||
if (definition.punchPolicy === 'enter' && inRangeControlId === 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 {
|
return {
|
||||||
nextState,
|
nextState,
|
||||||
presentation: buildPresentation(definition, nextState),
|
presentation: buildPresentation(definition, nextState),
|
||||||
effects: [],
|
effects: guidanceEffects,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,15 @@ Page({
|
|||||||
contentCardVisible: false,
|
contentCardVisible: false,
|
||||||
contentCardTitle: '',
|
contentCardTitle: '',
|
||||||
contentCardBody: '',
|
contentCardBody: '',
|
||||||
|
punchButtonFxClass: '',
|
||||||
|
punchFeedbackFxClass: '',
|
||||||
|
contentCardFxClass: '',
|
||||||
|
mapPulseVisible: false,
|
||||||
|
mapPulseLeftPx: 0,
|
||||||
|
mapPulseTopPx: 0,
|
||||||
|
mapPulseFxClass: '',
|
||||||
|
stageFxVisible: false,
|
||||||
|
stageFxClass: '',
|
||||||
compassTicks: buildCompassTicks(),
|
compassTicks: buildCompassTicks(),
|
||||||
compassLabels: buildCompassLabels(),
|
compassLabels: buildCompassLabels(),
|
||||||
...buildSideButtonVisibility('left'),
|
...buildSideButtonVisibility('left'),
|
||||||
@@ -146,6 +155,15 @@ Page({
|
|||||||
contentCardVisible: false,
|
contentCardVisible: false,
|
||||||
contentCardTitle: '',
|
contentCardTitle: '',
|
||||||
contentCardBody: '',
|
contentCardBody: '',
|
||||||
|
punchButtonFxClass: '',
|
||||||
|
punchFeedbackFxClass: '',
|
||||||
|
contentCardFxClass: '',
|
||||||
|
mapPulseVisible: false,
|
||||||
|
mapPulseLeftPx: 0,
|
||||||
|
mapPulseTopPx: 0,
|
||||||
|
mapPulseFxClass: '',
|
||||||
|
stageFxVisible: false,
|
||||||
|
stageFxClass: '',
|
||||||
compassTicks: buildCompassTicks(),
|
compassTicks: buildCompassTicks(),
|
||||||
compassLabels: buildCompassLabels(),
|
compassLabels: buildCompassLabels(),
|
||||||
...buildSideButtonVisibility('left'),
|
...buildSideButtonVisibility('left'),
|
||||||
|
|||||||
@@ -22,10 +22,12 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="map-stage__crosshair"></view>
|
<view class="map-stage__crosshair"></view>
|
||||||
|
<view class="map-stage__map-pulse {{mapPulseFxClass}}" wx:if="{{mapPulseVisible}}" style="left: {{mapPulseLeftPx}}px; top: {{mapPulseTopPx}}px;"></view>
|
||||||
|
<view class="map-stage__stage-fx {{stageFxClass}}" wx:if="{{stageFxVisible}}"></view>
|
||||||
|
|
||||||
<view class="game-punch-hint" wx:if="{{punchHintText}}">{{punchHintText}}</view>
|
<view class="game-punch-hint" wx:if="{{punchHintText}}">{{punchHintText}}</view>
|
||||||
<view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
|
<view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}} {{punchFeedbackFxClass}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
|
||||||
<view class="game-content-card" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
|
<view class="game-content-card {{contentCardFxClass}}" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
|
||||||
<view class="game-content-card__title">{{contentCardTitle}}</view>
|
<view class="game-content-card__title">{{contentCardTitle}}</view>
|
||||||
<view class="game-content-card__body">{{contentCardBody}}</view>
|
<view class="game-content-card__body">{{contentCardBody}}</view>
|
||||||
<view class="game-content-card__hint">点击关闭</view>
|
<view class="game-content-card__hint">点击关闭</view>
|
||||||
@@ -96,7 +98,7 @@
|
|||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">USER</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">USER</cover-view></cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
|
|
||||||
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
|
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
|
||||||
<cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
|
<cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,40 @@
|
|||||||
transform: translateY(-50%);
|
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 {
|
.map-stage__overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -732,6 +766,16 @@
|
|||||||
color: #064d46;
|
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 {
|
.race-panel__line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1076,6 +1120,18 @@
|
|||||||
background: rgba(196, 117, 18, 0.94);
|
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 {
|
.game-content-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -1111,6 +1167,14 @@
|
|||||||
color: #809284;
|
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 {
|
.race-panel__action-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
|
import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
|
||||||
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
|
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 {
|
export interface TileZoomBounds {
|
||||||
minX: number
|
minX: number
|
||||||
@@ -33,6 +45,9 @@ export interface RemoteMapConfig {
|
|||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
|
audioConfig: GameAudioConfig
|
||||||
|
hapticsConfig: GameHapticsConfig
|
||||||
|
uiEffectsConfig: GameUiEffectsConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedGameConfig {
|
interface ParsedGameConfig {
|
||||||
@@ -44,6 +59,9 @@ interface ParsedGameConfig {
|
|||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
|
audioConfig: GameAudioConfig
|
||||||
|
hapticsConfig: GameHapticsConfig
|
||||||
|
uiEffectsConfig: GameUiEffectsConfig
|
||||||
declinationDeg: number
|
declinationDeg: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +206,134 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
|
|||||||
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
|
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
|
||||||
|
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: Record<string, unknown> = {}
|
||||||
|
const keys = Object.keys(rawValue as Record<string, unknown>)
|
||||||
|
for (const key of keys) {
|
||||||
|
normalized[key.toLowerCase()] = (rawValue as Record<string, unknown>)[key]
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstDefined(record: Record<string, unknown>, keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (record[key] !== undefined) {
|
||||||
|
return record[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAudioSrc(baseUrl: string, rawValue: unknown): string | undefined {
|
||||||
|
if (typeof rawValue !== 'string') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = rawValue.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('/assets/')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('assets/')) {
|
||||||
|
return `/${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveUrl(baseUrl, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAudioCueOverride(rawValue: unknown, baseUrl: string): PartialAudioCueConfig | null {
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const src = resolveAudioSrc(baseUrl, rawValue)
|
||||||
|
return src ? { src } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = resolveAudioSrc(baseUrl, getFirstDefined(normalized, ['src', 'url', 'path']))
|
||||||
|
const volumeRaw = getFirstDefined(normalized, ['volume'])
|
||||||
|
const loopRaw = getFirstDefined(normalized, ['loop'])
|
||||||
|
const loopGapRaw = getFirstDefined(normalized, ['loopgapms', 'loopgap'])
|
||||||
|
const cue: PartialAudioCueConfig = {}
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
cue.src = src
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volumeRaw !== undefined) {
|
||||||
|
cue.volume = parsePositiveNumber(volumeRaw, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loopRaw !== undefined) {
|
||||||
|
cue.loop = parseBoolean(loopRaw, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loopGapRaw !== undefined) {
|
||||||
|
cue.loopGapMs = parsePositiveNumber(loopGapRaw, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cue.src || cue.volume !== undefined || cue.loop !== undefined || cue.loopGapMs !== undefined ? cue : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return mergeGameAudioConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
|
||||||
|
const cueMap: Array<{ key: AudioCueKey; aliases: string[] }> = [
|
||||||
|
{ key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start', 'session_start'] },
|
||||||
|
{ key: 'control_completed:start', aliases: ['control_completed:start', 'controlcompleted:start', 'start_completed', 'startcomplete', 'start-complete'] },
|
||||||
|
{ key: 'control_completed:control', aliases: ['control_completed:control', 'controlcompleted:control', 'control_completed', 'controlcompleted', 'control_complete', 'controlcomplete'] },
|
||||||
|
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
||||||
|
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
||||||
|
{ key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
|
||||||
|
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
|
||||||
|
{ key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const cues: GameAudioConfigOverrides['cues'] = {}
|
||||||
|
for (const cueDef of cueMap) {
|
||||||
|
const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases)
|
||||||
|
const cue = buildAudioCueOverride(cueRaw, baseUrl)
|
||||||
|
if (cue) {
|
||||||
|
cues[cueDef.key] = cue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeGameAudioConfig({
|
||||||
|
enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
|
||||||
|
masterVolume: normalized.mastervolume !== undefined
|
||||||
|
? parsePositiveNumber(normalized.mastervolume, 1)
|
||||||
|
: normalized.volume !== undefined
|
||||||
|
? parsePositiveNumber(normalized.volume, 1)
|
||||||
|
: undefined,
|
||||||
|
obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
|
||||||
|
approachDistanceMeters: normalized.approachdistancemeters !== undefined
|
||||||
|
? parsePositiveNumber(normalized.approachdistancemeters, 20)
|
||||||
|
: normalized.approachdistance !== undefined
|
||||||
|
? parsePositiveNumber(normalized.approachdistance, 20)
|
||||||
|
: undefined,
|
||||||
|
cues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function parseLooseJsonObject(text: string): Record<string, unknown> {
|
function parseLooseJsonObject(text: string): Record<string, unknown> {
|
||||||
const parsed: Record<string, unknown> = {}
|
const parsed: Record<string, unknown> = {}
|
||||||
const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
|
const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
|
||||||
@@ -214,7 +360,244 @@ function parseLooseJsonObject(text: string): Record<string, unknown> {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseGameConfigFromJson(text: string): ParsedGameConfig {
|
|
||||||
|
function parseHapticPattern(rawValue: unknown): 'short' | 'long' | undefined {
|
||||||
|
if (rawValue === 'short' || rawValue === 'long') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'short' || normalized === 'long') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePunchFeedbackMotion(rawValue: unknown): 'none' | 'pop' | 'success' | 'warning' | undefined {
|
||||||
|
if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'success' || rawValue === 'warning') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'none' || normalized === 'pop' || normalized === 'success' || normalized === 'warning') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentCardMotion(rawValue: unknown): 'none' | 'pop' | 'finish' | undefined {
|
||||||
|
if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'finish') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'none' || normalized === 'pop' || normalized === 'finish') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePunchButtonMotion(rawValue: unknown): 'none' | 'ready' | 'warning' | undefined {
|
||||||
|
if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'warning') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'none' || normalized === 'ready' || normalized === 'warning') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' | 'finish' | undefined {
|
||||||
|
if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'control' || rawValue === 'finish') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'none' || normalized === 'ready' || normalized === 'control' || normalized === 'finish') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
|
||||||
|
if (rawValue === 'none' || rawValue === 'finish') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'none' || normalized === 'finish') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null {
|
||||||
|
if (typeof rawValue === 'boolean') {
|
||||||
|
return { enabled: rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = parseHapticPattern(rawValue)
|
||||||
|
if (pattern) {
|
||||||
|
return { enabled: true, pattern }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cue: PartialHapticCueConfig = {}
|
||||||
|
if (normalized.enabled !== undefined) {
|
||||||
|
cue.enabled = parseBoolean(normalized.enabled, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type']))
|
||||||
|
if (parsedPattern) {
|
||||||
|
cue.pattern = parsedPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cue: PartialUiCueConfig = {}
|
||||||
|
if (normalized.enabled !== undefined) {
|
||||||
|
cue.enabled = parseBoolean(normalized.enabled, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion']))
|
||||||
|
if (punchFeedbackMotion) {
|
||||||
|
cue.punchFeedbackMotion = punchFeedbackMotion
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion']))
|
||||||
|
if (contentCardMotion) {
|
||||||
|
cue.contentCardMotion = contentCardMotion
|
||||||
|
}
|
||||||
|
|
||||||
|
const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion']))
|
||||||
|
if (punchButtonMotion) {
|
||||||
|
cue.punchButtonMotion = punchButtonMotion
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion']))
|
||||||
|
if (mapPulseMotion) {
|
||||||
|
cue.mapPulseMotion = mapPulseMotion
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion']))
|
||||||
|
if (stageMotion) {
|
||||||
|
cue.stageMotion = stageMotion
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationRaw = getFirstDefined(normalized, ['durationms', 'duration'])
|
||||||
|
if (durationRaw !== undefined) {
|
||||||
|
cue.durationMs = parsePositiveNumber(durationRaw, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cue.enabled !== undefined ||
|
||||||
|
cue.punchFeedbackMotion !== undefined ||
|
||||||
|
cue.contentCardMotion !== undefined ||
|
||||||
|
cue.punchButtonMotion !== undefined ||
|
||||||
|
cue.mapPulseMotion !== undefined ||
|
||||||
|
cue.stageMotion !== undefined ||
|
||||||
|
cue.durationMs !== undefined
|
||||||
|
? cue
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return mergeGameHapticsConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
|
||||||
|
const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
|
||||||
|
{ key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
|
||||||
|
{ key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
|
||||||
|
{ key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
|
||||||
|
{ key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
|
||||||
|
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
||||||
|
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
||||||
|
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
|
||||||
|
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
|
||||||
|
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const cues: GameHapticsConfigOverrides['cues'] = {}
|
||||||
|
for (const cueDef of cueMap) {
|
||||||
|
const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
|
||||||
|
if (cue) {
|
||||||
|
cues[cueDef.key] = cue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeGameHapticsConfig({
|
||||||
|
enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
|
||||||
|
cues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return mergeGameUiEffectsConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
|
||||||
|
const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
|
||||||
|
{ key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
|
||||||
|
{ key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
|
||||||
|
{ key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
|
||||||
|
{ key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
|
||||||
|
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
|
||||||
|
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
|
||||||
|
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
|
||||||
|
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
|
||||||
|
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const cues: GameUiEffectsConfigOverrides['cues'] = {}
|
||||||
|
for (const cueDef of cueMap) {
|
||||||
|
const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
|
||||||
|
if (cue) {
|
||||||
|
cues[cueDef.key] = cue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeGameUiEffectsConfig({
|
||||||
|
enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
|
||||||
|
cues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig {
|
||||||
let parsed: Record<string, unknown>
|
let parsed: Record<string, unknown>
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(text)
|
parsed = JSON.parse(text)
|
||||||
@@ -238,6 +621,19 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
|
|||||||
normalizedGame[key.toLowerCase()] = rawGame[key]
|
normalizedGame[key.toLowerCase()] = rawGame[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio
|
||||||
|
const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
|
||||||
|
const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
|
||||||
|
? rawGame.uiEffects
|
||||||
|
: rawGame && rawGame.uieffects !== undefined
|
||||||
|
? rawGame.uieffects
|
||||||
|
: rawGame && rawGame.ui !== undefined
|
||||||
|
? rawGame.ui
|
||||||
|
: (parsed as Record<string, unknown>).uiEffects !== undefined
|
||||||
|
? (parsed as Record<string, unknown>).uiEffects
|
||||||
|
: (parsed as Record<string, unknown>).uieffects !== undefined
|
||||||
|
? (parsed as Record<string, unknown>).uieffects
|
||||||
|
: (parsed as Record<string, unknown>).ui
|
||||||
|
|
||||||
const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
|
const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
|
||||||
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
|
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
|
||||||
@@ -272,11 +668,14 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
|
|||||||
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
|
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
|
||||||
|
hapticsConfig: parseHapticsConfig(rawHaptics),
|
||||||
|
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
|
||||||
declinationDeg: parseDeclinationValue(normalized.declination),
|
declinationDeg: parseDeclinationValue(normalized.declination),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseGameConfigFromYaml(text: string): ParsedGameConfig {
|
function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig {
|
||||||
const config: Record<string, string> = {}
|
const config: Record<string, string> = {}
|
||||||
const lines = text.split(/\r?\n/)
|
const lines = text.split(/\r?\n/)
|
||||||
|
|
||||||
@@ -317,6 +716,48 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig {
|
|||||||
5,
|
5,
|
||||||
),
|
),
|
||||||
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
|
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),
|
declinationDeg: parseDeclinationValue(config.declination),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +769,7 @@ function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig
|
|||||||
trimmedText.startsWith('[') ||
|
trimmedText.startsWith('[') ||
|
||||||
/\.json(?:[?#].*)?$/i.test(gameConfigUrl)
|
/\.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 {
|
function extractStringField(text: string, key: string): string | null {
|
||||||
@@ -538,6 +979,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
|||||||
punchPolicy: gameConfig.punchPolicy,
|
punchPolicy: gameConfig.punchPolicy,
|
||||||
punchRadiusMeters: gameConfig.punchRadiusMeters,
|
punchRadiusMeters: gameConfig.punchRadiusMeters,
|
||||||
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
||||||
|
audioConfig: gameConfig.audioConfig,
|
||||||
|
hapticsConfig: gameConfig.hapticsConfig,
|
||||||
|
uiEffectsConfig: gameConfig.uiEffectsConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user