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

View File

@@ -0,0 +1,421 @@
export interface HeartRateControllerCallbacks {
onHeartRate: (bpm: number) => void
onStatus: (message: string) => void
onError: (message: string) => void
onConnectionChange: (connected: boolean, deviceName: string | null) => void
}
type BluetoothDeviceLike = {
deviceId?: string
name?: string
localName?: string
advertisServiceUUIDs?: string[]
}
const HEART_RATE_SERVICE_UUID = '180D'
const HEART_RATE_MEASUREMENT_UUID = '2A37'
const DISCOVERY_TIMEOUT_MS = 12000
function normalizeUuid(uuid: string | undefined | null): string {
return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase()
}
function matchesShortUuid(uuid: string | undefined | null, shortUuid: string): boolean {
const normalized = normalizeUuid(uuid)
const normalizedShort = normalizeUuid(shortUuid)
if (!normalized || !normalizedShort) {
return false
}
return normalized === normalizedShort
|| normalized.indexOf(`0000${normalizedShort}00001000800000805F9B34FB`) === 0
|| normalized.endsWith(normalizedShort)
}
function getDeviceDisplayName(device: BluetoothDeviceLike | null | undefined): string {
if (!device) {
return '心率带'
}
return device.name || device.localName || '未命名心率带'
}
function isHeartRateDevice(device: BluetoothDeviceLike): boolean {
const serviceIds = Array.isArray(device.advertisServiceUUIDs) ? device.advertisServiceUUIDs : []
if (serviceIds.some((uuid) => matchesShortUuid(uuid, HEART_RATE_SERVICE_UUID))) {
return true
}
const name = `${device.name || ''} ${device.localName || ''}`.toUpperCase()
return name.indexOf('HR') !== -1
|| name.indexOf('HEART') !== -1
|| name.indexOf('POLAR') !== -1
|| name.indexOf('GARMIN') !== -1
|| name.indexOf('COOSPO') !== -1
}
function parseHeartRateMeasurement(buffer: ArrayBuffer): number | null {
if (!buffer || buffer.byteLength < 2) {
return null
}
const view = new DataView(buffer)
const flags = view.getUint8(0)
const isUint16 = (flags & 0x01) === 0x01
if (isUint16) {
if (buffer.byteLength < 3) {
return null
}
return view.getUint16(1, true)
}
return view.getUint8(1)
}
export class HeartRateController {
callbacks: HeartRateControllerCallbacks
scanning: boolean
connecting: boolean
connected: boolean
currentDeviceId: string | null
currentDeviceName: string | null
measurementServiceId: string | null
measurementCharacteristicId: string | null
discoveryTimer: number
deviceFoundHandler: ((result: any) => void) | null
characteristicHandler: ((result: any) => void) | null
connectionStateHandler: ((result: any) => void) | null
constructor(callbacks: HeartRateControllerCallbacks) {
this.callbacks = callbacks
this.scanning = false
this.connecting = false
this.connected = false
this.currentDeviceId = null
this.currentDeviceName = null
this.measurementServiceId = null
this.measurementCharacteristicId = null
this.discoveryTimer = 0
this.deviceFoundHandler = null
this.characteristicHandler = null
this.connectionStateHandler = null
}
startScanAndConnect(): void {
if (this.connected) {
this.callbacks.onStatus(`心率带已连接: ${this.currentDeviceName || '设备'}`)
return
}
if (this.scanning || this.connecting) {
this.callbacks.onStatus('心率带连接进行中')
return
}
const wxAny = wx as any
wxAny.openBluetoothAdapter({
success: () => {
this.beginDiscovery()
},
fail: (error: any) => {
const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
this.callbacks.onError(`蓝牙不可用: ${message}`)
},
})
}
disconnect(): void {
this.clearDiscoveryTimer()
this.stopDiscovery()
const deviceId = this.currentDeviceId
this.connecting = false
if (!deviceId) {
this.clearConnectionState()
this.callbacks.onStatus('心率带未连接')
return
}
const wxAny = wx as any
wxAny.closeBLEConnection({
deviceId,
complete: () => {
this.clearConnectionState()
this.callbacks.onStatus('心率带已断开')
},
})
}
destroy(): void {
this.clearDiscoveryTimer()
this.stopDiscovery()
this.detachListeners()
const deviceId = this.currentDeviceId
if (deviceId) {
const wxAny = wx as any
wxAny.closeBLEConnection({
deviceId,
complete: () => {},
})
}
const wxAny = wx as any
if (typeof wxAny.closeBluetoothAdapter === 'function') {
wxAny.closeBluetoothAdapter({
complete: () => {},
})
}
this.clearConnectionState()
}
beginDiscovery(): void {
this.bindListeners()
const wxAny = wx as any
wxAny.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
services: [HEART_RATE_SERVICE_UUID],
success: () => {
this.scanning = true
this.callbacks.onStatus('正在扫描心率带')
this.clearDiscoveryTimer()
this.discoveryTimer = setTimeout(() => {
this.discoveryTimer = 0
if (!this.scanning || this.connected || this.connecting) {
return
}
this.stopDiscovery()
this.callbacks.onError('未发现可连接的心率带')
}, DISCOVERY_TIMEOUT_MS) as unknown as number
},
fail: (error: any) => {
const message = error && error.errMsg ? error.errMsg : 'startBluetoothDevicesDiscovery 失败'
this.callbacks.onError(`扫描心率带失败: ${message}`)
},
})
}
stopDiscovery(): void {
this.clearDiscoveryTimer()
if (!this.scanning) {
return
}
this.scanning = false
const wxAny = wx as any
wxAny.stopBluetoothDevicesDiscovery({
complete: () => {},
})
}
bindListeners(): void {
const wxAny = wx as any
if (!this.deviceFoundHandler) {
this.deviceFoundHandler = (result: any) => {
const devices = Array.isArray(result && result.devices)
? result.devices
: result && result.deviceId
? [result]
: []
const targetDevice = devices.find((device: BluetoothDeviceLike) => isHeartRateDevice(device))
if (!targetDevice || !targetDevice.deviceId || !this.scanning || this.connecting || this.connected) {
return
}
this.stopDiscovery()
this.connectToDevice(targetDevice.deviceId, getDeviceDisplayName(targetDevice))
}
if (typeof wxAny.onBluetoothDeviceFound === 'function') {
wxAny.onBluetoothDeviceFound(this.deviceFoundHandler)
}
}
if (!this.characteristicHandler) {
this.characteristicHandler = (result: any) => {
if (!result || !result.value) {
return
}
if (this.currentDeviceId && result.deviceId && result.deviceId !== this.currentDeviceId) {
return
}
if (!matchesShortUuid(result.characteristicId, HEART_RATE_MEASUREMENT_UUID)) {
return
}
const bpm = parseHeartRateMeasurement(result.value)
if (bpm === null || !Number.isFinite(bpm) || bpm <= 0) {
return
}
this.callbacks.onHeartRate(Math.round(bpm))
}
if (typeof wxAny.onBLECharacteristicValueChange === 'function') {
wxAny.onBLECharacteristicValueChange(this.characteristicHandler)
}
}
if (!this.connectionStateHandler) {
this.connectionStateHandler = (result: any) => {
if (!result || !this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
return
}
if (result.connected) {
return
}
this.clearConnectionState()
this.callbacks.onStatus('心率带连接已断开')
}
if (typeof wxAny.onBLEConnectionStateChange === 'function') {
wxAny.onBLEConnectionStateChange(this.connectionStateHandler)
}
}
}
detachListeners(): void {
const wxAny = wx as any
if (this.deviceFoundHandler && typeof wxAny.offBluetoothDeviceFound === 'function') {
wxAny.offBluetoothDeviceFound(this.deviceFoundHandler)
}
if (this.characteristicHandler && typeof wxAny.offBLECharacteristicValueChange === 'function') {
wxAny.offBLECharacteristicValueChange(this.characteristicHandler)
}
if (this.connectionStateHandler && typeof wxAny.offBLEConnectionStateChange === 'function') {
wxAny.offBLEConnectionStateChange(this.connectionStateHandler)
}
this.deviceFoundHandler = null
this.characteristicHandler = null
this.connectionStateHandler = null
}
connectToDevice(deviceId: string, deviceName: string): void {
this.connecting = true
this.currentDeviceId = deviceId
this.currentDeviceName = deviceName
this.callbacks.onStatus(`正在连接 ${deviceName}`)
const wxAny = wx as any
wxAny.createBLEConnection({
deviceId,
timeout: 10000,
success: () => {
this.discoverMeasurementCharacteristic(deviceId, deviceName)
},
fail: (error: any) => {
const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败'
this.clearConnectionState()
this.callbacks.onError(`连接心率带失败: ${message}`)
},
})
}
discoverMeasurementCharacteristic(deviceId: string, deviceName: string): void {
const wxAny = wx as any
wxAny.getBLEDeviceServices({
deviceId,
success: (serviceResult: any) => {
const services = Array.isArray(serviceResult && serviceResult.services) ? serviceResult.services : []
const service = services.find((item: any) => matchesShortUuid(item && item.uuid, HEART_RATE_SERVICE_UUID))
if (!service || !service.uuid) {
this.failConnection(deviceId, '未找到标准心率服务')
return
}
wxAny.getBLEDeviceCharacteristics({
deviceId,
serviceId: service.uuid,
success: (characteristicResult: any) => {
const characteristics = Array.isArray(characteristicResult && characteristicResult.characteristics)
? characteristicResult.characteristics
: []
const characteristic = characteristics.find((item: any) => {
const properties = item && item.properties ? item.properties : {}
return matchesShortUuid(item && item.uuid, HEART_RATE_MEASUREMENT_UUID)
&& (properties.notify || properties.indicate)
})
if (!characteristic || !characteristic.uuid) {
this.failConnection(deviceId, '未找到心率通知特征')
return
}
this.measurementServiceId = service.uuid
this.measurementCharacteristicId = characteristic.uuid
wxAny.notifyBLECharacteristicValueChange({
state: true,
deviceId,
serviceId: service.uuid,
characteristicId: characteristic.uuid,
success: () => {
this.connecting = false
this.connected = true
this.callbacks.onConnectionChange(true, deviceName)
this.callbacks.onStatus(`心率带已连接: ${deviceName}`)
},
fail: (error: any) => {
const message = error && error.errMsg ? error.errMsg : 'notifyBLECharacteristicValueChange 失败'
this.failConnection(deviceId, `心率订阅失败: ${message}`)
},
})
},
fail: (error: any) => {
const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceCharacteristics 失败'
this.failConnection(deviceId, `读取心率特征失败: ${message}`)
},
})
},
fail: (error: any) => {
const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceServices 失败'
this.failConnection(deviceId, `读取心率服务失败: ${message}`)
},
})
}
failConnection(deviceId: string, message: string): void {
const wxAny = wx as any
wxAny.closeBLEConnection({
deviceId,
complete: () => {
this.clearConnectionState()
this.callbacks.onError(message)
},
})
}
clearConnectionState(): void {
const wasConnected = this.connected
this.scanning = false
this.connecting = false
this.connected = false
this.currentDeviceId = null
this.measurementServiceId = null
this.measurementCharacteristicId = null
const previousDeviceName = this.currentDeviceName
this.currentDeviceName = null
if (wasConnected || previousDeviceName) {
this.callbacks.onConnectionChange(false, null)
}
}
clearDiscoveryTimer(): void {
if (this.discoveryTimer) {
clearTimeout(this.discoveryTimer)
this.discoveryTimer = 0
}
}
}

