Add mock heart rate simulator flow
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
||||
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
||||
import { HeartRateController, type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
||||
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
||||
import { HeartRateInputController } from '../sensor/heartRateInputController'
|
||||
import { LocationController } from '../sensor/locationController'
|
||||
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
||||
import { type MapRendererStats } from '../renderer/mapRenderer'
|
||||
@@ -129,6 +130,8 @@ export interface MapEngineViewState {
|
||||
mockCoordText: string
|
||||
mockSpeedText: string
|
||||
gpsCoordText: string
|
||||
heartRateSourceMode: 'real' | 'mock'
|
||||
heartRateSourceText: string
|
||||
heartRateConnected: boolean
|
||||
heartRateStatusText: string
|
||||
heartRateDeviceText: string
|
||||
@@ -140,6 +143,10 @@ export interface MapEngineViewState {
|
||||
preferred: boolean
|
||||
connected: boolean
|
||||
}>
|
||||
mockHeartRateBridgeConnected: boolean
|
||||
mockHeartRateBridgeStatusText: string
|
||||
mockHeartRateBridgeUrlText: string
|
||||
mockHeartRateText: string
|
||||
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
||||
gameModeText: string
|
||||
panelTimerText: string
|
||||
@@ -232,11 +239,17 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
||||
'mockCoordText',
|
||||
'mockSpeedText',
|
||||
'gpsCoordText',
|
||||
'heartRateSourceMode',
|
||||
'heartRateSourceText',
|
||||
'heartRateConnected',
|
||||
'heartRateStatusText',
|
||||
'heartRateDeviceText',
|
||||
'heartRateScanText',
|
||||
'heartRateDiscoveredDevices',
|
||||
'mockHeartRateBridgeConnected',
|
||||
'mockHeartRateBridgeStatusText',
|
||||
'mockHeartRateBridgeUrlText',
|
||||
'mockHeartRateText',
|
||||
'gameSessionStatus',
|
||||
'gameModeText',
|
||||
'panelTimerText',
|
||||
@@ -514,7 +527,7 @@ export class MapEngine {
|
||||
renderer: WebGLMapRenderer
|
||||
compassController: CompassHeadingController
|
||||
locationController: LocationController
|
||||
heartRateController: HeartRateController
|
||||
heartRateController: HeartRateInputController
|
||||
feedbackDirector: FeedbackDirector
|
||||
onData: (patch: Partial<MapEngineViewState>) => void
|
||||
state: MapEngineViewState
|
||||
@@ -622,7 +635,7 @@ export class MapEngine {
|
||||
this.setState(this.getLocationControllerViewPatch(), true)
|
||||
},
|
||||
})
|
||||
this.heartRateController = new HeartRateController({
|
||||
this.heartRateController = new HeartRateInputController({
|
||||
onHeartRate: (bpm) => {
|
||||
this.telemetryRuntime.dispatch({
|
||||
type: 'heart_rate_updated',
|
||||
@@ -639,6 +652,7 @@ export class MapEngine {
|
||||
heartRateStatusText: message,
|
||||
heartRateDeviceText: deviceName,
|
||||
heartRateScanText: this.getHeartRateScanText(),
|
||||
...this.getHeartRateControllerViewPatch(),
|
||||
}, true)
|
||||
},
|
||||
onError: (message) => {
|
||||
@@ -651,6 +665,7 @@ export class MapEngine {
|
||||
heartRateStatusText: message,
|
||||
heartRateDeviceText: deviceName,
|
||||
heartRateScanText: this.getHeartRateScanText(),
|
||||
...this.getHeartRateControllerViewPatch(),
|
||||
statusText: `${message} (${this.buildVersion})`,
|
||||
}, true)
|
||||
},
|
||||
@@ -667,18 +682,23 @@ export class MapEngine {
|
||||
heartRateConnected: connected,
|
||||
heartRateDeviceText: resolvedDeviceName,
|
||||
heartRateStatusText: connected
|
||||
? '心率带已连接'
|
||||
: (this.heartRateController.reconnecting ? '心率带自动重连中' : '心率带未连接'),
|
||||
? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
|
||||
: (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')),
|
||||
heartRateScanText: this.getHeartRateScanText(),
|
||||
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
|
||||
...this.getHeartRateControllerViewPatch(),
|
||||
}, true)
|
||||
},
|
||||
onDeviceListChange: (devices) => {
|
||||
this.setState({
|
||||
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
|
||||
heartRateScanText: this.getHeartRateScanText(),
|
||||
...this.getHeartRateControllerViewPatch(),
|
||||
}, true)
|
||||
},
|
||||
onDebugStateChange: () => {
|
||||
this.setState(this.getHeartRateControllerViewPatch(), true)
|
||||
},
|
||||
})
|
||||
this.feedbackDirector = new FeedbackDirector({
|
||||
showPunchFeedback: (text, tone, motionClass) => {
|
||||
@@ -782,11 +802,17 @@ export class MapEngine {
|
||||
mockCoordText: '--',
|
||||
mockSpeedText: '--',
|
||||
gpsCoordText: '--',
|
||||
heartRateSourceMode: 'real',
|
||||
heartRateSourceText: '真实心率',
|
||||
heartRateConnected: false,
|
||||
heartRateStatusText: '心率带未连接',
|
||||
heartRateDeviceText: '--',
|
||||
heartRateScanText: '未扫描',
|
||||
heartRateDiscoveredDevices: [],
|
||||
mockHeartRateBridgeConnected: false,
|
||||
mockHeartRateBridgeStatusText: '未连接',
|
||||
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockHeartRateText: '--',
|
||||
panelTimerText: '00:00:00',
|
||||
panelMileageText: '0m',
|
||||
panelActionTagText: '目标',
|
||||
@@ -951,6 +977,18 @@ export class MapEngine {
|
||||
}
|
||||
}
|
||||
|
||||
getHeartRateControllerViewPatch(): Partial<MapEngineViewState> {
|
||||
const debugState = this.heartRateController.getDebugState()
|
||||
return {
|
||||
heartRateSourceMode: debugState.sourceMode,
|
||||
heartRateSourceText: debugState.sourceModeText,
|
||||
mockHeartRateBridgeConnected: debugState.mockBridgeConnected,
|
||||
mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText,
|
||||
mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText,
|
||||
mockHeartRateText: debugState.mockHeartRateText,
|
||||
}
|
||||
}
|
||||
|
||||
getGameModeText(): string {
|
||||
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||||
}
|
||||
@@ -1442,6 +1480,26 @@ export class MapEngine {
|
||||
this.heartRateController.disconnect()
|
||||
}
|
||||
|
||||
handleSetRealHeartRateMode(): void {
|
||||
this.heartRateController.setSourceMode('real')
|
||||
}
|
||||
|
||||
handleSetMockHeartRateMode(): void {
|
||||
this.heartRateController.setSourceMode('mock')
|
||||
}
|
||||
|
||||
handleConnectMockHeartRateBridge(): void {
|
||||
this.heartRateController.connectMockBridge()
|
||||
}
|
||||
|
||||
handleDisconnectMockHeartRateBridge(): void {
|
||||
this.heartRateController.disconnectMockBridge()
|
||||
}
|
||||
|
||||
handleSetMockHeartRateBridgeUrl(url: string): void {
|
||||
this.heartRateController.setMockBridgeUrl(url)
|
||||
}
|
||||
|
||||
handleConnectHeartRateDevice(deviceId: string): void {
|
||||
this.heartRateController.connectToDiscoveredDevice(deviceId)
|
||||
}
|
||||
@@ -1474,8 +1532,11 @@ export class MapEngine {
|
||||
bpm: null,
|
||||
})
|
||||
this.setState({
|
||||
heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
|
||||
heartRateStatusText: this.heartRateController.connected
|
||||
? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
|
||||
: (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
|
||||
heartRateScanText: this.getHeartRateScanText(),
|
||||
...this.getHeartRateControllerViewPatch(),
|
||||
}, true)
|
||||
this.syncSessionTimerText()
|
||||
}
|
||||
@@ -1491,6 +1552,18 @@ export class MapEngine {
|
||||
}
|
||||
|
||||
getHeartRateScanText(): string {
|
||||
if (this.heartRateController.sourceMode === 'mock') {
|
||||
if (this.heartRateController.connected) {
|
||||
return '模拟源已连接'
|
||||
}
|
||||
|
||||
if (this.heartRateController.connecting) {
|
||||
return '模拟源连接中'
|
||||
}
|
||||
|
||||
return '模拟模式'
|
||||
}
|
||||
|
||||
if (this.heartRateController.connected) {
|
||||
return '已连接'
|
||||
}
|
||||
|
||||
325
miniprogram/engine/sensor/heartRateInputController.ts
Normal file
325
miniprogram/engine/sensor/heartRateInputController.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { HeartRateController, type HeartRateControllerCallbacks, type HeartRateDiscoveredDevice } from './heartRateController'
|
||||
import { DEFAULT_MOCK_HEART_RATE_BRIDGE_URL, MockHeartRateBridge } from './mockHeartRateBridge'
|
||||
|
||||
export type HeartRateSourceMode = 'real' | 'mock'
|
||||
|
||||
export interface HeartRateInputControllerCallbacks {
|
||||
onHeartRate: (bpm: number) => void
|
||||
onStatus: (message: string) => void
|
||||
onError: (message: string) => void
|
||||
onConnectionChange: (connected: boolean, deviceName: string | null) => void
|
||||
onDeviceListChange: (devices: HeartRateDiscoveredDevice[]) => void
|
||||
onDebugStateChange?: () => void
|
||||
}
|
||||
|
||||
export interface HeartRateInputControllerDebugState {
|
||||
sourceMode: HeartRateSourceMode
|
||||
sourceModeText: string
|
||||
mockBridgeConnected: boolean
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrlText: string
|
||||
mockHeartRateText: string
|
||||
}
|
||||
|
||||
function formatSourceModeText(mode: HeartRateSourceMode): string {
|
||||
return mode === 'mock' ? '模拟心率' : '真实心率'
|
||||
}
|
||||
|
||||
function formatMockHeartRateText(bpm: number | null): string {
|
||||
return bpm === null ? '--' : `${bpm} bpm`
|
||||
}
|
||||
|
||||
function normalizeMockBridgeUrl(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim()
|
||||
if (!trimmed) {
|
||||
return DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||
}
|
||||
|
||||
let normalized = trimmed
|
||||
if (!/^wss?:\/\//i.test(normalized)) {
|
||||
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
|
||||
normalized = normalized.replace(/\/+$/, '')
|
||||
normalized = `${normalized}/mock-gps`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export class HeartRateInputController {
|
||||
callbacks: HeartRateInputControllerCallbacks
|
||||
realController: HeartRateController
|
||||
mockBridge: MockHeartRateBridge
|
||||
sourceMode: HeartRateSourceMode
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrl: string
|
||||
mockBpm: number | null
|
||||
|
||||
constructor(callbacks: HeartRateInputControllerCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.sourceMode = 'real'
|
||||
this.mockBridgeUrl = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||
this.mockBpm = null
|
||||
|
||||
const realCallbacks: HeartRateControllerCallbacks = {
|
||||
onHeartRate: (bpm) => {
|
||||
if (this.sourceMode !== 'real') {
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onHeartRate(bpm)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onStatus: (message) => {
|
||||
if (this.sourceMode !== 'real') {
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onStatus(message)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onError: (message) => {
|
||||
if (this.sourceMode !== 'real') {
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onError(message)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onConnectionChange: (connected, deviceName) => {
|
||||
if (this.sourceMode !== 'real') {
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onConnectionChange(connected, deviceName)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onDeviceListChange: (devices) => {
|
||||
this.callbacks.onDeviceListChange(devices)
|
||||
this.emitDebugState()
|
||||
},
|
||||
}
|
||||
|
||||
this.realController = new HeartRateController(realCallbacks)
|
||||
this.mockBridge = new MockHeartRateBridge({
|
||||
onOpen: () => {
|
||||
this.mockBridgeStatusText = `已连接 (${this.mockBridge.url})`
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onConnectionChange(true, '模拟心率源')
|
||||
this.callbacks.onStatus('模拟心率源已连接,等待外部输入')
|
||||
}
|
||||
this.emitDebugState()
|
||||
},
|
||||
onClose: () => {
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onConnectionChange(false, null)
|
||||
this.callbacks.onStatus('模拟心率源已断开')
|
||||
}
|
||||
this.emitDebugState()
|
||||
},
|
||||
onError: (message) => {
|
||||
this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})`
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onConnectionChange(false, null)
|
||||
this.callbacks.onError(`模拟心率源错误: ${message}`)
|
||||
}
|
||||
this.emitDebugState()
|
||||
},
|
||||
onBpm: (bpm) => {
|
||||
this.mockBpm = bpm
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onHeartRate(bpm)
|
||||
}
|
||||
this.emitDebugState()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
get currentDeviceId(): string | null {
|
||||
if (this.sourceMode === 'mock') {
|
||||
return this.mockBridge.connected ? 'mock-heart-rate' : null
|
||||
}
|
||||
|
||||
return this.realController.currentDeviceId
|
||||
}
|
||||
|
||||
get currentDeviceName(): string | null {
|
||||
if (this.sourceMode === 'mock') {
|
||||
return this.mockBridge.connected ? '模拟心率源' : null
|
||||
}
|
||||
|
||||
return this.realController.currentDeviceName
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this.sourceMode === 'mock' ? this.mockBridge.connected : this.realController.connected
|
||||
}
|
||||
|
||||
get connecting(): boolean {
|
||||
return this.sourceMode === 'mock' ? this.mockBridge.connecting : this.realController.connecting
|
||||
}
|
||||
|
||||
get scanning(): boolean {
|
||||
return this.sourceMode === 'mock' ? false : this.realController.scanning
|
||||
}
|
||||
|
||||
get reconnecting(): boolean {
|
||||
return this.sourceMode === 'mock' ? false : this.realController.reconnecting
|
||||
}
|
||||
|
||||
get disconnecting(): boolean {
|
||||
return this.sourceMode === 'mock' ? false : this.realController.disconnecting
|
||||
}
|
||||
|
||||
get discoveredDevices(): HeartRateDiscoveredDevice[] {
|
||||
return this.realController.discoveredDevices
|
||||
}
|
||||
|
||||
get lastDeviceId(): string | null {
|
||||
return this.realController.lastDeviceId
|
||||
}
|
||||
|
||||
get lastDeviceName(): string | null {
|
||||
return this.realController.lastDeviceName
|
||||
}
|
||||
|
||||
getDebugState(): HeartRateInputControllerDebugState {
|
||||
return {
|
||||
sourceMode: this.sourceMode,
|
||||
sourceModeText: formatSourceModeText(this.sourceMode),
|
||||
mockBridgeConnected: this.mockBridge.connected,
|
||||
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||
mockBridgeUrlText: this.mockBridgeUrl,
|
||||
mockHeartRateText: formatMockHeartRateText(this.mockBpm),
|
||||
}
|
||||
}
|
||||
|
||||
startScanAndConnect(): void {
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onStatus(this.mockBridge.connected ? '模拟心率源已连接' : '当前为模拟心率模式,请连接模拟源')
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.realController.startScanAndConnect()
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.sourceMode === 'mock') {
|
||||
if (!this.mockBridge.connected && !this.mockBridge.connecting) {
|
||||
this.callbacks.onStatus('模拟心率源未连接')
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.mockBridge.disconnect()
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.realController.disconnect()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.realController.destroy()
|
||||
this.mockBridge.destroy()
|
||||
}
|
||||
|
||||
setSourceMode(mode: HeartRateSourceMode): void {
|
||||
if (this.sourceMode === mode) {
|
||||
this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`)
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
const previousMode = this.sourceMode
|
||||
this.sourceMode = mode
|
||||
|
||||
if (previousMode === 'real') {
|
||||
this.realController.disconnect()
|
||||
} else {
|
||||
this.callbacks.onConnectionChange(false, null)
|
||||
}
|
||||
|
||||
const activeDeviceName = this.currentDeviceName
|
||||
this.callbacks.onConnectionChange(this.connected, activeDeviceName)
|
||||
this.callbacks.onStatus(mode === 'mock' ? '已切换到模拟心率模式' : '已切换到真实心率模式')
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
setMockBridgeUrl(url: string): void {
|
||||
this.mockBridgeUrl = normalizeMockBridgeUrl(url)
|
||||
|
||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||
this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})`
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onStatus('模拟心率源地址已更新,重连后生效')
|
||||
}
|
||||
} else {
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onStatus('模拟心率源地址已更新')
|
||||
}
|
||||
}
|
||||
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
connectMockBridge(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onStatus('模拟心率源已连接')
|
||||
}
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = normalizeMockBridgeUrl(url === DEFAULT_MOCK_HEART_RATE_BRIDGE_URL ? this.mockBridgeUrl : url)
|
||||
this.mockBridgeUrl = targetUrl
|
||||
this.mockBridgeStatusText = `连接中 (${targetUrl})`
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onStatus('模拟心率源连接中')
|
||||
}
|
||||
this.emitDebugState()
|
||||
this.mockBridge.connect(targetUrl)
|
||||
}
|
||||
|
||||
disconnectMockBridge(): void {
|
||||
if (!this.mockBridge.connected && !this.mockBridge.connecting) {
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onStatus('模拟心率源未连接')
|
||||
}
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.mockBridge.disconnect()
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
connectToDiscoveredDevice(deviceId: string): void {
|
||||
if (this.sourceMode !== 'real') {
|
||||
this.callbacks.onStatus('当前为模拟心率模式,无法连接真实心率带')
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.realController.connectToDiscoveredDevice(deviceId)
|
||||
}
|
||||
|
||||
clearPreferredDevice(): void {
|
||||
this.realController.clearPreferredDevice()
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
emitDebugState(): void {
|
||||
if (this.callbacks.onDebugStateChange) {
|
||||
this.callbacks.onDebugStateChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
134
miniprogram/engine/sensor/mockHeartRateBridge.ts
Normal file
134
miniprogram/engine/sensor/mockHeartRateBridge.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps'
|
||||
|
||||
export interface MockHeartRateBridgeCallbacks {
|
||||
onOpen: () => void
|
||||
onClose: (message: string) => void
|
||||
onError: (message: string) => void
|
||||
onBpm: (bpm: number) => void
|
||||
}
|
||||
|
||||
type RawMockHeartRateMessage = {
|
||||
type?: string
|
||||
timestamp?: number
|
||||
bpm?: number
|
||||
}
|
||||
|
||||
function safeParseMessage(data: string): RawMockHeartRateMessage | null {
|
||||
try {
|
||||
return JSON.parse(data) as RawMockHeartRateMessage
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toHeartRateValue(message: RawMockHeartRateMessage): number | null {
|
||||
if (message.type !== 'mock_heart_rate' || !Number.isFinite(message.bpm)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bpm = Math.round(Number(message.bpm))
|
||||
if (bpm <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return bpm
|
||||
}
|
||||
|
||||
export class MockHeartRateBridge {
|
||||
callbacks: MockHeartRateBridgeCallbacks
|
||||
socketTask: WechatMiniprogram.SocketTask | null
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
url: string
|
||||
|
||||
constructor(callbacks: MockHeartRateBridgeCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.socketTask = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||
}
|
||||
|
||||
connect(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
||||
if (this.connected || this.connecting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.url = url
|
||||
this.connecting = true
|
||||
|
||||
try {
|
||||
const socketTask = wx.connectSocket({
|
||||
url,
|
||||
})
|
||||
this.socketTask = socketTask
|
||||
|
||||
socketTask.onOpen(() => {
|
||||
this.connected = true
|
||||
this.connecting = false
|
||||
this.callbacks.onOpen()
|
||||
})
|
||||
|
||||
socketTask.onClose((result) => {
|
||||
const reason = result && result.reason ? result.reason : '模拟心率源连接已关闭'
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
this.callbacks.onClose(reason)
|
||||
})
|
||||
|
||||
socketTask.onError((result) => {
|
||||
const message = result && result.errMsg ? result.errMsg : '模拟心率源连接失败'
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
this.callbacks.onError(message)
|
||||
})
|
||||
|
||||
socketTask.onMessage((result) => {
|
||||
if (!result || typeof result.data !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = safeParseMessage(result.data)
|
||||
if (!parsed) {
|
||||
this.callbacks.onError('模拟心率消息不是合法 JSON')
|
||||
return
|
||||
}
|
||||
|
||||
const bpm = toHeartRateValue(parsed)
|
||||
if (bpm === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onBpm(bpm)
|
||||
})
|
||||
} catch (error) {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
const message = error && (error as Error).message ? (error as Error).message : '模拟心率源连接创建失败'
|
||||
this.callbacks.onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (!this.socketTask) {
|
||||
if (this.connected || this.connecting) {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const socketTask = this.socketTask
|
||||
this.socketTask = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
socketTask.close({})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type MapPageData = MapEngineViewState & {
|
||||
topInsetHeight: number
|
||||
hudPanelIndex: number
|
||||
mockBridgeUrlDraft: string
|
||||
mockHeartRateBridgeUrlDraft: string
|
||||
panelTimerText: string
|
||||
panelMileageText: string
|
||||
panelDistanceValueText: string
|
||||
@@ -31,7 +32,7 @@ type MapPageData = MapEngineViewState & {
|
||||
showRightButtonGroups: boolean
|
||||
showBottomDebugButton: boolean
|
||||
}
|
||||
const INTERNAL_BUILD_VERSION = 'map-build-195'
|
||||
const INTERNAL_BUILD_VERSION = 'map-build-196'
|
||||
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||
let mapEngine: MapEngine | null = null
|
||||
let stageCanvasAttached = false
|
||||
@@ -115,6 +116,13 @@ Page({
|
||||
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockCoordText: '--',
|
||||
mockSpeedText: '--',
|
||||
heartRateSourceMode: 'real',
|
||||
heartRateSourceText: '真实心率',
|
||||
mockHeartRateBridgeConnected: false,
|
||||
mockHeartRateBridgeStatusText: '未连接',
|
||||
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockHeartRateText: '--',
|
||||
heartRateScanText: '未扫描',
|
||||
heartRateDiscoveredDevices: [],
|
||||
panelSpeedValueText: '0',
|
||||
@@ -164,18 +172,25 @@ Page({
|
||||
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
||||
onData: (patch) => {
|
||||
const nextPatch = patch as Partial<MapPageData>
|
||||
const nextData: Partial<MapPageData> = {
|
||||
...nextPatch,
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nextPatch.mockBridgeUrlText === 'string'
|
||||
&& this.data.mockBridgeUrlDraft === this.data.mockBridgeUrlText
|
||||
) {
|
||||
this.setData({
|
||||
...nextPatch,
|
||||
mockBridgeUrlDraft: nextPatch.mockBridgeUrlText,
|
||||
})
|
||||
return
|
||||
nextData.mockBridgeUrlDraft = nextPatch.mockBridgeUrlText
|
||||
}
|
||||
|
||||
this.setData(nextPatch)
|
||||
if (
|
||||
typeof nextPatch.mockHeartRateBridgeUrlText === 'string'
|
||||
&& this.data.mockHeartRateBridgeUrlDraft === this.data.mockHeartRateBridgeUrlText
|
||||
) {
|
||||
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
|
||||
}
|
||||
|
||||
this.setData(nextData)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -202,6 +217,13 @@ Page({
|
||||
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockCoordText: '--',
|
||||
mockSpeedText: '--',
|
||||
heartRateSourceMode: 'real',
|
||||
heartRateSourceText: '真实心率',
|
||||
mockHeartRateBridgeConnected: false,
|
||||
mockHeartRateBridgeStatusText: '未连接',
|
||||
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockHeartRateText: '--',
|
||||
panelSpeedValueText: '0',
|
||||
panelTelemetryTone: 'blue',
|
||||
panelHeartRateZoneNameText: '--',
|
||||
@@ -469,6 +491,42 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
handleSetRealHeartRateMode() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetRealHeartRateMode()
|
||||
}
|
||||
},
|
||||
|
||||
handleSetMockHeartRateMode() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetMockHeartRateMode()
|
||||
}
|
||||
},
|
||||
|
||||
handleMockHeartRateBridgeUrlInput(event: WechatMiniprogram.Input) {
|
||||
this.setData({
|
||||
mockHeartRateBridgeUrlDraft: event.detail.value,
|
||||
})
|
||||
},
|
||||
|
||||
handleSaveMockHeartRateBridgeUrl() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft)
|
||||
}
|
||||
},
|
||||
|
||||
handleConnectMockHeartRateBridge() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleConnectMockHeartRateBridge()
|
||||
}
|
||||
},
|
||||
|
||||
handleDisconnectMockHeartRateBridge() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleDisconnectMockHeartRateBridge()
|
||||
}
|
||||
},
|
||||
|
||||
handleConnectHeartRate() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleConnectHeartRate()
|
||||
|
||||
@@ -328,15 +328,23 @@
|
||||
<text class="info-panel__label">Heart Rate</text>
|
||||
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Heart Source</text>
|
||||
<text class="info-panel__value">{{heartRateSourceText}}</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">
|
||||
<view class="control-row">
|
||||
<view class="control-chip {{heartRateSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealHeartRateMode">真实心率</view>
|
||||
<view class="control-chip {{heartRateSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockHeartRateMode">模拟心率</view>
|
||||
</view>
|
||||
<view class="info-panel__row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||
<text class="info-panel__label">HR Scan</text>
|
||||
<text class="info-panel__value">{{heartRateScanText}}</text>
|
||||
</view>
|
||||
<view class="debug-device-list" wx:if="{{heartRateDiscoveredDevices.length}}">
|
||||
<view class="debug-device-list" wx:if="{{heartRateSourceMode === 'real' && heartRateDiscoveredDevices.length}}">
|
||||
<view class="debug-device-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
|
||||
<view class="debug-device-card__main">
|
||||
<view class="debug-device-card__title-row">
|
||||
@@ -348,13 +356,37 @@
|
||||
<view class="debug-device-card__action {{item.connected ? 'debug-device-card__action--active' : ''}}" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||
<text class="info-panel__label">Mock HR Bridge</text>
|
||||
<text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||
<text class="info-panel__label">Mock HR URL</text>
|
||||
<view class="debug-inline-stack">
|
||||
<input
|
||||
class="debug-input"
|
||||
value="{{mockHeartRateBridgeUrlDraft}}"
|
||||
placeholder="ws://192.168.x.x:17865/mock-gps"
|
||||
bindinput="handleMockHeartRateBridgeUrlInput"
|
||||
/>
|
||||
<view class="control-row control-row--compact">
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockHeartRateBridgeUrl">保存地址</view>
|
||||
<view class="control-chip {{mockHeartRateBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockHeartRateBridge">连接模拟心率源</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockHeartRateBridge">断开模拟心率源</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-panel__row" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||
<text class="info-panel__label">Mock BPM</text>
|
||||
<text class="info-panel__value">{{mockHeartRateText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Heading Mode</text>
|
||||
<text class="info-panel__value">{{orientationModeText}}</text>
|
||||
|
||||
Reference in New Issue
Block a user