Add event-driven gameplay feedback framework

This commit is contained in:
2026-03-24 09:03:27 +08:00
parent 48159be900
commit 2c03d1a702
20 changed files with 1718 additions and 64 deletions

View File

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