View File

@@ -225,19 +225,22 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
? targets[currentIndex + 1]
: null
const completedFinish = currentTarget.kind === 'finish'
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
const nextState: GameSessionState = {
...state,
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
completedControlIds,
currentTargetControlId: nextTarget ? nextTarget.id : null,
inRangeControlId: null,
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
status: finished ? 'finished' : state.status,
endedAt: finished ? at : state.endedAt,
guidanceState: nextTarget ? 'searching' : 'searching',
}
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
if (!nextTarget && definition.autoFinishOnLastControl) {
if (finished) {
effects.push({ type: 'session_finished' })
}
@@ -275,7 +278,7 @@ export class ClassicSequentialRule implements RulePlugin {
const nextState: GameSessionState = {
...state,
status: 'running',
startedAt: event.at,
startedAt: null,
endedAt: null,
inRangeControlId: null,
guidanceState: 'searching',

View File

@@ -0,0 +1,141 @@
export interface TelemetryConfig {
heartRateAge: number
restingHeartRateBpm: number
userWeightKg: number
}
export type HeartRateTone = 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
type HeartRateToneMeta = {
label: string
heartRateRangeText: string
speedRangeText: string
}
const HEART_RATE_TONE_META: Record<HeartRateTone, HeartRateToneMeta> = {
blue: {
label: '激活放松',
heartRateRangeText: '<=39%',
speedRangeText: '<3.2 km/h',
},
purple: {
label: '动态热身',
heartRateRangeText: '40~54%',
speedRangeText: '3.2~4.0 km/h',
},
green: {
label: '脂肪燃烧',
heartRateRangeText: '55~69%',
speedRangeText: '4.1~5.5 km/h',
},
yellow: {
label: '糖分消耗',
heartRateRangeText: '70~79%',
speedRangeText: '5.6~7.1 km/h',
},
orange: {
label: '心肺训练',
heartRateRangeText: '80~89%',
speedRangeText: '7.2~8.8 km/h',
},
red: {
label: '峰值锻炼',
heartRateRangeText: '>=90%',
speedRangeText: '>=8.9 km/h',
},
}
export function clampTelemetryAge(age: number): number {
if (!Number.isFinite(age)) {
return 30
}
return Math.max(10, Math.min(85, Math.round(age)))
}
export function estimateRestingHeartRateBpm(age: number): number {
const safeAge = clampTelemetryAge(age)
const estimated = 68 + (safeAge - 30) * 0.12
return Math.max(56, Math.min(76, Math.round(estimated)))
}
export function normalizeRestingHeartRateBpm(restingHeartRateBpm: number, age: number): number {
if (!Number.isFinite(restingHeartRateBpm) || restingHeartRateBpm <= 0) {
return estimateRestingHeartRateBpm(age)
}
return Math.max(40, Math.min(95, Math.round(restingHeartRateBpm)))
}
export function normalizeUserWeightKg(userWeightKg: number): number {
if (!Number.isFinite(userWeightKg) || userWeightKg <= 0) {
return 65
}
return Math.max(35, Math.min(180, Math.round(userWeightKg)))
}
export const DEFAULT_TELEMETRY_CONFIG: TelemetryConfig = {
heartRateAge: 30,
restingHeartRateBpm: estimateRestingHeartRateBpm(30),
userWeightKg: 65,
}
export function mergeTelemetryConfig(overrides?: Partial<TelemetryConfig> | null): TelemetryConfig {
const heartRateAge = overrides && overrides.heartRateAge !== undefined
? clampTelemetryAge(overrides.heartRateAge)
: DEFAULT_TELEMETRY_CONFIG.heartRateAge
const restingHeartRateBpm = overrides && overrides.restingHeartRateBpm !== undefined
? normalizeRestingHeartRateBpm(overrides.restingHeartRateBpm, heartRateAge)
: estimateRestingHeartRateBpm(heartRateAge)
const userWeightKg = overrides && overrides.userWeightKg !== undefined
? normalizeUserWeightKg(overrides.userWeightKg)
: DEFAULT_TELEMETRY_CONFIG.userWeightKg
return {
heartRateAge,
restingHeartRateBpm,
userWeightKg,
}
}
export function getHeartRateToneSampleBpm(tone: HeartRateTone, config: TelemetryConfig): number {
const maxHeartRate = Math.max(120, 220 - config.heartRateAge)
const restingHeartRate = Math.min(maxHeartRate - 15, config.restingHeartRateBpm)
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
if (tone === 'blue') {
return Math.round(restingHeartRate + reserve * 0.3)
}
if (tone === 'purple') {
return Math.round(restingHeartRate + reserve * 0.47)
}
if (tone === 'green') {
return Math.round(restingHeartRate + reserve * 0.62)
}
if (tone === 'yellow') {
return Math.round(restingHeartRate + reserve * 0.745)
}
if (tone === 'orange') {
return Math.round(restingHeartRate + reserve * 0.845)
}
return Math.round(restingHeartRate + reserve * 0.93)
}
export function getHeartRateToneLabel(tone: HeartRateTone): string {
return HEART_RATE_TONE_META[tone].label
}
export function getHeartRateToneRangeText(tone: HeartRateTone): string {
return HEART_RATE_TONE_META[tone].heartRateRangeText
}
export function getSpeedToneRangeText(tone: HeartRateTone): string {
return HEART_RATE_TONE_META[tone].speedRangeText
}

View File

@@ -0,0 +1,9 @@
import { type LonLatPoint } from '../../utils/projection'
import { type GameSessionStatus } from '../core/gameSessionState'
export type TelemetryEvent =
| { type: 'reset' }
| { type: 'session_state_updated'; at: number; status: GameSessionStatus; startedAt: number | null; endedAt: number | null }
| { type: 'target_updated'; controlId: string | null; point: LonLatPoint | null }
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
| { type: 'heart_rate_updated'; at: number; bpm: number | null }

View File

@@ -0,0 +1,37 @@
export interface TelemetryPresentation {
timerText: string
mileageText: string
distanceToTargetValueText: string
distanceToTargetUnitText: string
speedText: string
heartRateTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
heartRateZoneNameText: string
heartRateZoneRangeText: string
heartRateValueText: string
heartRateUnitText: string
caloriesValueText: string
caloriesUnitText: string
averageSpeedValueText: string
averageSpeedUnitText: string
accuracyValueText: string
accuracyUnitText: string
}
export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = {
timerText: '00:00:00',
mileageText: '0m',
distanceToTargetValueText: '--',
distanceToTargetUnitText: '',
speedText: '0',
heartRateTone: 'blue',
heartRateZoneNameText: '激活放松',
heartRateZoneRangeText: '<=39%',
heartRateValueText: '--',
heartRateUnitText: '',
caloriesValueText: '0',
caloriesUnitText: 'kcal',
averageSpeedValueText: '0',
averageSpeedUnitText: 'km/h',
accuracyValueText: '--',
accuracyUnitText: '',
}

View File

@@ -0,0 +1,473 @@
import { type GameDefinition } from '../core/gameDefinition'
import {
DEFAULT_TELEMETRY_CONFIG,
getHeartRateToneLabel,
getHeartRateToneRangeText,
getSpeedToneRangeText,
mergeTelemetryConfig,
type HeartRateTone,
type TelemetryConfig,
} from './telemetryConfig'
import { type GameSessionState } from '../core/gameSessionState'
import { type TelemetryEvent } from './telemetryEvent'
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
import { EMPTY_TELEMETRY_STATE, type TelemetryState } from './telemetryState'
const SPEED_SMOOTHING_ALPHA = 0.35
function getApproxDistanceMeters(
a: { lon: number; lat: number },
b: { lon: number; lat: number },
): number {
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
const dy = (b.lat - a.lat) * 110540
return Math.sqrt(dx * dx + dy * dy)
}
function formatElapsedTimerText(totalMs: number): string {
const safeMs = Math.max(0, totalMs)
const totalSeconds = Math.floor(safeMs / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
function formatDistanceText(distanceMeters: number): string {
if (distanceMeters >= 1000) {
return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
}
return `${Math.round(distanceMeters)}m`
}
function formatTargetDistance(distanceMeters: number | null): { valueText: string; unitText: string } {
if (distanceMeters === null) {
return {
valueText: '--',
unitText: '',
}
}
return distanceMeters >= 1000
? {
valueText: `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}`,
unitText: 'km',
}
: {
valueText: String(Math.round(distanceMeters)),
unitText: 'm',
}
}
function formatSpeedText(speedKmh: number | null): string {
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.05) {
return '0'
}
return speedKmh >= 10 ? speedKmh.toFixed(1) : speedKmh.toFixed(2)
}
function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number): number {
if (previousSpeedKmh === null || !Number.isFinite(previousSpeedKmh)) {
return nextSpeedKmh
}
return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA
}
function getHeartRateTone(
heartRateBpm: number | null,
telemetryConfig: TelemetryConfig,
): HeartRateTone {
if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
return 'blue'
}
const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
const blueLimit = restingHeartRate + reserve * 0.39
const purpleLimit = restingHeartRate + reserve * 0.54
const greenLimit = restingHeartRate + reserve * 0.69
const yellowLimit = restingHeartRate + reserve * 0.79
const orangeLimit = restingHeartRate + reserve * 0.89
if (heartRateBpm <= blueLimit) {
return 'blue'
}
if (heartRateBpm <= purpleLimit) {
return 'purple'
}
if (heartRateBpm <= greenLimit) {
return 'green'
}
if (heartRateBpm <= yellowLimit) {
return 'yellow'
}
if (heartRateBpm <= orangeLimit) {
return 'orange'
}
return 'red'
}
function getSpeedFallbackTone(speedKmh: number | null): HeartRateTone {
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 3.2) {
return 'blue'
}
if (speedKmh <= 4.0) {
return 'purple'
}
if (speedKmh <= 5.5) {
return 'green'
}
if (speedKmh <= 7.1) {
return 'yellow'
}
if (speedKmh <= 8.8) {
return 'orange'
}
return 'red'
}
function formatHeartRateMetric(heartRateBpm: number | null): { valueText: string; unitText: string } {
if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
return {
valueText: '--',
unitText: '',
}
}
return {
valueText: String(Math.round(heartRateBpm)),
unitText: 'bpm',
}
}
function formatCaloriesMetric(caloriesKcal: number | null): { valueText: string; unitText: string } {
if (caloriesKcal === null || !Number.isFinite(caloriesKcal) || caloriesKcal < 0) {
return {
valueText: '0',
unitText: 'kcal',
}
}
return {
valueText: String(Math.round(caloriesKcal)),
unitText: 'kcal',
}
}
function formatAccuracyMetric(accuracyMeters: number | null): { valueText: string; unitText: string } {
if (accuracyMeters === null || !Number.isFinite(accuracyMeters) || accuracyMeters < 0) {
return {
valueText: '--',
unitText: '',
}
}
return {
valueText: String(Math.round(accuracyMeters)),
unitText: 'm',
}
}
function estimateCaloriesKcal(
elapsedMs: number,
heartRateBpm: number,
telemetryConfig: TelemetryConfig,
): number {
if (elapsedMs <= 0) {
return 0
}
if (!Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
return 0
}
const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
const intensity = Math.max(0, Math.min(1, (heartRateBpm - restingHeartRate) / reserve))
const met = 2 + intensity * 10
return met * telemetryConfig.userWeightKg * (elapsedMs / 3600000)
}
function estimateCaloriesFromSpeedKcal(
elapsedMs: number,
speedKmh: number | null,
telemetryConfig: TelemetryConfig,
): number {
if (elapsedMs <= 0 || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.5) {
return 0
}
let met = 2
if (speedKmh >= 8.9) {
met = 9.8
} else if (speedKmh >= 7.2) {
met = 7.8
} else if (speedKmh >= 5.6) {
met = 6
} else if (speedKmh >= 4.1) {
met = 4.3
} else if (speedKmh >= 3.2) {
met = 3.0
}
return (met * 3.5 * telemetryConfig.userWeightKg / 200) * (elapsedMs / 60000)
}
function hasHeartRateSignal(state: TelemetryState): boolean {
return state.heartRateBpm !== null
&& Number.isFinite(state.heartRateBpm)
&& state.heartRateBpm > 0
}
function hasSpeedSignal(state: TelemetryState): boolean {
return state.currentSpeedKmh !== null
&& Number.isFinite(state.currentSpeedKmh)
&& state.currentSpeedKmh >= 0.5
}
function shouldTrackCalories(state: TelemetryState): boolean {
return state.sessionStatus === 'running'
&& state.sessionEndedAt === null
&& (hasHeartRateSignal(state) || hasSpeedSignal(state))
}
export class TelemetryRuntime {
state: TelemetryState
config: TelemetryConfig
constructor() {
this.state = { ...EMPTY_TELEMETRY_STATE }
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
}
reset(): void {
this.state = { ...EMPTY_TELEMETRY_STATE }
}
configure(config?: Partial<TelemetryConfig> | null): void {
this.config = mergeTelemetryConfig(config)
}
loadDefinition(_definition: GameDefinition): void {
this.reset()
}
syncGameState(definition: GameDefinition | null, state: GameSessionState | null): void {
if (!definition || !state) {
this.dispatch({ type: 'reset' })
return
}
const targetControl = state.currentTargetControlId
? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
: null
this.dispatch({
type: 'session_state_updated',
at: Date.now(),
status: state.status,
startedAt: state.startedAt,
endedAt: state.endedAt,
})
this.dispatch({
type: 'target_updated',
controlId: targetControl ? targetControl.id : null,
point: targetControl ? targetControl.point : null,
})
}
dispatch(event: TelemetryEvent): void {
if (event.type === 'reset') {
this.reset()
return
}
if (event.type === 'session_state_updated') {
this.syncCalorieAccumulation(event.at)
this.state = {
...this.state,
sessionStatus: event.status,
sessionStartedAt: event.startedAt,
sessionEndedAt: event.endedAt,
elapsedMs: event.startedAt === null ? 0 : Math.max(0, (event.endedAt || Date.now()) - event.startedAt),
}
this.alignCalorieTracking(event.at)
this.recomputeDerivedState()
return
}
if (event.type === 'target_updated') {
this.state = {
...this.state,
targetControlId: event.controlId,
targetPoint: event.point,
}
this.recomputeDerivedState()
return
}
if (event.type === 'gps_updated') {
this.syncCalorieAccumulation(event.at)
const nextPoint = { lon: event.lon, lat: event.lat }
const previousPoint = this.state.lastGpsPoint
const previousAt = this.state.lastGpsAt
let nextDistanceMeters = this.state.distanceMeters
let nextSpeedKmh = this.state.currentSpeedKmh
if (previousPoint && previousAt !== null && event.at > previousAt) {
const segmentMeters = getApproxDistanceMeters(previousPoint, nextPoint)
nextDistanceMeters += segmentMeters
const rawSpeedKmh = segmentMeters <= 0
? 0
: (segmentMeters / ((event.at - previousAt) / 1000)) * 3.6
nextSpeedKmh = smoothSpeedKmh(this.state.currentSpeedKmh, rawSpeedKmh)
}
this.state = {
...this.state,
distanceMeters: nextDistanceMeters,
currentSpeedKmh: nextSpeedKmh,
lastGpsPoint: nextPoint,
lastGpsAt: event.at,
lastGpsAccuracyMeters: event.accuracyMeters,
}
this.alignCalorieTracking(event.at)
this.recomputeDerivedState()
return
}
if (event.type === 'heart_rate_updated') {
this.syncCalorieAccumulation(event.at)
this.state = {
...this.state,
heartRateBpm: event.bpm,
}
this.alignCalorieTracking(event.at)
this.recomputeDerivedState()
}
}
recomputeDerivedState(now = Date.now()): void {
const elapsedMs = this.state.sessionStartedAt === null
? 0
: Math.max(0, (this.state.sessionEndedAt || now) - this.state.sessionStartedAt)
const distanceToTargetMeters = this.state.lastGpsPoint && this.state.targetPoint
? getApproxDistanceMeters(this.state.lastGpsPoint, this.state.targetPoint)
: null
const averageSpeedKmh = elapsedMs > 0
? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
: null
this.state = {
...this.state,
elapsedMs,
distanceToTargetMeters,
averageSpeedKmh,
}
}
getPresentation(now = Date.now()): TelemetryPresentation {
this.syncCalorieAccumulation(now)
this.alignCalorieTracking(now)
this.recomputeDerivedState(now)
const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
const hasHeartRate = hasHeartRateSignal(this.state)
const heartRateTone = hasHeartRate
? getHeartRateTone(this.state.heartRateBpm, this.config)
: getSpeedFallbackTone(this.state.currentSpeedKmh)
const heartRate = formatHeartRateMetric(this.state.heartRateBpm)
const calories = formatCaloriesMetric(this.state.caloriesKcal)
const accuracy = formatAccuracyMetric(this.state.lastGpsAccuracyMeters)
return {
...EMPTY_TELEMETRY_PRESENTATION,
timerText: formatElapsedTimerText(this.state.elapsedMs),
mileageText: formatDistanceText(this.state.distanceMeters),
distanceToTargetValueText: targetDistance.valueText,
distanceToTargetUnitText: targetDistance.unitText,
speedText: formatSpeedText(this.state.currentSpeedKmh),
heartRateTone,
heartRateZoneNameText: hasHeartRate || hasSpeedSignal(this.state) ? getHeartRateToneLabel(heartRateTone) : '--',
heartRateZoneRangeText: hasHeartRate
? getHeartRateToneRangeText(heartRateTone)
: hasSpeedSignal(this.state)
? getSpeedToneRangeText(heartRateTone)
: '',
heartRateValueText: heartRate.valueText,
heartRateUnitText: heartRate.unitText,
caloriesValueText: calories.valueText,
caloriesUnitText: calories.unitText,
averageSpeedValueText: formatSpeedText(this.state.averageSpeedKmh),
averageSpeedUnitText: 'km/h',
accuracyValueText: accuracy.valueText,
accuracyUnitText: accuracy.unitText,
}
}
private syncCalorieAccumulation(now: number): void {
if (!shouldTrackCalories(this.state)) {
return
}
if (this.state.calorieTrackingAt === null) {
this.state = {
...this.state,
calorieTrackingAt: now,
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
}
return
}
if (now <= this.state.calorieTrackingAt) {
return
}
const deltaMs = now - this.state.calorieTrackingAt
const calorieDelta = hasHeartRateSignal(this.state)
? estimateCaloriesKcal(deltaMs, this.state.heartRateBpm as number, this.config)
: estimateCaloriesFromSpeedKcal(deltaMs, this.state.currentSpeedKmh, this.config)
this.state = {
...this.state,
calorieTrackingAt: now,
caloriesKcal: (this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal) + calorieDelta,
}
}
private alignCalorieTracking(now: number): void {
if (shouldTrackCalories(this.state)) {
if (this.state.calorieTrackingAt === null) {
this.state = {
...this.state,
calorieTrackingAt: now,
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
}
}
return
}
if (this.state.calorieTrackingAt !== null) {
this.state = {
...this.state,
calorieTrackingAt: null,
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
}
}
}
}

