Add mock GPS simulator and configurable location sources
This commit is contained in:
@@ -121,6 +121,13 @@ export interface MapEngineViewState {
|
||||
statusText: string
|
||||
gpsTracking: boolean
|
||||
gpsTrackingText: string
|
||||
locationSourceMode: 'real' | 'mock'
|
||||
locationSourceText: string
|
||||
mockBridgeConnected: boolean
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrlText: string
|
||||
mockCoordText: string
|
||||
mockSpeedText: string
|
||||
gpsCoordText: string
|
||||
heartRateConnected: boolean
|
||||
heartRateStatusText: string
|
||||
@@ -209,6 +216,13 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
||||
'statusText',
|
||||
'gpsTracking',
|
||||
'gpsTrackingText',
|
||||
'locationSourceMode',
|
||||
'locationSourceText',
|
||||
'mockBridgeConnected',
|
||||
'mockBridgeStatusText',
|
||||
'mockBridgeUrlText',
|
||||
'mockCoordText',
|
||||
'mockSpeedText',
|
||||
'gpsCoordText',
|
||||
'heartRateConnected',
|
||||
'heartRateStatusText',
|
||||
@@ -582,15 +596,20 @@ export class MapEngine {
|
||||
this.setState({
|
||||
gpsTracking: this.locationController.listening,
|
||||
gpsTrackingText: message,
|
||||
...this.getLocationControllerViewPatch(),
|
||||
}, true)
|
||||
},
|
||||
onError: (message) => {
|
||||
this.setState({
|
||||
gpsTracking: false,
|
||||
gpsTracking: this.locationController.listening,
|
||||
gpsTrackingText: message,
|
||||
...this.getLocationControllerViewPatch(),
|
||||
statusText: `${message} (${this.buildVersion})`,
|
||||
}, true)
|
||||
},
|
||||
onDebugStateChange: () => {
|
||||
this.setState(this.getLocationControllerViewPatch(), true)
|
||||
},
|
||||
})
|
||||
this.heartRateController = new HeartRateController({
|
||||
onHeartRate: (bpm) => {
|
||||
@@ -716,6 +735,13 @@ export class MapEngine {
|
||||
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
|
||||
gpsTracking: false,
|
||||
gpsTrackingText: '持续定位待启动',
|
||||
locationSourceMode: 'real',
|
||||
locationSourceText: '真实定位',
|
||||
mockBridgeConnected: false,
|
||||
mockBridgeStatusText: '未连接',
|
||||
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockCoordText: '--',
|
||||
mockSpeedText: '--',
|
||||
gpsCoordText: '--',
|
||||
heartRateConnected: false,
|
||||
heartRateStatusText: '心率带未连接',
|
||||
@@ -833,6 +859,20 @@ export class MapEngine {
|
||||
return this.gamePresentation.hud.hudTargetControlId
|
||||
}
|
||||
|
||||
getLocationControllerViewPatch(): Partial<MapEngineViewState> {
|
||||
const debugState = this.locationController.getDebugState()
|
||||
return {
|
||||
gpsTracking: debugState.listening,
|
||||
locationSourceMode: debugState.sourceMode,
|
||||
locationSourceText: debugState.sourceModeText,
|
||||
mockBridgeConnected: debugState.mockBridgeConnected,
|
||||
mockBridgeStatusText: debugState.mockBridgeStatusText,
|
||||
mockBridgeUrlText: debugState.mockBridgeUrlText,
|
||||
mockCoordText: debugState.mockCoordText,
|
||||
mockSpeedText: debugState.mockSpeedText,
|
||||
}
|
||||
}
|
||||
|
||||
getGameModeText(): string {
|
||||
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||||
}
|
||||
@@ -1272,6 +1312,26 @@ export class MapEngine {
|
||||
this.locationController.start()
|
||||
}
|
||||
|
||||
handleSetRealLocationMode(): void {
|
||||
this.locationController.setSourceMode('real')
|
||||
}
|
||||
|
||||
handleSetMockLocationMode(): void {
|
||||
this.locationController.setSourceMode('mock')
|
||||
}
|
||||
|
||||
handleConnectMockLocationBridge(): void {
|
||||
this.locationController.connectMockBridge()
|
||||
}
|
||||
|
||||
handleDisconnectMockLocationBridge(): void {
|
||||
this.locationController.disconnectMockBridge()
|
||||
}
|
||||
|
||||
handleSetMockLocationBridgeUrl(url: string): void {
|
||||
this.locationController.setMockBridgeUrl(url)
|
||||
}
|
||||
|
||||
handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
|
||||
if (this.gameMode === nextMode) {
|
||||
return
|
||||
|
||||
@@ -1,153 +1,227 @@
|
||||
export interface LocationUpdate {
|
||||
latitude: number
|
||||
longitude: number
|
||||
accuracy?: number
|
||||
speed?: number
|
||||
import { DEFAULT_MOCK_LOCATION_BRIDGE_URL, MockLocationBridge } from './mockLocationBridge'
|
||||
import { MockLocationSource } from './mockLocationSource'
|
||||
import { RealLocationSource } from './realLocationSource'
|
||||
import { type LocationSample, type LocationSourceCallbacks, type LocationSourceMode } from './locationSource'
|
||||
|
||||
export interface LocationUpdate extends LocationSample {}
|
||||
|
||||
export interface LocationControllerDebugState {
|
||||
sourceMode: LocationSourceMode
|
||||
sourceModeText: string
|
||||
listening: boolean
|
||||
mockBridgeConnected: boolean
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrlText: string
|
||||
mockCoordText: string
|
||||
mockSpeedText: string
|
||||
}
|
||||
|
||||
export interface LocationControllerCallbacks {
|
||||
onLocation: (update: LocationUpdate) => void
|
||||
onStatus: (message: string) => void
|
||||
onError: (message: string) => void
|
||||
onDebugStateChange?: (state: LocationControllerDebugState) => void
|
||||
}
|
||||
|
||||
function hasLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
||||
const authSettings = settings as Record<string, boolean | undefined>
|
||||
return !!authSettings['scope.userLocation']
|
||||
function formatSourceModeText(mode: LocationSourceMode): string {
|
||||
return mode === 'mock' ? '模拟定位' : '真实定位'
|
||||
}
|
||||
|
||||
function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
||||
const authSettings = settings as Record<string, boolean | undefined>
|
||||
return !!authSettings['scope.userLocationBackground']
|
||||
function formatMockCoordText(sample: LocationSample | null): string {
|
||||
if (!sample) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${sample.latitude.toFixed(6)}, ${sample.longitude.toFixed(6)}`
|
||||
}
|
||||
|
||||
function formatMockSpeedText(sample: LocationSample | null): string {
|
||||
if (!sample || !Number.isFinite(sample.speed)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${(Number(sample.speed) * 3.6).toFixed(1)} km/h`
|
||||
}
|
||||
|
||||
function normalizeMockBridgeUrl(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim()
|
||||
if (!trimmed) {
|
||||
return DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||
}
|
||||
|
||||
let normalized = trimmed
|
||||
if (!/^wss?:\/\//i.test(normalized)) {
|
||||
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
|
||||
normalized = normalized.replace(/\/+$/, '')
|
||||
normalized = `${normalized}/mock-gps`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export class LocationController {
|
||||
callbacks: LocationControllerCallbacks
|
||||
listening: boolean
|
||||
boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null
|
||||
realSource: RealLocationSource
|
||||
mockSource: MockLocationSource
|
||||
mockBridge: MockLocationBridge
|
||||
sourceMode: LocationSourceMode
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrl: string
|
||||
|
||||
constructor(callbacks: LocationControllerCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.listening = false
|
||||
this.boundLocationHandler = null
|
||||
this.sourceMode = 'real'
|
||||
this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||
|
||||
const sourceCallbacks: LocationSourceCallbacks = {
|
||||
onLocation: (sample) => {
|
||||
this.callbacks.onLocation(sample)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onStatus: (message) => {
|
||||
this.callbacks.onStatus(message)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onError: (message) => {
|
||||
this.callbacks.onError(message)
|
||||
this.emitDebugState()
|
||||
},
|
||||
}
|
||||
|
||||
this.realSource = new RealLocationSource(sourceCallbacks)
|
||||
this.mockSource = new MockLocationSource(sourceCallbacks)
|
||||
this.mockBridge = new MockLocationBridge({
|
||||
onOpen: () => {
|
||||
this.mockBridgeStatusText = `已连接 (${this.mockBridge.url})`
|
||||
this.callbacks.onStatus('模拟定位源已连接')
|
||||
this.emitDebugState()
|
||||
},
|
||||
onClose: () => {
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||
this.callbacks.onStatus('模拟定位源已断开')
|
||||
this.emitDebugState()
|
||||
},
|
||||
onError: (message) => {
|
||||
this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})`
|
||||
this.callbacks.onError(`模拟定位源错误: ${message}`)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onSample: (sample) => {
|
||||
this.mockSource.pushSample(sample)
|
||||
this.emitDebugState()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
get listening(): boolean {
|
||||
return this.sourceMode === 'mock' ? this.mockSource.active : this.realSource.active
|
||||
}
|
||||
|
||||
getDebugState(): LocationControllerDebugState {
|
||||
return {
|
||||
sourceMode: this.sourceMode,
|
||||
sourceModeText: formatSourceModeText(this.sourceMode),
|
||||
listening: this.listening,
|
||||
mockBridgeConnected: this.mockBridge.connected,
|
||||
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||
mockBridgeUrlText: this.mockBridgeUrl,
|
||||
mockCoordText: formatMockCoordText(this.mockSource.lastSample),
|
||||
mockSpeedText: formatMockSpeedText(this.mockSource.lastSample),
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.listening) {
|
||||
this.callbacks.onStatus('后台持续定位进行中')
|
||||
return
|
||||
}
|
||||
|
||||
wx.getSetting({
|
||||
success: (result) => {
|
||||
const settings = result.authSetting || {}
|
||||
if (hasBackgroundLocationPermission(settings)) {
|
||||
this.startBackgroundLocation()
|
||||
return
|
||||
}
|
||||
|
||||
if (hasLocationPermission(settings)) {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
return
|
||||
}
|
||||
|
||||
wx.authorize({
|
||||
scope: 'scope.userLocation',
|
||||
success: () => {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
},
|
||||
fail: () => {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
},
|
||||
})
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'getSetting 失败'
|
||||
this.callbacks.onError(`GPS授权检查失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
requestBackgroundPermissionInSettings(): void {
|
||||
this.callbacks.onStatus('请在授权面板开启后台定位')
|
||||
wx.openSetting({
|
||||
success: (result) => {
|
||||
const settings = result.authSetting || {}
|
||||
if (hasBackgroundLocationPermission(settings)) {
|
||||
this.startBackgroundLocation()
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onError('GPS启动失败: 未授予后台定位权限')
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'openSetting 失败'
|
||||
this.callbacks.onError(`GPS启动失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
startBackgroundLocation(): void {
|
||||
wx.startLocationUpdateBackground({
|
||||
type: 'wgs84',
|
||||
success: () => {
|
||||
this.bindLocationListener()
|
||||
this.listening = true
|
||||
this.callbacks.onStatus('后台持续定位已启动')
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败'
|
||||
this.callbacks.onError(`GPS启动失败: ${message}`)
|
||||
},
|
||||
})
|
||||
this.getActiveSource().start()
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.listening) {
|
||||
this.callbacks.onStatus('后台持续定位未启动')
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
||||
wx.offLocationChange(this.boundLocationHandler)
|
||||
}
|
||||
this.boundLocationHandler = null
|
||||
|
||||
wx.stopLocationUpdate({
|
||||
complete: () => {
|
||||
this.listening = false
|
||||
this.callbacks.onStatus('后台持续定位已停止')
|
||||
},
|
||||
})
|
||||
this.getActiveSource().stop()
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
||||
wx.offLocationChange(this.boundLocationHandler)
|
||||
}
|
||||
this.boundLocationHandler = null
|
||||
|
||||
if (this.listening) {
|
||||
wx.stopLocationUpdate({ complete: () => {} })
|
||||
this.listening = false
|
||||
}
|
||||
this.realSource.destroy()
|
||||
this.mockSource.destroy()
|
||||
this.mockBridge.destroy()
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
bindLocationListener(): void {
|
||||
if (this.boundLocationHandler) {
|
||||
setSourceMode(mode: LocationSourceMode): void {
|
||||
if (this.sourceMode === mode) {
|
||||
this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`)
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.boundLocationHandler = (result) => {
|
||||
this.callbacks.onLocation({
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
accuracy: result.accuracy,
|
||||
speed: result.speed,
|
||||
})
|
||||
const wasListening = this.listening
|
||||
if (wasListening) {
|
||||
this.getActiveSource().stop()
|
||||
}
|
||||
this.sourceMode = mode
|
||||
|
||||
if (wasListening) {
|
||||
this.getActiveSource().start()
|
||||
} else {
|
||||
this.callbacks.onStatus(`已切换到${formatSourceModeText(mode)}`)
|
||||
}
|
||||
|
||||
wx.onLocationChange(this.boundLocationHandler)
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
setMockBridgeUrl(url: string): void {
|
||||
this.mockBridgeUrl = normalizeMockBridgeUrl(url)
|
||||
|
||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||
this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})`
|
||||
this.callbacks.onStatus('模拟定位源地址已更新,重连后生效')
|
||||
} else {
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||
this.callbacks.onStatus('模拟定位源地址已更新')
|
||||
}
|
||||
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||
this.callbacks.onStatus('模拟定位源已连接')
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = normalizeMockBridgeUrl(url === DEFAULT_MOCK_LOCATION_BRIDGE_URL ? this.mockBridgeUrl : url)
|
||||
this.mockBridgeUrl = targetUrl
|
||||
this.mockBridgeStatusText = `连接中 (${targetUrl})`
|
||||
this.emitDebugState()
|
||||
this.callbacks.onStatus('模拟定位源连接中')
|
||||
this.mockBridge.connect(targetUrl)
|
||||
}
|
||||
|
||||
disconnectMockBridge(): void {
|
||||
if (!this.mockBridge.connected && !this.mockBridge.connecting) {
|
||||
this.callbacks.onStatus('模拟定位源未连接')
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.mockBridge.disconnect()
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||
this.callbacks.onStatus('模拟定位源已断开')
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
getActiveSource(): RealLocationSource | MockLocationSource {
|
||||
return this.sourceMode === 'mock' ? this.mockSource : this.realSource
|
||||
}
|
||||
|
||||
emitDebugState(): void {
|
||||
if (this.callbacks.onDebugStateChange) {
|
||||
this.callbacks.onDebugStateChange(this.getDebugState())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
miniprogram/engine/sensor/locationSource.ts
Normal file
25
miniprogram/engine/sensor/locationSource.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type LocationSourceMode = 'real' | 'mock'
|
||||
|
||||
export interface LocationSample {
|
||||
latitude: number
|
||||
longitude: number
|
||||
accuracy?: number
|
||||
speed?: number | null
|
||||
headingDeg?: number | null
|
||||
timestamp: number
|
||||
sourceMode: LocationSourceMode
|
||||
}
|
||||
|
||||
export interface LocationSourceCallbacks {
|
||||
onLocation: (sample: LocationSample) => void
|
||||
onStatus: (message: string) => void
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
export interface LocationSource {
|
||||
readonly mode: LocationSourceMode
|
||||
readonly active: boolean
|
||||
start(): void
|
||||
stop(): void
|
||||
destroy(): void
|
||||
}
|
||||
147
miniprogram/engine/sensor/mockLocationBridge.ts
Normal file
147
miniprogram/engine/sensor/mockLocationBridge.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { type LocationSample } from './locationSource'
|
||||
|
||||
export const DEFAULT_MOCK_LOCATION_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps'
|
||||
|
||||
export interface MockLocationBridgeCallbacks {
|
||||
onOpen: () => void
|
||||
onClose: (message: string) => void
|
||||
onError: (message: string) => void
|
||||
onSample: (sample: LocationSample) => void
|
||||
}
|
||||
|
||||
type RawMockGpsMessage = {
|
||||
type?: string
|
||||
timestamp?: number
|
||||
lat?: number
|
||||
lon?: number
|
||||
accuracyMeters?: number
|
||||
speedMps?: number
|
||||
headingDeg?: number
|
||||
}
|
||||
|
||||
function safeParseMessage(data: string): RawMockGpsMessage | null {
|
||||
try {
|
||||
return JSON.parse(data) as RawMockGpsMessage
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
|
||||
if (message.type !== 'mock_gps') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!Number.isFinite(message.lat) || !Number.isFinite(message.lon)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: Number(message.lat),
|
||||
longitude: Number(message.lon),
|
||||
accuracy: Number.isFinite(message.accuracyMeters) ? Number(message.accuracyMeters) : undefined,
|
||||
speed: Number.isFinite(message.speedMps) ? Number(message.speedMps) : null,
|
||||
headingDeg: Number.isFinite(message.headingDeg) ? Number(message.headingDeg) : null,
|
||||
timestamp: Number.isFinite(message.timestamp) ? Number(message.timestamp) : Date.now(),
|
||||
sourceMode: 'mock',
|
||||
}
|
||||
}
|
||||
|
||||
export class MockLocationBridge {
|
||||
callbacks: MockLocationBridgeCallbacks
|
||||
socketTask: WechatMiniprogram.SocketTask | null
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
url: string
|
||||
|
||||
constructor(callbacks: MockLocationBridgeCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.socketTask = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.url = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||
}
|
||||
|
||||
connect(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||
if (this.connected || this.connecting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.url = url
|
||||
this.connecting = true
|
||||
|
||||
try {
|
||||
const socketTask = wx.connectSocket({
|
||||
url,
|
||||
})
|
||||
this.socketTask = socketTask
|
||||
|
||||
socketTask.onOpen(() => {
|
||||
this.connected = true
|
||||
this.connecting = false
|
||||
this.callbacks.onOpen()
|
||||
})
|
||||
|
||||
socketTask.onClose((result) => {
|
||||
const reason = result && result.reason ? result.reason : '模拟源连接已关闭'
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
this.callbacks.onClose(reason)
|
||||
})
|
||||
|
||||
socketTask.onError((result) => {
|
||||
const message = result && result.errMsg ? result.errMsg : '模拟源连接失败'
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
this.callbacks.onError(message)
|
||||
})
|
||||
|
||||
socketTask.onMessage((result) => {
|
||||
if (!result || typeof result.data !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = safeParseMessage(result.data)
|
||||
if (!parsed) {
|
||||
this.callbacks.onError('模拟源消息不是合法 JSON')
|
||||
return
|
||||
}
|
||||
|
||||
const sample = toLocationSample(parsed)
|
||||
if (!sample) {
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onSample(sample)
|
||||
})
|
||||
} catch (error) {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
const message = error && (error as Error).message ? (error as Error).message : '模拟源连接创建失败'
|
||||
this.callbacks.onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (!this.socketTask) {
|
||||
if (this.connected || this.connecting) {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const socketTask = this.socketTask
|
||||
this.socketTask = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
socketTask.close({})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.disconnect()
|
||||
}
|
||||
}
|
||||
51
miniprogram/engine/sensor/mockLocationSource.ts
Normal file
51
miniprogram/engine/sensor/mockLocationSource.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { type LocationSample, type LocationSource, type LocationSourceCallbacks } from './locationSource'
|
||||
|
||||
export class MockLocationSource implements LocationSource {
|
||||
callbacks: LocationSourceCallbacks
|
||||
active: boolean
|
||||
lastSample: LocationSample | null
|
||||
|
||||
constructor(callbacks: LocationSourceCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.active = false
|
||||
this.lastSample = null
|
||||
}
|
||||
|
||||
get mode(): 'mock' {
|
||||
return 'mock'
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.active) {
|
||||
this.callbacks.onStatus('模拟定位进行中')
|
||||
return
|
||||
}
|
||||
|
||||
this.active = true
|
||||
this.callbacks.onStatus('模拟定位已启动,等待外部输入')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.active) {
|
||||
this.callbacks.onStatus('模拟定位未启动')
|
||||
return
|
||||
}
|
||||
|
||||
this.active = false
|
||||
this.callbacks.onStatus('模拟定位已停止')
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.active = false
|
||||
this.lastSample = null
|
||||
}
|
||||
|
||||
pushSample(sample: LocationSample): void {
|
||||
this.lastSample = sample
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onLocation(sample)
|
||||
}
|
||||
}
|
||||
147
miniprogram/engine/sensor/realLocationSource.ts
Normal file
147
miniprogram/engine/sensor/realLocationSource.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { type LocationSource, type LocationSourceCallbacks } from './locationSource'
|
||||
|
||||
function hasLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
||||
const authSettings = settings as Record<string, boolean | undefined>
|
||||
return !!authSettings['scope.userLocation']
|
||||
}
|
||||
|
||||
function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
||||
const authSettings = settings as Record<string, boolean | undefined>
|
||||
return !!authSettings['scope.userLocationBackground']
|
||||
}
|
||||
|
||||
export class RealLocationSource implements LocationSource {
|
||||
callbacks: LocationSourceCallbacks
|
||||
active: boolean
|
||||
boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null
|
||||
|
||||
constructor(callbacks: LocationSourceCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.active = false
|
||||
this.boundLocationHandler = null
|
||||
}
|
||||
|
||||
get mode(): 'real' {
|
||||
return 'real'
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.active) {
|
||||
this.callbacks.onStatus('后台持续定位进行中')
|
||||
return
|
||||
}
|
||||
|
||||
wx.getSetting({
|
||||
success: (result) => {
|
||||
const settings = result.authSetting || {}
|
||||
if (hasBackgroundLocationPermission(settings)) {
|
||||
this.startBackgroundLocation()
|
||||
return
|
||||
}
|
||||
|
||||
if (hasLocationPermission(settings)) {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
return
|
||||
}
|
||||
|
||||
wx.authorize({
|
||||
scope: 'scope.userLocation',
|
||||
success: () => {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
},
|
||||
fail: () => {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
},
|
||||
})
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'getSetting 失败'
|
||||
this.callbacks.onError(`GPS授权检查失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.active) {
|
||||
this.callbacks.onStatus('后台持续定位未启动')
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
||||
wx.offLocationChange(this.boundLocationHandler)
|
||||
}
|
||||
this.boundLocationHandler = null
|
||||
|
||||
wx.stopLocationUpdate({
|
||||
complete: () => {
|
||||
this.active = false
|
||||
this.callbacks.onStatus('后台持续定位已停止')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
||||
wx.offLocationChange(this.boundLocationHandler)
|
||||
}
|
||||
this.boundLocationHandler = null
|
||||
|
||||
if (this.active) {
|
||||
wx.stopLocationUpdate({ complete: () => {} })
|
||||
this.active = false
|
||||
}
|
||||
}
|
||||
|
||||
requestBackgroundPermissionInSettings(): void {
|
||||
this.callbacks.onStatus('请在授权面板开启后台定位')
|
||||
wx.openSetting({
|
||||
success: (result) => {
|
||||
const settings = result.authSetting || {}
|
||||
if (hasBackgroundLocationPermission(settings)) {
|
||||
this.startBackgroundLocation()
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onError('GPS启动失败: 未授予后台定位权限')
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'openSetting 失败'
|
||||
this.callbacks.onError(`GPS启动失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
startBackgroundLocation(): void {
|
||||
wx.startLocationUpdateBackground({
|
||||
type: 'wgs84',
|
||||
success: () => {
|
||||
this.bindLocationListener()
|
||||
this.active = true
|
||||
this.callbacks.onStatus('后台持续定位已启动')
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败'
|
||||
this.callbacks.onError(`GPS启动失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
bindLocationListener(): void {
|
||||
if (this.boundLocationHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.boundLocationHandler = (result) => {
|
||||
this.callbacks.onLocation({
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
accuracy: result.accuracy,
|
||||
speed: result.speed,
|
||||
timestamp: Date.now(),
|
||||
sourceMode: 'real',
|
||||
})
|
||||
}
|
||||
|
||||
wx.onLocationChange(this.boundLocationHandler)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type MapPageData = MapEngineViewState & {
|
||||
statusBarHeight: number
|
||||
topInsetHeight: number
|
||||
hudPanelIndex: number
|
||||
mockBridgeUrlDraft: string
|
||||
panelTimerText: string
|
||||
panelMileageText: string
|
||||
panelDistanceValueText: string
|
||||
@@ -30,7 +31,7 @@ type MapPageData = MapEngineViewState & {
|
||||
showRightButtonGroups: boolean
|
||||
showBottomDebugButton: boolean
|
||||
}
|
||||
const INTERNAL_BUILD_VERSION = 'map-build-166'
|
||||
const INTERNAL_BUILD_VERSION = 'map-build-172'
|
||||
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||
let mapEngine: MapEngine | null = null
|
||||
function buildSideButtonVisibility(mode: SideButtonMode) {
|
||||
@@ -105,6 +106,14 @@ Page({
|
||||
panelProgressText: '0/0',
|
||||
gameSessionStatus: 'idle',
|
||||
gameModeText: '顺序赛',
|
||||
locationSourceMode: 'real',
|
||||
locationSourceText: '真实定位',
|
||||
mockBridgeConnected: false,
|
||||
mockBridgeStatusText: '未连接',
|
||||
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockCoordText: '--',
|
||||
mockSpeedText: '--',
|
||||
panelSpeedValueText: '0',
|
||||
panelTelemetryTone: 'blue',
|
||||
panelHeartRateZoneNameText: '--',
|
||||
@@ -151,7 +160,19 @@ Page({
|
||||
|
||||
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
||||
onData: (patch) => {
|
||||
this.setData(patch)
|
||||
const nextPatch = patch as Partial<MapPageData>
|
||||
if (
|
||||
typeof nextPatch.mockBridgeUrlText === 'string'
|
||||
&& this.data.mockBridgeUrlDraft === this.data.mockBridgeUrlText
|
||||
) {
|
||||
this.setData({
|
||||
...nextPatch,
|
||||
mockBridgeUrlDraft: nextPatch.mockBridgeUrlText,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.setData(nextPatch)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -170,6 +191,14 @@ Page({
|
||||
panelProgressText: '0/0',
|
||||
gameSessionStatus: 'idle',
|
||||
gameModeText: '顺序赛',
|
||||
locationSourceMode: 'real',
|
||||
locationSourceText: '真实定位',
|
||||
mockBridgeConnected: false,
|
||||
mockBridgeStatusText: '未连接',
|
||||
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||
mockCoordText: '--',
|
||||
mockSpeedText: '--',
|
||||
panelSpeedValueText: '0',
|
||||
panelTelemetryTone: 'blue',
|
||||
panelHeartRateZoneNameText: '--',
|
||||
@@ -382,6 +411,42 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
handleSetRealLocationMode() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetRealLocationMode()
|
||||
}
|
||||
},
|
||||
|
||||
handleSetMockLocationMode() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetMockLocationMode()
|
||||
}
|
||||
},
|
||||
|
||||
handleConnectMockLocationBridge() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleConnectMockLocationBridge()
|
||||
}
|
||||
},
|
||||
|
||||
handleMockBridgeUrlInput(event: WechatMiniprogram.Input) {
|
||||
this.setData({
|
||||
mockBridgeUrlDraft: event.detail.value,
|
||||
})
|
||||
},
|
||||
|
||||
handleSaveMockBridgeUrl() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft)
|
||||
}
|
||||
},
|
||||
|
||||
handleDisconnectMockLocationBridge() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleDisconnectMockLocationBridge()
|
||||
}
|
||||
},
|
||||
|
||||
handleConnectHeartRate() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleConnectHeartRate()
|
||||
|
||||
@@ -285,10 +285,42 @@
|
||||
<text class="info-panel__label">GPS</text>
|
||||
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Location Source</text>
|
||||
<text class="info-panel__value">{{locationSourceText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">GPS Coord</text>
|
||||
<text class="info-panel__value">{{gpsCoordText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Mock Bridge</text>
|
||||
<text class="info-panel__value">{{mockBridgeStatusText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Mock URL</text>
|
||||
<view class="debug-inline-stack">
|
||||
<input
|
||||
class="debug-input"
|
||||
value="{{mockBridgeUrlDraft}}"
|
||||
placeholder="ws://192.168.x.x:17865/mock-gps"
|
||||
bindinput="handleMockBridgeUrlInput"
|
||||
/>
|
||||
<view class="control-row control-row--compact">
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockBridgeUrl">保存地址</view>
|
||||
<view class="control-chip {{mockBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockLocationBridge">连接模拟源</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockLocationBridge">断开模拟源</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Mock Coord</text>
|
||||
<text class="info-panel__value">{{mockCoordText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Mock Speed</text>
|
||||
<text class="info-panel__value">{{mockSpeedText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Heart Rate</text>
|
||||
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
||||
@@ -313,6 +345,10 @@
|
||||
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
||||
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-chip {{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>
|
||||
<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>
|
||||
|
||||
@@ -1175,12 +1175,45 @@
|
||||
color: #45624b;
|
||||
}
|
||||
|
||||
.debug-input {
|
||||
flex: 1;
|
||||
min-height: 72rpx;
|
||||
padding: 0 22rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: inset 0 0 0 2rpx rgba(22, 48, 32, 0.08);
|
||||
font-size: 24rpx;
|
||||
color: #244132;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.debug-inline-stack {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.control-row--compact {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.control-row--compact .control-chip {
|
||||
padding: 16rpx 10rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.control-row--single .control-chip {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.debug-section .control-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user