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