View File

@@ -0,0 +1,40 @@
import { type LonLatPoint } from '../../utils/projection'
import { type GameSessionStatus } from '../core/gameSessionState'
export interface TelemetryState {
sessionStatus: GameSessionStatus
sessionStartedAt: number | null
sessionEndedAt: number | null
elapsedMs: number
distanceMeters: number
currentSpeedKmh: number | null
averageSpeedKmh: number | null
distanceToTargetMeters: number | null
targetControlId: string | null
targetPoint: LonLatPoint | null
lastGpsPoint: LonLatPoint | null
lastGpsAt: number | null
lastGpsAccuracyMeters: number | null
heartRateBpm: number | null
caloriesKcal: number | null
calorieTrackingAt: number | null
}
export const EMPTY_TELEMETRY_STATE: TelemetryState = {
sessionStatus: 'idle',
sessionStartedAt: null,
sessionEndedAt: null,
elapsedMs: 0,
distanceMeters: 0,
currentSpeedKmh: null,
averageSpeedKmh: null,
distanceToTargetMeters: null,
targetControlId: null,
targetPoint: null,
lastGpsPoint: null,
lastGpsAt: null,
lastGpsAccuracyMeters: null,
heartRateBpm: null,
caloriesKcal: null,
calorieTrackingAt: null,
}

