فهرست منبع

Refine telemetry-driven HUD and fitness feedback

zhangyan 2 هفته پیش
والد
کامیت
a117a25824

+ 240 - 27
miniprogram/engine/map/mapEngine.ts

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

+ 421 - 0
miniprogram/engine/sensor/heartRateController.ts

@@ -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
+    }
+  }
+}

+ 7 - 4
miniprogram/game/rules/classicSequentialRule.ts

@@ -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',

+ 141 - 0
miniprogram/game/telemetry/telemetryConfig.ts

@@ -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
+}

+ 9 - 0
miniprogram/game/telemetry/telemetryEvent.ts

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

+ 37 - 0
miniprogram/game/telemetry/telemetryPresentation.ts

@@ -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: '',
+}

+ 473 - 0
miniprogram/game/telemetry/telemetryRuntime.ts

@@ -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,
+      }
+    }
+  }
+}

+ 40 - 0
miniprogram/game/telemetry/telemetryState.ts

@@ -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,
+}

+ 100 - 7
miniprogram/pages/map/map.ts

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

+ 321 - 159
miniprogram/pages/map/map.wxml

@@ -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,185 +118,343 @@
     <cover-view class="screen-button-layer__text">调试</cover-view>
   </cover-view>
 
-  <view class="race-panel">
-    <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__action-button"><!-- status only -->
-          <view class="race-panel__action-button-text">{{punchButtonText}}</view>
-        </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__mileage-wrap">
-          <text class="race-panel__mileage">{{panelMileageText}}</text>
-          <view class="race-panel__chevrons">
-            <view class="race-panel__chevron"></view>
-            <view class="race-panel__chevron race-panel__chevron--offset"></view>
+  <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>
+        <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__action-button"><!-- status only -->
+              <view class="race-panel__action-button-text">{{punchButtonText}}</view>
+            </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__mileage-wrap">
+              <text class="race-panel__mileage">{{panelMileageText}}</text>
+              <view class="race-panel__chevrons">
+                <view class="race-panel__chevron"></view>
+                <view class="race-panel__chevron race-panel__chevron--offset"></view>
+              </view>
+            </view>
+          </view>
+          <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">{{panelDistanceUnitText}}</text>
+            </view>
+          </view>
+          <view class="race-panel__cell race-panel__cell--progress">
+            <text class="race-panel__progress">{{panelProgressText}}</text>
+          </view>
+          <view class="race-panel__cell race-panel__cell--speed">
+            <view class="race-panel__metric-group race-panel__metric-group--right">
+              <text class="race-panel__metric-value race-panel__metric-value--speed">{{panelSpeedValueText}}</text>
+              <text class="race-panel__metric-unit race-panel__metric-unit--speed">km/h</text>
+            </view>
           </view>
         </view>
       </view>
-      <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>
-        </view>
-      </view>
-      <view class="race-panel__cell race-panel__cell--progress">
-        <text class="race-panel__progress">{{panelProgressText}}</text>
-      </view>
-      <view class="race-panel__cell race-panel__cell--speed">
-        <view class="race-panel__metric-group race-panel__metric-group--right">
-          <text class="race-panel__metric-value race-panel__metric-value--speed">{{panelSpeedValueText}}</text>
-          <text class="race-panel__metric-unit race-panel__metric-unit--speed">km/h</text>
+    </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>
-    </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 class="debug-modal__close" bindtap="handleCloseDebugPanel">关闭</view>
       </view>
 
       <scroll-view class="debug-modal__content" scroll-y enhanced show-scrollbar="true">
