diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 562fd81..a7e0773 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/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 = [ '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) => 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 { + const telemetryPresentation = this.telemetryRuntime.getPresentation() const patch: Partial = { 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 = { - 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 diff --git a/miniprogram/engine/sensor/heartRateController.ts b/miniprogram/engine/sensor/heartRateController.ts new file mode 100644 index 0000000..495379c --- /dev/null +++ b/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 + } + } +} diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts index b34741e..48316e2 100644 --- a/miniprogram/game/rules/classicSequentialRule.ts +++ b/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', diff --git a/miniprogram/game/telemetry/telemetryConfig.ts b/miniprogram/game/telemetry/telemetryConfig.ts new file mode 100644 index 0000000..dc5ea3a --- /dev/null +++ b/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 = { + 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 | 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 +} diff --git a/miniprogram/game/telemetry/telemetryEvent.ts b/miniprogram/game/telemetry/telemetryEvent.ts new file mode 100644 index 0000000..beafeda --- /dev/null +++ b/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 } diff --git a/miniprogram/game/telemetry/telemetryPresentation.ts b/miniprogram/game/telemetry/telemetryPresentation.ts new file mode 100644 index 0000000..b0c3153 --- /dev/null +++ b/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: '', +} diff --git a/miniprogram/game/telemetry/telemetryRuntime.ts b/miniprogram/game/telemetry/telemetryRuntime.ts new file mode 100644 index 0000000..345e8d0 --- /dev/null +++ b/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 | 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, + } + } + } +} diff --git a/miniprogram/game/telemetry/telemetryState.ts b/miniprogram/game/telemetry/telemetryState.ts new file mode 100644 index 0000000..afd29a9 --- /dev/null +++ b/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, +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 0947788..3030140 100644 --- a/miniprogram/pages/map/map.ts +++ b/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))) }, diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index c7f86e0..2df01f4 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -1,4 +1,8 @@ + 调试 - - 目标 - 里程 - 点距 - 速度 + + + + 目标 + 里程 + 点距 + 速度 - - - - - - - + + + + + + + - - - - {{punchButtonText}} - - - - {{panelTimerText}} - - - - {{panelMileageText}} - - - + + + + {{punchButtonText}} + + + + {{panelTimerText}} + + + + {{panelMileageText}} + + + + + + + + + {{panelDistanceValueText}} + {{panelDistanceUnitText}} + + + + {{panelProgressText}} + + + + {{panelSpeedValueText}} + km/h + - - - {{panelDistanceValueText}} - m + + + + 心率 + 卡路里 + 均速 + 精度 + + + + + + + + + + + + + {{panelHeartRateValueText}} + {{panelHeartRateUnitText}} + + + + {{panelTimerText}} + + + + {{panelCaloriesValueText}} + {{panelCaloriesUnitText}} + + + + + {{panelAverageSpeedValueText}} + {{panelAverageSpeedUnitText}} + + + + + {{panelHeartRateZoneNameText}} + {{panelHeartRateZoneRangeText}} + + + + + {{panelAccuracyValueText}} + {{panelAccuracyUnitText}} + + - - {{panelProgressText}} - - - - {{panelSpeedValueText}} - km/h - - - + + + + + - + DEBUG PANEL - 地图调试信息 + {{buildVersion}} + + + 关闭 - 关闭 - - Build - {{buildVersion}} - - - Config - {{configStatusText}} - - - Heading Mode - {{orientationModeText}} - - - Sensor Heading - {{sensorHeadingText}} - - - North Ref - {{northReferenceText}} - - - Zoom - {{zoom}} - - - Rotation - {{rotationText}} - - - Status - {{statusText}} - - - GPS - {{gpsTrackingText}} - - - GPS Coord - {{gpsCoordText}} - - - Renderer - {{renderMode}} - - - Projection - {{projectionMode}} - - - Auto Source - {{autoRotateSourceText}} - - - Calibration - {{autoRotateCalibrationText}} - - - Tile URL - {{tileSource}} - - - Center Tile - {{centerText}} - - - Tile Size - {{tileSizePx}}px - - - Visible Tiles - {{visibleTileCount}} - - - Ready Tiles - {{readyTileCount}} - - - Memory Tiles - {{memoryTileCount}} - - - Disk Tiles - {{diskTileCount}} - - - Cache Hit - {{cacheHitRateText}} - - - Disk Hits - {{diskHitCount}} - - - Net Fetches - {{networkFetchCount}} + + + Session + 当前局状态与主流程控制 + + + Game + {{gameSessionStatus}} + + + Progress + {{panelProgressText}} + + + Timer + {{panelTimerText}} + + + Punch Hint + {{punchHintText}} + + + 回到首屏 + 旋转归零 + - - 回到首屏 - 旋转归零 + + + Sensors + 定位、罗盘与心率带连接状态 + + + GPS + {{gpsTrackingText}} + + + GPS Coord + {{gpsCoordText}} + + + Heart Rate + {{heartRateStatusText}} + + + HR Device + {{heartRateDeviceText}} + + + Heading Mode + {{orientationModeText}} + + + Sensor Heading + {{sensorHeadingText}} + + + North Ref + {{northReferenceText}} + + + {{gpsTracking ? '停止定位' : '开启定位'}} + {{heartRateConnected ? '心率带已连接' : '连接心率带'}} + + + 断开心率带 + {{northReferenceButtonText}} + - - {{gpsTracking ? '停止定位' : '开启定位'}} - {{osmReferenceText}} + + + + Telemetry + HUD 派生数据与心率颜色测试 + + + HR + {{panelHeartRateValueText}} {{panelHeartRateUnitText}} + + + HR Zone + {{panelHeartRateZoneNameText}} {{panelHeartRateZoneRangeText}} + + + Calories + {{panelCaloriesValueText}} {{panelCaloriesUnitText}} + + + Speed + {{panelSpeedValueText}} km/h + + + Avg Speed + {{panelAverageSpeedValueText}} {{panelAverageSpeedUnitText}} + + + Target Dist + {{panelDistanceValueText}} {{panelDistanceUnitText}} + + + Accuracy + {{panelAccuracyValueText}} {{panelAccuracyUnitText}} + + + + + 绿 + + + + + + + + 清除 + - - 手动 - 北朝上 - 朝向朝上 + + + + Rendering + 地图渲染、视角与参考图层 + + + Renderer + {{renderMode}} + + + Projection + {{projectionMode}} + + + Zoom + {{zoom}} + + + Rotation + {{rotationText}} + + + Auto Source + {{autoRotateSourceText}} + + + Calibration + {{autoRotateCalibrationText}} + + + {{osmReferenceText}} + 旋转 +15° + + + 手动 + 北朝上 + 朝向朝上 + + + 按当前方向校准 + - - {{northReferenceButtonText}} - - - 按当前方向校准 - - - 旋转 +15° + + + + Diagnostics + 配置、瓦片缓存与运行状态 + + + Config + {{configStatusText}} + + + Status + {{statusText}} + + + Tile URL + {{tileSource}} + + + Center Tile + {{centerText}} + + + Tile Size + {{tileSizePx}}px + + + Visible Tiles + {{visibleTileCount}} + + + Ready Tiles + {{readyTileCount}} + + + Memory Tiles + {{memoryTileCount}} + + + Disk Tiles + {{diskTileCount}} + + + Cache Hit + {{cacheHitRateText}} + + + Disk Hits + {{diskHitCount}} + + + Net Fetches + {{networkFetchCount}} + diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 80bf8da..b9dbc19 100644 --- a/miniprogram/pages/map/map.wxss +++ b/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__eyebrow { - font-size: 20rpx; - letter-spacing: 4rpx; - color: #5f7a65; +.debug-modal__header-main { + flex: 1; + min-width: 0; } -.debug-modal__title { - margin-top: 8rpx; - font-size: 34rpx; - font-weight: 600; - color: #163020; +.debug-modal__header-actions { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.debug-modal__eyebrow { + font-size: 22rpx; + font-weight: 800; + letter-spacing: 4rpx; + color: #5f7a65; + line-height: 1; + flex-shrink: 0; +} + +.debug-modal__build { + margin-top: 10rpx; + display: inline-flex; + padding: 8rpx 14rpx; + border-radius: 999rpx; + background: rgba(22, 48, 32, 0.08); + color: #45624b; + font-size: 20rpx; + line-height: 1; + letter-spacing: 1rpx; + flex-shrink: 0; } .debug-modal__close { flex-shrink: 0; + min-width: 108rpx; padding: 14rpx 22rpx; border-radius: 999rpx; background: #163020; color: #f7fbf2; font-size: 24rpx; + text-align: center; } .debug-modal__content { max-height: calc(72vh - 108rpx); - padding: 12rpx 28rpx 30rpx; + padding: 12rpx 24rpx 30rpx; box-sizing: border-box; } +.debug-section { + margin-top: 16rpx; + padding: 18rpx 20rpx 22rpx; + border-radius: 24rpx; + background: rgba(242, 247, 239, 0.98); + box-shadow: inset 0 0 0 1rpx rgba(22, 48, 32, 0.05); +} + +.debug-section:first-child { + margin-top: 0; +} + +.debug-section__header { + margin-bottom: 12rpx; +} + +.debug-section__title { + font-size: 24rpx; + line-height: 1.2; + font-weight: 800; + letter-spacing: 2rpx; + color: #163020; + text-transform: uppercase; +} + +.debug-section__desc { + margin-top: 6rpx; + font-size: 20rpx; + line-height: 1.45; + color: #6a826f; +} + .info-panel__row { display: flex; align-items: flex-start; @@ -972,6 +1180,10 @@ gap: 14rpx; margin-top: 18rpx; } + +.debug-section .control-row:last-child { + margin-bottom: 0; +} .control-row--triple .control-chip { font-size: 23rpx; } @@ -1041,6 +1253,10 @@ box-sizing: border-box; } +.race-panel__metric-group--panel { + max-width: calc(100% - 8rpx); +} + diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index ebddf7c..c7d5bbc 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/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 = {} + 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 { 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