View File

@@ -17,6 +17,7 @@ type MapPageData = MapEngineViewState & {
showDebugPanel: boolean
statusBarHeight: number
topInsetHeight: number
hudPanelIndex: number
panelTimerText: string
panelMileageText: string
panelDistanceValueText: string
@@ -29,7 +30,7 @@ type MapPageData = MapEngineViewState & {
showRightButtonGroups: boolean
showBottomDebugButton: boolean
}
const INTERNAL_BUILD_VERSION = 'map-build-134'
const INTERNAL_BUILD_VERSION = 'map-build-157'
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
let mapEngine: MapEngine | null = null
function buildSideButtonVisibility(mode: SideButtonMode) {
@@ -94,12 +95,28 @@ Page({
showDebugPanel: false,
statusBarHeight: 0,
topInsetHeight: 12,
hudPanelIndex: 0,
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelDistanceValueText: '108',
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
gameSessionStatus: 'idle',
panelSpeedValueText: '0',
panelTelemetryTone: 'blue',
panelHeartRateZoneNameText: '--',
panelHeartRateZoneRangeText: '',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
panelHeartRateValueText: '--',
panelHeartRateUnitText: '',
panelCaloriesValueText: '0',
panelCaloriesUnitText: 'kcal',
panelAverageSpeedValueText: '0',
panelAverageSpeedUnitText: 'km/h',
panelAccuracyValueText: '--',
panelAccuracyUnitText: '',
punchButtonText: '打点',
punchButtonEnabled: false,
punchHintText: '等待进入检查点范围',
@@ -140,12 +157,28 @@ Page({
showDebugPanel: false,
statusBarHeight,
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
hudPanelIndex: 0,
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelDistanceValueText: '108',
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
gameSessionStatus: 'idle',
panelSpeedValueText: '0',
panelTelemetryTone: 'blue',
panelHeartRateZoneNameText: '--',
panelHeartRateZoneRangeText: '',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
panelHeartRateValueText: '--',
panelHeartRateUnitText: '',
panelCaloriesValueText: '0',
panelCaloriesUnitText: 'kcal',
panelAverageSpeedValueText: '0',
panelAverageSpeedUnitText: 'km/h',
panelAccuracyValueText: '--',
panelAccuracyUnitText: '',
punchButtonText: '打点',
punchButtonEnabled: false,
punchHintText: '等待进入检查点范围',
@@ -201,10 +234,10 @@ Page({
return
}
const errorMessage = error && error.message ? error.message : '鏈煡閿欒'
const errorMessage = error && error.message ? error.message : '未知错误'
this.setData({
configStatusText: `杞藉叆澶辫触: ${errorMessage}`,
statusText: `杩滅▼鍦板浘閰嶇疆杞藉叆澶辫触: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
configStatusText: `载入失败: ${errorMessage}`,
statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
})
})
},
@@ -235,7 +268,7 @@ Page({
const labelCanvasRef = canvasRes[1] as any
if (!canvasRef || !canvasRef.node) {
page.setData({
statusText: `WebGL 寮曟搸鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`,
statusText: `WebGL 引擎初始化失败 (${INTERNAL_BUILD_VERSION})`,
})
return
}
@@ -343,6 +376,60 @@ Page({
}
},
handleConnectHeartRate() {
if (mapEngine) {
mapEngine.handleConnectHeartRate()
}
},
handleDisconnectHeartRate() {
if (mapEngine) {
mapEngine.handleDisconnectHeartRate()
}
},
handleDebugHeartRateBlue() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('blue')
}
},
handleDebugHeartRatePurple() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('purple')
}
},
handleDebugHeartRateGreen() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('green')
}
},
handleDebugHeartRateYellow() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('yellow')
}
},
handleDebugHeartRateOrange() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('orange')
}
},
handleDebugHeartRateRed() {
if (mapEngine) {
mapEngine.handleDebugHeartRateTone('red')
}
},
handleClearDebugHeartRate() {
if (mapEngine) {
mapEngine.handleClearDebugHeartRate()
}
},
handleToggleOsmReference() {
if (mapEngine) {
mapEngine.handleToggleOsmReference()
@@ -373,6 +460,12 @@ Page({
}
},
handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) {
this.setData({
hudPanelIndex: event.detail.current || 0,
})
},
handleCycleSideButtons() {
this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
},

View File

@@ -1,4 +1,8 @@
<view class="page">
<view
class="app-edge-glow app-edge-glow--{{panelTelemetryTone}}"
wx:if="{{panelTelemetryTone === 'orange' || panelTelemetryTone === 'red'}}"
></view>
<view
class="map-stage"
catchtouchstart="handleTouchStart"
@@ -114,7 +118,9 @@
<cover-view class="screen-button-layer__text">调试</cover-view>
</cover-view>
<view class="race-panel">
<swiper class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
<swiper-item>
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
<view class="race-panel__tag race-panel__tag--top-left">目标</view>
<view class="race-panel__tag race-panel__tag--top-right">里程</view>
<view class="race-panel__tag race-panel__tag--bottom-left">点距</view>
@@ -149,7 +155,7 @@
<view class="race-panel__cell race-panel__cell--distance">
<view class="race-panel__metric-group race-panel__metric-group--left">
<text class="race-panel__metric-value race-panel__metric-value--distance">{{panelDistanceValueText}}</text>
<text class="race-panel__metric-unit race-panel__metric-unit--distance">m</text>
<text class="race-panel__metric-unit race-panel__metric-unit--distance">{{panelDistanceUnitText}}</text>
</view>
</view>
<view class="race-panel__cell race-panel__cell--progress">
@@ -163,25 +169,125 @@
</view>
</view>
</view>
</swiper-item>
<swiper-item>
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
<view class="race-panel__tag race-panel__tag--top-left">心率</view>
<view class="race-panel__tag race-panel__tag--top-right">卡路里</view>
<view class="race-panel__tag race-panel__tag--bottom-left">均速</view>
<view class="race-panel__tag race-panel__tag--bottom-right">精度</view>
<view class="race-panel__line race-panel__line--center"></view>
<view class="race-panel__line race-panel__line--left-mid"></view>
<view class="race-panel__line race-panel__line--right-mid"></view>
<view class="race-panel__line race-panel__line--left-top"></view>
<view class="race-panel__line race-panel__line--left-bottom"></view>
<view class="race-panel__line race-panel__line--right-top"></view>
<view class="race-panel__line race-panel__line--right-bottom"></view>
<view class="race-panel__grid">
<view class="race-panel__cell race-panel__cell--action">
<view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel">
<text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelHeartRateValueText}}</text>
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelHeartRateUnitText}}</text>
</view>
</view>
<view class="race-panel__cell race-panel__cell--timer">
<text class="race-panel__timer">{{panelTimerText}}</text>
</view>
<view class="race-panel__cell race-panel__cell--mileage">
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
<text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelCaloriesValueText}}</text>
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelCaloriesUnitText}}</text>
</view>
</view>
<view class="race-panel__cell race-panel__cell--distance">
<view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel">
<text class="race-panel__metric-value race-panel__metric-value--telemetry-secondary">{{panelAverageSpeedValueText}}</text>
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelAverageSpeedUnitText}}</text>
</view>
</view>
<view class="race-panel__cell race-panel__cell--progress">
<view class="race-panel__zone">
<text class="race-panel__zone-name">{{panelHeartRateZoneNameText}}</text>
<text class="race-panel__zone-range">{{panelHeartRateZoneRangeText}}</text>
</view>
</view>
<view class="race-panel__cell race-panel__cell--speed">
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
<text class="race-panel__metric-value race-panel__metric-value--telemetry-secondary">{{panelAccuracyValueText}}</text>
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelAccuracyUnitText}}</text>
</view>
</view>
</view>
</view>
</swiper-item>
</swiper>
<view class="race-panel-pager" wx:if="{{!showDebugPanel}}">
<view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view>
<view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view>
</view>
<view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel">
<view class="debug-modal__dialog" catchtap="handleDebugPanelTap">
<view class="debug-modal__header">
<view>
<view class="debug-modal__header-main">
<view class="debug-modal__eyebrow">DEBUG PANEL</view>
<view class="debug-modal__title">地图调试信息</view>
<view class="debug-modal__build">{{buildVersion}}</view>
</view>
<view class="debug-modal__header-actions">
<view class="debug-modal__close" bindtap="handleCloseDebugPanel">关闭</view>
</view>
</view>
<scroll-view class="debug-modal__content" scroll-y enhanced show-scrollbar="true">
<view class="debug-section">
<view class="debug-section__header">
<view class="debug-section__title">Session</view>
<view class="debug-section__desc">当前局状态与主流程控制</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Build</text>
<text class="info-panel__value">{{buildVersion}}</text>
<text class="info-panel__label">Game</text>
<text class="info-panel__value">{{gameSessionStatus}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Progress</text>
<text class="info-panel__value">{{panelProgressText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Timer</text>
<text class="info-panel__value">{{panelTimerText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">Config</text>
<text class="info-panel__value">{{configStatusText}}</text>
<text class="info-panel__label">Punch Hint</text>
<text class="info-panel__value">{{punchHintText}}</text>
</view>
<view class="control-row">
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
</view>
</view>
<view class="debug-section">
<view class="debug-section__header">
<view class="debug-section__title">Sensors</view>
<view class="debug-section__desc">定位、罗盘与心率带连接状态</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">GPS</text>
<text class="info-panel__value">{{gpsTrackingText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">GPS Coord</text>
<text class="info-panel__value">{{gpsCoordText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Heart Rate</text>
<text class="info-panel__value">{{heartRateStatusText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">HR Device</text>
<text class="info-panel__value">{{heartRateDeviceText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Heading Mode</text>
@@ -195,25 +301,68 @@
<text class="info-panel__label">North Ref</text>
<text class="info-panel__value">{{northReferenceText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Zoom</text>
<text class="info-panel__value">{{zoom}}</text>
<view class="control-row">
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
</view>
<view class="control-row">
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
</view>
</view>
<view class="debug-section">
<view class="debug-section__header">
<view class="debug-section__title">Telemetry</view>
<view class="debug-section__desc">HUD 派生数据与心率颜色测试</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Rotation</text>
<text class="info-panel__value">{{rotationText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">Status</text>
<text class="info-panel__value">{{statusText}}</text>
<text class="info-panel__label">HR</text>
<text class="info-panel__value">{{panelHeartRateValueText}} {{panelHeartRateUnitText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">GPS</text>
<text class="info-panel__value">{{gpsTrackingText}}</text>
<text class="info-panel__label">HR Zone</text>
<text class="info-panel__value">{{panelHeartRateZoneNameText}} {{panelHeartRateZoneRangeText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">GPS Coord</text>
<text class="info-panel__value">{{gpsCoordText}}</text>
<view class="info-panel__row">
<text class="info-panel__label">Calories</text>
<text class="info-panel__value">{{panelCaloriesValueText}} {{panelCaloriesUnitText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Speed</text>
<text class="info-panel__value">{{panelSpeedValueText}} km/h</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Avg Speed</text>
<text class="info-panel__value">{{panelAverageSpeedValueText}} {{panelAverageSpeedUnitText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Target Dist</text>
<text class="info-panel__value">{{panelDistanceValueText}} {{panelDistanceUnitText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Accuracy</text>
<text class="info-panel__value">{{panelAccuracyValueText}} {{panelAccuracyUnitText}}</text>
</view>
<view class="control-row control-row--triple">
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateBlue">蓝</view>
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRatePurple">紫</view>
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateGreen">绿</view>
</view>
<view class="control-row control-row--triple">
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateYellow">黄</view>
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateOrange">橙</view>
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateRed">红</view>
</view>
<view class="control-row">
<view class="control-chip control-chip--secondary" bindtap="handleClearDebugHeartRate">清除</view>
</view>
</view>
<view class="debug-section">
<view class="debug-section__header">
<view class="debug-section__title">Rendering</view>
<view class="debug-section__desc">地图渲染、视角与参考图层</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Renderer</text>
@@ -223,6 +372,14 @@
<text class="info-panel__label">Projection</text>
<text class="info-panel__value">{{projectionMode}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Zoom</text>
<text class="info-panel__value">{{zoom}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Rotation</text>
<text class="info-panel__value">{{rotationText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Auto Source</text>
<text class="info-panel__value">{{autoRotateSourceText}}</text>
@@ -231,6 +388,33 @@
<text class="info-panel__label">Calibration</text>
<text class="info-panel__value">{{autoRotateCalibrationText}}</text>
</view>
<view class="control-row">
<view class="control-chip {{osmReferenceEnabled ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleOsmReference">{{osmReferenceText}}</view>
<view class="control-chip" wx:if="{{orientationMode === 'manual'}}" bindtap="handleRotateStep">旋转 +15°</view>
</view>
<view class="control-row control-row--triple">
<view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>
<view class="control-chip {{orientationMode === 'north-up' ? 'control-chip--active' : ''}}" bindtap="handleSetNorthUpMode">北朝上</view>
<view class="control-chip {{orientationMode === 'heading-up' ? 'control-chip--active' : ''}}" bindtap="handleSetHeadingUpMode">朝向朝上</view>
</view>
<view class="control-row" wx:if="{{orientationMode === 'heading-up'}}">
<view class="control-chip" bindtap="handleAutoRotateCalibrate">按当前方向校准</view>
</view>
</view>
<view class="debug-section">
<view class="debug-section__header">
<view class="debug-section__title">Diagnostics</view>
<view class="debug-section__desc">配置、瓦片缓存与运行状态</view>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">Config</text>
<text class="info-panel__value">{{configStatusText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">Status</text>
<text class="info-panel__value">{{statusText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">Tile URL</text>
<text class="info-panel__value">{{tileSource}}</text>
@@ -271,28 +455,6 @@
<text class="info-panel__label">Net Fetches</text>
<text class="info-panel__value">{{networkFetchCount}}</text>
</view>
<view class="control-row">
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
</view>
<view class="control-row">
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
<view class="control-chip {{osmReferenceEnabled ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleOsmReference">{{osmReferenceText}}</view>
</view>
<view class="control-row control-row--triple">
<view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>
<view class="control-chip {{orientationMode === 'north-up' ? 'control-chip--active' : ''}}" bindtap="handleSetNorthUpMode">北朝上</view>
<view class="control-chip {{orientationMode === 'heading-up' ? 'control-chip--active' : ''}}" bindtap="handleSetHeadingUpMode">朝向朝上</view>
</view>
<view class="control-row">
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
</view>
<view class="control-row" wx:if="{{orientationMode === 'heading-up'}}">
<view class="control-chip" bindtap="handleAutoRotateCalibrate">按当前方向校准</view>
</view>
<view class="control-row" wx:if="{{orientationMode === 'manual'}}">
<view class="control-chip" bindtap="handleRotateStep">旋转 +15°</view>
</view>
</scroll-view>
</view>

View File

@@ -6,6 +6,22 @@
color: #163020;
}
.app-edge-glow {
position: absolute;
inset: 0;
border-radius: 0;
pointer-events: none;
z-index: 40;
}
.app-edge-glow--orange {
animation: app-edge-breathe-orange 1.55s ease-in-out infinite;
}
.app-edge-glow--red {
animation: app-edge-breathe-red 1.15s ease-in-out infinite;
}
.map-stage {
position: absolute;
inset: 0;
@@ -534,18 +550,115 @@
font-weight: 700;
text-shadow: 0 1rpx 0 rgba(255, 255, 255, 0.24);
}
.race-panel {
.race-panel-swiper {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 216rpx;
background: linear-gradient(180deg, #1d97ec 0%, #168ce4 100%);
box-shadow: 0 -10rpx 24rpx rgba(10, 75, 125, 0.2);
z-index: 15;
}
.race-panel {
position: relative;
height: 100%;
background: linear-gradient(180deg, #1d8fd2 0%, #197dba 100%);
box-shadow: 0 -10rpx 24rpx rgba(18, 101, 150, 0.2);
overflow: hidden;
}
.race-panel--tone-blue {
background: linear-gradient(180deg, #1d8fd2 0%, #197dba 100%);
box-shadow: 0 -10rpx 24rpx rgba(18, 101, 150, 0.22);
}
.race-panel--tone-purple {
background: linear-gradient(180deg, #5317d4 0%, #4310b7 100%);
box-shadow: 0 -10rpx 24rpx rgba(58, 16, 145, 0.24);
}
.race-panel--tone-green {
background: linear-gradient(180deg, #08c805 0%, #05ab03 100%);
box-shadow: 0 -10rpx 24rpx rgba(6, 112, 9, 0.24);
}
.race-panel--tone-yellow {
background: linear-gradient(180deg, #ffbf1f 0%, #ffad0f 100%);
box-shadow: 0 -10rpx 24rpx rgba(163, 105, 0, 0.24);
}
.race-panel--tone-yellow .race-panel__cell,
.race-panel--tone-yellow .race-panel__tag,
.race-panel--tone-orange .race-panel__tag,
.race-panel--tone-red .race-panel__tag {
color: #fff;
}
.race-panel--tone-orange {
background: linear-gradient(180deg, #ff7b12 0%, #ff6500 100%);
box-shadow: 0 -10rpx 24rpx rgba(156, 68, 0, 0.26);
}
.race-panel--tone-red {
background: linear-gradient(180deg, #e1122c 0%, #c90e27 100%);
box-shadow: 0 -10rpx 24rpx rgba(141, 16, 38, 0.28);
}
@keyframes app-edge-breathe-orange {
0%,
100% {
box-shadow:
inset 0 0 22rpx 6rpx rgba(255, 123, 18, 0.12),
inset 0 0 54rpx 14rpx rgba(255, 148, 46, 0.06);
}
50% {
box-shadow:
inset 0 0 34rpx 12rpx rgba(255, 133, 36, 0.24),
inset 0 0 78rpx 24rpx rgba(255, 160, 78, 0.12);
}
}
@keyframes app-edge-breathe-red {
0%,
100% {
box-shadow:
inset 0 0 24rpx 7rpx rgba(225, 18, 44, 0.14),
inset 0 0 58rpx 16rpx rgba(233, 44, 67, 0.07);
}
50% {
box-shadow:
inset 0 0 38rpx 14rpx rgba(233, 44, 67, 0.28),
inset 0 0 86rpx 26rpx rgba(241, 82, 104, 0.14);
}
}
.race-panel-pager {
position: absolute;
left: 50%;
bottom: 18rpx;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10rpx;
z-index: 16;
pointer-events: none;
}
.race-panel-pager__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
box-shadow: 0 0 0 2rpx rgba(255, 255, 255, 0.08);
}
.race-panel-pager__dot--active {
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 0 0 4rpx rgba(255, 255, 255, 0.12);
}
.race-panel__grid {
position: relative;
z-index: 2;
@@ -678,6 +791,16 @@
font-weight: 400;
}
.race-panel__metric-value--telemetry {
font-size: 46rpx;
font-weight: 600;
}
.race-panel__metric-value--telemetry-secondary {
font-size: 42rpx;
font-weight: 500;
}
.race-panel__metric-unit {
line-height: 1;
margin-left: 6rpx;
@@ -694,6 +817,11 @@
font-weight: 500;
}
.race-panel__metric-unit--telemetry {
font-size: 18rpx;
font-weight: 600;
}
.race-panel__progress {
max-width: 100%;
box-sizing: border-box;
@@ -703,6 +831,31 @@
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16);
}
.race-panel__zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
max-width: calc(100% - 12rpx);
text-align: center;
}
.race-panel__zone-name {
font-size: 32rpx;
line-height: 1.08;
font-weight: 700;
color: #ffffff;
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.14);
}
.race-panel__zone-range {
font-size: 20rpx;
line-height: 1;
color: rgba(255, 255, 255, 0.86);
letter-spacing: 1rpx;
}
.race-panel__tag {
position: absolute;
z-index: 3;
@@ -890,40 +1043,95 @@
.debug-modal__header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 20rpx;
padding: 28rpx 28rpx 20rpx;
justify-content: space-between;
gap: 24rpx;
padding: 22rpx 28rpx 18rpx;
border-bottom: 1rpx solid rgba(22, 48, 32, 0.08);
}
.debug-modal__eyebrow {
font-size: 20rpx;
letter-spacing: 4rpx;
color: #5f7a65;
.debug-modal__header-main {
flex: 1;
min-width: 0;
}
.debug-modal__title {
margin-top: 8rpx;
font-size: 34rpx;
font-weight: 600;
color: #163020;
.debug-modal__header-actions {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.debug-modal__eyebrow {
font-size: 22rpx;
font-weight: 800;
letter-spacing: 4rpx;
color: #5f7a65;
line-height: 1;
flex-shrink: 0;
}
.debug-modal__build {
margin-top: 10rpx;
display: inline-flex;
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(22, 48, 32, 0.08);
color: #45624b;
font-size: 20rpx;
line-height: 1;
letter-spacing: 1rpx;
flex-shrink: 0;
}
.debug-modal__close {
flex-shrink: 0;
min-width: 108rpx;
padding: 14rpx 22rpx;
border-radius: 999rpx;
background: #163020;
color: #f7fbf2;
font-size: 24rpx;
text-align: center;
}
.debug-modal__content {
max-height: calc(72vh - 108rpx);
padding: 12rpx 28rpx 30rpx;
padding: 12rpx 24rpx 30rpx;
box-sizing: border-box;
}
.debug-section {
margin-top: 16rpx;
padding: 18rpx 20rpx 22rpx;
border-radius: 24rpx;
background: rgba(242, 247, 239, 0.98);
box-shadow: inset 0 0 0 1rpx rgba(22, 48, 32, 0.05);
}
.debug-section:first-child {
margin-top: 0;
}
.debug-section__header {
margin-bottom: 12rpx;
}
.debug-section__title {
font-size: 24rpx;
line-height: 1.2;
font-weight: 800;
letter-spacing: 2rpx;
color: #163020;
text-transform: uppercase;
}
.debug-section__desc {
margin-top: 6rpx;
font-size: 20rpx;
line-height: 1.45;
color: #6a826f;
}
.info-panel__row {
display: flex;
align-items: flex-start;
@@ -972,6 +1180,10 @@
gap: 14rpx;
margin-top: 18rpx;
}
.debug-section .control-row:last-child {
margin-bottom: 0;
}
.control-row--triple .control-chip {
font-size: 23rpx;
}
@@ -1041,6 +1253,10 @@
box-sizing: border-box;
}
.race-panel__metric-group--panel {
max-width: calc(100% - 8rpx);
}

View File

@@ -1,6 +1,7 @@
import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
import {
mergeGameHapticsConfig,
mergeGameUiEffectsConfig,
@@ -45,6 +46,7 @@ export interface RemoteMapConfig {
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
telemetryConfig: TelemetryConfig
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
uiEffectsConfig: GameUiEffectsConfig
@@ -59,6 +61,7 @@ interface ParsedGameConfig {
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
telemetryConfig: TelemetryConfig
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
uiEffectsConfig: GameUiEffectsConfig
@@ -206,6 +209,40 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
}
function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return mergeTelemetryConfig()
}
const rawHeartRate = getFirstDefined(normalized, ['heartrate', 'heart_rate'])
const normalizedHeartRate = normalizeObjectRecord(rawHeartRate)
const ageRaw = getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage']) !== undefined
? getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage'])
: getFirstDefined(normalized, ['age', 'userage', 'heartrateage', 'hrage'])
const restingHeartRateRaw = getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
!== undefined
? getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
: getFirstDefined(normalized, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
const userWeightRaw = getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
!== undefined
? getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
: getFirstDefined(normalized, ['userweightkg', 'weightkg', 'weight'])
const telemetryOverrides: Partial<TelemetryConfig> = {}
if (ageRaw !== undefined) {
telemetryOverrides.heartRateAge = Number(ageRaw)
}
if (restingHeartRateRaw !== undefined) {
telemetryOverrides.restingHeartRateBpm = Number(restingHeartRateRaw)
}
if (userWeightRaw !== undefined) {
telemetryOverrides.userWeightKg = Number(userWeightRaw)
}
return mergeTelemetryConfig(telemetryOverrides)
}
function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
@@ -622,6 +659,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
}
}
const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio
const rawTelemetry = rawGame && rawGame.telemetry !== undefined ? rawGame.telemetry : parsed.telemetry
const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
? rawGame.uiEffects
@@ -668,6 +706,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
true,
),
telemetryConfig: parseTelemetryConfig(rawTelemetry),
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
hapticsConfig: parseHapticsConfig(rawHaptics),
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
@@ -716,6 +755,18 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
5,
),
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
telemetryConfig: parseTelemetryConfig({
heartRate: {
age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
restingHeartRateBpm: config.restingheartratebpm !== undefined
? config.restingheartratebpm
: config.restingheartrate !== undefined
? config.restingheartrate
: config.telemetryrestingheartratebpm !== undefined
? config.telemetryrestingheartratebpm
: config.telemetryrestingheartrate,
},
}),
audioConfig: parseAudioConfig({
enabled: config.audioenabled,
masterVolume: config.audiomastervolume,
@@ -979,6 +1030,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
punchPolicy: gameConfig.punchPolicy,
punchRadiusMeters: gameConfig.punchRadiusMeters,
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
telemetryConfig: gameConfig.telemetryConfig,
audioConfig: gameConfig.audioConfig,
hapticsConfig: gameConfig.hapticsConfig,
uiEffectsConfig: gameConfig.uiEffectsConfig,