Refine telemetry-driven HUD and fitness feedback

This commit is contained in:
2026-03-24 11:24:50 +08:00
parent 2c03d1a702
commit a117a25824
12 changed files with 2071 additions and 211 deletions

View File

@@ -1,5 +1,6 @@
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
import { CompassHeadingController } from '../sensor/compassHeadingController'
import { HeartRateController } from '../sensor/heartRateController'
import { LocationController } from '../sensor/locationController'
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
import { type MapRendererStats } from '../renderer/mapRenderer'
@@ -11,6 +12,8 @@ import { type GameEffect } from '../../game/core/gameResult'
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime'
import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig'
const RENDER_MODE = 'Single WebGL Pipeline'
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
@@ -117,8 +120,27 @@ export interface MapEngineViewState {
gpsTracking: boolean
gpsTrackingText: string
gpsCoordText: string
heartRateConnected: boolean
heartRateStatusText: string
heartRateDeviceText: string
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
panelTimerText: string
panelMileageText: string
panelDistanceValueText: string
panelDistanceUnitText: string
panelProgressText: string
panelSpeedValueText: string
panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
panelHeartRateZoneNameText: string
panelHeartRateZoneRangeText: string
panelHeartRateValueText: string
panelHeartRateUnitText: string
panelCaloriesValueText: string
panelCaloriesUnitText: string
panelAverageSpeedValueText: string
panelAverageSpeedUnitText: string
panelAccuracyValueText: string
panelAccuracyUnitText: string
punchButtonText: string
punchButtonEnabled: boolean
punchHintText: string
@@ -183,8 +205,27 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'gpsTracking',
'gpsTrackingText',
'gpsCoordText',
'heartRateConnected',
'heartRateStatusText',
'heartRateDeviceText',
'gameSessionStatus',
'panelTimerText',
'panelMileageText',
'panelDistanceValueText',
'panelDistanceUnitText',
'panelProgressText',
'panelSpeedValueText',
'panelTelemetryTone',
'panelHeartRateZoneNameText',
'panelHeartRateZoneRangeText',
'panelHeartRateValueText',
'panelHeartRateUnitText',
'panelCaloriesValueText',
'panelCaloriesUnitText',
'panelAverageSpeedValueText',
'panelAverageSpeedUnitText',
'panelAccuracyValueText',
'panelAccuracyUnitText',
'punchButtonText',
'punchButtonEnabled',
'punchHintText',
@@ -289,7 +330,7 @@ function formatRotationToggleText(mode: OrientationMode): string {
return '切到朝向朝上'
}
return '鍒囧埌鎵嬪姩鏃嬭浆'
return '切到手动旋转'
}
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
@@ -369,7 +410,7 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string {
}
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳'
return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北'
}
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
@@ -441,6 +482,7 @@ export class MapEngine {
renderer: WebGLMapRenderer
compassController: CompassHeadingController
locationController: LocationController
heartRateController: HeartRateController
feedbackDirector: FeedbackDirector
onData: (patch: Partial<MapEngineViewState>) => void
state: MapEngineViewState
@@ -487,6 +529,7 @@ export class MapEngine {
courseData: OrienteeringCourseData | null
cpRadiusMeters: number
gameRuntime: GameRuntime
telemetryRuntime: TelemetryRuntime
gamePresentation: GamePresentationState
gameMode: 'classic-sequential'
punchPolicy: 'enter' | 'enter-confirm'
@@ -496,6 +539,7 @@ export class MapEngine {
contentCardTimer: number
mapPulseTimer: number
stageFxTimer: number
sessionTimerInterval: number
hasGpsCenteredOnce: boolean
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
@@ -537,6 +581,37 @@ export class MapEngine {
}, true)
},
})
this.heartRateController = new HeartRateController({
onHeartRate: (bpm) => {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm,
})
this.syncSessionTimerText()
},
onStatus: (message) => {
this.setState({
heartRateStatusText: message,
heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
}, true)
},
onError: (message) => {
this.setState({
heartRateConnected: false,
heartRateStatusText: message,
heartRateDeviceText: '--',
statusText: `${message} (${this.buildVersion})`,
}, true)
},
onConnectionChange: (connected, deviceName) => {
this.setState({
heartRateConnected: connected,
heartRateDeviceText: deviceName || '--',
heartRateStatusText: connected ? '心率带已连接' : '心率带未连接',
}, true)
},
})
this.feedbackDirector = new FeedbackDirector({
showPunchFeedback: (text, tone, motionClass) => {
this.showPunchFeedback(text, tone, motionClass)
@@ -571,6 +646,8 @@ export class MapEngine {
this.courseData = null
this.cpRadiusMeters = 5
this.gameRuntime = new GameRuntime()
this.telemetryRuntime = new TelemetryRuntime()
this.telemetryRuntime.configure()
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
this.gameMode = 'classic-sequential'
this.punchPolicy = 'enter-confirm'
@@ -580,6 +657,7 @@ export class MapEngine {
this.contentCardTimer = 0
this.mapPulseTimer = 0
this.stageFxTimer = 0
this.sessionTimerInterval = 0
this.hasGpsCenteredOnce = false
this.state = {
buildVersion: this.buildVersion,
@@ -587,7 +665,7 @@ export class MapEngine {
projectionMode: PROJECTION_MODE,
mapReady: false,
mapReadyText: 'BOOTING',
mapName: 'LCX 娴嬭瘯鍦板浘',
mapName: 'LCX 测试地图',
configStatusText: '远程配置待加载',
zoom: DEFAULT_ZOOM,
rotationDeg: 0,
@@ -624,15 +702,34 @@ export class MapEngine {
stageHeight: 0,
stageLeft: 0,
stageTop: 0,
statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`,
statusText: `WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
gpsTracking: false,
gpsTrackingText: '持续定位待启动',
gpsCoordText: '--',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
punchButtonText: '鎵撶偣',
panelSpeedValueText: '0',
panelTelemetryTone: 'blue',
panelHeartRateZoneNameText: '激活放松',
panelHeartRateZoneRangeText: '<=39%',
panelHeartRateValueText: '--',
panelHeartRateUnitText: '',
panelCaloriesValueText: '0',
panelCaloriesUnitText: 'kcal',
panelAverageSpeedValueText: '0',
panelAverageSpeedUnitText: 'km/h',
panelAccuracyValueText: '--',
panelAccuracyUnitText: '',
punchButtonText: '打点',
gameSessionStatus: 'idle',
punchButtonEnabled: false,
punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿',
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
@@ -697,8 +794,10 @@ export class MapEngine {
this.clearContentCardTimer()
this.clearMapPulseTimer()
this.clearStageFxTimer()
this.clearSessionTimerInterval()
this.compassController.destroy()
this.locationController.destroy()
this.heartRateController.destroy()
this.feedbackDirector.destroy()
this.renderer.destroy()
this.mounted = false
@@ -707,7 +806,9 @@ export class MapEngine {
clearGameRuntime(): void {
this.gameRuntime.clear()
this.telemetryRuntime.reset()
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
this.clearSessionTimerInterval()
this.setCourseHeading(null)
}
@@ -726,8 +827,11 @@ export class MapEngine {
this.punchRadiusMeters,
)
const result = this.gameRuntime.loadDefinition(definition)
this.telemetryRuntime.loadDefinition(definition)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState)
this.gamePresentation = result.presentation
this.refreshCourseHeadingFromPresentation()
this.updateSessionTimerLoop()
return result.effects
}
@@ -769,8 +873,25 @@ export class MapEngine {
return null
}
getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
const telemetryPresentation = this.telemetryRuntime.getPresentation()
const patch: Partial<MapEngineViewState> = {
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
panelTimerText: telemetryPresentation.timerText,
panelMileageText: telemetryPresentation.mileageText,
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
panelSpeedValueText: telemetryPresentation.speedText,
panelTelemetryTone: telemetryPresentation.heartRateTone,
panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
panelHeartRateValueText: telemetryPresentation.heartRateValueText,
panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
panelCaloriesValueText: telemetryPresentation.caloriesValueText,
panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
panelProgressText: this.gamePresentation.progressText,
punchButtonText: this.gamePresentation.punchButtonText,
punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
@@ -812,6 +933,54 @@ export class MapEngine {
}
}
clearSessionTimerInterval(): void {
if (this.sessionTimerInterval) {
clearInterval(this.sessionTimerInterval)
this.sessionTimerInterval = 0
}
}
syncSessionTimerText(): void {
const telemetryPresentation = this.telemetryRuntime.getPresentation()
this.setState({
panelTimerText: telemetryPresentation.timerText,
panelMileageText: telemetryPresentation.mileageText,
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
panelSpeedValueText: telemetryPresentation.speedText,
panelTelemetryTone: telemetryPresentation.heartRateTone,
panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
panelHeartRateValueText: telemetryPresentation.heartRateValueText,
panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
panelCaloriesValueText: telemetryPresentation.caloriesValueText,
panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
}, true)
}
updateSessionTimerLoop(): void {
const gameState = this.gameRuntime.state
const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null
this.syncSessionTimerText()
if (!shouldRun) {
this.clearSessionTimerInterval()
return
}
if (this.sessionTimerInterval) {
return
}
this.sessionTimerInterval = setInterval(() => {
this.syncSessionTimerText()
}, 1000) as unknown as number
}
getControlScreenPoint(controlId: string): { x: number; y: number } | null {
if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
return null
@@ -930,6 +1099,8 @@ export class MapEngine {
applyGameEffects(effects: GameEffect[]): string | null {
this.feedbackDirector.handleEffects(effects)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state)
this.updateSessionTimerLoop()
return this.resolveGameStatusText(effects)
}
@@ -1005,9 +1176,17 @@ export class MapEngine {
let gameStatusText: string | null = null
if (this.courseData) {
const eventAt = Date.now()
const gameResult = this.gameRuntime.dispatch({
type: 'gps_updated',
at: Date.now(),
at: eventAt,
lon: longitude,
lat: latitude,
accuracyMeters,
})
this.telemetryRuntime.dispatch({
type: 'gps_updated',
at: eventAt,
lon: longitude,
lat: latitude,
accuracyMeters,
@@ -1059,6 +1238,39 @@ export class MapEngine {
this.locationController.start()
}
handleConnectHeartRate(): void {
this.heartRateController.startScanAndConnect()
}
handleDisconnectHeartRate(): void {
this.heartRateController.disconnect()
}
handleDebugHeartRateTone(tone: HeartRateTone): void {
const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm: sampleBpm,
})
this.setState({
heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`,
}, true)
this.syncSessionTimerText()
}
handleClearDebugHeartRate(): void {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm: null,
})
this.setState({
heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
}, true)
this.syncSessionTimerText()
}
setStage(rect: MapEngineStageRect): void {
this.previewScale = 1
this.previewOriginX = rect.width / 2
@@ -1070,7 +1282,7 @@ export class MapEngine {
stageLeft: rect.left,
stageTop: rect.top,
},
`鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`,
`地图视口已与 WebGL 引擎对齐 (${this.buildVersion})`,
true,
)
}
@@ -1083,7 +1295,7 @@ export class MapEngine {
this.onData({
mapReady: true,
mapReadyText: 'READY',
statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`,
statusText: `WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
})
this.syncRenderer()
this.compassController.start()
@@ -1104,6 +1316,7 @@ export class MapEngine {
this.punchPolicy = config.punchPolicy
this.punchRadiusMeters = config.punchRadiusMeters
this.autoFinishOnLastControl = config.autoFinishOnLastControl
this.telemetryRuntime.configure(config.telemetryConfig)
this.feedbackDirector.configure({
audioConfig: config.audioConfig,
hapticsConfig: config.hapticsConfig,
@@ -1113,7 +1326,7 @@ export class MapEngine {
const gameEffects = this.loadGameDefinitionFromCourse()
const gameStatusText = this.applyGameEffects(gameEffects)
const statePatch: Partial<MapEngineViewState> = {
configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`,
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
projectionMode: config.projectionModeText,
tileSource: config.tileSource,
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
@@ -1219,8 +1432,8 @@ export class MapEngine {
rotationText: formatRotationText(nextRotationDeg),
},
this.state.orientationMode === 'heading-up'
? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})`
: `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`,
? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
: `双指缩放与旋转中 (${this.buildVersion})`,
)
return
}
@@ -1327,7 +1540,7 @@ export class MapEngine {
tileTranslateX: 0,
tileTranslateY: 0,
},
`宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`,
`已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
@@ -1341,7 +1554,7 @@ export class MapEngine {
handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
if (this.state.rotationMode === 'auto') {
this.setState({
statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
}, true)
return
}
@@ -1361,7 +1574,7 @@ export class MapEngine {
rotationDeg: nextRotationDeg,
rotationText: formatRotationText(nextRotationDeg),
},
`鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
`旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
@@ -1374,7 +1587,7 @@ export class MapEngine {
handleRotationReset(): void {
if (this.state.rotationMode === 'auto') {
this.setState({
statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
}, true)
return
}
@@ -1398,7 +1611,7 @@ export class MapEngine {
rotationDeg: targetRotationDeg,
rotationText: formatRotationText(targetRotationDeg),
},
`鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`,
`旋转角度已回到真北参考 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
@@ -1441,20 +1654,20 @@ export class MapEngine {
handleAutoRotateCalibrate(): void {
if (this.state.orientationMode !== 'heading-up') {
this.setState({
statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`,
statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
}, true)
return
}
if (!this.calibrateAutoRotateToCurrentOrientation()) {
this.setState({
statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`,
statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
}, true)
return
}
this.setState({
statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`,
statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
}, true)
this.scheduleAutoRotate()
}
@@ -1470,7 +1683,7 @@ export class MapEngine {
orientationMode: 'manual',
orientationModeText: formatOrientationModeText('manual'),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`,
statusText: `已切回手动地图旋转 (${this.buildVersion})`,
}, true)
}
@@ -1497,7 +1710,7 @@ export class MapEngine {
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
},
`鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`,
`地图已固定为真北朝上 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
@@ -1518,7 +1731,7 @@ export class MapEngine {
orientationModeText: formatOrientationModeText('heading-up'),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`,
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
}, true)
if (this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
@@ -2142,7 +2355,7 @@ export class MapEngine {
tileTranslateX: 0,
tileTranslateY: 0,
},
`缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
`缩放级别调整到 ${nextZoom}`,
true,
() => {
this.setPreviewState(residualScale, stageX, stageY)
@@ -2169,7 +2382,7 @@ export class MapEngine {
zoom: nextZoom,
...resolvedViewport,
},
`缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
`缩放级别调整到 ${nextZoom}`,
true,
() => {
this.setPreviewState(residualScale, stageX, stageY)
@@ -2189,7 +2402,7 @@ export class MapEngine {
if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
this.setState({
statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`,
statusText: `惯性滑动结束 (${this.buildVersion})`,
})
this.renderer.setAnimationPaused(false)
this.inertiaTimer = 0
@@ -2200,7 +2413,7 @@ export class MapEngine {
this.normalizeTranslate(
this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
`鎯€ф粦鍔ㄤ腑 (${this.buildVersion})`,
`惯性滑动中 (${this.buildVersion})`,
)
this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number