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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user