Improve heart rate device reconnect flow
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
||||||
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
||||||
import { HeartRateController } from '../sensor/heartRateController'
|
import { HeartRateController, type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
||||||
import { LocationController } from '../sensor/locationController'
|
import { LocationController } from '../sensor/locationController'
|
||||||
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
||||||
import { type MapRendererStats } from '../renderer/mapRenderer'
|
import { type MapRendererStats } from '../renderer/mapRenderer'
|
||||||
@@ -132,6 +132,14 @@ export interface MapEngineViewState {
|
|||||||
heartRateConnected: boolean
|
heartRateConnected: boolean
|
||||||
heartRateStatusText: string
|
heartRateStatusText: string
|
||||||
heartRateDeviceText: string
|
heartRateDeviceText: string
|
||||||
|
heartRateScanText: string
|
||||||
|
heartRateDiscoveredDevices: Array<{
|
||||||
|
deviceId: string
|
||||||
|
name: string
|
||||||
|
rssiText: string
|
||||||
|
preferred: boolean
|
||||||
|
connected: boolean
|
||||||
|
}>
|
||||||
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
||||||
gameModeText: string
|
gameModeText: string
|
||||||
panelTimerText: string
|
panelTimerText: string
|
||||||
@@ -227,6 +235,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'heartRateConnected',
|
'heartRateConnected',
|
||||||
'heartRateStatusText',
|
'heartRateStatusText',
|
||||||
'heartRateDeviceText',
|
'heartRateDeviceText',
|
||||||
|
'heartRateScanText',
|
||||||
|
'heartRateDiscoveredDevices',
|
||||||
'gameSessionStatus',
|
'gameSessionStatus',
|
||||||
'gameModeText',
|
'gameModeText',
|
||||||
'panelTimerText',
|
'panelTimerText',
|
||||||
@@ -612,37 +622,64 @@ export class MapEngine {
|
|||||||
this.setState(this.getLocationControllerViewPatch(), true)
|
this.setState(this.getLocationControllerViewPatch(), true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.heartRateController = new HeartRateController({
|
this.heartRateController = new HeartRateController({
|
||||||
onHeartRate: (bpm) => {
|
onHeartRate: (bpm) => {
|
||||||
this.telemetryRuntime.dispatch({
|
this.telemetryRuntime.dispatch({
|
||||||
type: 'heart_rate_updated',
|
type: 'heart_rate_updated',
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
bpm,
|
bpm,
|
||||||
})
|
})
|
||||||
this.syncSessionTimerText()
|
this.syncSessionTimerText()
|
||||||
},
|
},
|
||||||
onStatus: (message) => {
|
onStatus: (message) => {
|
||||||
this.setState({
|
const deviceName = this.heartRateController.currentDeviceName
|
||||||
heartRateStatusText: message,
|
|| (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null)
|
||||||
heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
|
|| '--'
|
||||||
}, true)
|
this.setState({
|
||||||
},
|
heartRateStatusText: message,
|
||||||
onError: (message) => {
|
heartRateDeviceText: deviceName,
|
||||||
this.setState({
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
heartRateConnected: false,
|
}, true)
|
||||||
heartRateStatusText: message,
|
},
|
||||||
heartRateDeviceText: '--',
|
onError: (message) => {
|
||||||
statusText: `${message} (${this.buildVersion})`,
|
this.clearHeartRateSignal()
|
||||||
}, true)
|
const deviceName = this.heartRateController.reconnecting
|
||||||
},
|
? (this.heartRateController.lastDeviceName || '--')
|
||||||
onConnectionChange: (connected, deviceName) => {
|
: '--'
|
||||||
this.setState({
|
this.setState({
|
||||||
heartRateConnected: connected,
|
heartRateConnected: false,
|
||||||
heartRateDeviceText: deviceName || '--',
|
heartRateStatusText: message,
|
||||||
heartRateStatusText: connected ? '心率带已连接' : '心率带未连接',
|
heartRateDeviceText: deviceName,
|
||||||
}, true)
|
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({
|
this.feedbackDirector = new FeedbackDirector({
|
||||||
showPunchFeedback: (text, tone, motionClass) => {
|
showPunchFeedback: (text, tone, motionClass) => {
|
||||||
this.showPunchFeedback(text, tone, motionClass)
|
this.showPunchFeedback(text, tone, motionClass)
|
||||||
@@ -745,9 +782,11 @@ export class MapEngine {
|
|||||||
mockCoordText: '--',
|
mockCoordText: '--',
|
||||||
mockSpeedText: '--',
|
mockSpeedText: '--',
|
||||||
gpsCoordText: '--',
|
gpsCoordText: '--',
|
||||||
heartRateConnected: false,
|
heartRateConnected: false,
|
||||||
heartRateStatusText: '心率带未连接',
|
heartRateStatusText: '心率带未连接',
|
||||||
heartRateDeviceText: '--',
|
heartRateDeviceText: '--',
|
||||||
|
heartRateScanText: '未扫描',
|
||||||
|
heartRateDiscoveredDevices: [],
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
panelActionTagText: '目标',
|
panelActionTagText: '目标',
|
||||||
@@ -848,6 +887,14 @@ export class MapEngine {
|
|||||||
this.mounted = false
|
this.mounted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleAppShow(): void {
|
||||||
|
this.feedbackDirector.setAppAudioMode('foreground')
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAppHide(): void {
|
||||||
|
this.feedbackDirector.setAppAudioMode('foreground')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
clearGameRuntime(): void {
|
clearGameRuntime(): void {
|
||||||
this.gameRuntime.clear()
|
this.gameRuntime.clear()
|
||||||
@@ -858,6 +905,15 @@ export class MapEngine {
|
|||||||
this.setCourseHeading(null)
|
this.setCourseHeading(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearHeartRateSignal(): void {
|
||||||
|
this.telemetryRuntime.dispatch({
|
||||||
|
type: 'heart_rate_updated',
|
||||||
|
at: Date.now(),
|
||||||
|
bpm: null,
|
||||||
|
})
|
||||||
|
this.syncSessionTimerText()
|
||||||
|
}
|
||||||
|
|
||||||
clearFinishedTestOverlay(): void {
|
clearFinishedTestOverlay(): void {
|
||||||
this.currentGpsPoint = null
|
this.currentGpsPoint = null
|
||||||
this.currentGpsTrack = []
|
this.currentGpsTrack = []
|
||||||
@@ -1386,6 +1442,18 @@ export class MapEngine {
|
|||||||
this.heartRateController.disconnect()
|
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 {
|
handleDebugHeartRateTone(tone: HeartRateTone): void {
|
||||||
const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
|
const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
|
||||||
this.telemetryRuntime.dispatch({
|
this.telemetryRuntime.dispatch({
|
||||||
@@ -1407,9 +1475,42 @@ export class MapEngine {
|
|||||||
})
|
})
|
||||||
this.setState({
|
this.setState({
|
||||||
heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
|
heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
|
||||||
|
heartRateScanText: this.getHeartRateScanText(),
|
||||||
}, true)
|
}, true)
|
||||||
this.syncSessionTimerText()
|
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 {
|
setStage(rect: MapEngineStageRect): void {
|
||||||
this.previewScale = 1
|
this.previewScale = 1
|
||||||
this.previewOriginX = rect.width / 2
|
this.previewOriginX = rect.width / 2
|
||||||
|
|||||||
@@ -3,18 +3,30 @@ export interface HeartRateControllerCallbacks {
|
|||||||
onStatus: (message: string) => void
|
onStatus: (message: string) => void
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
onConnectionChange: (connected: boolean, deviceName: string | null) => 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 = {
|
type BluetoothDeviceLike = {
|
||||||
deviceId?: string
|
deviceId?: string
|
||||||
name?: string
|
name?: string
|
||||||
localName?: string
|
localName?: string
|
||||||
|
RSSI?: number
|
||||||
advertisServiceUUIDs?: string[]
|
advertisServiceUUIDs?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEART_RATE_SERVICE_UUID = '180D'
|
const HEART_RATE_SERVICE_UUID = '180D'
|
||||||
const HEART_RATE_MEASUREMENT_UUID = '2A37'
|
const HEART_RATE_MEASUREMENT_UUID = '2A37'
|
||||||
const DISCOVERY_TIMEOUT_MS = 12000
|
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 {
|
function normalizeUuid(uuid: string | undefined | null): string {
|
||||||
return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase()
|
return String(uuid || '').replace(/[^0-9a-f]/gi, '').toUpperCase()
|
||||||
@@ -80,9 +92,18 @@ export class HeartRateController {
|
|||||||
connected: boolean
|
connected: boolean
|
||||||
currentDeviceId: string | null
|
currentDeviceId: string | null
|
||||||
currentDeviceName: 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
|
measurementServiceId: string | null
|
||||||
measurementCharacteristicId: string | null
|
measurementCharacteristicId: string | null
|
||||||
discoveryTimer: number
|
discoveryTimer: number
|
||||||
|
reconnectTimer: number
|
||||||
deviceFoundHandler: ((result: any) => void) | null
|
deviceFoundHandler: ((result: any) => void) | null
|
||||||
characteristicHandler: ((result: any) => void) | null
|
characteristicHandler: ((result: any) => void) | null
|
||||||
connectionStateHandler: ((result: any) => void) | null
|
connectionStateHandler: ((result: any) => void) | null
|
||||||
@@ -94,12 +115,22 @@ export class HeartRateController {
|
|||||||
this.connected = false
|
this.connected = false
|
||||||
this.currentDeviceId = null
|
this.currentDeviceId = null
|
||||||
this.currentDeviceName = 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.measurementServiceId = null
|
||||||
this.measurementCharacteristicId = null
|
this.measurementCharacteristicId = null
|
||||||
this.discoveryTimer = 0
|
this.discoveryTimer = 0
|
||||||
|
this.reconnectTimer = 0
|
||||||
this.deviceFoundHandler = null
|
this.deviceFoundHandler = null
|
||||||
this.characteristicHandler = null
|
this.characteristicHandler = null
|
||||||
this.connectionStateHandler = null
|
this.connectionStateHandler = null
|
||||||
|
this.restorePreferredDevice()
|
||||||
}
|
}
|
||||||
|
|
||||||
startScanAndConnect(): void {
|
startScanAndConnect(): void {
|
||||||
@@ -108,40 +139,55 @@ export class HeartRateController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.disconnecting) {
|
||||||
|
this.callbacks.onStatus('心率带断开中,请稍后再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.scanning || this.connecting) {
|
if (this.scanning || this.connecting) {
|
||||||
this.callbacks.onStatus('心率带连接进行中')
|
this.callbacks.onStatus('心率带连接进行中')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const wxAny = wx as any
|
this.manualDisconnect = false
|
||||||
wxAny.openBluetoothAdapter({
|
this.reconnecting = false
|
||||||
success: () => {
|
this.clearReconnectTimer()
|
||||||
|
|
||||||
|
this.withFreshBluetoothAdapter(() => {
|
||||||
|
if (this.lastDeviceId) {
|
||||||
|
this.callbacks.onStatus(`正在扫描并优先连接 ${this.lastDeviceName || '心率带'}`)
|
||||||
this.beginDiscovery()
|
this.beginDiscovery()
|
||||||
},
|
return
|
||||||
fail: (error: any) => {
|
}
|
||||||
const message = error && error.errMsg ? error.errMsg : 'openBluetoothAdapter 失败'
|
|
||||||
this.callbacks.onError(`蓝牙不可用: ${message}`)
|
this.beginDiscovery()
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.clearDiscoveryTimer()
|
this.clearDiscoveryTimer()
|
||||||
|
this.clearReconnectTimer()
|
||||||
this.stopDiscovery()
|
this.stopDiscovery()
|
||||||
|
|
||||||
const deviceId = this.currentDeviceId
|
const deviceId = this.currentDeviceId
|
||||||
this.connecting = false
|
this.connecting = false
|
||||||
|
this.reconnecting = false
|
||||||
|
this.manualDisconnect = true
|
||||||
|
this.disconnecting = true
|
||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
|
this.disconnecting = false
|
||||||
this.clearConnectionState()
|
this.clearConnectionState()
|
||||||
this.callbacks.onStatus('心率带未连接')
|
this.callbacks.onStatus('心率带未连接')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.disconnectingDeviceId = deviceId
|
||||||
const wxAny = wx as any
|
const wxAny = wx as any
|
||||||
wxAny.closeBLEConnection({
|
wxAny.closeBLEConnection({
|
||||||
deviceId,
|
deviceId,
|
||||||
complete: () => {
|
complete: () => {
|
||||||
|
this.disconnecting = false
|
||||||
this.clearConnectionState()
|
this.clearConnectionState()
|
||||||
this.callbacks.onStatus('心率带已断开')
|
this.callbacks.onStatus('心率带已断开')
|
||||||
},
|
},
|
||||||
@@ -150,6 +196,7 @@ export class HeartRateController {
|
|||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.clearDiscoveryTimer()
|
this.clearDiscoveryTimer()
|
||||||
|
this.clearReconnectTimer()
|
||||||
this.stopDiscovery()
|
this.stopDiscovery()
|
||||||
this.detachListeners()
|
this.detachListeners()
|
||||||
|
|
||||||
@@ -173,14 +220,20 @@ export class HeartRateController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beginDiscovery(): void {
|
beginDiscovery(): void {
|
||||||
|
if (this.scanning || this.connecting || this.connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.bindListeners()
|
this.bindListeners()
|
||||||
|
this.autoConnectDeviceId = this.lastDeviceId
|
||||||
const wxAny = wx as any
|
const wxAny = wx as any
|
||||||
wxAny.startBluetoothDevicesDiscovery({
|
wxAny.startBluetoothDevicesDiscovery({
|
||||||
allowDuplicatesKey: false,
|
allowDuplicatesKey: false,
|
||||||
services: [HEART_RATE_SERVICE_UUID],
|
services: [HEART_RATE_SERVICE_UUID],
|
||||||
success: () => {
|
success: () => {
|
||||||
this.scanning = true
|
this.scanning = true
|
||||||
this.callbacks.onStatus('正在扫描心率带')
|
this.pruneDiscoveredDevices()
|
||||||
|
this.callbacks.onStatus(this.autoConnectDeviceId ? '正在扫描心率带并等待自动连接' : '正在扫描心率带,请选择设备')
|
||||||
this.clearDiscoveryTimer()
|
this.clearDiscoveryTimer()
|
||||||
this.discoveryTimer = setTimeout(() => {
|
this.discoveryTimer = setTimeout(() => {
|
||||||
this.discoveryTimer = 0
|
this.discoveryTimer = 0
|
||||||
@@ -189,7 +242,7 @@ export class HeartRateController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.stopDiscovery()
|
this.stopDiscovery()
|
||||||
this.callbacks.onError('未发现可连接的心率带')
|
this.callbacks.onError(this.discoveredDevices.length ? '已发现心率带,请从列表选择连接' : '未发现可连接的心率带')
|
||||||
}, DISCOVERY_TIMEOUT_MS) as unknown as number
|
}, DISCOVERY_TIMEOUT_MS) as unknown as number
|
||||||
},
|
},
|
||||||
fail: (error: any) => {
|
fail: (error: any) => {
|
||||||
@@ -207,6 +260,7 @@ export class HeartRateController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.scanning = false
|
this.scanning = false
|
||||||
|
this.autoConnectDeviceId = null
|
||||||
const wxAny = wx as any
|
const wxAny = wx as any
|
||||||
wxAny.stopBluetoothDevicesDiscovery({
|
wxAny.stopBluetoothDevicesDiscovery({
|
||||||
complete: () => {},
|
complete: () => {},
|
||||||
@@ -224,8 +278,13 @@ export class HeartRateController {
|
|||||||
? [result]
|
? [result]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const targetDevice = devices.find((device: BluetoothDeviceLike) => isHeartRateDevice(device))
|
this.mergeDiscoveredDevices(devices)
|
||||||
if (!targetDevice || !targetDevice.deviceId || !this.scanning || this.connecting || this.connected) {
|
if (!this.scanning || this.connecting || this.connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDevice = this.selectTargetDevice(devices)
|
||||||
|
if (!targetDevice || !targetDevice.deviceId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +326,7 @@ export class HeartRateController {
|
|||||||
|
|
||||||
if (!this.connectionStateHandler) {
|
if (!this.connectionStateHandler) {
|
||||||
this.connectionStateHandler = (result: any) => {
|
this.connectionStateHandler = (result: any) => {
|
||||||
if (!result || !this.currentDeviceId || result.deviceId !== this.currentDeviceId) {
|
if (!result || !result.deviceId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,8 +334,20 @@ export class HeartRateController {
|
|||||||
return
|
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.clearConnectionState()
|
||||||
this.callbacks.onStatus('心率带连接已断开')
|
this.callbacks.onStatus('心率带连接已断开')
|
||||||
|
this.scheduleAutoReconnect(disconnectedDeviceId, disconnectedDeviceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof wxAny.onBLEConnectionStateChange === 'function') {
|
if (typeof wxAny.onBLEConnectionStateChange === 'function') {
|
||||||
@@ -303,8 +374,13 @@ export class HeartRateController {
|
|||||||
this.connectionStateHandler = null
|
this.connectionStateHandler = null
|
||||||
}
|
}
|
||||||
|
|
||||||
connectToDevice(deviceId: string, deviceName: string): void {
|
connectToDevice(deviceId: string, deviceName: string, fallbackToDiscovery: boolean = false): void {
|
||||||
this.connecting = true
|
this.connecting = true
|
||||||
|
this.reconnecting = false
|
||||||
|
this.disconnecting = false
|
||||||
|
this.manualDisconnect = false
|
||||||
|
this.disconnectingDeviceId = null
|
||||||
|
this.autoConnectDeviceId = deviceId
|
||||||
this.currentDeviceId = deviceId
|
this.currentDeviceId = deviceId
|
||||||
this.currentDeviceName = deviceName
|
this.currentDeviceName = deviceName
|
||||||
this.callbacks.onStatus(`正在连接 ${deviceName}`)
|
this.callbacks.onStatus(`正在连接 ${deviceName}`)
|
||||||
@@ -319,6 +395,11 @@ export class HeartRateController {
|
|||||||
fail: (error: any) => {
|
fail: (error: any) => {
|
||||||
const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败'
|
const message = error && error.errMsg ? error.errMsg : 'createBLEConnection 失败'
|
||||||
this.clearConnectionState()
|
this.clearConnectionState()
|
||||||
|
if (fallbackToDiscovery && !this.manualDisconnect) {
|
||||||
|
this.callbacks.onStatus(`直连失败,转入扫描: ${deviceName}`)
|
||||||
|
this.beginDiscovery()
|
||||||
|
return
|
||||||
|
}
|
||||||
this.callbacks.onError(`连接心率带失败: ${message}`)
|
this.callbacks.onError(`连接心率带失败: ${message}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -364,6 +445,15 @@ export class HeartRateController {
|
|||||||
success: () => {
|
success: () => {
|
||||||
this.connecting = false
|
this.connecting = false
|
||||||
this.connected = true
|
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.onConnectionChange(true, deviceName)
|
||||||
this.callbacks.onStatus(`心率带已连接: ${deviceName}`)
|
this.callbacks.onStatus(`心率带已连接: ${deviceName}`)
|
||||||
},
|
},
|
||||||
@@ -393,6 +483,7 @@ export class HeartRateController {
|
|||||||
complete: () => {
|
complete: () => {
|
||||||
this.clearConnectionState()
|
this.clearConnectionState()
|
||||||
this.callbacks.onError(message)
|
this.callbacks.onError(message)
|
||||||
|
this.scheduleAutoReconnect(this.lastDeviceId, this.lastDeviceName)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -402,6 +493,9 @@ export class HeartRateController {
|
|||||||
this.scanning = false
|
this.scanning = false
|
||||||
this.connecting = false
|
this.connecting = false
|
||||||
this.connected = false
|
this.connected = false
|
||||||
|
this.disconnecting = false
|
||||||
|
this.disconnectingDeviceId = null
|
||||||
|
this.autoConnectDeviceId = null
|
||||||
this.currentDeviceId = null
|
this.currentDeviceId = null
|
||||||
this.measurementServiceId = null
|
this.measurementServiceId = null
|
||||||
this.measurementCharacteristicId = null
|
this.measurementCharacteristicId = null
|
||||||
@@ -418,4 +512,264 @@ export class HeartRateController {
|
|||||||
this.discoveryTimer = 0
|
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 {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ export interface AudioCueConfig {
|
|||||||
volume: number
|
volume: number
|
||||||
loop: boolean
|
loop: boolean
|
||||||
loopGapMs: number
|
loopGapMs: number
|
||||||
|
backgroundMode: 'disabled' | 'guidance'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameAudioConfig {
|
export interface GameAudioConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
masterVolume: number
|
masterVolume: number
|
||||||
obeyMuteSwitch: boolean
|
obeyMuteSwitch: boolean
|
||||||
|
backgroundAudioEnabled: boolean
|
||||||
approachDistanceMeters: number
|
approachDistanceMeters: number
|
||||||
cues: Record<AudioCueKey, AudioCueConfig>
|
cues: Record<AudioCueKey, AudioCueConfig>
|
||||||
}
|
}
|
||||||
@@ -28,12 +30,14 @@ export interface PartialAudioCueConfig {
|
|||||||
volume?: number
|
volume?: number
|
||||||
loop?: boolean
|
loop?: boolean
|
||||||
loopGapMs?: number
|
loopGapMs?: number
|
||||||
|
backgroundMode?: 'disabled' | 'guidance'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameAudioConfigOverrides {
|
export interface GameAudioConfigOverrides {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
masterVolume?: number
|
masterVolume?: number
|
||||||
obeyMuteSwitch?: boolean
|
obeyMuteSwitch?: boolean
|
||||||
|
backgroundAudioEnabled?: boolean
|
||||||
approachDistanceMeters?: number
|
approachDistanceMeters?: number
|
||||||
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
|
cues?: Partial<Record<AudioCueKey, PartialAudioCueConfig>>
|
||||||
}
|
}
|
||||||
@@ -42,6 +46,7 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
masterVolume: 1,
|
masterVolume: 1,
|
||||||
obeyMuteSwitch: true,
|
obeyMuteSwitch: true,
|
||||||
|
backgroundAudioEnabled: true,
|
||||||
approachDistanceMeters: 20,
|
approachDistanceMeters: 20,
|
||||||
cues: {
|
cues: {
|
||||||
session_started: {
|
session_started: {
|
||||||
@@ -49,48 +54,56 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = {
|
|||||||
volume: 0.78,
|
volume: 0.78,
|
||||||
loop: false,
|
loop: false,
|
||||||
loopGapMs: 0,
|
loopGapMs: 0,
|
||||||
|
backgroundMode: 'disabled',
|
||||||
},
|
},
|
||||||
'control_completed:start': {
|
'control_completed:start': {
|
||||||
src: '/assets/sounds/start-complete.wav',
|
src: '/assets/sounds/start-complete.wav',
|
||||||
volume: 0.84,
|
volume: 0.84,
|
||||||
loop: false,
|
loop: false,
|
||||||
loopGapMs: 0,
|
loopGapMs: 0,
|
||||||
|
backgroundMode: 'disabled',
|
||||||
},
|
},
|
||||||
'control_completed:control': {
|
'control_completed:control': {
|
||||||
src: '/assets/sounds/control-complete.wav',
|
src: '/assets/sounds/control-complete.wav',
|
||||||
volume: 0.8,
|
volume: 0.8,
|
||||||
loop: false,
|
loop: false,
|
||||||
loopGapMs: 0,
|
loopGapMs: 0,
|
||||||
|
backgroundMode: 'disabled',
|
||||||
},
|
},
|
||||||
'control_completed:finish': {
|
'control_completed:finish': {
|
||||||
src: '/assets/sounds/finish-complete.wav',
|
src: '/assets/sounds/finish-complete.wav',
|
||||||
volume: 0.92,
|
volume: 0.92,
|
||||||
loop: false,
|
loop: false,
|
||||||
loopGapMs: 0,
|
loopGapMs: 0,
|
||||||
|
backgroundMode: 'disabled',
|
||||||
},
|
},
|
||||||
'punch_feedback:warning': {
|
'punch_feedback:warning': {
|
||||||
src: '/assets/sounds/warning.wav',
|
src: '/assets/sounds/warning.wav',
|
||||||
volume: 0.72,
|
volume: 0.72,
|
||||||
loop: false,
|
loop: false,
|
||||||
loopGapMs: 0,
|
loopGapMs: 0,
|
||||||
|
backgroundMode: 'disabled',
|
||||||
},
|
},
|
||||||
'guidance:searching': {
|
'guidance:searching': {
|
||||||
src: '/assets/sounds/guidance-searching.wav',
|
src: '/assets/sounds/guidance-searching.wav',
|
||||||
volume: 0.48,
|
volume: 0.48,
|
||||||
loop: true,
|
loop: true,
|
||||||
loopGapMs: 1800,
|
loopGapMs: 1800,
|
||||||
|
backgroundMode: 'guidance',
|
||||||
},
|
},
|
||||||
'guidance:approaching': {
|
'guidance:approaching': {
|
||||||
src: '/assets/sounds/guidance-approaching.wav',
|
src: '/assets/sounds/guidance-approaching.wav',
|
||||||
volume: 0.58,
|
volume: 0.58,
|
||||||
loop: true,
|
loop: true,
|
||||||
loopGapMs: 950,
|
loopGapMs: 950,
|
||||||
|
backgroundMode: 'guidance',
|
||||||
},
|
},
|
||||||
'guidance:ready': {
|
'guidance:ready': {
|
||||||
src: '/assets/sounds/guidance-ready.wav',
|
src: '/assets/sounds/guidance-ready.wav',
|
||||||
volume: 0.68,
|
volume: 0.68,
|
||||||
loop: true,
|
loop: true,
|
||||||
loopGapMs: 650,
|
loopGapMs: 650,
|
||||||
|
backgroundMode: 'guidance',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -143,6 +156,10 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null
|
|||||||
if (cue.loopGapMs !== undefined) {
|
if (cue.loopGapMs !== undefined) {
|
||||||
cues[key].loopGapMs = clampGap(Number(cue.loopGapMs), cues[key].loopGapMs)
|
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,
|
enabled: overrides && overrides.enabled !== undefined ? !!overrides.enabled : DEFAULT_GAME_AUDIO_CONFIG.enabled,
|
||||||
masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume),
|
masterVolume: clampVolume(Number(overrides && overrides.masterVolume), DEFAULT_GAME_AUDIO_CONFIG.masterVolume),
|
||||||
obeyMuteSwitch: overrides && overrides.obeyMuteSwitch !== undefined ? !!overrides.obeyMuteSwitch : DEFAULT_GAME_AUDIO_CONFIG.obeyMuteSwitch,
|
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),
|
approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters),
|
||||||
cues,
|
cues,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ export class SoundDirector {
|
|||||||
config: GameAudioConfig
|
config: GameAudioConfig
|
||||||
contexts: Partial<Record<AudioCueKey, WechatMiniprogram.InnerAudioContext>>
|
contexts: Partial<Record<AudioCueKey, WechatMiniprogram.InnerAudioContext>>
|
||||||
loopTimers: Partial<Record<AudioCueKey, number>>
|
loopTimers: Partial<Record<AudioCueKey, number>>
|
||||||
|
backgroundLoopTimer: number
|
||||||
activeGuidanceCue: AudioCueKey | null
|
activeGuidanceCue: AudioCueKey | null
|
||||||
|
backgroundManager: WechatMiniprogram.BackgroundAudioManager | null
|
||||||
|
appAudioMode: 'foreground' | 'background'
|
||||||
|
|
||||||
constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) {
|
constructor(config: GameAudioConfig = DEFAULT_GAME_AUDIO_CONFIG) {
|
||||||
this.enabled = true
|
this.enabled = true
|
||||||
this.config = config
|
this.config = config
|
||||||
this.contexts = {}
|
this.contexts = {}
|
||||||
this.loopTimers = {}
|
this.loopTimers = {}
|
||||||
|
this.backgroundLoopTimer = 0
|
||||||
this.activeGuidanceCue = null
|
this.activeGuidanceCue = null
|
||||||
|
this.backgroundManager = null
|
||||||
|
this.appAudioMode = 'foreground'
|
||||||
}
|
}
|
||||||
|
|
||||||
configure(config: GameAudioConfig): void {
|
configure(config: GameAudioConfig): void {
|
||||||
@@ -34,6 +40,7 @@ export class SoundDirector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.loopTimers = {}
|
this.loopTimers = {}
|
||||||
|
this.clearBackgroundLoopTimer()
|
||||||
|
|
||||||
const keys = Object.keys(this.contexts) as AudioCueKey[]
|
const keys = Object.keys(this.contexts) as AudioCueKey[]
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -46,6 +53,7 @@ export class SoundDirector {
|
|||||||
}
|
}
|
||||||
this.contexts = {}
|
this.contexts = {}
|
||||||
this.activeGuidanceCue = null
|
this.activeGuidanceCue = null
|
||||||
|
this.stopBackgroundGuidance()
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
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 {
|
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]
|
const cue = this.config.cues[key]
|
||||||
if (!cue || !cue.src) {
|
if (!cue || !cue.src) {
|
||||||
return
|
return
|
||||||
@@ -132,11 +176,17 @@ export class SoundDirector {
|
|||||||
|
|
||||||
this.stopGuidanceLoop()
|
this.stopGuidanceLoop()
|
||||||
this.activeGuidanceCue = key
|
this.activeGuidanceCue = key
|
||||||
this.play(key)
|
if (this.appAudioMode === 'background') {
|
||||||
|
this.startBackgroundGuidance(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playForeground(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
stopGuidanceLoop(): void {
|
stopGuidanceLoop(): void {
|
||||||
if (!this.activeGuidanceCue) {
|
if (!this.activeGuidanceCue) {
|
||||||
|
this.stopBackgroundGuidance()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +198,7 @@ export class SoundDirector {
|
|||||||
context.seek(0)
|
context.seek(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.stopBackgroundGuidance()
|
||||||
this.activeGuidanceCue = null
|
this.activeGuidanceCue = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +226,105 @@ export class SoundDirector {
|
|||||||
}, cue.loopGapMs) as unknown as number
|
}, 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 {
|
getContext(key: AudioCueKey): WechatMiniprogram.InnerAudioContext {
|
||||||
const existing = this.contexts[key]
|
const existing = this.contexts[key]
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export class FeedbackDirector {
|
|||||||
this.uiEffectDirector.destroy()
|
this.uiEffectDirector.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAppAudioMode(mode: 'foreground' | 'background'): void {
|
||||||
|
this.soundDirector.setAppAudioMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
handleEffects(effects: GameEffect[]): void {
|
handleEffects(effects: GameEffect[]): void {
|
||||||
this.soundDirector.handleEffects(effects)
|
this.soundDirector.handleEffects(effects)
|
||||||
this.hapticsDirector.handleEffects(effects)
|
this.hapticsDirector.handleEffects(effects)
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showRightButtonGroups: boolean
|
showRightButtonGroups: boolean
|
||||||
showBottomDebugButton: 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'
|
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||||
let mapEngine: MapEngine | null = null
|
let mapEngine: MapEngine | null = null
|
||||||
|
let stageCanvasAttached = false
|
||||||
function buildSideButtonVisibility(mode: SideButtonMode) {
|
function buildSideButtonVisibility(mode: SideButtonMode) {
|
||||||
return {
|
return {
|
||||||
sideButtonMode: mode,
|
sideButtonMode: mode,
|
||||||
@@ -114,6 +115,8 @@ Page({
|
|||||||
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
mockCoordText: '--',
|
mockCoordText: '--',
|
||||||
mockSpeedText: '--',
|
mockSpeedText: '--',
|
||||||
|
heartRateScanText: '未扫描',
|
||||||
|
heartRateDiscoveredDevices: [],
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
panelTelemetryTone: 'blue',
|
panelTelemetryTone: 'blue',
|
||||||
panelHeartRateZoneNameText: '--',
|
panelHeartRateZoneNameText: '--',
|
||||||
@@ -150,7 +153,7 @@ Page({
|
|||||||
compassTicks: buildCompassTicks(),
|
compassTicks: buildCompassTicks(),
|
||||||
compassLabels: buildCompassLabels(),
|
compassLabels: buildCompassLabels(),
|
||||||
...buildSideButtonVisibility('left'),
|
...buildSideButtonVisibility('left'),
|
||||||
} as MapPageData,
|
} as unknown as MapPageData,
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
const systemInfo = wx.getSystemInfoSync()
|
const systemInfo = wx.getSystemInfoSync()
|
||||||
@@ -239,15 +242,29 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
onReady() {
|
onReady() {
|
||||||
|
stageCanvasAttached = false
|
||||||
this.measureStageAndCanvas()
|
this.measureStageAndCanvas()
|
||||||
this.loadMapConfigFromRemote()
|
this.loadMapConfigFromRemote()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleAppShow()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onHide() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleAppHide()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
onUnload() {
|
onUnload() {
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.destroy()
|
mapEngine.destroy()
|
||||||
mapEngine = null
|
mapEngine = null
|
||||||
}
|
}
|
||||||
|
stageCanvasAttached = false
|
||||||
},
|
},
|
||||||
|
|
||||||
loadMapConfigFromRemote() {
|
loadMapConfigFromRemote() {
|
||||||
@@ -295,6 +312,10 @@ Page({
|
|||||||
|
|
||||||
currentEngine.setStage(rect)
|
currentEngine.setStage(rect)
|
||||||
|
|
||||||
|
if (stageCanvasAttached) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const canvasQuery = wx.createSelectorQuery().in(page)
|
const canvasQuery = wx.createSelectorQuery().in(page)
|
||||||
canvasQuery.select('#mapCanvas').fields({ node: true, size: true })
|
canvasQuery.select('#mapCanvas').fields({ node: true, size: true })
|
||||||
canvasQuery.select('#routeLabelCanvas').fields({ node: true, size: true })
|
canvasQuery.select('#routeLabelCanvas').fields({ node: true, size: true })
|
||||||
@@ -317,6 +338,7 @@ Page({
|
|||||||
dpr,
|
dpr,
|
||||||
labelCanvasRef && labelCanvasRef.node ? labelCanvasRef.node : undefined,
|
labelCanvasRef && labelCanvasRef.node ? labelCanvasRef.node : undefined,
|
||||||
)
|
)
|
||||||
|
stageCanvasAttached = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
page.setData({
|
page.setData({
|
||||||
statusText: `WebGL 鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`,
|
statusText: `WebGL 鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`,
|
||||||
@@ -453,11 +475,23 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleDisconnectHeartRate() {
|
handleDisconnectHeartRate() {
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.handleDisconnectHeartRate()
|
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() {
|
handleDebugHeartRateBlue() {
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
|
|||||||
@@ -78,28 +78,28 @@
|
|||||||
|
|
||||||
<cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
|
<cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
|
||||||
<cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
|
<cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
|
||||||
<cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">LOC</cover-view></cover-view>
|
<cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">1</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">LOCK</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">2</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">SUN</cover-view></cover-view>
|
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">3</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">EXIT</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">4</cover-view></cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
|
|
||||||
<cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
|
<cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">N</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">5</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">DIR</cover-view></cover-view>
|
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">6</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">COMP</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">7</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">GUIDE</cover-view></cover-view>
|
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">8</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">NET</cover-view></cover-view>
|
<cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">9</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">GO</cover-view></cover-view>
|
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">10</cover-view></cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
|
|
||||||
<cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
|
<cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">INFO</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">11</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">SET</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">12</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">m</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">13</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">PIN</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">14</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">LIST</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">15</cover-view></cover-view>
|
||||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">USER</cover-view></cover-view>
|
<cover-view class="map-side-button"><cover-view class="map-side-button__text">16</cover-view></cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
|
|
||||||
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
|
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
|
||||||
@@ -332,6 +332,29 @@
|
|||||||
<text class="info-panel__label">HR Device</text>
|
<text class="info-panel__label">HR Device</text>
|
||||||
<text class="info-panel__value">{{heartRateDeviceText}}</text>
|
<text class="info-panel__value">{{heartRateDeviceText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<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-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
|
||||||
|
<view class="debug-device-card__main">
|
||||||
|
<view class="debug-device-card__title-row">
|
||||||
|
<text class="debug-device-card__name">{{item.name}}</text>
|
||||||
|
<text class="debug-device-card__badge" wx:if="{{item.preferred}}">首选</text>
|
||||||
|
</view>
|
||||||
|
<text class="debug-device-card__meta">{{item.rssiText}}</text>
|
||||||
|
</view>
|
||||||
|
<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-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-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">Heading Mode</text>
|
<text class="info-panel__label">Heading Mode</text>
|
||||||
<text class="info-panel__value">{{orientationModeText}}</text>
|
<text class="info-panel__value">{{orientationModeText}}</text>
|
||||||
@@ -346,14 +369,12 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
||||||
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip {{locationSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealLocationMode">真实定位</view>
|
<view class="control-chip {{locationSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealLocationMode">真实定位</view>
|
||||||
<view class="control-chip {{locationSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockLocationMode">模拟定位</view>
|
<view class="control-chip {{locationSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockLocationMode">模拟定位</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -1194,6 +1194,81 @@
|
|||||||
gap: 14rpx;
|
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 {
|
.control-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
|
|||||||
Reference in New Issue
Block a user