-        <view class="info-panel__row">
-          <text class="info-panel__label">Build</text>
-          <text class="info-panel__value">{{buildVersion}}</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>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Heading Mode</text>
-          <text class="info-panel__value">{{orientationModeText}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Sensor Heading</text>
-          <text class="info-panel__value">{{sensorHeadingText}}</text>
-        </view>
-        <view class="info-panel__row info-panel__row--stack">
-          <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>
-        <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>
-        </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">Renderer</text>
-          <text class="info-panel__value">{{renderMode}}</text>
-        </view>
-        <view class="info-panel__row info-panel__row--stack">
-          <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">Auto Source</text>
-          <text class="info-panel__value">{{autoRotateSourceText}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Calibration</text>
-          <text class="info-panel__value">{{autoRotateCalibrationText}}</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>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Center Tile</text>
-          <text class="info-panel__value">{{centerText}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Tile Size</text>
-          <text class="info-panel__value">{{tileSizePx}}px</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Visible Tiles</text>
-          <text class="info-panel__value">{{visibleTileCount}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Ready Tiles</text>
-          <text class="info-panel__value">{{readyTileCount}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Memory Tiles</text>
-          <text class="info-panel__value">{{memoryTileCount}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Disk Tiles</text>
-          <text class="info-panel__value">{{diskTileCount}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Cache Hit</text>
-          <text class="info-panel__value">{{cacheHitRateText}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Disk Hits</text>
-          <text class="info-panel__value">{{diskHitCount}}</text>
-        </view>
-        <view class="info-panel__row">
-          <text class="info-panel__label">Net Fetches</text>
-          <text class="info-panel__value">{{networkFetchCount}}</text>
+        <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">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">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="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 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>
+            <text class="info-panel__value">{{orientationModeText}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Sensor Heading</text>
+            <text class="info-panel__value">{{sensorHeadingText}}</text>
+          </view>
+          <view class="info-panel__row info-panel__row--stack">
+            <text class="info-panel__label">North Ref</text>
+            <text class="info-panel__value">{{northReferenceText}}</text>
+          </view>
+          <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="control-row">
-          <view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</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">HR</text>
+            <text class="info-panel__value">{{panelHeartRateValueText}} {{panelHeartRateUnitText}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">HR Zone</text>
+            <text class="info-panel__value">{{panelHeartRateZoneNameText}} {{panelHeartRateZoneRangeText}}</text>
+          </view>
+          <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="control-row" wx:if="{{orientationMode === 'heading-up'}}">
-          <view class="control-chip" bindtap="handleAutoRotateCalibrate">按当前方向校准</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>
+            <text class="info-panel__value">{{renderMode}}</text>
+          </view>
+          <view class="info-panel__row info-panel__row--stack">
+            <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>
+          </view>
+          <view class="info-panel__row">
+            <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="control-row" wx:if="{{orientationMode === 'manual'}}">
-          <view class="control-chip" bindtap="handleRotateStep">旋转 +15°</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>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Center Tile</text>
+            <text class="info-panel__value">{{centerText}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Tile Size</text>
+            <text class="info-panel__value">{{tileSizePx}}px</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Visible Tiles</text>
+            <text class="info-panel__value">{{visibleTileCount}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Ready Tiles</text>
+            <text class="info-panel__value">{{readyTileCount}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Memory Tiles</text>
+            <text class="info-panel__value">{{memoryTileCount}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Disk Tiles</text>
+            <text class="info-panel__value">{{diskTileCount}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Cache Hit</text>
+            <text class="info-panel__value">{{cacheHitRateText}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Disk Hits</text>
+            <text class="info-panel__value">{{diskHitCount}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Net Fetches</text>
+            <text class="info-panel__value">{{networkFetchCount}}</text>
+          </view>
         </view>
       </scroll-view>
     </view>

+ 229 - 13
miniprogram/pages/map/map.wxss

@@ -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__header-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.debug-modal__header-actions {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+}
+
 .debug-modal__eyebrow {
-  font-size: 20rpx;
+  font-size: 22rpx;
+  font-weight: 800;
   letter-spacing: 4rpx;
   color: #5f7a65;
+  line-height: 1;
+  flex-shrink: 0;
 }
 
-.debug-modal__title {
-  margin-top: 8rpx;
-  font-size: 34rpx;
-  font-weight: 600;
-  color: #163020;
+.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);
+}
+
 
 
 

+ 52 - 0
miniprogram/utils/remoteMapConfig.ts

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