Refine telemetry-driven HUD and fitness feedback
This commit is contained in:
@@ -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
miniprogram/engine/sensor/heartRateController.ts
Normal file
421
miniprogram/engine/sensor/heartRateController.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
export interface HeartRateControllerCallbacks {
|
||||
onHeartRate: (bpm: number) => void
|
||||
onStatus: (message: string) => void
|
||||
onError: (message: string) => void
|
||||
onConnectionChange: (connected: boolean, deviceName: string | null) => void
|
||||
}
|
||||
|
||||
type BluetoothDeviceLike = {
|
||||
deviceId?: string
|
||||
name?: string
|
||||
localName?: string
|
||||
advertisServiceUUIDs?: string[]
|
||||
}
|
||||
|
||||
const HEART_RATE_SERVICE_UUID = '180D'
|
||||
const HEART_RATE_MEASUREMENT_UUID = '2A37'
|
||||
const DISCOVERY_TIMEOUT_MS = 12000
|
||||
|
||||
function normalizeUuid(uuid: string | undefined | null): string {
|
||||
return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase()
|
||||
}
|
||||
|
||||
function matchesShortUuid(uuid: string | undefined | null, shortUuid: string): boolean {
|
||||
const normalized = normalizeUuid(uuid)
|
||||
const normalizedShort = normalizeUuid(shortUuid)
|
||||
if (!normalized || !normalizedShort) {
|
||||
return false
|
||||
}
|
||||
|
||||
return normalized === normalizedShort
|
||||
|| normalized.indexOf(`0000${normalizedShort}00001000800000805F9B34FB`) === 0
|
||||
|| normalized.endsWith(normalizedShort)
|
||||
}
|
||||
|
||||
function getDeviceDisplayName(device: BluetoothDeviceLike | null | undefined): string {
|
||||
if (!device) {
|
||||
return '心率带'
|
||||
}
|
||||
|
||||
return device.name || device.localName || '未命名心率带'
|
||||
}
|
||||
|
||||
function isHeartRateDevice(device: BluetoothDeviceLike): boolean {
|
||||
const serviceIds = Array.isArray(device.advertisServiceUUIDs) ? device.advertisServiceUUIDs : []
|
||||
if (serviceIds.some((uuid) => matchesShortUuid(uuid, HEART_RATE_SERVICE_UUID))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const name = `${device.name || ''} ${device.localName || ''}`.toUpperCase()
|
||||
return name.indexOf('HR') !== -1
|
||||
|| name.indexOf('HEART') !== -1
|
||||
|| name.indexOf('POLAR') !== -1
|
||||
|| name.indexOf('GARMIN') !== -1
|
||||
|| name.indexOf('COOSPO') !== -1
|
||||
}
|
||||
|
||||
function parseHeartRateMeasurement(buffer: ArrayBuffer): number | null {
|
||||
if (!buffer || buffer.byteLength < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const view = new DataView(buffer)
|
||||
const flags = view.getUint8(0)
|
||||
const isUint16 = (flags & 0x01) === 0x01
|
||||
|
||||
if (isUint16) {
|
||||
if (buffer.byteLength < 3) {
|
||||
return null
|
||||
}
|
||||
return view.getUint16(1, true)
|
||||
}
|
||||
|
||||
return view.getUint8(1)
|
||||
}
|
||||
|
||||
export class HeartRateController {
|
||||
callbacks: HeartRateControllerCallbacks
|
||||
scanning: boolean
|
||||
connecting: boolean
|
||||
connected: boolean
|
||||
currentDeviceId: string | null
|
||||
currentDeviceName: string | null
|
||||
measurementServiceId: string | null
|
||||
measurementCharacteristicId: string | null
|
||||
discoveryTimer: number
|
||||
deviceFoundHandler: ((result: any) => void) | null
|
||||
characteristicHandler: ((result: any) => void) | null
|
||||
connectionStateHandler: ((result: any) => void) | null
|
||||
|
||||
constructor(callbacks: HeartRateControllerCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.scanning = false
|
||||
this.connecting = false
|
||||
this.connected = false
|
||||
this.currentDeviceId = null
|
||||
this.currentDeviceName = null
|
||||
this.measurementServiceId = null
|
||||
this.measurementCharacteristicId = null
|
||||
this.discoveryTimer = 0
|
||||
this.deviceFoundHandler = null
|
||||
this.characteristicHandler = null
|
||||
this.connectionStateHandler = null
|
||||
}
|
||||
|
||||
startScanAndConnect(): void {
|
||||
if (this.connected) {
|
||||
this.callbacks.onStatus(`心率带已连接: ${this.currentDeviceName || '设备'}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.scanning || this.connecting) {
|
||||
this.callbacks.onStatus('心率带连接进行中')
|
||||
return
|
||||
}
|
||||
|
||||
const wxAny = wx as any
|
||||
wxAny.openBluetoothAdapter({
|
||||
success: () => {
|
||||
this.beginDiscovery()
|
||||
},
|
||||
fail: (error: any) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
|
||||
this.callbacks.onError(`蓝牙不可用: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.clearDiscoveryTimer()
|
||||
this.stopDiscovery()
|
||||
|
||||
const deviceId = this.currentDeviceId
|
||||
this.connecting = false
|
||||
|
||||
if (!deviceId) {
|
||||
this.clearConnectionState()
|
||||
this.callbacks.onStatus('心率带未连接')
|
||||
return
|
||||
}
|
||||
|
||||
const wxAny = wx as any
|
||||
wxAny.closeBLEConnection({
|
||||
deviceId,
|
||||
complete: () => {
|
||||
this.clearConnectionState()
|
||||
this.callbacks.onStatus('心率带已断开')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clearDiscoveryTimer()
|
||||
this.stopDiscovery()
|
||||
this.detachListeners()
|
||||
|
||||
const deviceId = this.currentDeviceId
|
||||
if (deviceId) {
|
||||
const wxAny = wx as any
|
||||
wxAny.closeBLEConnection({
|
||||
deviceId,
|
||||
complete: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
const wxAny = wx as any
|
||||
if (typeof wxAny.closeBluetoothAdapter === 'function') {
|
||||
wxAny.closeBluetoothAdapter({
|
||||
complete: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
this.clearConnectionState()
|
||||
}
|
||||
|
||||
beginDiscovery(): void {
|
||||
this.bindListeners()
|
||||
const wxAny = wx as any
|
||||
wxAny.startBluetoothDevicesDiscovery({
|
||||
allowDuplicatesKey: false,
|
||||
services: [HEART_RATE_SERVICE_UUID],
|
||||
success: () => {
|
||||
this.scanning = true
|
||||
this.callbacks.onStatus('正在扫描心率带')
|
||||
this.clearDiscoveryTimer()
|
||||
this.discoveryTimer = setTimeout(() => {
|
||||
this.discoveryTimer = 0
|
||||
if (!this.scanning || this.connected || this.connecting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.stopDiscovery()
|
||||
this.callbacks.onError('未发现可连接的心率带')
|
||||
}, DISCOVERY_TIMEOUT_MS) as unknown as number
|
||||
},
|
||||
fail: (error: any) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'startBluetoothDevicesDiscovery 失败'
|
||||
this.callbacks.onError(`扫描心率带失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
stopDiscovery(): void {
|
||||
this.clearDiscoveryTimer()
|
||||
|
||||
if (!this.scanning) {
|
||||
return
|
||||
}
|
||||
|
||||
this.scanning = false
|
||||
const wxAny = wx as any
|
||||
wxAny.stopBluetoothDevicesDiscovery({
|
||||
complete: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
bindListeners(): void {
|
||||
const wxAny = wx as any
|
||||
|
||||
if (!this.deviceFoundHandler) {
|
||||
this.deviceFoundHandler = (result: any) => {
|
||||
const devices = Array.isArray(result && result.devices)
|
||||
? result.devices
|
||||
: result && result.deviceId
|
||||
? [result]
|
||||
: []
|
||||
|
||||
const targetDevice = devices.find((device: BluetoothDeviceLike) => isHeartRateDevice(device))
|
||||
if (!targetDevice || !targetDevice.deviceId || !this.scanning || this.connecting || this.connected) {
|
||||
return
|
||||
}
|
||||
|
||||
this.stopDiscovery()
|
||||
this.connectToDevice(targetDevice.deviceId, getDeviceDisplayName(targetDevice))
|
||||
}
|
||||
|
||||
if (typeof wxAny.onBluetoothDeviceFound === 'function') {
|
||||
wxAny.onBluetoothDeviceFound(this.deviceFoundHandler)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.characteristicHandler) {
|
||||
this.characteristicHandler = (result: any) => {
|
||||
if (!result || !result.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.currentDeviceId && result.deviceId && result.deviceId !== this.currentDeviceId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!matchesShortUuid(result.characteristicId, HEART_RATE_MEASUREMENT_UUID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const bpm = parseHeartRateMeasurement(result.value)
|
||||
if (bpm === null || !Number.isFinite(bpm) || bpm <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onHeartRate(Math.round(bpm))
|
||||
}
|
||||
|
||||
if (typeof wxAny.onBLECharacteristicValueChange === 'function') {
|
||||
wxAny.onBLECharacteristicValueChange(this.characteristicHandler)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.connectionStateHandler) {
|
||||
this.connectionStateHandler = (result: any) => {
|
||||
if (!result || !this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.connected) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearConnectionState()
|
||||
this.callbacks.onStatus('心率带连接已断开')
|
||||
}
|
||||
|
||||
if (typeof wxAny.onBLEConnectionStateChange === 'function') {
|
||||
wxAny.onBLEConnectionStateChange(this.connectionStateHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detachListeners(): void {
|
||||
const wxAny = wx as any
|
||||
|
||||
if (this.deviceFoundHandler && typeof wxAny.offBluetoothDeviceFound === 'function') {
|
||||
wxAny.offBluetoothDeviceFound(this.deviceFoundHandler)
|
||||
}
|
||||
if (this.characteristicHandler && typeof wxAny.offBLECharacteristicValueChange === 'function') {
|
||||
wxAny.offBLECharacteristicValueChange(this.characteristicHandler)
|
||||
}
|
||||
if (this.connectionStateHandler && typeof wxAny.offBLEConnectionStateChange === 'function') {
|
||||
wxAny.offBLEConnectionStateChange(this.connectionStateHandler)
|
||||
}
|
||||
|
||||
this.deviceFoundHandler = null
|
||||
this.characteristicHandler = null
|
||||
this.connectionStateHandler = null
|
||||
}
|
||||
|
||||
connectToDevice(deviceId: string, deviceName: string): void {
|
||||
this.connecting = true
|
||||
this.currentDeviceId = deviceId
|
||||
this.currentDeviceName = deviceName
|
||||
this.callbacks.onStatus(`正在连接 ${deviceName}`)
|
||||
|
||||
const wxAny = wx as any
|
||||
wxAny.createBLEConnection({
|
||||
deviceId,
|
||||
timeout: 10000,
|
||||
success: () => {
|
||||
this.discoverMeasurementCharacteristic(deviceId, deviceName)
|
||||
},
|
||||
fail: (error: any) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败'
|
||||
this.clearConnectionState()
|
||||
this.callbacks.onError(`连接心率带失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
discoverMeasurementCharacteristic(deviceId: string, deviceName: string): void {
|
||||
const wxAny = wx as any
|
||||
wxAny.getBLEDeviceServices({
|
||||
deviceId,
|
||||
success: (serviceResult: any) => {
|
||||
const services = Array.isArray(serviceResult && serviceResult.services) ? serviceResult.services : []
|
||||
const service = services.find((item: any) => matchesShortUuid(item && item.uuid, HEART_RATE_SERVICE_UUID))
|
||||
if (!service || !service.uuid) {
|
||||
this.failConnection(deviceId, '未找到标准心率服务')
|
||||
return
|
||||
}
|
||||
|
||||
wxAny.getBLEDeviceCharacteristics({
|
||||
deviceId,
|
||||
serviceId: service.uuid,
|
||||
success: (characteristicResult: any) => {
|
||||
const characteristics = Array.isArray(characteristicResult && characteristicResult.characteristics)
|
||||
? characteristicResult.characteristics
|
||||
: []
|
||||
const characteristic = characteristics.find((item: any) => {
|
||||
const properties = item && item.properties ? item.properties : {}
|
||||
return matchesShortUuid(item && item.uuid, HEART_RATE_MEASUREMENT_UUID)
|
||||
&& (properties.notify || properties.indicate)
|
||||
})
|
||||
|
||||
if (!characteristic || !characteristic.uuid) {
|
||||
this.failConnection(deviceId, '未找到心率通知特征')
|
||||
return
|
||||
}
|
||||
|
||||
this.measurementServiceId = service.uuid
|
||||
this.measurementCharacteristicId = characteristic.uuid
|
||||
wxAny.notifyBLECharacteristicValueChange({
|
||||
state: true,
|
||||
deviceId,
|
||||
serviceId: service.uuid,
|
||||
characteristicId: characteristic.uuid,
|
||||
success: () => {
|
||||
this.connecting = false
|
||||
this.connected = true
|
||||
this.callbacks.onConnectionChange(true, deviceName)
|
||||
this.callbacks.onStatus(`心率带已连接: ${deviceName}`)
|
||||
},
|
||||
fail: (error: any) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'notifyBLECharacteristicValueChange 失败'
|
||||
this.failConnection(deviceId, `心率订阅失败: ${message}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
fail: (error: any) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceCharacteristics 失败'
|
||||
this.failConnection(deviceId, `读取心率特征失败: ${message}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
fail: (error: any) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'getBLEDeviceServices 失败'
|
||||
this.failConnection(deviceId, `读取心率服务失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
failConnection(deviceId: string, message: string): void {
|
||||
const wxAny = wx as any
|
||||
wxAny.closeBLEConnection({
|
||||
deviceId,
|
||||
complete: () => {
|
||||
this.clearConnectionState()
|
||||
this.callbacks.onError(message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
clearConnectionState(): void {
|
||||
const wasConnected = this.connected
|
||||
this.scanning = false
|
||||
this.connecting = false
|
||||
this.connected = false
|
||||
this.currentDeviceId = null
|
||||
this.measurementServiceId = null
|
||||
this.measurementCharacteristicId = null
|
||||
const previousDeviceName = this.currentDeviceName
|
||||
this.currentDeviceName = null
|
||||
if (wasConnected || previousDeviceName) {
|
||||
this.callbacks.onConnectionChange(false, null)
|
||||
}
|
||||
}
|
||||
|
||||
clearDiscoveryTimer(): void {
|
||||
if (this.discoveryTimer) {
|
||||
clearTimeout(this.discoveryTimer)
|
||||
this.discoveryTimer = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
miniprogram/game/telemetry/telemetryConfig.ts
Normal file
141
miniprogram/game/telemetry/telemetryConfig.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export interface TelemetryConfig {
|
||||
heartRateAge: number
|
||||
restingHeartRateBpm: number
|
||||
userWeightKg: number
|
||||
}
|
||||
|
||||
export type HeartRateTone = 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
|
||||
|
||||
type HeartRateToneMeta = {
|
||||
label: string
|
||||
heartRateRangeText: string
|
||||
speedRangeText: string
|
||||
}
|
||||
|
||||
const HEART_RATE_TONE_META: Record<HeartRateTone, HeartRateToneMeta> = {
|
||||
blue: {
|
||||
label: '激活放松',
|
||||
heartRateRangeText: '<=39%',
|
||||
speedRangeText: '<3.2 km/h',
|
||||
},
|
||||
purple: {
|
||||
label: '动态热身',
|
||||
heartRateRangeText: '40~54%',
|
||||
speedRangeText: '3.2~4.0 km/h',
|
||||
},
|
||||
green: {
|
||||
label: '脂肪燃烧',
|
||||
heartRateRangeText: '55~69%',
|
||||
speedRangeText: '4.1~5.5 km/h',
|
||||
},
|
||||
yellow: {
|
||||
label: '糖分消耗',
|
||||
heartRateRangeText: '70~79%',
|
||||
speedRangeText: '5.6~7.1 km/h',
|
||||
},
|
||||
orange: {
|
||||
label: '心肺训练',
|
||||
heartRateRangeText: '80~89%',
|
||||
speedRangeText: '7.2~8.8 km/h',
|
||||
},
|
||||
red: {
|
||||
label: '峰值锻炼',
|
||||
heartRateRangeText: '>=90%',
|
||||
speedRangeText: '>=8.9 km/h',
|
||||
},
|
||||
}
|
||||
|
||||
export function clampTelemetryAge(age: number): number {
|
||||
if (!Number.isFinite(age)) {
|
||||
return 30
|
||||
}
|
||||
|
||||
return Math.max(10, Math.min(85, Math.round(age)))
|
||||
}
|
||||
|
||||
export function estimateRestingHeartRateBpm(age: number): number {
|
||||
const safeAge = clampTelemetryAge(age)
|
||||
const estimated = 68 + (safeAge - 30) * 0.12
|
||||
return Math.max(56, Math.min(76, Math.round(estimated)))
|
||||
}
|
||||
|
||||
export function normalizeRestingHeartRateBpm(restingHeartRateBpm: number, age: number): number {
|
||||
if (!Number.isFinite(restingHeartRateBpm) || restingHeartRateBpm <= 0) {
|
||||
return estimateRestingHeartRateBpm(age)
|
||||
}
|
||||
|
||||
return Math.max(40, Math.min(95, Math.round(restingHeartRateBpm)))
|
||||
}
|
||||
|
||||
export function normalizeUserWeightKg(userWeightKg: number): number {
|
||||
if (!Number.isFinite(userWeightKg) || userWeightKg <= 0) {
|
||||
return 65
|
||||
}
|
||||
|
||||
return Math.max(35, Math.min(180, Math.round(userWeightKg)))
|
||||
}
|
||||
|
||||
export const DEFAULT_TELEMETRY_CONFIG: TelemetryConfig = {
|
||||
heartRateAge: 30,
|
||||
restingHeartRateBpm: estimateRestingHeartRateBpm(30),
|
||||
userWeightKg: 65,
|
||||
}
|
||||
|
||||
export function mergeTelemetryConfig(overrides?: Partial<TelemetryConfig> | null): TelemetryConfig {
|
||||
const heartRateAge = overrides && overrides.heartRateAge !== undefined
|
||||
? clampTelemetryAge(overrides.heartRateAge)
|
||||
: DEFAULT_TELEMETRY_CONFIG.heartRateAge
|
||||
|
||||
const restingHeartRateBpm = overrides && overrides.restingHeartRateBpm !== undefined
|
||||
? normalizeRestingHeartRateBpm(overrides.restingHeartRateBpm, heartRateAge)
|
||||
: estimateRestingHeartRateBpm(heartRateAge)
|
||||
const userWeightKg = overrides && overrides.userWeightKg !== undefined
|
||||
? normalizeUserWeightKg(overrides.userWeightKg)
|
||||
: DEFAULT_TELEMETRY_CONFIG.userWeightKg
|
||||
|
||||
return {
|
||||
heartRateAge,
|
||||
restingHeartRateBpm,
|
||||
userWeightKg,
|
||||
}
|
||||
}
|
||||
|
||||
export function getHeartRateToneSampleBpm(tone: HeartRateTone, config: TelemetryConfig): number {
|
||||
const maxHeartRate = Math.max(120, 220 - config.heartRateAge)
|
||||
const restingHeartRate = Math.min(maxHeartRate - 15, config.restingHeartRateBpm)
|
||||
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
|
||||
|
||||
if (tone === 'blue') {
|
||||
return Math.round(restingHeartRate + reserve * 0.3)
|
||||
}
|
||||
|
||||
if (tone === 'purple') {
|
||||
return Math.round(restingHeartRate + reserve * 0.47)
|
||||
}
|
||||
|
||||
if (tone === 'green') {
|
||||
return Math.round(restingHeartRate + reserve * 0.62)
|
||||
}
|
||||
|
||||
if (tone === 'yellow') {
|
||||
return Math.round(restingHeartRate + reserve * 0.745)
|
||||
}
|
||||
|
||||
if (tone === 'orange') {
|
||||
return Math.round(restingHeartRate + reserve * 0.845)
|
||||
}
|
||||
|
||||
return Math.round(restingHeartRate + reserve * 0.93)
|
||||
}
|
||||
|
||||
export function getHeartRateToneLabel(tone: HeartRateTone): string {
|
||||
return HEART_RATE_TONE_META[tone].label
|
||||
}
|
||||
|
||||
export function getHeartRateToneRangeText(tone: HeartRateTone): string {
|
||||
return HEART_RATE_TONE_META[tone].heartRateRangeText
|
||||
}
|
||||
|
||||
export function getSpeedToneRangeText(tone: HeartRateTone): string {
|
||||
return HEART_RATE_TONE_META[tone].speedRangeText
|
||||
}
|
||||
9
miniprogram/game/telemetry/telemetryEvent.ts
Normal file
9
miniprogram/game/telemetry/telemetryEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type LonLatPoint } from '../../utils/projection'
|
||||
import { type GameSessionStatus } from '../core/gameSessionState'
|
||||
|
||||
export type TelemetryEvent =
|
||||
| { type: 'reset' }
|
||||
| { type: 'session_state_updated'; at: number; status: GameSessionStatus; startedAt: number | null; endedAt: number | null }
|
||||
| { type: 'target_updated'; controlId: string | null; point: LonLatPoint | null }
|
||||
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
||||
| { type: 'heart_rate_updated'; at: number; bpm: number | null }
|
||||
37
miniprogram/game/telemetry/telemetryPresentation.ts
Normal file
37
miniprogram/game/telemetry/telemetryPresentation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface TelemetryPresentation {
|
||||
timerText: string
|
||||
mileageText: string
|
||||
distanceToTargetValueText: string
|
||||
distanceToTargetUnitText: string
|
||||
speedText: string
|
||||
heartRateTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
|
||||
heartRateZoneNameText: string
|
||||
heartRateZoneRangeText: string
|
||||
heartRateValueText: string
|
||||
heartRateUnitText: string
|
||||
caloriesValueText: string
|
||||
caloriesUnitText: string
|
||||
averageSpeedValueText: string
|
||||
averageSpeedUnitText: string
|
||||
accuracyValueText: string
|
||||
accuracyUnitText: string
|
||||
}
|
||||
|
||||
export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = {
|
||||
timerText: '00:00:00',
|
||||
mileageText: '0m',
|
||||
distanceToTargetValueText: '--',
|
||||
distanceToTargetUnitText: '',
|
||||
speedText: '0',
|
||||
heartRateTone: 'blue',
|
||||
heartRateZoneNameText: '激活放松',
|
||||
heartRateZoneRangeText: '<=39%',
|
||||
heartRateValueText: '--',
|
||||
heartRateUnitText: '',
|
||||
caloriesValueText: '0',
|
||||
caloriesUnitText: 'kcal',
|
||||
averageSpeedValueText: '0',
|
||||
averageSpeedUnitText: 'km/h',
|
||||
accuracyValueText: '--',
|
||||
accuracyUnitText: '',
|
||||
}
|
||||
473
miniprogram/game/telemetry/telemetryRuntime.ts
Normal file
473
miniprogram/game/telemetry/telemetryRuntime.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { type GameDefinition } from '../core/gameDefinition'
|
||||
import {
|
||||
DEFAULT_TELEMETRY_CONFIG,
|
||||
getHeartRateToneLabel,
|
||||
getHeartRateToneRangeText,
|
||||
getSpeedToneRangeText,
|
||||
mergeTelemetryConfig,
|
||||
type HeartRateTone,
|
||||
type TelemetryConfig,
|
||||
} from './telemetryConfig'
|
||||
import { type GameSessionState } from '../core/gameSessionState'
|
||||
import { type TelemetryEvent } from './telemetryEvent'
|
||||
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
|
||||
import { EMPTY_TELEMETRY_STATE, type TelemetryState } from './telemetryState'
|
||||
const SPEED_SMOOTHING_ALPHA = 0.35
|
||||
|
||||
function getApproxDistanceMeters(
|
||||
a: { lon: number; lat: number },
|
||||
b: { lon: number; lat: number },
|
||||
): number {
|
||||
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
|
||||
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
|
||||
const dy = (b.lat - a.lat) * 110540
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
function formatElapsedTimerText(totalMs: number): string {
|
||||
const safeMs = Math.max(0, totalMs)
|
||||
const totalSeconds = Math.floor(safeMs / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDistanceText(distanceMeters: number): string {
|
||||
if (distanceMeters >= 1000) {
|
||||
return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
|
||||
}
|
||||
|
||||
return `${Math.round(distanceMeters)}m`
|
||||
}
|
||||
|
||||
function formatTargetDistance(distanceMeters: number | null): { valueText: string; unitText: string } {
|
||||
if (distanceMeters === null) {
|
||||
return {
|
||||
valueText: '--',
|
||||
unitText: '',
|
||||
}
|
||||
}
|
||||
|
||||
return distanceMeters >= 1000
|
||||
? {
|
||||
valueText: `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}`,
|
||||
unitText: 'km',
|
||||
}
|
||||
: {
|
||||
valueText: String(Math.round(distanceMeters)),
|
||||
unitText: 'm',
|
||||
}
|
||||
}
|
||||
|
||||
function formatSpeedText(speedKmh: number | null): string {
|
||||
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.05) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
return speedKmh >= 10 ? speedKmh.toFixed(1) : speedKmh.toFixed(2)
|
||||
}
|
||||
|
||||
function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number): number {
|
||||
if (previousSpeedKmh === null || !Number.isFinite(previousSpeedKmh)) {
|
||||
return nextSpeedKmh
|
||||
}
|
||||
|
||||
return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA
|
||||
}
|
||||
|
||||
function getHeartRateTone(
|
||||
heartRateBpm: number | null,
|
||||
telemetryConfig: TelemetryConfig,
|
||||
): HeartRateTone {
|
||||
if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
|
||||
const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
|
||||
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
|
||||
const blueLimit = restingHeartRate + reserve * 0.39
|
||||
const purpleLimit = restingHeartRate + reserve * 0.54
|
||||
const greenLimit = restingHeartRate + reserve * 0.69
|
||||
const yellowLimit = restingHeartRate + reserve * 0.79
|
||||
const orangeLimit = restingHeartRate + reserve * 0.89
|
||||
|
||||
if (heartRateBpm <= blueLimit) {
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
if (heartRateBpm <= purpleLimit) {
|
||||
return 'purple'
|
||||
}
|
||||
|
||||
if (heartRateBpm <= greenLimit) {
|
||||
return 'green'
|
||||
}
|
||||
|
||||
if (heartRateBpm <= yellowLimit) {
|
||||
return 'yellow'
|
||||
}
|
||||
|
||||
if (heartRateBpm <= orangeLimit) {
|
||||
return 'orange'
|
||||
}
|
||||
|
||||
return 'red'
|
||||
}
|
||||
|
||||
function getSpeedFallbackTone(speedKmh: number | null): HeartRateTone {
|
||||
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 3.2) {
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
if (speedKmh <= 4.0) {
|
||||
return 'purple'
|
||||
}
|
||||
|
||||
if (speedKmh <= 5.5) {
|
||||
return 'green'
|
||||
}
|
||||
|
||||
if (speedKmh <= 7.1) {
|
||||
return 'yellow'
|
||||
}
|
||||
|
||||
if (speedKmh <= 8.8) {
|
||||
return 'orange'
|
||||
}
|
||||
|
||||
return 'red'
|
||||
}
|
||||
|
||||
function formatHeartRateMetric(heartRateBpm: number | null): { valueText: string; unitText: string } {
|
||||
if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
|
||||
return {
|
||||
valueText: '--',
|
||||
unitText: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valueText: String(Math.round(heartRateBpm)),
|
||||
unitText: 'bpm',
|
||||
}
|
||||
}
|
||||
|
||||
function formatCaloriesMetric(caloriesKcal: number | null): { valueText: string; unitText: string } {
|
||||
if (caloriesKcal === null || !Number.isFinite(caloriesKcal) || caloriesKcal < 0) {
|
||||
return {
|
||||
valueText: '0',
|
||||
unitText: 'kcal',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valueText: String(Math.round(caloriesKcal)),
|
||||
unitText: 'kcal',
|
||||
}
|
||||
}
|
||||
|
||||
function formatAccuracyMetric(accuracyMeters: number | null): { valueText: string; unitText: string } {
|
||||
if (accuracyMeters === null || !Number.isFinite(accuracyMeters) || accuracyMeters < 0) {
|
||||
return {
|
||||
valueText: '--',
|
||||
unitText: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valueText: String(Math.round(accuracyMeters)),
|
||||
unitText: 'm',
|
||||
}
|
||||
}
|
||||
|
||||
function estimateCaloriesKcal(
|
||||
elapsedMs: number,
|
||||
heartRateBpm: number,
|
||||
telemetryConfig: TelemetryConfig,
|
||||
): number {
|
||||
if (elapsedMs <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
|
||||
const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
|
||||
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
|
||||
const intensity = Math.max(0, Math.min(1, (heartRateBpm - restingHeartRate) / reserve))
|
||||
const met = 2 + intensity * 10
|
||||
|
||||
return met * telemetryConfig.userWeightKg * (elapsedMs / 3600000)
|
||||
}
|
||||
|
||||
function estimateCaloriesFromSpeedKcal(
|
||||
elapsedMs: number,
|
||||
speedKmh: number | null,
|
||||
telemetryConfig: TelemetryConfig,
|
||||
): number {
|
||||
if (elapsedMs <= 0 || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.5) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let met = 2
|
||||
if (speedKmh >= 8.9) {
|
||||
met = 9.8
|
||||
} else if (speedKmh >= 7.2) {
|
||||
met = 7.8
|
||||
} else if (speedKmh >= 5.6) {
|
||||
met = 6
|
||||
} else if (speedKmh >= 4.1) {
|
||||
met = 4.3
|
||||
} else if (speedKmh >= 3.2) {
|
||||
met = 3.0
|
||||
}
|
||||
|
||||
return (met * 3.5 * telemetryConfig.userWeightKg / 200) * (elapsedMs / 60000)
|
||||
}
|
||||
|
||||
function hasHeartRateSignal(state: TelemetryState): boolean {
|
||||
return state.heartRateBpm !== null
|
||||
&& Number.isFinite(state.heartRateBpm)
|
||||
&& state.heartRateBpm > 0
|
||||
}
|
||||
|
||||
function hasSpeedSignal(state: TelemetryState): boolean {
|
||||
return state.currentSpeedKmh !== null
|
||||
&& Number.isFinite(state.currentSpeedKmh)
|
||||
&& state.currentSpeedKmh >= 0.5
|
||||
}
|
||||
|
||||
function shouldTrackCalories(state: TelemetryState): boolean {
|
||||
return state.sessionStatus === 'running'
|
||||
&& state.sessionEndedAt === null
|
||||
&& (hasHeartRateSignal(state) || hasSpeedSignal(state))
|
||||
}
|
||||
|
||||
export class TelemetryRuntime {
|
||||
state: TelemetryState
|
||||
config: TelemetryConfig
|
||||
|
||||
constructor() {
|
||||
this.state = { ...EMPTY_TELEMETRY_STATE }
|
||||
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = { ...EMPTY_TELEMETRY_STATE }
|
||||
}
|
||||
|
||||
configure(config?: Partial<TelemetryConfig> | null): void {
|
||||
this.config = mergeTelemetryConfig(config)
|
||||
}
|
||||
|
||||
loadDefinition(_definition: GameDefinition): void {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
syncGameState(definition: GameDefinition | null, state: GameSessionState | null): void {
|
||||
if (!definition || !state) {
|
||||
this.dispatch({ type: 'reset' })
|
||||
return
|
||||
}
|
||||
|
||||
const targetControl = state.currentTargetControlId
|
||||
? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
|
||||
: null
|
||||
|
||||
this.dispatch({
|
||||
type: 'session_state_updated',
|
||||
at: Date.now(),
|
||||
status: state.status,
|
||||
startedAt: state.startedAt,
|
||||
endedAt: state.endedAt,
|
||||
})
|
||||
this.dispatch({
|
||||
type: 'target_updated',
|
||||
controlId: targetControl ? targetControl.id : null,
|
||||
point: targetControl ? targetControl.point : null,
|
||||
})
|
||||
}
|
||||
|
||||
dispatch(event: TelemetryEvent): void {
|
||||
if (event.type === 'reset') {
|
||||
this.reset()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'session_state_updated') {
|
||||
this.syncCalorieAccumulation(event.at)
|
||||
this.state = {
|
||||
...this.state,
|
||||
sessionStatus: event.status,
|
||||
sessionStartedAt: event.startedAt,
|
||||
sessionEndedAt: event.endedAt,
|
||||
elapsedMs: event.startedAt === null ? 0 : Math.max(0, (event.endedAt || Date.now()) - event.startedAt),
|
||||
}
|
||||
this.alignCalorieTracking(event.at)
|
||||
this.recomputeDerivedState()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'target_updated') {
|
||||
this.state = {
|
||||
...this.state,
|
||||
targetControlId: event.controlId,
|
||||
targetPoint: event.point,
|
||||
}
|
||||
this.recomputeDerivedState()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'gps_updated') {
|
||||
this.syncCalorieAccumulation(event.at)
|
||||
const nextPoint = { lon: event.lon, lat: event.lat }
|
||||
const previousPoint = this.state.lastGpsPoint
|
||||
const previousAt = this.state.lastGpsAt
|
||||
let nextDistanceMeters = this.state.distanceMeters
|
||||
let nextSpeedKmh = this.state.currentSpeedKmh
|
||||
|
||||
if (previousPoint && previousAt !== null && event.at > previousAt) {
|
||||
const segmentMeters = getApproxDistanceMeters(previousPoint, nextPoint)
|
||||
nextDistanceMeters += segmentMeters
|
||||
const rawSpeedKmh = segmentMeters <= 0
|
||||
? 0
|
||||
: (segmentMeters / ((event.at - previousAt) / 1000)) * 3.6
|
||||
nextSpeedKmh = smoothSpeedKmh(this.state.currentSpeedKmh, rawSpeedKmh)
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
distanceMeters: nextDistanceMeters,
|
||||
currentSpeedKmh: nextSpeedKmh,
|
||||
lastGpsPoint: nextPoint,
|
||||
lastGpsAt: event.at,
|
||||
lastGpsAccuracyMeters: event.accuracyMeters,
|
||||
}
|
||||
this.alignCalorieTracking(event.at)
|
||||
this.recomputeDerivedState()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'heart_rate_updated') {
|
||||
this.syncCalorieAccumulation(event.at)
|
||||
this.state = {
|
||||
...this.state,
|
||||
heartRateBpm: event.bpm,
|
||||
}
|
||||
this.alignCalorieTracking(event.at)
|
||||
this.recomputeDerivedState()
|
||||
}
|
||||
}
|
||||
|
||||
recomputeDerivedState(now = Date.now()): void {
|
||||
const elapsedMs = this.state.sessionStartedAt === null
|
||||
? 0
|
||||
: Math.max(0, (this.state.sessionEndedAt || now) - this.state.sessionStartedAt)
|
||||
const distanceToTargetMeters = this.state.lastGpsPoint && this.state.targetPoint
|
||||
? getApproxDistanceMeters(this.state.lastGpsPoint, this.state.targetPoint)
|
||||
: null
|
||||
const averageSpeedKmh = elapsedMs > 0
|
||||
? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
|
||||
: null
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
elapsedMs,
|
||||
distanceToTargetMeters,
|
||||
averageSpeedKmh,
|
||||
}
|
||||
}
|
||||
|
||||
getPresentation(now = Date.now()): TelemetryPresentation {
|
||||
this.syncCalorieAccumulation(now)
|
||||
this.alignCalorieTracking(now)
|
||||
this.recomputeDerivedState(now)
|
||||
const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
|
||||
const hasHeartRate = hasHeartRateSignal(this.state)
|
||||
const heartRateTone = hasHeartRate
|
||||
? getHeartRateTone(this.state.heartRateBpm, this.config)
|
||||
: getSpeedFallbackTone(this.state.currentSpeedKmh)
|
||||
const heartRate = formatHeartRateMetric(this.state.heartRateBpm)
|
||||
const calories = formatCaloriesMetric(this.state.caloriesKcal)
|
||||
const accuracy = formatAccuracyMetric(this.state.lastGpsAccuracyMeters)
|
||||
|
||||
return {
|
||||
...EMPTY_TELEMETRY_PRESENTATION,
|
||||
timerText: formatElapsedTimerText(this.state.elapsedMs),
|
||||
mileageText: formatDistanceText(this.state.distanceMeters),
|
||||
distanceToTargetValueText: targetDistance.valueText,
|
||||
distanceToTargetUnitText: targetDistance.unitText,
|
||||
speedText: formatSpeedText(this.state.currentSpeedKmh),
|
||||
heartRateTone,
|
||||
heartRateZoneNameText: hasHeartRate || hasSpeedSignal(this.state) ? getHeartRateToneLabel(heartRateTone) : '--',
|
||||
heartRateZoneRangeText: hasHeartRate
|
||||
? getHeartRateToneRangeText(heartRateTone)
|
||||
: hasSpeedSignal(this.state)
|
||||
? getSpeedToneRangeText(heartRateTone)
|
||||
: '',
|
||||
heartRateValueText: heartRate.valueText,
|
||||
heartRateUnitText: heartRate.unitText,
|
||||
caloriesValueText: calories.valueText,
|
||||
caloriesUnitText: calories.unitText,
|
||||
averageSpeedValueText: formatSpeedText(this.state.averageSpeedKmh),
|
||||
averageSpeedUnitText: 'km/h',
|
||||
accuracyValueText: accuracy.valueText,
|
||||
accuracyUnitText: accuracy.unitText,
|
||||
}
|
||||
}
|
||||
|
||||
private syncCalorieAccumulation(now: number): void {
|
||||
if (!shouldTrackCalories(this.state)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.state.calorieTrackingAt === null) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
calorieTrackingAt: now,
|
||||
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (now <= this.state.calorieTrackingAt) {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaMs = now - this.state.calorieTrackingAt
|
||||
const calorieDelta = hasHeartRateSignal(this.state)
|
||||
? estimateCaloriesKcal(deltaMs, this.state.heartRateBpm as number, this.config)
|
||||
: estimateCaloriesFromSpeedKcal(deltaMs, this.state.currentSpeedKmh, this.config)
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
calorieTrackingAt: now,
|
||||
caloriesKcal: (this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal) + calorieDelta,
|
||||
}
|
||||
}
|
||||
|
||||
private alignCalorieTracking(now: number): void {
|
||||
if (shouldTrackCalories(this.state)) {
|
||||
if (this.state.calorieTrackingAt === null) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
calorieTrackingAt: now,
|
||||
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.state.calorieTrackingAt !== null) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
calorieTrackingAt: null,
|
||||
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
miniprogram/game/telemetry/telemetryState.ts
Normal file
40
miniprogram/game/telemetry/telemetryState.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type LonLatPoint } from '../../utils/projection'
|
||||
import { type GameSessionStatus } from '../core/gameSessionState'
|
||||
|
||||
export interface TelemetryState {
|
||||
sessionStatus: GameSessionStatus
|
||||
sessionStartedAt: number | null
|
||||
sessionEndedAt: number | null
|
||||
elapsedMs: number
|
||||
distanceMeters: number
|
||||
currentSpeedKmh: number | null
|
||||
averageSpeedKmh: number | null
|
||||
distanceToTargetMeters: number | null
|
||||
targetControlId: string | null
|
||||
targetPoint: LonLatPoint | null
|
||||
lastGpsPoint: LonLatPoint | null
|
||||
lastGpsAt: number | null
|
||||
lastGpsAccuracyMeters: number | null
|
||||
heartRateBpm: number | null
|
||||
caloriesKcal: number | null
|
||||
calorieTrackingAt: number | null
|
||||
}
|
||||
|
||||
export const EMPTY_TELEMETRY_STATE: TelemetryState = {
|
||||
sessionStatus: 'idle',
|
||||
sessionStartedAt: null,
|
||||
sessionEndedAt: null,
|
||||
elapsedMs: 0,
|
||||
distanceMeters: 0,
|
||||
currentSpeedKmh: null,
|
||||
averageSpeedKmh: null,
|
||||
distanceToTargetMeters: null,
|
||||
targetControlId: null,
|
||||
targetPoint: null,
|
||||
lastGpsPoint: null,
|
||||
lastGpsAt: null,
|
||||
lastGpsAccuracyMeters: null,
|
||||
heartRateBpm: null,
|
||||
caloriesKcal: null,
|
||||
calorieTrackingAt: null,
|
||||
}
|
||||
@@ -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)))
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<view class="page">
|
||||
<view
|
||||
class="app-edge-glow app-edge-glow--{{panelTelemetryTone}}"
|
||||
wx:if="{{panelTelemetryTone === 'orange' || panelTelemetryTone === 'red'}}"
|
||||
></view>
|
||||
<view
|
||||
class="map-stage"
|
||||
catchtouchstart="handleTouchStart"
|
||||
@@ -114,7 +118,9 @@
|
||||
<cover-view class="screen-button-layer__text">调试</cover-view>
|
||||
</cover-view>
|
||||
|
||||
<view class="race-panel">
|
||||
<swiper class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
|
||||
<swiper-item>
|
||||
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
|
||||
<view class="race-panel__tag race-panel__tag--top-left">目标</view>
|
||||
<view class="race-panel__tag race-panel__tag--top-right">里程</view>
|
||||
<view class="race-panel__tag race-panel__tag--bottom-left">点距</view>
|
||||
@@ -149,7 +155,7 @@
|
||||
<view class="race-panel__cell race-panel__cell--distance">
|
||||
<view class="race-panel__metric-group race-panel__metric-group--left">
|
||||
<text class="race-panel__metric-value race-panel__metric-value--distance">{{panelDistanceValueText}}</text>
|
||||
<text class="race-panel__metric-unit race-panel__metric-unit--distance">m</text>
|
||||
<text class="race-panel__metric-unit race-panel__metric-unit--distance">{{panelDistanceUnitText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="race-panel__cell race-panel__cell--progress">
|
||||
@@ -163,25 +169,125 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
<swiper-item>
|
||||
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
|
||||
<view class="race-panel__tag race-panel__tag--top-left">心率</view>
|
||||
<view class="race-panel__tag race-panel__tag--top-right">卡路里</view>
|
||||
<view class="race-panel__tag race-panel__tag--bottom-left">均速</view>
|
||||
<view class="race-panel__tag race-panel__tag--bottom-right">精度</view>
|
||||
|
||||
<view class="race-panel__line race-panel__line--center"></view>
|
||||
<view class="race-panel__line race-panel__line--left-mid"></view>
|
||||
<view class="race-panel__line race-panel__line--right-mid"></view>
|
||||
<view class="race-panel__line race-panel__line--left-top"></view>
|
||||
<view class="race-panel__line race-panel__line--left-bottom"></view>
|
||||
<view class="race-panel__line race-panel__line--right-top"></view>
|
||||
<view class="race-panel__line race-panel__line--right-bottom"></view>
|
||||
|
||||
<view class="race-panel__grid">
|
||||
<view class="race-panel__cell race-panel__cell--action">
|
||||
<view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel">
|
||||
<text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelHeartRateValueText}}</text>
|
||||
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelHeartRateUnitText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="race-panel__cell race-panel__cell--timer">
|
||||
<text class="race-panel__timer">{{panelTimerText}}</text>
|
||||
</view>
|
||||
<view class="race-panel__cell race-panel__cell--mileage">
|
||||
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
|
||||
<text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelCaloriesValueText}}</text>
|
||||
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelCaloriesUnitText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="race-panel__cell race-panel__cell--distance">
|
||||
<view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel">
|
||||
<text class="race-panel__metric-value race-panel__metric-value--telemetry-secondary">{{panelAverageSpeedValueText}}</text>
|
||||
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelAverageSpeedUnitText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="race-panel__cell race-panel__cell--progress">
|
||||
<view class="race-panel__zone">
|
||||
<text class="race-panel__zone-name">{{panelHeartRateZoneNameText}}</text>
|
||||
<text class="race-panel__zone-range">{{panelHeartRateZoneRangeText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="race-panel__cell race-panel__cell--speed">
|
||||
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
|
||||
<text class="race-panel__metric-value race-panel__metric-value--telemetry-secondary">{{panelAccuracyValueText}}</text>
|
||||
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelAccuracyUnitText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="race-panel-pager" wx:if="{{!showDebugPanel}}">
|
||||
<view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view>
|
||||
<view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view>
|
||||
</view>
|
||||
|
||||
<view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel">
|
||||
<view class="debug-modal__dialog" catchtap="handleDebugPanelTap">
|
||||
<view class="debug-modal__header">
|
||||
<view>
|
||||
<view class="debug-modal__header-main">
|
||||
<view class="debug-modal__eyebrow">DEBUG PANEL</view>
|
||||
<view class="debug-modal__title">地图调试信息</view>
|
||||
<view class="debug-modal__build">{{buildVersion}}</view>
|
||||
</view>
|
||||
<view class="debug-modal__header-actions">
|
||||
<view class="debug-modal__close" bindtap="handleCloseDebugPanel">关闭</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="debug-modal__content" scroll-y enhanced show-scrollbar="true">
|
||||
<view class="debug-section">
|
||||
<view class="debug-section__header">
|
||||
<view class="debug-section__title">Session</view>
|
||||
<view class="debug-section__desc">当前局状态与主流程控制</view>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Build</text>
|
||||
<text class="info-panel__value">{{buildVersion}}</text>
|
||||
<text class="info-panel__label">Game</text>
|
||||
<text class="info-panel__value">{{gameSessionStatus}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Progress</text>
|
||||
<text class="info-panel__value">{{panelProgressText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Timer</text>
|
||||
<text class="info-panel__value">{{panelTimerText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Config</text>
|
||||
<text class="info-panel__value">{{configStatusText}}</text>
|
||||
<text class="info-panel__label">Punch Hint</text>
|
||||
<text class="info-panel__value">{{punchHintText}}</text>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="debug-section">
|
||||
<view class="debug-section__header">
|
||||
<view class="debug-section__title">Sensors</view>
|
||||
<view class="debug-section__desc">定位、罗盘与心率带连接状态</view>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">GPS</text>
|
||||
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">GPS Coord</text>
|
||||
<text class="info-panel__value">{{gpsCoordText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Heart Rate</text>
|
||||
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">HR Device</text>
|
||||
<text class="info-panel__value">{{heartRateDeviceText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Heading Mode</text>
|
||||
@@ -195,25 +301,68 @@
|
||||
<text class="info-panel__label">North Ref</text>
|
||||
<text class="info-panel__value">{{northReferenceText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Zoom</text>
|
||||
<text class="info-panel__value">{{zoom}}</text>
|
||||
<view class="control-row">
|
||||
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
||||
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="debug-section">
|
||||
<view class="debug-section__header">
|
||||
<view class="debug-section__title">Telemetry</view>
|
||||
<view class="debug-section__desc">HUD 派生数据与心率颜色测试</view>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Rotation</text>
|
||||
<text class="info-panel__value">{{rotationText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Status</text>
|
||||
<text class="info-panel__value">{{statusText}}</text>
|
||||
<text class="info-panel__label">HR</text>
|
||||
<text class="info-panel__value">{{panelHeartRateValueText}} {{panelHeartRateUnitText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">GPS</text>
|
||||
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
||||
<text class="info-panel__label">HR Zone</text>
|
||||
<text class="info-panel__value">{{panelHeartRateZoneNameText}} {{panelHeartRateZoneRangeText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">GPS Coord</text>
|
||||
<text class="info-panel__value">{{gpsCoordText}}</text>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Calories</text>
|
||||
<text class="info-panel__value">{{panelCaloriesValueText}} {{panelCaloriesUnitText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Speed</text>
|
||||
<text class="info-panel__value">{{panelSpeedValueText}} km/h</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Avg Speed</text>
|
||||
<text class="info-panel__value">{{panelAverageSpeedValueText}} {{panelAverageSpeedUnitText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Target Dist</text>
|
||||
<text class="info-panel__value">{{panelDistanceValueText}} {{panelDistanceUnitText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Accuracy</text>
|
||||
<text class="info-panel__value">{{panelAccuracyValueText}} {{panelAccuracyUnitText}}</text>
|
||||
</view>
|
||||
<view class="control-row control-row--triple">
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateBlue">蓝</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRatePurple">紫</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateGreen">绿</view>
|
||||
</view>
|
||||
<view class="control-row control-row--triple">
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateYellow">黄</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateOrange">橙</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateRed">红</view>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleClearDebugHeartRate">清除</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="debug-section">
|
||||
<view class="debug-section__header">
|
||||
<view class="debug-section__title">Rendering</view>
|
||||
<view class="debug-section__desc">地图渲染、视角与参考图层</view>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Renderer</text>
|
||||
@@ -223,6 +372,14 @@
|
||||
<text class="info-panel__label">Projection</text>
|
||||
<text class="info-panel__value">{{projectionMode}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Zoom</text>
|
||||
<text class="info-panel__value">{{zoom}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Rotation</text>
|
||||
<text class="info-panel__value">{{rotationText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Auto Source</text>
|
||||
<text class="info-panel__value">{{autoRotateSourceText}}</text>
|
||||
@@ -231,6 +388,33 @@
|
||||
<text class="info-panel__label">Calibration</text>
|
||||
<text class="info-panel__value">{{autoRotateCalibrationText}}</text>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-chip {{osmReferenceEnabled ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleOsmReference">{{osmReferenceText}}</view>
|
||||
<view class="control-chip" wx:if="{{orientationMode === 'manual'}}" bindtap="handleRotateStep">旋转 +15°</view>
|
||||
</view>
|
||||
<view class="control-row control-row--triple">
|
||||
<view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>
|
||||
<view class="control-chip {{orientationMode === 'north-up' ? 'control-chip--active' : ''}}" bindtap="handleSetNorthUpMode">北朝上</view>
|
||||
<view class="control-chip {{orientationMode === 'heading-up' ? 'control-chip--active' : ''}}" bindtap="handleSetHeadingUpMode">朝向朝上</view>
|
||||
</view>
|
||||
<view class="control-row" wx:if="{{orientationMode === 'heading-up'}}">
|
||||
<view class="control-chip" bindtap="handleAutoRotateCalibrate">按当前方向校准</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="debug-section">
|
||||
<view class="debug-section__header">
|
||||
<view class="debug-section__title">Diagnostics</view>
|
||||
<view class="debug-section__desc">配置、瓦片缓存与运行状态</view>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Config</text>
|
||||
<text class="info-panel__value">{{configStatusText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Status</text>
|
||||
<text class="info-panel__value">{{statusText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Tile URL</text>
|
||||
<text class="info-panel__value">{{tileSource}}</text>
|
||||
@@ -271,28 +455,6 @@
|
||||
<text class="info-panel__label">Net Fetches</text>
|
||||
<text class="info-panel__value">{{networkFetchCount}}</text>
|
||||
</view>
|
||||
|
||||
<view class="control-row">
|
||||
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
||||
<view class="control-chip {{osmReferenceEnabled ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleOsmReference">{{osmReferenceText}}</view>
|
||||
</view>
|
||||
<view class="control-row control-row--triple">
|
||||
<view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>
|
||||
<view class="control-chip {{orientationMode === 'north-up' ? 'control-chip--active' : ''}}" bindtap="handleSetNorthUpMode">北朝上</view>
|
||||
<view class="control-chip {{orientationMode === 'heading-up' ? 'control-chip--active' : ''}}" bindtap="handleSetHeadingUpMode">朝向朝上</view>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
|
||||
</view>
|
||||
<view class="control-row" wx:if="{{orientationMode === 'heading-up'}}">
|
||||
<view class="control-chip" bindtap="handleAutoRotateCalibrate">按当前方向校准</view>
|
||||
</view>
|
||||
<view class="control-row" wx:if="{{orientationMode === 'manual'}}">
|
||||
<view class="control-chip" bindtap="handleRotateStep">旋转 +15°</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user