diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index fb62545..5d3b86e 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -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 = [ '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) => 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 { + 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 '已连接' } diff --git a/miniprogram/engine/sensor/heartRateInputController.ts b/miniprogram/engine/sensor/heartRateInputController.ts new file mode 100644 index 0000000..30201ff --- /dev/null +++ b/miniprogram/engine/sensor/heartRateInputController.ts @@ -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() + } + } +} diff --git a/miniprogram/engine/sensor/mockHeartRateBridge.ts b/miniprogram/engine/sensor/mockHeartRateBridge.ts new file mode 100644 index 0000000..2a5a651 --- /dev/null +++ b/miniprogram/engine/sensor/mockHeartRateBridge.ts @@ -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() + } +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 670a036..52a3a30 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -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 + const nextData: Partial = { + ...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() diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 1b52aee..92a5120 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -328,15 +328,23 @@ Heart Rate {{heartRateStatusText}} + + Heart Source + {{heartRateSourceText}} + HR Device {{heartRateDeviceText}} - + + 真实心率 + 模拟心率 + + HR Scan {{heartRateScanText}} - + @@ -348,13 +356,37 @@ {{item.connected ? '已连接' : '连接'}} - + {{heartRateConnected ? '心率带已连接' : '连接心率带'}} 断开心率带 - + 清除首选 + + Mock HR Bridge + {{mockHeartRateBridgeStatusText}} + + + Mock HR URL + + + + 保存地址 + 连接模拟心率源 + 断开模拟心率源 + + + + + Mock BPM + {{mockHeartRateText}} + Heading Mode {{orientationModeText}} diff --git a/tools/mock-gps-sim/public/index.html b/tools/mock-gps-sim/public/index.html index 08fd6b6..5729944 100644 --- a/tools/mock-gps-sim/public/index.html +++ b/tools/mock-gps-sim/public/index.html @@ -73,6 +73,44 @@ +
+
心率模拟
+
心率模拟待命
+
最近发送: --
+
+ + +
+
+ + +
+
+ +
+ + + +
+
路径回放
路径待命
diff --git a/tools/mock-gps-sim/public/simulator.js b/tools/mock-gps-sim/public/simulator.js index 5deaa45..818d0fb 100644 --- a/tools/mock-gps-sim/public/simulator.js +++ b/tools/mock-gps-sim/public/simulator.js @@ -33,11 +33,15 @@ connected: false, socketConnecting: false, streaming: false, + heartRateStreaming: false, + heartRateSampleMode: false, pathEditMode: false, playbackRunning: false, playbackTimer: 0, streamTimer: 0, + heartRateStreamTimer: 0, lastSentText: '--', + lastHeartRateSentText: '--', lastResourceDetailText: '尚未载入资源', lastTrackSourceText: '路径待命', currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]), @@ -45,6 +49,7 @@ currentSegmentIndex: 0, currentSegmentProgress: 0, lastPlaybackAt: 0, + heartRateSampleStartedAt: 0, loadedCourse: null, resourceLoading: false, } @@ -66,6 +71,16 @@ realtimeStatus: document.getElementById('realtimeStatus'), lastSendStatus: document.getElementById('lastSendStatus'), playbackStatus: document.getElementById('playbackStatus'), + heartRateStatus: document.getElementById('heartRateStatus'), + lastHeartRateStatus: document.getElementById('lastHeartRateStatus'), + sendHeartRateOnceBtn: document.getElementById('sendHeartRateOnceBtn'), + startHeartRateStreamBtn: document.getElementById('startHeartRateStreamBtn'), + stopHeartRateStreamBtn: document.getElementById('stopHeartRateStreamBtn'), + applyHeartRatePresetBtn: document.getElementById('applyHeartRatePresetBtn'), + toggleHeartRateSampleBtn: document.getElementById('toggleHeartRateSampleBtn'), + heartRateInput: document.getElementById('heartRateInput'), + heartRateHzSelect: document.getElementById('heartRateHzSelect'), + heartRateSampleTemplateSelect: document.getElementById('heartRateSampleTemplateSelect'), trackFileInput: document.getElementById('trackFileInput'), importTrackBtn: document.getElementById('importTrackBtn'), connectBtn: document.getElementById('connectBtn'), @@ -144,6 +159,13 @@ elements.streamBtn.classList.toggle('is-active', state.streaming) elements.streamBtn.disabled = !state.connected || state.streaming elements.stopStreamBtn.disabled = !state.streaming + elements.sendHeartRateOnceBtn.disabled = !state.connected + elements.startHeartRateStreamBtn.textContent = state.heartRateStreaming ? '发送中' : '开始连续发送' + elements.startHeartRateStreamBtn.classList.toggle('is-active', state.heartRateStreaming) + elements.startHeartRateStreamBtn.disabled = !state.connected || state.heartRateStreaming + elements.stopHeartRateStreamBtn.disabled = !state.heartRateStreaming + elements.toggleHeartRateSampleBtn.textContent = state.heartRateSampleMode ? '关闭真实样本' : '模拟真实样本' + elements.toggleHeartRateSampleBtn.classList.toggle('is-active', state.heartRateSampleMode) elements.togglePathModeBtn.textContent = state.pathEditMode ? '关闭路径编辑' : '开启路径编辑' elements.togglePathModeBtn.classList.toggle('is-active', state.pathEditMode) @@ -166,6 +188,7 @@ elements.applyTilesBtn.disabled = state.resourceLoading elements.resetTilesBtn.disabled = state.resourceLoading elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}` + elements.lastHeartRateStatus.textContent = `最近发送: ${state.lastHeartRateSentText}` elements.resourceDetail.textContent = state.lastResourceDetailText if (state.connected && state.streaming) { @@ -178,6 +201,18 @@ elements.realtimeStatus.textContent = '桥接未连接' } + if (state.connected && state.heartRateStreaming) { + elements.heartRateStatus.textContent = state.heartRateSampleMode + ? `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 发送真实心率样本` + : `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 连续发送心率` + } else if (state.connected) { + elements.heartRateStatus.textContent = state.heartRateSampleMode ? '真实心率样本待命' : '心率模拟待命' + } else if (state.socketConnecting) { + elements.heartRateStatus.textContent = '桥接连接中' + } else { + elements.heartRateStatus.textContent = '桥接未连接' + } + if (state.playbackRunning) { elements.playbackStatus.textContent = `路径回放中,速度 ${elements.speedInput.value} km/h` } else if (state.pathEditMode) { @@ -212,6 +247,8 @@ socket.addEventListener('close', () => { state.connected = false state.socketConnecting = false + stopStream() + stopHeartRateStream() setSocketBadge(false) updateUiState() log('桥接已断开') @@ -220,6 +257,8 @@ socket.addEventListener('error', () => { state.connected = false state.socketConnecting = false + stopStream() + stopHeartRateStream() setSocketBadge(false) updateUiState() log('桥接连接失败') @@ -685,6 +724,79 @@ return Math.max(0.2, (Number(elements.speedInput.value) || 6) / 3.6) } + function getHeartRateBpm() { + return Math.max(40, Math.min(220, Math.round(Number(elements.heartRateInput.value) || 120))) + } + + function getSampleHeartRateBpm() { + const now = Date.now() + if (!state.heartRateSampleStartedAt) { + state.heartRateSampleStartedAt = now + } + + const elapsedSeconds = (now - state.heartRateSampleStartedAt) / 1000 + const template = elements.heartRateSampleTemplateSelect.value || 'jog' + + let cycleSeconds = 360 + let bpm = 120 + const jitter = Math.sin(elapsedSeconds * 1.7) * 1.8 + Math.sin(elapsedSeconds * 0.47) * 1.2 + + if (template === 'recovery') { + cycleSeconds = 300 + const phase = elapsedSeconds % cycleSeconds + if (phase < 80) { + bpm = 82 + phase * 0.08 + } else if (phase < 190) { + bpm = 89 + Math.sin((phase - 80) / 20) * 3 + } else { + bpm = 90 - (phase - 190) * 0.06 + Math.sin((phase - 190) / 18) * 2 + } + } else if (template === 'tempo') { + cycleSeconds = 320 + const phase = elapsedSeconds % cycleSeconds + if (phase < 50) { + bpm = 102 + phase * 0.42 + } else if (phase < 230) { + bpm = 124 + Math.sin((phase - 50) / 14) * 5 + Math.sin((phase - 50) / 36) * 3 + } else { + bpm = 126 - (phase - 230) * 0.18 + Math.sin((phase - 230) / 12) * 3 + } + } else if (template === 'interval') { + cycleSeconds = 260 + const phase = elapsedSeconds % cycleSeconds + if (phase < 40) { + bpm = 100 + phase * 0.35 + } else { + const wavePhase = phase - 40 + const intervalCycle = wavePhase % 44 + if (intervalCycle < 20) { + bpm = 140 + intervalCycle * 1.2 + } else if (intervalCycle < 32) { + bpm = 164 - (intervalCycle - 20) * 0.45 + } else { + bpm = 158 - (intervalCycle - 32) * 2.7 + } + } + } else { + const phase = elapsedSeconds % cycleSeconds + if (phase < 60) { + bpm = 96 + phase * 0.35 + } else if (phase < 150) { + bpm = 118 + Math.sin((phase - 60) / 18) * 6 + } else if (phase < 240) { + bpm = 138 + Math.sin((phase - 150) / 10) * 9 + } else if (phase < 300) { + bpm = 158 + Math.sin((phase - 240) / 7) * 8 + } else { + bpm = 124 - (phase - 300) * 0.22 + Math.sin((phase - 300) / 15) * 4 + } + } + + const nextBpm = Math.max(72, Math.min(182, Math.round(bpm + jitter))) + elements.heartRateInput.value = String(nextBpm) + return nextBpm + } + function sendCurrentPoint() { if (!state.socket || state.socket.readyState !== WebSocket.OPEN) { log('未连接桥接,无法发送') @@ -705,6 +817,22 @@ updateUiState() } + function sendCurrentHeartRate() { + if (!state.socket || state.socket.readyState !== WebSocket.OPEN) { + log('未连接桥接,无法发送心率') + return + } + + const payload = { + type: 'mock_heart_rate', + timestamp: Date.now(), + bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(), + } + state.socket.send(JSON.stringify(payload)) + state.lastHeartRateSentText = `${formatClockTime(payload.timestamp)} @ ${payload.bpm} bpm` + updateUiState() + } + function startStream() { stopStream() state.streaming = true @@ -725,6 +853,53 @@ updateUiState() } + function startHeartRateStream() { + stopHeartRateStream() + state.heartRateStreaming = true + if (state.heartRateSampleMode && !state.heartRateSampleStartedAt) { + state.heartRateSampleStartedAt = Date.now() + } + const intervalMs = Math.max(150, 1000 / (Number(elements.heartRateHzSelect.value) || 1)) + sendCurrentHeartRate() + state.heartRateStreamTimer = window.setInterval(sendCurrentHeartRate, intervalMs) + updateUiState() + log(`开始连续发送心率 (${Math.round(1000 / intervalMs)} Hz)`) + } + + function stopHeartRateStream() { + state.heartRateStreaming = false + if (state.heartRateStreamTimer) { + window.clearInterval(state.heartRateStreamTimer) + state.heartRateStreamTimer = 0 + log('已停止连续发送心率') + } + updateUiState() + } + + function applyHeartRatePreset() { + const sampleBpm = [88, 102, 118, 136, 154, 170] + const current = getHeartRateBpm() + let nextIndex = sampleBpm.findIndex((value) => value > current) + if (nextIndex === -1) { + nextIndex = 0 + } + + elements.heartRateInput.value = String(sampleBpm[nextIndex]) + log(`已应用心率分区样本: ${sampleBpm[nextIndex]} bpm`) + } + + function toggleHeartRateSampleMode() { + state.heartRateSampleMode = !state.heartRateSampleMode + state.heartRateSampleStartedAt = state.heartRateSampleMode ? Date.now() : 0 + if (state.heartRateSampleMode) { + const bpm = getSampleHeartRateBpm() + log(`已开启真实心率样本 (${elements.heartRateSampleTemplateSelect.value || 'jog'}): ${bpm} bpm`) + } else { + log('已关闭真实心率样本') + } + updateUiState() + } + function syncPathLine() { pathLine.setLatLngs(pathPoints) elements.pathCountText.textContent = String(pathPoints.length) @@ -1128,6 +1303,14 @@ }) elements.streamBtn.addEventListener('click', startStream) elements.stopStreamBtn.addEventListener('click', stopStream) + elements.sendHeartRateOnceBtn.addEventListener('click', () => { + sendCurrentHeartRate() + log('已发送当前心率') + }) + elements.startHeartRateStreamBtn.addEventListener('click', startHeartRateStream) + elements.stopHeartRateStreamBtn.addEventListener('click', stopHeartRateStream) + elements.applyHeartRatePresetBtn.addEventListener('click', applyHeartRatePreset) + elements.toggleHeartRateSampleBtn.addEventListener('click', toggleHeartRateSampleMode) elements.togglePathModeBtn.addEventListener('click', () => { state.pathEditMode = !state.pathEditMode elements.pathHint.textContent = state.pathEditMode diff --git a/tools/mock-gps-sim/public/style.css b/tools/mock-gps-sim/public/style.css index bcbec7f..a601dd3 100644 --- a/tools/mock-gps-sim/public/style.css +++ b/tools/mock-gps-sim/public/style.css @@ -2,24 +2,30 @@ box-sizing: border-box; } +html, body { + height: 100%; margin: 0; font-family: "Segoe UI", "PingFang SC", sans-serif; background: #edf3ea; color: #163126; + overflow: hidden; } .layout { display: grid; grid-template-columns: 400px 1fr; - min-height: 100vh; + height: 100vh; + overflow: hidden; } .panel { + height: 100vh; padding: 20px; background: rgba(250, 252, 248, 0.96); border-right: 1px solid rgba(22, 49, 38, 0.08); overflow-y: auto; + overscroll-behavior: contain; } .panel__header h1 { @@ -221,7 +227,9 @@ body { } .map-shell { - min-height: 100vh; + position: relative; + height: 100vh; + overflow: hidden; } #map { diff --git a/tools/mock-gps-sim/server.js b/tools/mock-gps-sim/server.js index ee67821..fcc5bf7 100644 --- a/tools/mock-gps-sim/server.js +++ b/tools/mock-gps-sim/server.js @@ -60,6 +60,12 @@ function isMockGpsPayload(payload) { && Number.isFinite(payload.lon) } +function isMockHeartRatePayload(payload) { + return payload + && payload.type === 'mock_heart_rate' + && Number.isFinite(payload.bpm) +} + async function handleProxyRequest(request, response) { const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`) const targetUrl = requestUrl.searchParams.get('url') @@ -111,19 +117,25 @@ wss.on('connection', (socket) => { return } - if (!isMockGpsPayload(parsed)) { + if (!isMockGpsPayload(parsed) && !isMockHeartRatePayload(parsed)) { return } - const serialized = JSON.stringify({ - type: 'mock_gps', - timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), - lat: Number(parsed.lat), - lon: Number(parsed.lon), - accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6, - speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0, - headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0, - }) + const serialized = isMockGpsPayload(parsed) + ? JSON.stringify({ + type: 'mock_gps', + timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), + lat: Number(parsed.lat), + lon: Number(parsed.lon), + accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6, + speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0, + headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0, + }) + : JSON.stringify({ + type: 'mock_heart_rate', + timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(), + bpm: Math.max(1, Math.round(Number(parsed.bpm))), + }) wss.clients.forEach((client) => { if (client.readyState === client.OPEN) {