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 { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
||||||
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
||||||
|
import { HeartRateController } from '../sensor/heartRateController'
|
||||||
import { LocationController } from '../sensor/locationController'
|
import { LocationController } from '../sensor/locationController'
|
||||||
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
||||||
import { type MapRendererStats } from '../renderer/mapRenderer'
|
import { type MapRendererStats } from '../renderer/mapRenderer'
|
||||||
@@ -11,6 +12,8 @@ import { type GameEffect } from '../../game/core/gameResult'
|
|||||||
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
|
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
|
||||||
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
|
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
|
||||||
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
|
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 RENDER_MODE = 'Single WebGL Pipeline'
|
||||||
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
|
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
|
||||||
@@ -117,8 +120,27 @@ export interface MapEngineViewState {
|
|||||||
gpsTracking: boolean
|
gpsTracking: boolean
|
||||||
gpsTrackingText: string
|
gpsTrackingText: string
|
||||||
gpsCoordText: string
|
gpsCoordText: string
|
||||||
|
heartRateConnected: boolean
|
||||||
|
heartRateStatusText: string
|
||||||
|
heartRateDeviceText: string
|
||||||
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
||||||
|
panelTimerText: string
|
||||||
|
panelMileageText: string
|
||||||
|
panelDistanceValueText: string
|
||||||
|
panelDistanceUnitText: string
|
||||||
panelProgressText: 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
|
punchButtonText: string
|
||||||
punchButtonEnabled: boolean
|
punchButtonEnabled: boolean
|
||||||
punchHintText: string
|
punchHintText: string
|
||||||
@@ -183,8 +205,27 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'gpsTracking',
|
'gpsTracking',
|
||||||
'gpsTrackingText',
|
'gpsTrackingText',
|
||||||
'gpsCoordText',
|
'gpsCoordText',
|
||||||
|
'heartRateConnected',
|
||||||
|
'heartRateStatusText',
|
||||||
|
'heartRateDeviceText',
|
||||||
'gameSessionStatus',
|
'gameSessionStatus',
|
||||||
|
'panelTimerText',
|
||||||
|
'panelMileageText',
|
||||||
|
'panelDistanceValueText',
|
||||||
|
'panelDistanceUnitText',
|
||||||
'panelProgressText',
|
'panelProgressText',
|
||||||
|
'panelSpeedValueText',
|
||||||
|
'panelTelemetryTone',
|
||||||
|
'panelHeartRateZoneNameText',
|
||||||
|
'panelHeartRateZoneRangeText',
|
||||||
|
'panelHeartRateValueText',
|
||||||
|
'panelHeartRateUnitText',
|
||||||
|
'panelCaloriesValueText',
|
||||||
|
'panelCaloriesUnitText',
|
||||||
|
'panelAverageSpeedValueText',
|
||||||
|
'panelAverageSpeedUnitText',
|
||||||
|
'panelAccuracyValueText',
|
||||||
|
'panelAccuracyUnitText',
|
||||||
'punchButtonText',
|
'punchButtonText',
|
||||||
'punchButtonEnabled',
|
'punchButtonEnabled',
|
||||||
'punchHintText',
|
'punchHintText',
|
||||||
@@ -289,7 +330,7 @@ function formatRotationToggleText(mode: OrientationMode): string {
|
|||||||
return '切到朝向朝上'
|
return '切到朝向朝上'
|
||||||
}
|
}
|
||||||
|
|
||||||
return '鍒囧埌鎵嬪姩鏃嬭浆'
|
return '切到手动旋转'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
|
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
|
||||||
@@ -369,7 +410,7 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
|
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
|
||||||
return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳'
|
return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
|
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
|
||||||
@@ -441,6 +482,7 @@ export class MapEngine {
|
|||||||
renderer: WebGLMapRenderer
|
renderer: WebGLMapRenderer
|
||||||
compassController: CompassHeadingController
|
compassController: CompassHeadingController
|
||||||
locationController: LocationController
|
locationController: LocationController
|
||||||
|
heartRateController: HeartRateController
|
||||||
feedbackDirector: FeedbackDirector
|
feedbackDirector: FeedbackDirector
|
||||||
onData: (patch: Partial<MapEngineViewState>) => void
|
onData: (patch: Partial<MapEngineViewState>) => void
|
||||||
state: MapEngineViewState
|
state: MapEngineViewState
|
||||||
@@ -487,6 +529,7 @@ export class MapEngine {
|
|||||||
courseData: OrienteeringCourseData | null
|
courseData: OrienteeringCourseData | null
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
gameRuntime: GameRuntime
|
gameRuntime: GameRuntime
|
||||||
|
telemetryRuntime: TelemetryRuntime
|
||||||
gamePresentation: GamePresentationState
|
gamePresentation: GamePresentationState
|
||||||
gameMode: 'classic-sequential'
|
gameMode: 'classic-sequential'
|
||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
@@ -496,6 +539,7 @@ export class MapEngine {
|
|||||||
contentCardTimer: number
|
contentCardTimer: number
|
||||||
mapPulseTimer: number
|
mapPulseTimer: number
|
||||||
stageFxTimer: number
|
stageFxTimer: number
|
||||||
|
sessionTimerInterval: number
|
||||||
hasGpsCenteredOnce: boolean
|
hasGpsCenteredOnce: boolean
|
||||||
|
|
||||||
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
||||||
@@ -537,6 +581,37 @@ export class MapEngine {
|
|||||||
}, true)
|
}, 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({
|
this.feedbackDirector = new FeedbackDirector({
|
||||||
showPunchFeedback: (text, tone, motionClass) => {
|
showPunchFeedback: (text, tone, motionClass) => {
|
||||||
this.showPunchFeedback(text, tone, motionClass)
|
this.showPunchFeedback(text, tone, motionClass)
|
||||||
@@ -571,6 +646,8 @@ export class MapEngine {
|
|||||||
this.courseData = null
|
this.courseData = null
|
||||||
this.cpRadiusMeters = 5
|
this.cpRadiusMeters = 5
|
||||||
this.gameRuntime = new GameRuntime()
|
this.gameRuntime = new GameRuntime()
|
||||||
|
this.telemetryRuntime = new TelemetryRuntime()
|
||||||
|
this.telemetryRuntime.configure()
|
||||||
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
|
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
|
||||||
this.gameMode = 'classic-sequential'
|
this.gameMode = 'classic-sequential'
|
||||||
this.punchPolicy = 'enter-confirm'
|
this.punchPolicy = 'enter-confirm'
|
||||||
@@ -580,6 +657,7 @@ export class MapEngine {
|
|||||||
this.contentCardTimer = 0
|
this.contentCardTimer = 0
|
||||||
this.mapPulseTimer = 0
|
this.mapPulseTimer = 0
|
||||||
this.stageFxTimer = 0
|
this.stageFxTimer = 0
|
||||||
|
this.sessionTimerInterval = 0
|
||||||
this.hasGpsCenteredOnce = false
|
this.hasGpsCenteredOnce = false
|
||||||
this.state = {
|
this.state = {
|
||||||
buildVersion: this.buildVersion,
|
buildVersion: this.buildVersion,
|
||||||
@@ -587,7 +665,7 @@ export class MapEngine {
|
|||||||
projectionMode: PROJECTION_MODE,
|
projectionMode: PROJECTION_MODE,
|
||||||
mapReady: false,
|
mapReady: false,
|
||||||
mapReadyText: 'BOOTING',
|
mapReadyText: 'BOOTING',
|
||||||
mapName: 'LCX 娴嬭瘯鍦板浘',
|
mapName: 'LCX 测试地图',
|
||||||
configStatusText: '远程配置待加载',
|
configStatusText: '远程配置待加载',
|
||||||
zoom: DEFAULT_ZOOM,
|
zoom: DEFAULT_ZOOM,
|
||||||
rotationDeg: 0,
|
rotationDeg: 0,
|
||||||
@@ -624,15 +702,34 @@ export class MapEngine {
|
|||||||
stageHeight: 0,
|
stageHeight: 0,
|
||||||
stageLeft: 0,
|
stageLeft: 0,
|
||||||
stageTop: 0,
|
stageTop: 0,
|
||||||
statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`,
|
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
|
||||||
gpsTracking: false,
|
gpsTracking: false,
|
||||||
gpsTrackingText: '持续定位待启动',
|
gpsTrackingText: '持续定位待启动',
|
||||||
gpsCoordText: '--',
|
gpsCoordText: '--',
|
||||||
|
heartRateConnected: false,
|
||||||
|
heartRateStatusText: '心率带未连接',
|
||||||
|
heartRateDeviceText: '--',
|
||||||
|
panelTimerText: '00:00:00',
|
||||||
|
panelMileageText: '0m',
|
||||||
|
panelDistanceValueText: '--',
|
||||||
|
panelDistanceUnitText: '',
|
||||||
panelProgressText: '0/0',
|
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',
|
gameSessionStatus: 'idle',
|
||||||
punchButtonEnabled: false,
|
punchButtonEnabled: false,
|
||||||
punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿',
|
punchHintText: '等待进入检查点范围',
|
||||||
punchFeedbackVisible: false,
|
punchFeedbackVisible: false,
|
||||||
punchFeedbackText: '',
|
punchFeedbackText: '',
|
||||||
punchFeedbackTone: 'neutral',
|
punchFeedbackTone: 'neutral',
|
||||||
@@ -697,8 +794,10 @@ export class MapEngine {
|
|||||||
this.clearContentCardTimer()
|
this.clearContentCardTimer()
|
||||||
this.clearMapPulseTimer()
|
this.clearMapPulseTimer()
|
||||||
this.clearStageFxTimer()
|
this.clearStageFxTimer()
|
||||||
|
this.clearSessionTimerInterval()
|
||||||
this.compassController.destroy()
|
this.compassController.destroy()
|
||||||
this.locationController.destroy()
|
this.locationController.destroy()
|
||||||
|
this.heartRateController.destroy()
|
||||||
this.feedbackDirector.destroy()
|
this.feedbackDirector.destroy()
|
||||||
this.renderer.destroy()
|
this.renderer.destroy()
|
||||||
this.mounted = false
|
this.mounted = false
|
||||||
@@ -707,7 +806,9 @@ export class MapEngine {
|
|||||||
|
|
||||||
clearGameRuntime(): void {
|
clearGameRuntime(): void {
|
||||||
this.gameRuntime.clear()
|
this.gameRuntime.clear()
|
||||||
|
this.telemetryRuntime.reset()
|
||||||
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
|
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
|
||||||
|
this.clearSessionTimerInterval()
|
||||||
this.setCourseHeading(null)
|
this.setCourseHeading(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,8 +827,11 @@ export class MapEngine {
|
|||||||
this.punchRadiusMeters,
|
this.punchRadiusMeters,
|
||||||
)
|
)
|
||||||
const result = this.gameRuntime.loadDefinition(definition)
|
const result = this.gameRuntime.loadDefinition(definition)
|
||||||
|
this.telemetryRuntime.loadDefinition(definition)
|
||||||
|
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState)
|
||||||
this.gamePresentation = result.presentation
|
this.gamePresentation = result.presentation
|
||||||
this.refreshCourseHeadingFromPresentation()
|
this.refreshCourseHeadingFromPresentation()
|
||||||
|
this.updateSessionTimerLoop()
|
||||||
return result.effects
|
return result.effects
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,8 +873,25 @@ export class MapEngine {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
|
getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
|
||||||
|
const telemetryPresentation = this.telemetryRuntime.getPresentation()
|
||||||
const patch: Partial<MapEngineViewState> = {
|
const patch: Partial<MapEngineViewState> = {
|
||||||
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
|
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,
|
panelProgressText: this.gamePresentation.progressText,
|
||||||
punchButtonText: this.gamePresentation.punchButtonText,
|
punchButtonText: this.gamePresentation.punchButtonText,
|
||||||
punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
|
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 {
|
getControlScreenPoint(controlId: string): { x: number; y: number } | null {
|
||||||
if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
|
if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
|
||||||
return null
|
return null
|
||||||
@@ -930,6 +1099,8 @@ export class MapEngine {
|
|||||||
|
|
||||||
applyGameEffects(effects: GameEffect[]): string | null {
|
applyGameEffects(effects: GameEffect[]): string | null {
|
||||||
this.feedbackDirector.handleEffects(effects)
|
this.feedbackDirector.handleEffects(effects)
|
||||||
|
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state)
|
||||||
|
this.updateSessionTimerLoop()
|
||||||
return this.resolveGameStatusText(effects)
|
return this.resolveGameStatusText(effects)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,9 +1176,17 @@ export class MapEngine {
|
|||||||
let gameStatusText: string | null = null
|
let gameStatusText: string | null = null
|
||||||
|
|
||||||
if (this.courseData) {
|
if (this.courseData) {
|
||||||
|
const eventAt = Date.now()
|
||||||
const gameResult = this.gameRuntime.dispatch({
|
const gameResult = this.gameRuntime.dispatch({
|
||||||
type: 'gps_updated',
|
type: 'gps_updated',
|
||||||
at: Date.now(),
|
at: eventAt,
|
||||||
|
lon: longitude,
|
||||||
|
lat: latitude,
|
||||||
|
accuracyMeters,
|
||||||
|
})
|
||||||
|
this.telemetryRuntime.dispatch({
|
||||||
|
type: 'gps_updated',
|
||||||
|
at: eventAt,
|
||||||
lon: longitude,
|
lon: longitude,
|
||||||
lat: latitude,
|
lat: latitude,
|
||||||
accuracyMeters,
|
accuracyMeters,
|
||||||
@@ -1059,6 +1238,39 @@ export class MapEngine {
|
|||||||
|
|
||||||
this.locationController.start()
|
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 {
|
setStage(rect: MapEngineStageRect): void {
|
||||||
this.previewScale = 1
|
this.previewScale = 1
|
||||||
this.previewOriginX = rect.width / 2
|
this.previewOriginX = rect.width / 2
|
||||||
@@ -1070,7 +1282,7 @@ export class MapEngine {
|
|||||||
stageLeft: rect.left,
|
stageLeft: rect.left,
|
||||||
stageTop: rect.top,
|
stageTop: rect.top,
|
||||||
},
|
},
|
||||||
`鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`,
|
`地图视口已与 WebGL 引擎对齐 (${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1083,7 +1295,7 @@ export class MapEngine {
|
|||||||
this.onData({
|
this.onData({
|
||||||
mapReady: true,
|
mapReady: true,
|
||||||
mapReadyText: 'READY',
|
mapReadyText: 'READY',
|
||||||
statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`,
|
statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
|
||||||
})
|
})
|
||||||
this.syncRenderer()
|
this.syncRenderer()
|
||||||
this.compassController.start()
|
this.compassController.start()
|
||||||
@@ -1104,6 +1316,7 @@ export class MapEngine {
|
|||||||
this.punchPolicy = config.punchPolicy
|
this.punchPolicy = config.punchPolicy
|
||||||
this.punchRadiusMeters = config.punchRadiusMeters
|
this.punchRadiusMeters = config.punchRadiusMeters
|
||||||
this.autoFinishOnLastControl = config.autoFinishOnLastControl
|
this.autoFinishOnLastControl = config.autoFinishOnLastControl
|
||||||
|
this.telemetryRuntime.configure(config.telemetryConfig)
|
||||||
this.feedbackDirector.configure({
|
this.feedbackDirector.configure({
|
||||||
audioConfig: config.audioConfig,
|
audioConfig: config.audioConfig,
|
||||||
hapticsConfig: config.hapticsConfig,
|
hapticsConfig: config.hapticsConfig,
|
||||||
@@ -1113,7 +1326,7 @@ export class MapEngine {
|
|||||||
const gameEffects = this.loadGameDefinitionFromCourse()
|
const gameEffects = this.loadGameDefinitionFromCourse()
|
||||||
const gameStatusText = this.applyGameEffects(gameEffects)
|
const gameStatusText = this.applyGameEffects(gameEffects)
|
||||||
const statePatch: Partial<MapEngineViewState> = {
|
const statePatch: Partial<MapEngineViewState> = {
|
||||||
configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`,
|
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
|
||||||
projectionMode: config.projectionModeText,
|
projectionMode: config.projectionModeText,
|
||||||
tileSource: config.tileSource,
|
tileSource: config.tileSource,
|
||||||
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
|
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
|
||||||
@@ -1219,8 +1432,8 @@ export class MapEngine {
|
|||||||
rotationText: formatRotationText(nextRotationDeg),
|
rotationText: formatRotationText(nextRotationDeg),
|
||||||
},
|
},
|
||||||
this.state.orientationMode === 'heading-up'
|
this.state.orientationMode === 'heading-up'
|
||||||
? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})`
|
? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
|
||||||
: `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`,
|
: `双指缩放与旋转中 (${this.buildVersion})`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1327,7 +1540,7 @@ export class MapEngine {
|
|||||||
tileTranslateX: 0,
|
tileTranslateX: 0,
|
||||||
tileTranslateY: 0,
|
tileTranslateY: 0,
|
||||||
},
|
},
|
||||||
`宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`,
|
`已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
@@ -1341,7 +1554,7 @@ export class MapEngine {
|
|||||||
handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
|
handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
|
||||||
if (this.state.rotationMode === 'auto') {
|
if (this.state.rotationMode === 'auto') {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
|
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1361,7 +1574,7 @@ export class MapEngine {
|
|||||||
rotationDeg: nextRotationDeg,
|
rotationDeg: nextRotationDeg,
|
||||||
rotationText: formatRotationText(nextRotationDeg),
|
rotationText: formatRotationText(nextRotationDeg),
|
||||||
},
|
},
|
||||||
`鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
|
`旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
@@ -1374,7 +1587,7 @@ export class MapEngine {
|
|||||||
handleRotationReset(): void {
|
handleRotationReset(): void {
|
||||||
if (this.state.rotationMode === 'auto') {
|
if (this.state.rotationMode === 'auto') {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
|
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1398,7 +1611,7 @@ export class MapEngine {
|
|||||||
rotationDeg: targetRotationDeg,
|
rotationDeg: targetRotationDeg,
|
||||||
rotationText: formatRotationText(targetRotationDeg),
|
rotationText: formatRotationText(targetRotationDeg),
|
||||||
},
|
},
|
||||||
`鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`,
|
`旋转角度已回到真北参考 (${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
@@ -1441,20 +1654,20 @@ export class MapEngine {
|
|||||||
handleAutoRotateCalibrate(): void {
|
handleAutoRotateCalibrate(): void {
|
||||||
if (this.state.orientationMode !== 'heading-up') {
|
if (this.state.orientationMode !== 'heading-up') {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`,
|
statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.calibrateAutoRotateToCurrentOrientation()) {
|
if (!this.calibrateAutoRotateToCurrentOrientation()) {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`,
|
statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`,
|
statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
this.scheduleAutoRotate()
|
this.scheduleAutoRotate()
|
||||||
}
|
}
|
||||||
@@ -1470,7 +1683,7 @@ export class MapEngine {
|
|||||||
orientationMode: 'manual',
|
orientationMode: 'manual',
|
||||||
orientationModeText: formatOrientationModeText('manual'),
|
orientationModeText: formatOrientationModeText('manual'),
|
||||||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||||||
statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`,
|
statusText: `已切回手动地图旋转 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1497,7 +1710,7 @@ export class MapEngine {
|
|||||||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
|
||||||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||||
},
|
},
|
||||||
`鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`,
|
`地图已固定为真北朝上 (${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
@@ -1518,7 +1731,7 @@ export class MapEngine {
|
|||||||
orientationModeText: formatOrientationModeText('heading-up'),
|
orientationModeText: formatOrientationModeText('heading-up'),
|
||||||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||||||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||||
statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`,
|
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
if (this.refreshAutoRotateTarget()) {
|
if (this.refreshAutoRotateTarget()) {
|
||||||
this.scheduleAutoRotate()
|
this.scheduleAutoRotate()
|
||||||
@@ -2142,7 +2355,7 @@ export class MapEngine {
|
|||||||
tileTranslateX: 0,
|
tileTranslateX: 0,
|
||||||
tileTranslateY: 0,
|
tileTranslateY: 0,
|
||||||
},
|
},
|
||||||
`缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
|
`缩放级别调整到 ${nextZoom}`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.setPreviewState(residualScale, stageX, stageY)
|
this.setPreviewState(residualScale, stageX, stageY)
|
||||||
@@ -2169,7 +2382,7 @@ export class MapEngine {
|
|||||||
zoom: nextZoom,
|
zoom: nextZoom,
|
||||||
...resolvedViewport,
|
...resolvedViewport,
|
||||||
},
|
},
|
||||||
`缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
|
`缩放级别调整到 ${nextZoom}`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.setPreviewState(residualScale, stageX, stageY)
|
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) {
|
if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`,
|
statusText: `惯性滑动结束 (${this.buildVersion})`,
|
||||||
})
|
})
|
||||||
this.renderer.setAnimationPaused(false)
|
this.renderer.setAnimationPaused(false)
|
||||||
this.inertiaTimer = 0
|
this.inertiaTimer = 0
|
||||||
@@ -2200,7 +2413,7 @@ export class MapEngine {
|
|||||||
this.normalizeTranslate(
|
this.normalizeTranslate(
|
||||||
this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
|
this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
|
||||||
this.state.tileTranslateY + this.panVelocityY * 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
|
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
|
const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
|
||||||
? targets[currentIndex + 1]
|
? targets[currentIndex + 1]
|
||||||
: null
|
: null
|
||||||
|
const completedFinish = currentTarget.kind === 'finish'
|
||||||
|
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
|
||||||
const nextState: GameSessionState = {
|
const nextState: GameSessionState = {
|
||||||
...state,
|
...state,
|
||||||
|
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
||||||
completedControlIds,
|
completedControlIds,
|
||||||
currentTargetControlId: nextTarget ? nextTarget.id : null,
|
currentTargetControlId: nextTarget ? nextTarget.id : null,
|
||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
|
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
|
||||||
status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
|
status: finished ? 'finished' : state.status,
|
||||||
endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
|
endedAt: finished ? at : state.endedAt,
|
||||||
guidanceState: nextTarget ? 'searching' : 'searching',
|
guidanceState: nextTarget ? 'searching' : 'searching',
|
||||||
}
|
}
|
||||||
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
||||||
|
|
||||||
if (!nextTarget && definition.autoFinishOnLastControl) {
|
if (finished) {
|
||||||
effects.push({ type: 'session_finished' })
|
effects.push({ type: 'session_finished' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +278,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
const nextState: GameSessionState = {
|
const nextState: GameSessionState = {
|
||||||
...state,
|
...state,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
startedAt: event.at,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
guidanceState: 'searching',
|
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
|
showDebugPanel: boolean
|
||||||
statusBarHeight: number
|
statusBarHeight: number
|
||||||
topInsetHeight: number
|
topInsetHeight: number
|
||||||
|
hudPanelIndex: number
|
||||||
panelTimerText: string
|
panelTimerText: string
|
||||||
panelMileageText: string
|
panelMileageText: string
|
||||||
panelDistanceValueText: string
|
panelDistanceValueText: string
|
||||||
@@ -29,7 +30,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showRightButtonGroups: boolean
|
showRightButtonGroups: boolean
|
||||||
showBottomDebugButton: 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'
|
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||||
let mapEngine: MapEngine | null = null
|
let mapEngine: MapEngine | null = null
|
||||||
function buildSideButtonVisibility(mode: SideButtonMode) {
|
function buildSideButtonVisibility(mode: SideButtonMode) {
|
||||||
@@ -94,12 +95,28 @@ Page({
|
|||||||
showDebugPanel: false,
|
showDebugPanel: false,
|
||||||
statusBarHeight: 0,
|
statusBarHeight: 0,
|
||||||
topInsetHeight: 12,
|
topInsetHeight: 12,
|
||||||
|
hudPanelIndex: 0,
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
panelDistanceValueText: '108',
|
panelDistanceValueText: '--',
|
||||||
|
panelDistanceUnitText: '',
|
||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
|
panelTelemetryTone: 'blue',
|
||||||
|
panelHeartRateZoneNameText: '--',
|
||||||
|
panelHeartRateZoneRangeText: '',
|
||||||
|
heartRateConnected: false,
|
||||||
|
heartRateStatusText: '心率带未连接',
|
||||||
|
heartRateDeviceText: '--',
|
||||||
|
panelHeartRateValueText: '--',
|
||||||
|
panelHeartRateUnitText: '',
|
||||||
|
panelCaloriesValueText: '0',
|
||||||
|
panelCaloriesUnitText: 'kcal',
|
||||||
|
panelAverageSpeedValueText: '0',
|
||||||
|
panelAverageSpeedUnitText: 'km/h',
|
||||||
|
panelAccuracyValueText: '--',
|
||||||
|
panelAccuracyUnitText: '',
|
||||||
punchButtonText: '打点',
|
punchButtonText: '打点',
|
||||||
punchButtonEnabled: false,
|
punchButtonEnabled: false,
|
||||||
punchHintText: '等待进入检查点范围',
|
punchHintText: '等待进入检查点范围',
|
||||||
@@ -140,12 +157,28 @@ Page({
|
|||||||
showDebugPanel: false,
|
showDebugPanel: false,
|
||||||
statusBarHeight,
|
statusBarHeight,
|
||||||
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
|
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
|
||||||
|
hudPanelIndex: 0,
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
panelDistanceValueText: '108',
|
panelDistanceValueText: '--',
|
||||||
|
panelDistanceUnitText: '',
|
||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
|
panelTelemetryTone: 'blue',
|
||||||
|
panelHeartRateZoneNameText: '--',
|
||||||
|
panelHeartRateZoneRangeText: '',
|
||||||
|
heartRateConnected: false,
|
||||||
|
heartRateStatusText: '心率带未连接',
|
||||||
|
heartRateDeviceText: '--',
|
||||||
|
panelHeartRateValueText: '--',
|
||||||
|
panelHeartRateUnitText: '',
|
||||||
|
panelCaloriesValueText: '0',
|
||||||
|
panelCaloriesUnitText: 'kcal',
|
||||||
|
panelAverageSpeedValueText: '0',
|
||||||
|
panelAverageSpeedUnitText: 'km/h',
|
||||||
|
panelAccuracyValueText: '--',
|
||||||
|
panelAccuracyUnitText: '',
|
||||||
punchButtonText: '打点',
|
punchButtonText: '打点',
|
||||||
punchButtonEnabled: false,
|
punchButtonEnabled: false,
|
||||||
punchHintText: '等待进入检查点范围',
|
punchHintText: '等待进入检查点范围',
|
||||||
@@ -201,10 +234,10 @@ Page({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = error && error.message ? error.message : '鏈煡閿欒'
|
const errorMessage = error && error.message ? error.message : '未知错误'
|
||||||
this.setData({
|
this.setData({
|
||||||
configStatusText: `杞藉叆澶辫触: ${errorMessage}`,
|
configStatusText: `载入失败: ${errorMessage}`,
|
||||||
statusText: `杩滅▼鍦板浘閰嶇疆杞藉叆澶辫触: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
|
statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -235,7 +268,7 @@ Page({
|
|||||||
const labelCanvasRef = canvasRes[1] as any
|
const labelCanvasRef = canvasRes[1] as any
|
||||||
if (!canvasRef || !canvasRef.node) {
|
if (!canvasRef || !canvasRef.node) {
|
||||||
page.setData({
|
page.setData({
|
||||||
statusText: `WebGL 寮曟搸鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`,
|
statusText: `WebGL 引擎初始化失败 (${INTERNAL_BUILD_VERSION})`,
|
||||||
})
|
})
|
||||||
return
|
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() {
|
handleToggleOsmReference() {
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.handleToggleOsmReference()
|
mapEngine.handleToggleOsmReference()
|
||||||
@@ -373,6 +460,12 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) {
|
||||||
|
this.setData({
|
||||||
|
hudPanelIndex: event.detail.current || 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
handleCycleSideButtons() {
|
handleCycleSideButtons() {
|
||||||
this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
|
this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<view class="page">
|
<view class="page">
|
||||||
|
<view
|
||||||
|
class="app-edge-glow app-edge-glow--{{panelTelemetryTone}}"
|
||||||
|
wx:if="{{panelTelemetryTone === 'orange' || panelTelemetryTone === 'red'}}"
|
||||||
|
></view>
|
||||||
<view
|
<view
|
||||||
class="map-stage"
|
class="map-stage"
|
||||||
catchtouchstart="handleTouchStart"
|
catchtouchstart="handleTouchStart"
|
||||||
@@ -114,185 +118,343 @@
|
|||||||
<cover-view class="screen-button-layer__text">调试</cover-view>
|
<cover-view class="screen-button-layer__text">调试</cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
|
|
||||||
<view class="race-panel">
|
<swiper class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
|
||||||
<view class="race-panel__tag race-panel__tag--top-left">目标</view>
|
<swiper-item>
|
||||||
<view class="race-panel__tag race-panel__tag--top-right">里程</view>
|
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
|
||||||
<view class="race-panel__tag race-panel__tag--bottom-left">点距</view>
|
<view class="race-panel__tag race-panel__tag--top-left">目标</view>
|
||||||
<view class="race-panel__tag race-panel__tag--bottom-right">速度</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--center"></view>
|
||||||
<view class="race-panel__line race-panel__line--left-mid"></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--right-mid"></view>
|
||||||
<view class="race-panel__line race-panel__line--left-top"></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--left-bottom"></view>
|
||||||
<view class="race-panel__line race-panel__line--right-top"></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__line race-panel__line--right-bottom"></view>
|
||||||
|
|
||||||
<view class="race-panel__grid">
|
<view class="race-panel__grid">
|
||||||
<view class="race-panel__cell race-panel__cell--action">
|
<view class="race-panel__cell race-panel__cell--action">
|
||||||
<view class="race-panel__action-button"><!-- status only -->
|
<view class="race-panel__action-button"><!-- status only -->
|
||||||
<view class="race-panel__action-button-text">{{punchButtonText}}</view>
|
<view class="race-panel__action-button-text">{{punchButtonText}}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="race-panel__cell race-panel__cell--timer">
|
<view class="race-panel__cell race-panel__cell--timer">
|
||||||
<text class="race-panel__timer">{{panelTimerText}}</text>
|
<text class="race-panel__timer">{{panelTimerText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="race-panel__cell race-panel__cell--mileage">
|
<view class="race-panel__cell race-panel__cell--mileage">
|
||||||
<view class="race-panel__mileage-wrap">
|
<view class="race-panel__mileage-wrap">
|
||||||
<text class="race-panel__mileage">{{panelMileageText}}</text>
|
<text class="race-panel__mileage">{{panelMileageText}}</text>
|
||||||
<view class="race-panel__chevrons">
|
<view class="race-panel__chevrons">
|
||||||
<view class="race-panel__chevron"></view>
|
<view class="race-panel__chevron"></view>
|
||||||
<view class="race-panel__chevron race-panel__chevron--offset"></view>
|
<view class="race-panel__chevron race-panel__chevron--offset"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="race-panel__cell race-panel__cell--distance">
|
||||||
|
<view class="race-panel__metric-group race-panel__metric-group--left">
|
||||||
|
<text class="race-panel__metric-value race-panel__metric-value--distance">{{panelDistanceValueText}}</text>
|
||||||
|
<text class="race-panel__metric-unit race-panel__metric-unit--distance">{{panelDistanceUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="race-panel__cell race-panel__cell--progress">
|
||||||
|
<text class="race-panel__progress">{{panelProgressText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="race-panel__cell race-panel__cell--speed">
|
||||||
|
<view class="race-panel__metric-group race-panel__metric-group--right">
|
||||||
|
<text class="race-panel__metric-value race-panel__metric-value--speed">{{panelSpeedValueText}}</text>
|
||||||
|
<text class="race-panel__metric-unit race-panel__metric-unit--speed">km/h</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="race-panel__cell race-panel__cell--distance">
|
</swiper-item>
|
||||||
<view class="race-panel__metric-group race-panel__metric-group--left">
|
<swiper-item>
|
||||||
<text class="race-panel__metric-value race-panel__metric-value--distance">{{panelDistanceValueText}}</text>
|
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
|
||||||
<text class="race-panel__metric-unit race-panel__metric-unit--distance">m</text>
|
<view class="race-panel__tag race-panel__tag--top-left">心率</view>
|
||||||
|
<view class="race-panel__tag race-panel__tag--top-right">卡路里</view>
|
||||||
|
<view class="race-panel__tag race-panel__tag--bottom-left">均速</view>
|
||||||
|
<view class="race-panel__tag race-panel__tag--bottom-right">精度</view>
|
||||||
|
|
||||||
|
<view class="race-panel__line race-panel__line--center"></view>
|
||||||
|
<view class="race-panel__line race-panel__line--left-mid"></view>
|
||||||
|
<view class="race-panel__line race-panel__line--right-mid"></view>
|
||||||
|
<view class="race-panel__line race-panel__line--left-top"></view>
|
||||||
|
<view class="race-panel__line race-panel__line--left-bottom"></view>
|
||||||
|
<view class="race-panel__line race-panel__line--right-top"></view>
|
||||||
|
<view class="race-panel__line race-panel__line--right-bottom"></view>
|
||||||
|
|
||||||
|
<view class="race-panel__grid">
|
||||||
|
<view class="race-panel__cell race-panel__cell--action">
|
||||||
|
<view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel">
|
||||||
|
<text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelHeartRateValueText}}</text>
|
||||||
|
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelHeartRateUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="race-panel__cell race-panel__cell--timer">
|
||||||
|
<text class="race-panel__timer">{{panelTimerText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="race-panel__cell race-panel__cell--mileage">
|
||||||
|
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
|
||||||
|
<text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelCaloriesValueText}}</text>
|
||||||
|
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelCaloriesUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="race-panel__cell race-panel__cell--distance">
|
||||||
|
<view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel">
|
||||||
|
<text class="race-panel__metric-value race-panel__metric-value--telemetry-secondary">{{panelAverageSpeedValueText}}</text>
|
||||||
|
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelAverageSpeedUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="race-panel__cell race-panel__cell--progress">
|
||||||
|
<view class="race-panel__zone">
|
||||||
|
<text class="race-panel__zone-name">{{panelHeartRateZoneNameText}}</text>
|
||||||
|
<text class="race-panel__zone-range">{{panelHeartRateZoneRangeText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="race-panel__cell race-panel__cell--speed">
|
||||||
|
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
|
||||||
|
<text class="race-panel__metric-value race-panel__metric-value--telemetry-secondary">{{panelAccuracyValueText}}</text>
|
||||||
|
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelAccuracyUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="race-panel__cell race-panel__cell--progress">
|
</swiper-item>
|
||||||
<text class="race-panel__progress">{{panelProgressText}}</text>
|
</swiper>
|
||||||
</view>
|
<view class="race-panel-pager" wx:if="{{!showDebugPanel}}">
|
||||||
<view class="race-panel__cell race-panel__cell--speed">
|
<view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view>
|
||||||
<view class="race-panel__metric-group race-panel__metric-group--right">
|
<view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view>
|
||||||
<text class="race-panel__metric-value race-panel__metric-value--speed">{{panelSpeedValueText}}</text>
|
|
||||||
<text class="race-panel__metric-unit race-panel__metric-unit--speed">km/h</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel">
|
<view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel">
|
||||||
<view class="debug-modal__dialog" catchtap="handleDebugPanelTap">
|
<view class="debug-modal__dialog" catchtap="handleDebugPanelTap">
|
||||||
<view class="debug-modal__header">
|
<view class="debug-modal__header">
|
||||||
<view>
|
<view class="debug-modal__header-main">
|
||||||
<view class="debug-modal__eyebrow">DEBUG PANEL</view>
|
<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>
|
||||||
<view class="debug-modal__close" bindtap="handleCloseDebugPanel">关闭</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view class="debug-modal__content" scroll-y enhanced show-scrollbar="true">
|
<scroll-view class="debug-modal__content" scroll-y enhanced show-scrollbar="true">
|
||||||
<view class="info-panel__row">
|
<view class="debug-section">
|
||||||
<text class="info-panel__label">Build</text>
|
<view class="debug-section__header">
|
||||||
<text class="info-panel__value">{{buildVersion}}</text>
|
<view class="debug-section__title">Session</view>
|
||||||
</view>
|
<view class="debug-section__desc">当前局状态与主流程控制</view>
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
</view>
|
||||||
<text class="info-panel__label">Config</text>
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__value">{{configStatusText}}</text>
|
<text class="info-panel__label">Game</text>
|
||||||
</view>
|
<text class="info-panel__value">{{gameSessionStatus}}</text>
|
||||||
<view class="info-panel__row">
|
</view>
|
||||||
<text class="info-panel__label">Heading Mode</text>
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__value">{{orientationModeText}}</text>
|
<text class="info-panel__label">Progress</text>
|
||||||
</view>
|
<text class="info-panel__value">{{panelProgressText}}</text>
|
||||||
<view class="info-panel__row">
|
</view>
|
||||||
<text class="info-panel__label">Sensor Heading</text>
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__value">{{sensorHeadingText}}</text>
|
<text class="info-panel__label">Timer</text>
|
||||||
</view>
|
<text class="info-panel__value">{{panelTimerText}}</text>
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
</view>
|
||||||
<text class="info-panel__label">North Ref</text>
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
<text class="info-panel__value">{{northReferenceText}}</text>
|
<text class="info-panel__label">Punch Hint</text>
|
||||||
</view>
|
<text class="info-panel__value">{{punchHintText}}</text>
|
||||||
<view class="info-panel__row">
|
</view>
|
||||||
<text class="info-panel__label">Zoom</text>
|
<view class="control-row">
|
||||||
<text class="info-panel__value">{{zoom}}</text>
|
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
|
||||||
</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
|
||||||
<view class="info-panel__row">
|
</view>
|
||||||
<text class="info-panel__label">Rotation</text>
|
|
||||||
<text class="info-panel__value">{{rotationText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
|
||||||
<text class="info-panel__label">Status</text>
|
|
||||||
<text class="info-panel__value">{{statusText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">GPS</text>
|
|
||||||
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
|
||||||
<text class="info-panel__label">GPS Coord</text>
|
|
||||||
<text class="info-panel__value">{{gpsCoordText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Renderer</text>
|
|
||||||
<text class="info-panel__value">{{renderMode}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
|
||||||
<text class="info-panel__label">Projection</text>
|
|
||||||
<text class="info-panel__value">{{projectionMode}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Auto Source</text>
|
|
||||||
<text class="info-panel__value">{{autoRotateSourceText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Calibration</text>
|
|
||||||
<text class="info-panel__value">{{autoRotateCalibrationText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
|
||||||
<text class="info-panel__label">Tile URL</text>
|
|
||||||
<text class="info-panel__value">{{tileSource}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Center Tile</text>
|
|
||||||
<text class="info-panel__value">{{centerText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Tile Size</text>
|
|
||||||
<text class="info-panel__value">{{tileSizePx}}px</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Visible Tiles</text>
|
|
||||||
<text class="info-panel__value">{{visibleTileCount}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Ready Tiles</text>
|
|
||||||
<text class="info-panel__value">{{readyTileCount}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Memory Tiles</text>
|
|
||||||
<text class="info-panel__value">{{memoryTileCount}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Disk Tiles</text>
|
|
||||||
<text class="info-panel__value">{{diskTileCount}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Cache Hit</text>
|
|
||||||
<text class="info-panel__value">{{cacheHitRateText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Disk Hits</text>
|
|
||||||
<text class="info-panel__value">{{diskHitCount}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Net Fetches</text>
|
|
||||||
<text class="info-panel__value">{{networkFetchCount}}</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="control-row">
|
<view class="debug-section">
|
||||||
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
|
<view class="debug-section__header">
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
|
<view class="debug-section__title">Sensors</view>
|
||||||
|
<view class="debug-section__desc">定位、罗盘与心率带连接状态</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">GPS</text>
|
||||||
|
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">GPS Coord</text>
|
||||||
|
<text class="info-panel__value">{{gpsCoordText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Heart Rate</text>
|
||||||
|
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">HR Device</text>
|
||||||
|
<text class="info-panel__value">{{heartRateDeviceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Heading Mode</text>
|
||||||
|
<text class="info-panel__value">{{orientationModeText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Sensor Heading</text>
|
||||||
|
<text class="info-panel__value">{{sensorHeadingText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">North Ref</text>
|
||||||
|
<text class="info-panel__value">{{northReferenceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
||||||
|
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
|
||||||
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
<view class="debug-section">
|
||||||
<view class="control-chip {{osmReferenceEnabled ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleOsmReference">{{osmReferenceText}}</view>
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__title">Telemetry</view>
|
||||||
|
<view class="debug-section__desc">HUD 派生数据与心率颜色测试</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">HR</text>
|
||||||
|
<text class="info-panel__value">{{panelHeartRateValueText}} {{panelHeartRateUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">HR Zone</text>
|
||||||
|
<text class="info-panel__value">{{panelHeartRateZoneNameText}} {{panelHeartRateZoneRangeText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Calories</text>
|
||||||
|
<text class="info-panel__value">{{panelCaloriesValueText}} {{panelCaloriesUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Speed</text>
|
||||||
|
<text class="info-panel__value">{{panelSpeedValueText}} km/h</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Avg Speed</text>
|
||||||
|
<text class="info-panel__value">{{panelAverageSpeedValueText}} {{panelAverageSpeedUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Target Dist</text>
|
||||||
|
<text class="info-panel__value">{{panelDistanceValueText}} {{panelDistanceUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Accuracy</text>
|
||||||
|
<text class="info-panel__value">{{panelAccuracyValueText}} {{panelAccuracyUnitText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--triple">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateBlue">蓝</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRatePurple">紫</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateGreen">绿</view>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--triple">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateYellow">黄</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateOrange">橙</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDebugHeartRateRed">红</view>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleClearDebugHeartRate">清除</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row control-row--triple">
|
|
||||||
<view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>
|
<view class="debug-section">
|
||||||
<view class="control-chip {{orientationMode === 'north-up' ? 'control-chip--active' : ''}}" bindtap="handleSetNorthUpMode">北朝上</view>
|
<view class="debug-section__header">
|
||||||
<view class="control-chip {{orientationMode === 'heading-up' ? 'control-chip--active' : ''}}" bindtap="handleSetHeadingUpMode">朝向朝上</view>
|
<view class="debug-section__title">Rendering</view>
|
||||||
|
<view class="debug-section__desc">地图渲染、视角与参考图层</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Renderer</text>
|
||||||
|
<text class="info-panel__value">{{renderMode}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">Projection</text>
|
||||||
|
<text class="info-panel__value">{{projectionMode}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Zoom</text>
|
||||||
|
<text class="info-panel__value">{{zoom}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Rotation</text>
|
||||||
|
<text class="info-panel__value">{{rotationText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Auto Source</text>
|
||||||
|
<text class="info-panel__value">{{autoRotateSourceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Calibration</text>
|
||||||
|
<text class="info-panel__value">{{autoRotateCalibrationText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{osmReferenceEnabled ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleOsmReference">{{osmReferenceText}}</view>
|
||||||
|
<view class="control-chip" wx:if="{{orientationMode === 'manual'}}" bindtap="handleRotateStep">旋转 +15°</view>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--triple">
|
||||||
|
<view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>
|
||||||
|
<view class="control-chip {{orientationMode === 'north-up' ? 'control-chip--active' : ''}}" bindtap="handleSetNorthUpMode">北朝上</view>
|
||||||
|
<view class="control-chip {{orientationMode === 'heading-up' ? 'control-chip--active' : ''}}" bindtap="handleSetHeadingUpMode">朝向朝上</view>
|
||||||
|
</view>
|
||||||
|
<view class="control-row" wx:if="{{orientationMode === 'heading-up'}}">
|
||||||
|
<view class="control-chip" bindtap="handleAutoRotateCalibrate">按当前方向校准</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
|
<view class="debug-section">
|
||||||
</view>
|
<view class="debug-section__header">
|
||||||
<view class="control-row" wx:if="{{orientationMode === 'heading-up'}}">
|
<view class="debug-section__title">Diagnostics</view>
|
||||||
<view class="control-chip" bindtap="handleAutoRotateCalibrate">按当前方向校准</view>
|
<view class="debug-section__desc">配置、瓦片缓存与运行状态</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row" wx:if="{{orientationMode === 'manual'}}">
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
<view class="control-chip" bindtap="handleRotateStep">旋转 +15°</view>
|
<text class="info-panel__label">Config</text>
|
||||||
|
<text class="info-panel__value">{{configStatusText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">Status</text>
|
||||||
|
<text class="info-panel__value">{{statusText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">Tile URL</text>
|
||||||
|
<text class="info-panel__value">{{tileSource}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Center Tile</text>
|
||||||
|
<text class="info-panel__value">{{centerText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Tile Size</text>
|
||||||
|
<text class="info-panel__value">{{tileSizePx}}px</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Visible Tiles</text>
|
||||||
|
<text class="info-panel__value">{{visibleTileCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Ready Tiles</text>
|
||||||
|
<text class="info-panel__value">{{readyTileCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Memory Tiles</text>
|
||||||
|
<text class="info-panel__value">{{memoryTileCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Disk Tiles</text>
|
||||||
|
<text class="info-panel__value">{{diskTileCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Cache Hit</text>
|
||||||
|
<text class="info-panel__value">{{cacheHitRateText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Disk Hits</text>
|
||||||
|
<text class="info-panel__value">{{diskHitCount}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Net Fetches</text>
|
||||||
|
<text class="info-panel__value">{{networkFetchCount}}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -6,6 +6,22 @@
|
|||||||
color: #163020;
|
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 {
|
.map-stage {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -534,18 +550,115 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-shadow: 0 1rpx 0 rgba(255, 255, 255, 0.24);
|
text-shadow: 0 1rpx 0 rgba(255, 255, 255, 0.24);
|
||||||
}
|
}
|
||||||
.race-panel {
|
.race-panel-swiper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 216rpx;
|
height: 216rpx;
|
||||||
background: linear-gradient(180deg, #1d97ec 0%, #168ce4 100%);
|
|
||||||
box-shadow: 0 -10rpx 24rpx rgba(10, 75, 125, 0.2);
|
|
||||||
z-index: 15;
|
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;
|
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 {
|
.race-panel__grid {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@@ -678,6 +791,16 @@
|
|||||||
font-weight: 400;
|
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 {
|
.race-panel__metric-unit {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-left: 6rpx;
|
margin-left: 6rpx;
|
||||||
@@ -694,6 +817,11 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-panel__metric-unit--telemetry {
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.race-panel__progress {
|
.race-panel__progress {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -703,6 +831,31 @@
|
|||||||
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16);
|
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 {
|
.race-panel__tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
@@ -890,40 +1043,95 @@
|
|||||||
.debug-modal__header {
|
.debug-modal__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
gap: 20rpx;
|
gap: 24rpx;
|
||||||
padding: 28rpx 28rpx 20rpx;
|
padding: 22rpx 28rpx 18rpx;
|
||||||
border-bottom: 1rpx solid rgba(22, 48, 32, 0.08);
|
border-bottom: 1rpx solid rgba(22, 48, 32, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-modal__eyebrow {
|
.debug-modal__header-main {
|
||||||
font-size: 20rpx;
|
flex: 1;
|
||||||
letter-spacing: 4rpx;
|
min-width: 0;
|
||||||
color: #5f7a65;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-modal__title {
|
.debug-modal__header-actions {
|
||||||
margin-top: 8rpx;
|
flex-shrink: 0;
|
||||||
font-size: 34rpx;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
color: #163020;
|
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 {
|
.debug-modal__close {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 108rpx;
|
||||||
padding: 14rpx 22rpx;
|
padding: 14rpx 22rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
background: #163020;
|
background: #163020;
|
||||||
color: #f7fbf2;
|
color: #f7fbf2;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-modal__content {
|
.debug-modal__content {
|
||||||
max-height: calc(72vh - 108rpx);
|
max-height: calc(72vh - 108rpx);
|
||||||
padding: 12rpx 28rpx 30rpx;
|
padding: 12rpx 24rpx 30rpx;
|
||||||
box-sizing: border-box;
|
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 {
|
.info-panel__row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -972,6 +1180,10 @@
|
|||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
margin-top: 18rpx;
|
margin-top: 18rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.debug-section .control-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
.control-row--triple .control-chip {
|
.control-row--triple .control-chip {
|
||||||
font-size: 23rpx;
|
font-size: 23rpx;
|
||||||
}
|
}
|
||||||
@@ -1041,6 +1253,10 @@
|
|||||||
box-sizing: border-box;
|
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 { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
|
||||||
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
|
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
|
||||||
import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
|
import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
|
||||||
|
import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
|
||||||
import {
|
import {
|
||||||
mergeGameHapticsConfig,
|
mergeGameHapticsConfig,
|
||||||
mergeGameUiEffectsConfig,
|
mergeGameUiEffectsConfig,
|
||||||
@@ -45,6 +46,7 @@ export interface RemoteMapConfig {
|
|||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
|
telemetryConfig: TelemetryConfig
|
||||||
audioConfig: GameAudioConfig
|
audioConfig: GameAudioConfig
|
||||||
hapticsConfig: GameHapticsConfig
|
hapticsConfig: GameHapticsConfig
|
||||||
uiEffectsConfig: GameUiEffectsConfig
|
uiEffectsConfig: GameUiEffectsConfig
|
||||||
@@ -59,6 +61,7 @@ interface ParsedGameConfig {
|
|||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
|
telemetryConfig: TelemetryConfig
|
||||||
audioConfig: GameAudioConfig
|
audioConfig: GameAudioConfig
|
||||||
hapticsConfig: GameHapticsConfig
|
hapticsConfig: GameHapticsConfig
|
||||||
uiEffectsConfig: GameUiEffectsConfig
|
uiEffectsConfig: GameUiEffectsConfig
|
||||||
@@ -206,6 +209,40 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
|
|||||||
return rawValue === 'enter' ? '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> {
|
function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
|
||||||
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
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 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 rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
|
||||||
const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
|
const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
|
||||||
? rawGame.uiEffects
|
? rawGame.uiEffects
|
||||||
@@ -668,6 +706,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
|
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
telemetryConfig: parseTelemetryConfig(rawTelemetry),
|
||||||
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
|
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
|
||||||
hapticsConfig: parseHapticsConfig(rawHaptics),
|
hapticsConfig: parseHapticsConfig(rawHaptics),
|
||||||
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
|
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
|
||||||
@@ -716,6 +755,18 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
5,
|
5,
|
||||||
),
|
),
|
||||||
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
|
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({
|
audioConfig: parseAudioConfig({
|
||||||
enabled: config.audioenabled,
|
enabled: config.audioenabled,
|
||||||
masterVolume: config.audiomastervolume,
|
masterVolume: config.audiomastervolume,
|
||||||
@@ -979,6 +1030,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
|||||||
punchPolicy: gameConfig.punchPolicy,
|
punchPolicy: gameConfig.punchPolicy,
|
||||||
punchRadiusMeters: gameConfig.punchRadiusMeters,
|
punchRadiusMeters: gameConfig.punchRadiusMeters,
|
||||||
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
||||||
|
telemetryConfig: gameConfig.telemetryConfig,
|
||||||
audioConfig: gameConfig.audioConfig,
|
audioConfig: gameConfig.audioConfig,
|
||||||
hapticsConfig: gameConfig.hapticsConfig,
|
hapticsConfig: gameConfig.hapticsConfig,
|
||||||
uiEffectsConfig: gameConfig.uiEffectsConfig,
|
uiEffectsConfig: gameConfig.uiEffectsConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user