diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 54dfd8c..fb62545 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -1,6 +1,6 @@ import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera' import { CompassHeadingController } from '../sensor/compassHeadingController' -import { HeartRateController } from '../sensor/heartRateController' +import { HeartRateController, type HeartRateDiscoveredDevice } from '../sensor/heartRateController' import { LocationController } from '../sensor/locationController' import { WebGLMapRenderer } from '../renderer/webglMapRenderer' import { type MapRendererStats } from '../renderer/mapRenderer' @@ -132,6 +132,14 @@ export interface MapEngineViewState { heartRateConnected: boolean heartRateStatusText: string heartRateDeviceText: string + heartRateScanText: string + heartRateDiscoveredDevices: Array<{ + deviceId: string + name: string + rssiText: string + preferred: boolean + connected: boolean + }> gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed' gameModeText: string panelTimerText: string @@ -227,6 +235,8 @@ const VIEW_SYNC_KEYS: Array = [ 'heartRateConnected', 'heartRateStatusText', 'heartRateDeviceText', + 'heartRateScanText', + 'heartRateDiscoveredDevices', 'gameSessionStatus', 'gameModeText', 'panelTimerText', @@ -612,37 +622,64 @@ export class MapEngine { this.setState(this.getLocationControllerViewPatch(), true) }, }) - this.heartRateController = new HeartRateController({ - onHeartRate: (bpm) => { - this.telemetryRuntime.dispatch({ - type: 'heart_rate_updated', - at: Date.now(), + 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) - }, - }) + }, + onStatus: (message) => { + const deviceName = this.heartRateController.currentDeviceName + || (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null) + || '--' + this.setState({ + heartRateStatusText: message, + heartRateDeviceText: deviceName, + heartRateScanText: this.getHeartRateScanText(), + }, true) + }, + onError: (message) => { + this.clearHeartRateSignal() + const deviceName = this.heartRateController.reconnecting + ? (this.heartRateController.lastDeviceName || '--') + : '--' + this.setState({ + heartRateConnected: false, + heartRateStatusText: message, + heartRateDeviceText: deviceName, + heartRateScanText: this.getHeartRateScanText(), + statusText: `${message} (${this.buildVersion})`, + }, true) + }, + onConnectionChange: (connected, deviceName) => { + if (!connected) { + this.clearHeartRateSignal() + } + const resolvedDeviceName = connected + ? (deviceName || '--') + : (this.heartRateController.reconnecting + ? (this.heartRateController.lastDeviceName || '--') + : '--') + this.setState({ + heartRateConnected: connected, + heartRateDeviceText: resolvedDeviceName, + heartRateStatusText: connected + ? '心率带已连接' + : (this.heartRateController.reconnecting ? '心率带自动重连中' : '心率带未连接'), + heartRateScanText: this.getHeartRateScanText(), + heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices), + }, true) + }, + onDeviceListChange: (devices) => { + this.setState({ + heartRateDiscoveredDevices: this.formatHeartRateDevices(devices), + heartRateScanText: this.getHeartRateScanText(), + }, true) + }, + }) this.feedbackDirector = new FeedbackDirector({ showPunchFeedback: (text, tone, motionClass) => { this.showPunchFeedback(text, tone, motionClass) @@ -745,9 +782,11 @@ export class MapEngine { mockCoordText: '--', mockSpeedText: '--', gpsCoordText: '--', - heartRateConnected: false, - heartRateStatusText: '心率带未连接', - heartRateDeviceText: '--', + heartRateConnected: false, + heartRateStatusText: '心率带未连接', + heartRateDeviceText: '--', + heartRateScanText: '未扫描', + heartRateDiscoveredDevices: [], panelTimerText: '00:00:00', panelMileageText: '0m', panelActionTagText: '目标', @@ -848,6 +887,14 @@ export class MapEngine { this.mounted = false } + handleAppShow(): void { + this.feedbackDirector.setAppAudioMode('foreground') + } + + handleAppHide(): void { + this.feedbackDirector.setAppAudioMode('foreground') + } + clearGameRuntime(): void { this.gameRuntime.clear() @@ -858,6 +905,15 @@ export class MapEngine { this.setCourseHeading(null) } + clearHeartRateSignal(): void { + this.telemetryRuntime.dispatch({ + type: 'heart_rate_updated', + at: Date.now(), + bpm: null, + }) + this.syncSessionTimerText() + } + clearFinishedTestOverlay(): void { this.currentGpsPoint = null this.currentGpsTrack = [] @@ -1386,6 +1442,18 @@ export class MapEngine { this.heartRateController.disconnect() } + handleConnectHeartRateDevice(deviceId: string): void { + this.heartRateController.connectToDiscoveredDevice(deviceId) + } + + handleClearPreferredHeartRateDevice(): void { + this.heartRateController.clearPreferredDevice() + this.setState({ + heartRateDeviceText: this.heartRateController.currentDeviceName || '--', + heartRateScanText: this.getHeartRateScanText(), + }, true) + } + handleDebugHeartRateTone(tone: HeartRateTone): void { const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config) this.telemetryRuntime.dispatch({ @@ -1407,9 +1475,42 @@ export class MapEngine { }) this.setState({ heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接', + heartRateScanText: this.getHeartRateScanText(), }, true) this.syncSessionTimerText() } + + formatHeartRateDevices(devices: HeartRateDiscoveredDevice[]): Array<{ deviceId: string; name: string; rssiText: string; preferred: boolean; connected: boolean }> { + return devices.map((device) => ({ + deviceId: device.deviceId, + name: device.name, + rssiText: device.rssi === null ? '--' : `${device.rssi} dBm`, + preferred: device.isPreferred, + connected: !!this.heartRateController.currentDeviceId && this.heartRateController.currentDeviceId === device.deviceId && this.heartRateController.connected, + })) + } + + getHeartRateScanText(): string { + if (this.heartRateController.connected) { + return '已连接' + } + + if (this.heartRateController.connecting) { + return '连接中' + } + + if (this.heartRateController.disconnecting) { + return '断开中' + } + + if (this.heartRateController.scanning) { + return this.heartRateController.lastDeviceId ? '扫描中(优先首选)' : '扫描中(等待选择)' + } + + return this.heartRateController.discoveredDevices.length + ? `已发现 ${this.heartRateController.discoveredDevices.length} 个设备` + : '未扫描' + } setStage(rect: MapEngineStageRect): void { this.previewScale = 1 this.previewOriginX = rect.width / 2 diff --git a/miniprogram/engine/sensor/heartRateController.ts b/miniprogram/engine/sensor/heartRateController.ts index 495379c..cb260ba 100644 --- a/miniprogram/engine/sensor/heartRateController.ts +++ b/miniprogram/engine/sensor/heartRateController.ts @@ -3,18 +3,30 @@ export interface HeartRateControllerCallbacks { onStatus: (message: string) => void onError: (message: string) => void onConnectionChange: (connected: boolean, deviceName: string | null) => void + onDeviceListChange: (devices: HeartRateDiscoveredDevice[]) => void +} + +export interface HeartRateDiscoveredDevice { + deviceId: string + name: string + rssi: number | null + lastSeenAt: number + isPreferred: boolean } type BluetoothDeviceLike = { deviceId?: string name?: string localName?: string + RSSI?: number advertisServiceUUIDs?: string[] } const HEART_RATE_SERVICE_UUID = '180D' const HEART_RATE_MEASUREMENT_UUID = '2A37' const DISCOVERY_TIMEOUT_MS = 12000 +const DEVICE_STALE_MS = 15000 +const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice' function normalizeUuid(uuid: string | undefined | null): string { return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase() @@ -80,9 +92,18 @@ export class HeartRateController { connected: boolean currentDeviceId: string | null currentDeviceName: string | null + discoveredDevices: HeartRateDiscoveredDevice[] + lastDeviceId: string | null + lastDeviceName: string | null + manualDisconnect: boolean + reconnecting: boolean + disconnecting: boolean + disconnectingDeviceId: string | null + autoConnectDeviceId: string | null measurementServiceId: string | null measurementCharacteristicId: string | null discoveryTimer: number + reconnectTimer: number deviceFoundHandler: ((result: any) => void) | null characteristicHandler: ((result: any) => void) | null connectionStateHandler: ((result: any) => void) | null @@ -94,12 +115,22 @@ export class HeartRateController { this.connected = false this.currentDeviceId = null this.currentDeviceName = null + this.discoveredDevices = [] + this.lastDeviceId = null + this.lastDeviceName = null + this.manualDisconnect = false + this.reconnecting = false + this.disconnecting = false + this.disconnectingDeviceId = null + this.autoConnectDeviceId = null this.measurementServiceId = null this.measurementCharacteristicId = null this.discoveryTimer = 0 + this.reconnectTimer = 0 this.deviceFoundHandler = null this.characteristicHandler = null this.connectionStateHandler = null + this.restorePreferredDevice() } startScanAndConnect(): void { @@ -108,40 +139,55 @@ export class HeartRateController { return } + if (this.disconnecting) { + this.callbacks.onStatus('心率带断开中,请稍后再试') + return + } + if (this.scanning || this.connecting) { this.callbacks.onStatus('心率带连接进行中') return } - const wxAny = wx as any - wxAny.openBluetoothAdapter({ - success: () => { + this.manualDisconnect = false + this.reconnecting = false + this.clearReconnectTimer() + + this.withFreshBluetoothAdapter(() => { + if (this.lastDeviceId) { + this.callbacks.onStatus(`正在扫描并优先连接 ${this.lastDeviceName || '心率带'}`) this.beginDiscovery() - }, - fail: (error: any) => { - const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败' - this.callbacks.onError(`蓝牙不可用: ${message}`) - }, + return + } + + this.beginDiscovery() }) } disconnect(): void { this.clearDiscoveryTimer() + this.clearReconnectTimer() this.stopDiscovery() const deviceId = this.currentDeviceId this.connecting = false + this.reconnecting = false + this.manualDisconnect = true + this.disconnecting = true if (!deviceId) { + this.disconnecting = false this.clearConnectionState() this.callbacks.onStatus('心率带未连接') return } + this.disconnectingDeviceId = deviceId const wxAny = wx as any wxAny.closeBLEConnection({ deviceId, complete: () => { + this.disconnecting = false this.clearConnectionState() this.callbacks.onStatus('心率带已断开') }, @@ -150,6 +196,7 @@ export class HeartRateController { destroy(): void { this.clearDiscoveryTimer() + this.clearReconnectTimer() this.stopDiscovery() this.detachListeners() @@ -173,14 +220,20 @@ export class HeartRateController { } beginDiscovery(): void { + if (this.scanning || this.connecting || this.connected) { + return + } + this.bindListeners() + this.autoConnectDeviceId = this.lastDeviceId const wxAny = wx as any wxAny.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, services: [HEART_RATE_SERVICE_UUID], success: () => { this.scanning = true - this.callbacks.onStatus('正在扫描心率带') + this.pruneDiscoveredDevices() + this.callbacks.onStatus(this.autoConnectDeviceId ? '正在扫描心率带并等待自动连接' : '正在扫描心率带,请选择设备') this.clearDiscoveryTimer() this.discoveryTimer = setTimeout(() => { this.discoveryTimer = 0 @@ -189,7 +242,7 @@ export class HeartRateController { } this.stopDiscovery() - this.callbacks.onError('未发现可连接的心率带') + this.callbacks.onError(this.discoveredDevices.length ? '已发现心率带,请从列表选择连接' : '未发现可连接的心率带') }, DISCOVERY_TIMEOUT_MS) as unknown as number }, fail: (error: any) => { @@ -207,6 +260,7 @@ export class HeartRateController { } this.scanning = false + this.autoConnectDeviceId = null const wxAny = wx as any wxAny.stopBluetoothDevicesDiscovery({ complete: () => {}, @@ -224,8 +278,13 @@ export class HeartRateController { ? [result] : [] - const targetDevice = devices.find((device: BluetoothDeviceLike) => isHeartRateDevice(device)) - if (!targetDevice || !targetDevice.deviceId || !this.scanning || this.connecting || this.connected) { + this.mergeDiscoveredDevices(devices) + if (!this.scanning || this.connecting || this.connected) { + return + } + + const targetDevice = this.selectTargetDevice(devices) + if (!targetDevice || !targetDevice.deviceId) { return } @@ -267,7 +326,7 @@ export class HeartRateController { if (!this.connectionStateHandler) { this.connectionStateHandler = (result: any) => { - if (!result || !this.currentDeviceId || result.deviceId !== this.currentDeviceId) { + if (!result || !result.deviceId) { return } @@ -275,8 +334,20 @@ export class HeartRateController { return } + if (this.disconnectingDeviceId && result.deviceId === this.disconnectingDeviceId) { + this.disconnectingDeviceId = null + return + } + + if (!this.currentDeviceId || result.deviceId !== this.currentDeviceId) { + return + } + + const disconnectedDeviceId = this.currentDeviceId + const disconnectedDeviceName = this.currentDeviceName this.clearConnectionState() this.callbacks.onStatus('心率带连接已断开') + this.scheduleAutoReconnect(disconnectedDeviceId, disconnectedDeviceName) } if (typeof wxAny.onBLEConnectionStateChange === 'function') { @@ -303,8 +374,13 @@ export class HeartRateController { this.connectionStateHandler = null } - connectToDevice(deviceId: string, deviceName: string): void { + connectToDevice(deviceId: string, deviceName: string, fallbackToDiscovery: boolean = false): void { this.connecting = true + this.reconnecting = false + this.disconnecting = false + this.manualDisconnect = false + this.disconnectingDeviceId = null + this.autoConnectDeviceId = deviceId this.currentDeviceId = deviceId this.currentDeviceName = deviceName this.callbacks.onStatus(`正在连接 ${deviceName}`) @@ -319,6 +395,11 @@ export class HeartRateController { fail: (error: any) => { const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败' this.clearConnectionState() + if (fallbackToDiscovery && !this.manualDisconnect) { + this.callbacks.onStatus(`直连失败,转入扫描: ${deviceName}`) + this.beginDiscovery() + return + } this.callbacks.onError(`连接心率带失败: ${message}`) }, }) @@ -364,6 +445,15 @@ export class HeartRateController { success: () => { this.connecting = false this.connected = true + this.lastDeviceId = deviceId + this.lastDeviceName = deviceName + this.persistPreferredDevice() + this.manualDisconnect = false + this.reconnecting = false + this.disconnectingDeviceId = null + this.autoConnectDeviceId = deviceId + this.refreshPreferredFlags() + this.clearReconnectTimer() this.callbacks.onConnectionChange(true, deviceName) this.callbacks.onStatus(`心率带已连接: ${deviceName}`) }, @@ -393,6 +483,7 @@ export class HeartRateController { complete: () => { this.clearConnectionState() this.callbacks.onError(message) + this.scheduleAutoReconnect(this.lastDeviceId, this.lastDeviceName) }, }) } @@ -402,6 +493,9 @@ export class HeartRateController { this.scanning = false this.connecting = false this.connected = false + this.disconnecting = false + this.disconnectingDeviceId = null + this.autoConnectDeviceId = null this.currentDeviceId = null this.measurementServiceId = null this.measurementCharacteristicId = null @@ -418,4 +512,264 @@ export class HeartRateController { this.discoveryTimer = 0 } } + + clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = 0 + } + } + + selectTargetDevice(devices: BluetoothDeviceLike[]): BluetoothDeviceLike | null { + if (!Array.isArray(devices) || !devices.length) { + return null + } + + if (this.autoConnectDeviceId) { + const rememberedDevice = devices.find((device) => device && device.deviceId === this.autoConnectDeviceId) + if (rememberedDevice && isHeartRateDevice(rememberedDevice)) { + return rememberedDevice + } + + return null + } + + return null + } + + scheduleAutoReconnect(deviceId: string | null, deviceName: string | null): void { + if (this.manualDisconnect || !deviceId || this.connected || this.connecting || this.scanning) { + return + } + + this.lastDeviceId = deviceId + this.lastDeviceName = deviceName || this.lastDeviceName + this.clearReconnectTimer() + this.reconnecting = true + this.callbacks.onStatus(`心率带已断开,等待自动重连: ${this.lastDeviceName || '设备'}`) + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = 0 + if (this.manualDisconnect || this.connected || this.connecting || this.scanning) { + return + } + + const rememberedDeviceId = this.lastDeviceId + const rememberedDeviceName = this.lastDeviceName || '心率带' + this.reconnecting = false + this.withFreshBluetoothAdapter(() => { + if (rememberedDeviceId) { + this.callbacks.onStatus(`正在自动重连 ${rememberedDeviceName}`) + this.resetConnectionAndConnect(rememberedDeviceId, rememberedDeviceName, true) + return + } + + this.beginDiscovery() + }) + }, 600) as unknown as number + } + + resetConnectionAndConnect(deviceId: string, deviceName: string, fallbackToDiscovery: boolean): void { + const wxAny = wx as any + this.disconnectingDeviceId = deviceId + wxAny.closeBLEConnection({ + deviceId, + complete: () => { + if (this.disconnectingDeviceId === deviceId) { + this.disconnectingDeviceId = null + } + + setTimeout(() => { + if (this.connected || this.connecting || this.scanning || this.disconnecting) { + return + } + + this.connectToDevice(deviceId, deviceName, fallbackToDiscovery) + }, 320) + }, + }) + } + + connectToDiscoveredDevice(deviceId: string): void { + if (!deviceId) { + return + } + + if (this.disconnecting) { + this.callbacks.onStatus('心率带断开中,请稍后再试') + return + } + + const targetDevice = this.discoveredDevices.find((device) => device.deviceId === deviceId) + const deviceName = targetDevice ? targetDevice.name : (this.lastDeviceId === deviceId ? (this.lastDeviceName || '心率带') : '心率带') + this.lastDeviceId = deviceId + this.lastDeviceName = deviceName + this.refreshPreferredFlags() + this.stopDiscovery() + + this.withFreshBluetoothAdapter(() => { + this.callbacks.onStatus(`正在连接 ${deviceName}`) + this.resetConnectionAndConnect(deviceId, deviceName, true) + }) + } + + clearPreferredDevice(): void { + this.lastDeviceId = null + this.lastDeviceName = null + this.autoConnectDeviceId = null + this.removePreferredDevice() + this.refreshPreferredFlags() + this.callbacks.onStatus('已清除首选心率带') + } + + mergeDiscoveredDevices(devices: BluetoothDeviceLike[]): void { + if (!Array.isArray(devices) || !devices.length) { + return + } + + const now = Date.now() + let changed = false + const nextDevices = [...this.discoveredDevices] + for (const rawDevice of devices) { + if (!rawDevice || !rawDevice.deviceId || !isHeartRateDevice(rawDevice)) { + continue + } + + const name = getDeviceDisplayName(rawDevice) + const rssi = typeof rawDevice.RSSI === 'number' && Number.isFinite(rawDevice.RSSI) ? rawDevice.RSSI : null + const existingIndex = nextDevices.findIndex((item) => item.deviceId === rawDevice.deviceId) + const nextDevice: HeartRateDiscoveredDevice = { + deviceId: rawDevice.deviceId, + name, + rssi, + lastSeenAt: now, + isPreferred: rawDevice.deviceId === this.lastDeviceId, + } + + if (existingIndex >= 0) { + nextDevices[existingIndex] = nextDevice + } else { + nextDevices.push(nextDevice) + } + changed = true + } + + if (!changed) { + return + } + + this.discoveredDevices = this.sortDiscoveredDevices(nextDevices.filter((device) => now - device.lastSeenAt <= DEVICE_STALE_MS)) + this.callbacks.onDeviceListChange([...this.discoveredDevices]) + } + + pruneDiscoveredDevices(): void { + const now = Date.now() + const nextDevices = this.sortDiscoveredDevices( + this.discoveredDevices.filter((device) => now - device.lastSeenAt <= DEVICE_STALE_MS), + ) + const changed = nextDevices.length !== this.discoveredDevices.length + || nextDevices.some((device, index) => { + const previous = this.discoveredDevices[index] + return !previous || previous.deviceId !== device.deviceId || previous.isPreferred !== device.isPreferred || previous.rssi !== device.rssi + }) + + this.discoveredDevices = nextDevices + if (changed) { + this.callbacks.onDeviceListChange([...this.discoveredDevices]) + } + } + + refreshPreferredFlags(): void { + if (!this.discoveredDevices.length) { + this.callbacks.onDeviceListChange([]) + return + } + + this.discoveredDevices = this.sortDiscoveredDevices( + this.discoveredDevices.map((device) => ({ + ...device, + isPreferred: !!this.lastDeviceId && device.deviceId === this.lastDeviceId, + })), + ) + this.callbacks.onDeviceListChange([...this.discoveredDevices]) + } + + sortDiscoveredDevices(devices: HeartRateDiscoveredDevice[]): HeartRateDiscoveredDevice[] { + return [...devices].sort((a, b) => { + if (a.isPreferred !== b.isPreferred) { + return a.isPreferred ? -1 : 1 + } + + const aRssi = a.rssi === null ? -999 : a.rssi + const bRssi = b.rssi === null ? -999 : b.rssi + if (aRssi !== bRssi) { + return bRssi - aRssi + } + + return b.lastSeenAt - a.lastSeenAt + }) + } + + withFreshBluetoothAdapter(onReady: () => void): void { + const wxAny = wx as any + const openAdapter = () => { + wxAny.openBluetoothAdapter({ + success: () => { + onReady() + }, + fail: (error: any) => { + const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败' + this.callbacks.onError(`蓝牙不可用: ${message}`) + }, + }) + } + + if (typeof wxAny.closeBluetoothAdapter !== 'function') { + openAdapter() + return + } + + wxAny.closeBluetoothAdapter({ + complete: () => { + setTimeout(() => { + openAdapter() + }, 180) + }, + }) + } + + restorePreferredDevice(): void { + try { + const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY) + if (!stored || typeof stored !== 'object') { + return + } + + const normalized = stored as { deviceId?: unknown; name?: unknown } + if (typeof normalized.deviceId !== 'string' || !normalized.deviceId) { + return + } + + this.lastDeviceId = normalized.deviceId + this.lastDeviceName = typeof normalized.name === 'string' && normalized.name ? normalized.name : '心率带' + } catch {} + } + + persistPreferredDevice(): void { + if (!this.lastDeviceId) { + return + } + + try { + wx.setStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY, { + deviceId: this.lastDeviceId, + name: this.lastDeviceName || '心率带', + }) + } catch {} + } + + removePreferredDevice(): void { + try { + wx.removeStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY) + } catch {} + } } diff --git a/miniprogram/game/audio/audioConfig.ts b/miniprogram/game/audio/audioConfig.ts index 2fa36ae..4ad7fa2 100644 --- a/miniprogram/game/audio/audioConfig.ts +++ b/miniprogram/game/audio/audioConfig.ts @@ -13,12 +13,14 @@ export interface AudioCueConfig { volume: number loop: boolean loopGapMs: number + backgroundMode: 'disabled' | 'guidance' } export interface GameAudioConfig { enabled: boolean masterVolume: number obeyMuteSwitch: boolean + backgroundAudioEnabled: boolean approachDistanceMeters: number cues: Record } @@ -28,12 +30,14 @@ export interface PartialAudioCueConfig { volume?: number loop?: boolean loopGapMs?: number + backgroundMode?: 'disabled' | 'guidance' } export interface GameAudioConfigOverrides { enabled?: boolean masterVolume?: number obeyMuteSwitch?: boolean + backgroundAudioEnabled?: boolean approachDistanceMeters?: number cues?: Partial> } @@ -42,6 +46,7 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = { enabled: true, masterVolume: 1, obeyMuteSwitch: true, + backgroundAudioEnabled: true, approachDistanceMeters: 20, cues: { session_started: { @@ -49,48 +54,56 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = { volume: 0.78, loop: false, loopGapMs: 0, + backgroundMode: 'disabled', }, 'control_completed:start': { src: '/assets/sounds/start-complete.wav', volume: 0.84, loop: false, loopGapMs: 0, + backgroundMode: 'disabled', }, 'control_completed:control': { src: '/assets/sounds/control-complete.wav', volume: 0.8, loop: false, loopGapMs: 0, + backgroundMode: 'disabled', }, 'control_completed:finish': { src: '/assets/sounds/finish-complete.wav', volume: 0.92, loop: false, loopGapMs: 0, + backgroundMode: 'disabled', }, 'punch_feedback:warning': { src: '/assets/sounds/warning.wav', volume: 0.72, loop: false, loopGapMs: 0, + backgroundMode: 'disabled', }, 'guidance:searching': { src: '/assets/sounds/guidance-searching.wav', volume: 0.48, loop: true, loopGapMs: 1800, + backgroundMode: 'guidance', }, 'guidance:approaching': { src: '/assets/sounds/guidance-approaching.wav', volume: 0.58, loop: true, loopGapMs: 950, + backgroundMode: 'guidance', }, 'guidance:ready': { src: '/assets/sounds/guidance-ready.wav', volume: 0.68, loop: true, loopGapMs: 650, + backgroundMode: 'guidance', }, }, } @@ -143,6 +156,10 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null if (cue.loopGapMs !== undefined) { cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs) } + + if (cue.backgroundMode === 'disabled' || cue.backgroundMode === 'guidance') { + cues[key].backgroundMode = cue.backgroundMode + } } } @@ -150,6 +167,9 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled, masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume), obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch, + backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined + ? !!overrides.backgroundAudioEnabled + : true, approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters), cues, } diff --git a/miniprogram/game/audio/soundDirector.ts b/miniprogram/game/audio/soundDirector.ts index d50231c..6af7a6c 100644 --- a/miniprogram/game/audio/soundDirector.ts +++ b/miniprogram/game/audio/soundDirector.ts @@ -6,14 +6,20 @@ export class SoundDirector { config: GameAudioConfig contexts: Partial> loopTimers: Partial> + backgroundLoopTimer: number activeGuidanceCue: AudioCueKey | null + backgroundManager: WechatMiniprogram.BackgroundAudioManager | null + appAudioMode: 'foreground' | 'background' constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) { this.enabled = true this.config = config this.contexts = {} this.loopTimers = {} + this.backgroundLoopTimer = 0 this.activeGuidanceCue = null + this.backgroundManager = null + this.appAudioMode = 'foreground' } configure(config: GameAudioConfig): void { @@ -34,6 +40,7 @@ export class SoundDirector { } } this.loopTimers = {} + this.clearBackgroundLoopTimer() const keys = Object.keys(this.contexts) as AudioCueKey[] for (const key of keys) { @@ -46,6 +53,7 @@ export class SoundDirector { } this.contexts = {} this.activeGuidanceCue = null + this.stopBackgroundGuidance() } destroy(): void { @@ -108,7 +116,43 @@ export class SoundDirector { } } + setAppAudioMode(mode: 'foreground' | 'background'): void { + if (this.appAudioMode === mode) { + return + } + + this.appAudioMode = mode + const activeGuidanceCue = this.activeGuidanceCue + if (!activeGuidanceCue) { + this.stopBackgroundGuidance() + return + } + + if (mode === 'background') { + this.stopForegroundCue(activeGuidanceCue) + this.startBackgroundGuidance(activeGuidanceCue) + return + } + + this.stopBackgroundGuidance() + this.playForeground(activeGuidanceCue) + } + play(key: AudioCueKey): void { + if (this.appAudioMode === 'background') { + const cue = this.config.cues[key] + if (!cue || cue.backgroundMode !== 'guidance' || !this.isGuidanceCue(key)) { + return + } + + this.startBackgroundGuidance(key) + return + } + + this.playForeground(key) + } + + playForeground(key: AudioCueKey): void { const cue = this.config.cues[key] if (!cue || !cue.src) { return @@ -132,11 +176,17 @@ export class SoundDirector { this.stopGuidanceLoop() this.activeGuidanceCue = key - this.play(key) + if (this.appAudioMode === 'background') { + this.startBackgroundGuidance(key) + return + } + + this.playForeground(key) } stopGuidanceLoop(): void { if (!this.activeGuidanceCue) { + this.stopBackgroundGuidance() return } @@ -148,6 +198,7 @@ export class SoundDirector { context.seek(0) } } + this.stopBackgroundGuidance() this.activeGuidanceCue = null } @@ -175,6 +226,105 @@ export class SoundDirector { }, cue.loopGapMs) as unknown as number } + handleBackgroundCueEnded(): void { + const key = this.activeGuidanceCue + if (!key || !this.enabled || !this.config.enabled || this.appAudioMode !== 'background') { + return + } + + const cue = this.config.cues[key] + if (!cue || !cue.loop) { + return + } + + this.clearBackgroundLoopTimer() + this.backgroundLoopTimer = setTimeout(() => { + this.backgroundLoopTimer = 0 + if (this.activeGuidanceCue === key && this.appAudioMode === 'background' && this.enabled && this.config.enabled) { + this.playBackgroundCue(key) + } + }, cue.loopGapMs) as unknown as number + } + + clearBackgroundLoopTimer(): void { + if (this.backgroundLoopTimer) { + clearTimeout(this.backgroundLoopTimer) + this.backgroundLoopTimer = 0 + } + } + + stopForegroundCue(key: AudioCueKey): void { + this.clearLoopTimer(key) + const context = this.contexts[key] + if (!context) { + return + } + context.stop() + if (typeof context.seek === 'function') { + context.seek(0) + } + } + + isGuidanceCue(key: AudioCueKey): boolean { + return key === 'guidance:searching' + || key === 'guidance:approaching' + || key === 'guidance:ready' + } + + startBackgroundGuidance(key: AudioCueKey): void { + if (!this.enabled || !this.config.enabled || !this.config.backgroundAudioEnabled) { + return + } + + const cue = this.config.cues[key] + if (!cue || cue.backgroundMode !== 'guidance' || !cue.src) { + return + } + + this.playBackgroundCue(key) + } + + playBackgroundCue(key: AudioCueKey): void { + const cue = this.config.cues[key] + if (!cue || !cue.src) { + return + } + + const manager = this.getBackgroundManager() + this.clearBackgroundLoopTimer() + manager.stop() + manager.title = 'ColorMapRun 引导音' + manager.epname = 'ColorMapRun' + manager.singer = 'ColorMapRun' + manager.coverImgUrl = '' + manager.src = cue.src + manager.play() + } + + stopBackgroundGuidance(): void { + this.clearBackgroundLoopTimer() + if (!this.backgroundManager) { + return + } + + this.backgroundManager.stop() + } + + getBackgroundManager(): WechatMiniprogram.BackgroundAudioManager { + if (this.backgroundManager) { + return this.backgroundManager + } + + const manager = wx.getBackgroundAudioManager() + if (typeof manager.onEnded === 'function') { + manager.onEnded(() => { + this.handleBackgroundCueEnded() + }) + } + this.backgroundManager = manager + return manager + } + getContext(key: AudioCueKey): WechatMiniprogram.InnerAudioContext { const existing = this.contexts[key] if (existing) { diff --git a/miniprogram/game/feedback/feedbackDirector.ts b/miniprogram/game/feedback/feedbackDirector.ts index 25e7d30..9cd502f 100644 --- a/miniprogram/game/feedback/feedbackDirector.ts +++ b/miniprogram/game/feedback/feedbackDirector.ts @@ -45,6 +45,10 @@ export class FeedbackDirector { this.uiEffectDirector.destroy() } + setAppAudioMode(mode: 'foreground' | 'background'): void { + this.soundDirector.setAppAudioMode(mode) + } + handleEffects(effects: GameEffect[]): void { this.soundDirector.handleEffects(effects) this.hapticsDirector.handleEffects(effects) diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index d52aae0..670a036 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -31,9 +31,10 @@ type MapPageData = MapEngineViewState & { showRightButtonGroups: boolean showBottomDebugButton: boolean } -const INTERNAL_BUILD_VERSION = 'map-build-175' +const INTERNAL_BUILD_VERSION = 'map-build-195' const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json' let mapEngine: MapEngine | null = null +let stageCanvasAttached = false function buildSideButtonVisibility(mode: SideButtonMode) { return { sideButtonMode: mode, @@ -114,6 +115,8 @@ Page({ mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockCoordText: '--', mockSpeedText: '--', + heartRateScanText: '未扫描', + heartRateDiscoveredDevices: [], panelSpeedValueText: '0', panelTelemetryTone: 'blue', panelHeartRateZoneNameText: '--', @@ -150,7 +153,7 @@ Page({ compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('left'), - } as MapPageData, + } as unknown as MapPageData, onLoad() { const systemInfo = wx.getSystemInfoSync() @@ -239,15 +242,29 @@ Page({ }, onReady() { + stageCanvasAttached = false this.measureStageAndCanvas() this.loadMapConfigFromRemote() }, + onShow() { + if (mapEngine) { + mapEngine.handleAppShow() + } + }, + + onHide() { + if (mapEngine) { + mapEngine.handleAppHide() + } + }, + onUnload() { if (mapEngine) { mapEngine.destroy() mapEngine = null } + stageCanvasAttached = false }, loadMapConfigFromRemote() { @@ -295,6 +312,10 @@ Page({ currentEngine.setStage(rect) + if (stageCanvasAttached) { + return + } + const canvasQuery = wx.createSelectorQuery().in(page) canvasQuery.select('#mapCanvas').fields({ node: true, size: true }) canvasQuery.select('#routeLabelCanvas').fields({ node: true, size: true }) @@ -317,6 +338,7 @@ Page({ dpr, labelCanvasRef && labelCanvasRef.node ? labelCanvasRef.node : undefined, ) + stageCanvasAttached = true } catch (error) { page.setData({ statusText: `WebGL 鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`, @@ -453,11 +475,23 @@ Page({ } }, - handleDisconnectHeartRate() { - if (mapEngine) { - mapEngine.handleDisconnectHeartRate() - } - }, + handleDisconnectHeartRate() { + if (mapEngine) { + mapEngine.handleDisconnectHeartRate() + } + }, + + handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) { + if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) { + mapEngine.handleConnectHeartRateDevice(event.currentTarget.dataset.deviceId) + } + }, + + handleClearPreferredHeartRateDevice() { + if (mapEngine) { + mapEngine.handleClearPreferredHeartRateDevice() + } + }, handleDebugHeartRateBlue() { if (mapEngine) { diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index b860cb3..1b52aee 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -78,28 +78,28 @@ - LOC - LOCK - SUN - EXIT + 1 + 2 + 3 + 4 - N - DIR - COMP - GUIDE - NET - GO + 5 + 6 + 7 + 8 + 9 + 10 - INFO - SET - m - PIN - LIST - USER + 11 + 12 + 13 + 14 + 15 + 16 @@ -332,6 +332,29 @@ HR Device {{heartRateDeviceText}} + + HR Scan + {{heartRateScanText}} + + + + + + {{item.name}} + 首选 + + {{item.rssiText}} + + {{item.connected ? '已连接' : '连接'}} + + + + {{heartRateConnected ? '心率带已连接' : '连接心率带'}} + 断开心率带 + + + 清除首选 + Heading Mode {{orientationModeText}} @@ -346,14 +369,12 @@ {{gpsTracking ? '停止定位' : '开启定位'}} - {{heartRateConnected ? '心率带已连接' : '连接心率带'}} 真实定位 模拟定位 - 断开心率带 {{northReferenceButtonText}} diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index bca96bf..18c2587 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -1194,6 +1194,81 @@ gap: 14rpx; } +.debug-device-list { + display: flex; + flex-direction: column; + gap: 12rpx; + margin-top: 16rpx; +} + +.debug-device-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; + padding: 16rpx 18rpx; + border-radius: 18rpx; + background: rgba(255, 255, 255, 0.82); + box-shadow: inset 0 0 0 2rpx rgba(22, 48, 32, 0.06); +} + +.debug-device-card__main { + flex: 1; + min-width: 0; +} + +.debug-device-card__title-row { + display: flex; + align-items: center; + gap: 10rpx; +} + +.debug-device-card__name { + flex: 1; + min-width: 0; + font-size: 24rpx; + line-height: 1.3; + font-weight: 700; + color: #163020; + word-break: break-all; +} + +.debug-device-card__badge { + flex-shrink: 0; + padding: 4rpx 10rpx; + border-radius: 999rpx; + background: rgba(45, 106, 79, 0.14); + color: #2d6a4f; + font-size: 18rpx; + line-height: 1; + font-weight: 700; +} + +.debug-device-card__meta { + margin-top: 8rpx; + font-size: 20rpx; + line-height: 1.2; + color: #6a826f; +} + +.debug-device-card__action { + flex-shrink: 0; + min-width: 96rpx; + padding: 16rpx 18rpx; + border-radius: 999rpx; + background: #eef6ea; + color: #2d6a4f; + font-size: 22rpx; + line-height: 1; + text-align: center; + font-weight: 700; +} + +.debug-device-card__action--active { + background: #2d6a4f; + color: #f7fbf2; +} + .control-row { display: flex; gap: 14rpx;