Selaa lähdekoodia

Add mock GPS simulator and configurable location sources

zhangyan 1 viikko sitten
vanhempi
commit
2cf0bb76b4

+ 61 - 1
miniprogram/engine/map/mapEngine.ts

@@ -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

+ 179 - 105
miniprogram/engine/sensor/locationController.ts

@@ -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
-  }
-
-  start(): void {
-    if (this.listening) {
-      this.callbacks.onStatus('后台持续定位进行中')
-      return
+    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()
+      },
     }
 
-    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()
-          },
-        })
+    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()
       },
-      fail: (error) => {
-        const message = error && error.errMsg ? error.errMsg : 'getSetting 失败'
-        this.callbacks.onError(`GPS授权检查失败: ${message}`)
+      onError: (message) => {
+        this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})`
+        this.callbacks.onError(`模拟定位源错误: ${message}`)
+        this.emitDebugState()
+      },
+      onSample: (sample) => {
+        this.mockSource.pushSample(sample)
+        this.emitDebugState()
       },
     })
   }
 
-  requestBackgroundPermissionInSettings(): void {
-    this.callbacks.onStatus('请在授权面板开启后台定位')
-    wx.openSetting({
-      success: (result) => {
-        const settings = result.authSetting || {}
-        if (hasBackgroundLocationPermission(settings)) {
-          this.startBackgroundLocation()
-          return
-        }
+  get listening(): boolean {
+    return this.sourceMode === 'mock' ? this.mockSource.active : this.realSource.active
+  }
 
-        this.callbacks.onError('GPS启动失败: 未授予后台定位权限')
-      },
-      fail: (error) => {
-        const message = error && error.errMsg ? error.errMsg : 'openSetting 失败'
-        this.callbacks.onError(`GPS启动失败: ${message}`)
-      },
-    })
+  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),
+    }
   }
 
-  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}`)
-      },
-    })
+  start(): void {
+    this.getActiveSource().start()
+    this.emitDebugState()
   }
 
   stop(): void {
-    if (!this.listening) {
-      this.callbacks.onStatus('后台持续定位未启动')
+    this.getActiveSource().stop()
+    this.emitDebugState()
+  }
+
+  destroy(): void {
+    this.realSource.destroy()
+    this.mockSource.destroy()
+    this.mockBridge.destroy()
+    this.emitDebugState()
+  }
+
+  setSourceMode(mode: LocationSourceMode): void {
+    if (this.sourceMode === mode) {
+      this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`)
+      this.emitDebugState()
       return
     }
 
-    if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
-      wx.offLocationChange(this.boundLocationHandler)
+    const wasListening = this.listening
+    if (wasListening) {
+      this.getActiveSource().stop()
     }
-    this.boundLocationHandler = null
+    this.sourceMode = mode
 
-    wx.stopLocationUpdate({
-      complete: () => {
-        this.listening = false
-        this.callbacks.onStatus('后台持续定位已停止')
-      },
-    })
+    if (wasListening) {
+      this.getActiveSource().start()
+    } else {
+      this.callbacks.onStatus(`已切换到${formatSourceModeText(mode)}`)
+    }
+
+    this.emitDebugState()
   }
 
-  destroy(): void {
-    if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
-      wx.offLocationChange(this.boundLocationHandler)
-    }
-    this.boundLocationHandler = null
+  setMockBridgeUrl(url: string): void {
+    this.mockBridgeUrl = normalizeMockBridgeUrl(url)
 
-    if (this.listening) {
-      wx.stopLocationUpdate({ complete: () => {} })
-      this.listening = false
+    if (this.mockBridge.connected || this.mockBridge.connecting) {
+      this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})`
+      this.callbacks.onStatus('模拟定位源地址已更新,重连后生效')
+    } else {
+      this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
+      this.callbacks.onStatus('模拟定位源地址已更新')
     }
+
+    this.emitDebugState()
   }
 
-  bindLocationListener(): void {
-    if (this.boundLocationHandler) {
+  connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
+    if (this.mockBridge.connected || this.mockBridge.connecting) {
+      this.callbacks.onStatus('模拟定位源已连接')
+      this.emitDebugState()
       return
     }
 
-    this.boundLocationHandler = (result) => {
-      this.callbacks.onLocation({
-        latitude: result.latitude,
-        longitude: result.longitude,
-        accuracy: result.accuracy,
-        speed: result.speed,
-      })
+    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
     }
 
-    wx.onLocationChange(this.boundLocationHandler)
+    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 - 0
miniprogram/engine/sensor/locationSource.ts

@@ -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 - 0
miniprogram/engine/sensor/mockLocationBridge.ts

@@ -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 - 0
miniprogram/engine/sensor/mockLocationSource.ts

@@ -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 - 0
miniprogram/engine/sensor/realLocationSource.ts

@@ -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)
+  }
+}

+ 67 - 2
miniprogram/pages/map/map.ts

@@ -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()

+ 36 - 0
miniprogram/pages/map/map.wxml

@@ -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>

+ 33 - 0
miniprogram/pages/map/map.wxss

@@ -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;
 }

+ 24 - 0
package-lock.json

@@ -7,6 +7,9 @@
     "": {
       "name": "miniprogram-ts-quickstart",
       "version": "1.0.0",
+      "dependencies": {
+        "ws": "^8.18.3"
+      },
       "devDependencies": {
         "miniprogram-api-typings": "^2.8.3-1",
         "typescript": "^5.9.3"
@@ -32,6 +35,27 @@
       "engines": {
         "node": ">=14.17"
       }
+    },
+    "node_modules/ws": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz",
+      "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
     }
   }
 }

+ 5 - 1
package.json

@@ -4,11 +4,15 @@
   "description": "",
   "scripts": {
     "typecheck": "tsc --noEmit -p tsconfig.json",
-    "typecheck:watch": "tsc --noEmit -p tsconfig.json --watch"
+    "typecheck:watch": "tsc --noEmit -p tsconfig.json --watch",
+    "mock-gps-sim": "node tools/mock-gps-sim/server.js"
   },
   "keywords": [],
   "author": "",
   "license": "",
+  "dependencies": {
+    "ws": "^8.18.3"
+  },
   "devDependencies": {
     "miniprogram-api-typings": "^2.8.3-1",
     "typescript": "^5.9.3"

+ 75 - 0
tools/mock-gps-sim/README.md

@@ -0,0 +1,75 @@
+# Mock GPS Simulator
+
+## 启动
+
+在仓库根目录运行:
+
+```bash
+npm run mock-gps-sim
+```
+
+启动后:
+
+- 控制台页面: `http://127.0.0.1:17865/`
+- 小程序接收地址: `ws://127.0.0.1:17865/mock-gps`
+- 资源代理: `http://127.0.0.1:17865/proxy?url=<remote-url>`
+
+## 当前能力
+
+- 直接载入 `game.json`
+- 自动解析 `map / mapmeta / course`
+- 自动切换自定义瓦片
+- 自动渲染 KML 控制点
+- 一键跳到开始点 / 结束点 / 任意检查点
+- 地图点击跳点
+- 实时连续发送 `mock_gps`
+- 路径编辑
+- 上传轨迹文件回放(GPX / KML / GeoJSON)
+- 路径回放
+- 速度、频率、精度调节
+
+## 加载自己的地图
+
+推荐方式:
+
+1. 启动模拟器后,打开 `http://127.0.0.1:17865/`
+2. 在“资源加载”里填自己的 `game.json` 地址
+3. 点“载入配置”
+
+模拟器会自动:
+
+- 读取 `map` 和 `mapmeta`
+- 切换到你的瓦片底图
+- 读取 `course`
+- 渲染开始点、检查点、结束点
+
+如果你不想走整套配置,也可以:
+
+- 直接填“瓦片模板”,例如 `https://host/tiles/{z}/{x}/{y}.webp`
+- 直接填 `KML URL`
+
+路径回放也支持直接导入轨迹文件:
+
+- `GPX`
+- `KML`
+- `GeoJSON / JSON`
+
+说明:
+
+- 配置和 KML 是通过本地代理拉取的,所以浏览器跨域问题会少很多
+- 如果你的资源需要鉴权,第一版代理还没有加认证头透传
+
+## 真机调试注意
+
+如果小程序跑在手机上,不要用 `127.0.0.1`。  
+把小程序里的 mock bridge 地址改成你电脑在局域网里的 IP,例如:
+
+```text
+ws://192.168.1.23:17865/mock-gps
+```
+
+同理,浏览器里的模拟器页面也建议用电脑局域网地址打开,例如:
+
+```text
+http://192.168.1.23:17865/
+```

+ 125 - 0
tools/mock-gps-sim/public/index.html

@@ -0,0 +1,125 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Mock GPS Simulator</title>
+    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
+    <link rel="stylesheet" href="./style.css">
+  </head>
+  <body>
+    <div class="layout">
+      <aside class="panel">
+        <div class="panel__header">
+          <div class="panel__eyebrow">MOCK GPS SIM</div>
+          <h1>外部模拟器</h1>
+          <div id="socketStatus" class="badge badge--muted">未连接</div>
+        </div>
+
+        <section class="group">
+          <div class="group__title">资源加载</div>
+          <label class="field">
+            <span>游戏配置 URL</span>
+            <input id="configUrlInput" type="text" value="https://oss-mbh5.colormaprun.com/wxmini/test/game.json">
+          </label>
+          <div class="row">
+            <button id="loadConfigBtn" class="btn btn--primary">载入配置</button>
+            <button id="fitCourseBtn" class="btn">适配视野</button>
+          </div>
+          <label class="field">
+            <span>瓦片模板</span>
+            <input id="tileUrlInput" type="text" placeholder="https://host/tiles/{z}/{x}/{y}.webp">
+          </label>
+          <div class="row">
+            <button id="applyTilesBtn" class="btn">应用瓦片</button>
+            <button id="resetTilesBtn" class="btn">恢复 OSM</button>
+          </div>
+          <label class="field">
+            <span>KML URL</span>
+            <input id="courseUrlInput" type="text" placeholder="https://host/course/c01.kml">
+          </label>
+          <div class="row">
+            <button id="loadCourseBtn" class="btn">载入控制点</button>
+            <button id="clearCourseBtn" class="btn">清空控制点</button>
+          </div>
+          <div id="resourceStatus" class="hint">支持直接载入 game.json,也支持单独填瓦片模板和 KML 地址。</div>
+          <div id="resourceDetail" class="group__status">尚未载入资源</div>
+          <div id="courseJumpList" class="jump-list"></div>
+        </section>
+
+        <section class="group">
+          <div class="group__title">实时发送</div>
+          <div id="realtimeStatus" class="group__status">桥接未连接</div>
+          <div id="lastSendStatus" class="group__status">最近发送: --</div>
+          <div class="row">
+            <button id="connectBtn" class="btn btn--primary">连接桥接</button>
+            <button id="sendOnceBtn" class="btn">发送一次</button>
+          </div>
+          <div class="row">
+            <button id="streamBtn" class="btn btn--accent">开始连续发送</button>
+            <button id="stopStreamBtn" class="btn">停止发送</button>
+          </div>
+          <label class="field">
+            <span>发送频率</span>
+            <select id="hzSelect">
+              <option value="2">2 Hz</option>
+              <option value="5" selected>5 Hz</option>
+              <option value="10">10 Hz</option>
+            </select>
+          </label>
+          <label class="field">
+            <span>精度 (m)</span>
+            <input id="accuracyInput" type="number" min="1" max="100" value="6">
+          </label>
+        </section>
+
+        <section class="group">
+          <div class="group__title">路径回放</div>
+          <div id="playbackStatus" class="group__status">路径待命</div>
+          <input id="trackFileInput" class="file-input-hidden" type="file" accept=".gpx,.kml,.geojson,.json,application/json,application/gpx+xml,application/vnd.google-earth.kml+xml">
+          <div class="row">
+            <button id="importTrackBtn" class="btn">导入轨迹文件</button>
+            <button id="togglePathModeBtn" class="btn">开启路径编辑</button>
+          </div>
+          <div class="row">
+            <button id="clearPathBtn" class="btn">清空路径</button>
+            <button id="fitPathBtn" class="btn">适配路径</button>
+          </div>
+          <div class="row">
+            <button id="playPathBtn" class="btn btn--accent">开始回放</button>
+            <button id="pausePathBtn" class="btn">暂停回放</button>
+          </div>
+          <label class="field">
+            <span>移动速度 (km/h)</span>
+            <input id="speedInput" type="number" min="1" max="25" step="0.1" value="6">
+          </label>
+          <label class="field field--check">
+            <input id="loopPathInput" type="checkbox" checked>
+            <span>循环回放</span>
+          </label>
+          <div id="pathHint" class="hint">点击“开启路径编辑”后,在地图上逐点添加路径。</div>
+        </section>
+
+        <section class="group">
+          <div class="group__title">当前位置</div>
+          <div class="stat"><span>纬度</span><strong id="latText">--</strong></div>
+          <div class="stat"><span>经度</span><strong id="lonText">--</strong></div>
+          <div class="stat"><span>航向</span><strong id="headingText">--</strong></div>
+          <div class="stat"><span>路径点</span><strong id="pathCountText">0</strong></div>
+        </section>
+
+        <section class="group">
+          <div class="group__title">日志</div>
+          <div id="log" class="log"></div>
+        </section>
+      </aside>
+
+      <main class="map-shell">
+        <div id="map"></div>
+      </main>
+    </div>
+
+    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+    <script src="./simulator.js"></script>
+  </body>
+  </html>

+ 1157 - 0
tools/mock-gps-sim/public/simulator.js

@@ -0,0 +1,1157 @@
+(function () {
+  const DEFAULT_CENTER = [31.2304, 121.4737]
+  const DEFAULT_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
+  const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
+  const PROXY_BASE_URL = `${location.origin}/proxy?url=`
+  const WS_URL = `ws://${location.hostname}:17865/mock-gps`
+
+  const map = L.map('map').setView(DEFAULT_CENTER, 16)
+  let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
+    maxZoom: 20,
+    attribution: '&copy; OpenStreetMap',
+  }).addTo(map)
+
+  const liveMarker = L.circleMarker(DEFAULT_CENTER, {
+    radius: 11,
+    color: '#ffffff',
+    weight: 3,
+    fillColor: '#ff2f92',
+    fillOpacity: 0.94,
+  }).addTo(map)
+
+  const pathLine = L.polyline([], {
+    color: '#0ea5a4',
+    weight: 4,
+    opacity: 0.9,
+  }).addTo(map)
+
+  const courseLayer = L.layerGroup().addTo(map)
+  const pathMarkers = []
+  const pathPoints = []
+  const state = {
+    socket: null,
+    connected: false,
+    socketConnecting: false,
+    streaming: false,
+    pathEditMode: false,
+    playbackRunning: false,
+    playbackTimer: 0,
+    streamTimer: 0,
+    lastSentText: '--',
+    lastResourceDetailText: '尚未载入资源',
+    lastTrackSourceText: '路径待命',
+    currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]),
+    headingDeg: 0,
+    currentSegmentIndex: 0,
+    currentSegmentProgress: 0,
+    lastPlaybackAt: 0,
+    loadedCourse: null,
+    resourceLoading: false,
+  }
+
+  const elements = {
+    socketStatus: document.getElementById('socketStatus'),
+    configUrlInput: document.getElementById('configUrlInput'),
+    loadConfigBtn: document.getElementById('loadConfigBtn'),
+    fitCourseBtn: document.getElementById('fitCourseBtn'),
+    tileUrlInput: document.getElementById('tileUrlInput'),
+    applyTilesBtn: document.getElementById('applyTilesBtn'),
+    resetTilesBtn: document.getElementById('resetTilesBtn'),
+    courseUrlInput: document.getElementById('courseUrlInput'),
+    loadCourseBtn: document.getElementById('loadCourseBtn'),
+    clearCourseBtn: document.getElementById('clearCourseBtn'),
+    resourceStatus: document.getElementById('resourceStatus'),
+    resourceDetail: document.getElementById('resourceDetail'),
+    courseJumpList: document.getElementById('courseJumpList'),
+    realtimeStatus: document.getElementById('realtimeStatus'),
+    lastSendStatus: document.getElementById('lastSendStatus'),
+    playbackStatus: document.getElementById('playbackStatus'),
+    trackFileInput: document.getElementById('trackFileInput'),
+    importTrackBtn: document.getElementById('importTrackBtn'),
+    connectBtn: document.getElementById('connectBtn'),
+    sendOnceBtn: document.getElementById('sendOnceBtn'),
+    streamBtn: document.getElementById('streamBtn'),
+    stopStreamBtn: document.getElementById('stopStreamBtn'),
+    togglePathModeBtn: document.getElementById('togglePathModeBtn'),
+    clearPathBtn: document.getElementById('clearPathBtn'),
+    fitPathBtn: document.getElementById('fitPathBtn'),
+    playPathBtn: document.getElementById('playPathBtn'),
+    pausePathBtn: document.getElementById('pausePathBtn'),
+    hzSelect: document.getElementById('hzSelect'),
+    accuracyInput: document.getElementById('accuracyInput'),
+    speedInput: document.getElementById('speedInput'),
+    loopPathInput: document.getElementById('loopPathInput'),
+    pathHint: document.getElementById('pathHint'),
+    latText: document.getElementById('latText'),
+    lonText: document.getElementById('lonText'),
+    headingText: document.getElementById('headingText'),
+    pathCountText: document.getElementById('pathCountText'),
+    log: document.getElementById('log'),
+  }
+
+  elements.configUrlInput.value = DEFAULT_CONFIG_URL
+
+  function createTileLayer(urlTemplate, extraOptions) {
+    return L.tileLayer(urlTemplate, Object.assign({
+      maxZoom: 20,
+      attribution: 'Custom Map',
+    }, extraOptions || {}))
+  }
+
+  function log(message) {
+    const time = new Date().toLocaleTimeString()
+    elements.log.textContent = `[${time}] ${message}\n` + elements.log.textContent
+  }
+
+  function setResourceStatus(message, tone) {
+    elements.resourceStatus.textContent = message
+    elements.resourceStatus.className = 'hint'
+    if (tone === 'ok') {
+      elements.resourceStatus.classList.add('hint--ok')
+    } else if (tone === 'warn') {
+      elements.resourceStatus.classList.add('hint--warn')
+    }
+  }
+
+  function updateReadout() {
+    elements.latText.textContent = state.currentLatLng.lat.toFixed(6)
+    elements.lonText.textContent = state.currentLatLng.lng.toFixed(6)
+    elements.headingText.textContent = `${Math.round(state.headingDeg)}°`
+    elements.pathCountText.textContent = String(pathPoints.length)
+    liveMarker.setLatLng(state.currentLatLng)
+  }
+
+  function setSocketBadge(connected) {
+    elements.socketStatus.textContent = connected ? '已连接' : '未连接'
+    elements.socketStatus.className = connected ? 'badge badge--ok' : 'badge badge--muted'
+  }
+
+  function formatClockTime(timestamp) {
+    if (!timestamp) {
+      return '--'
+    }
+
+    return new Date(timestamp).toLocaleTimeString()
+  }
+
+  function updateUiState() {
+    elements.connectBtn.textContent = state.connected ? '桥接已连接' : state.socketConnecting ? '连接中...' : '连接桥接'
+    elements.connectBtn.classList.toggle('is-active', state.connected)
+    elements.connectBtn.disabled = state.connected || state.socketConnecting
+
+    elements.sendOnceBtn.disabled = !state.connected
+    elements.streamBtn.textContent = state.streaming ? '发送中' : '开始连续发送'
+    elements.streamBtn.classList.toggle('is-active', state.streaming)
+    elements.streamBtn.disabled = !state.connected || state.streaming
+    elements.stopStreamBtn.disabled = !state.streaming
+
+    elements.togglePathModeBtn.textContent = state.pathEditMode ? '关闭路径编辑' : '开启路径编辑'
+    elements.togglePathModeBtn.classList.toggle('is-active', state.pathEditMode)
+    elements.importTrackBtn.disabled = state.resourceLoading
+    elements.clearPathBtn.textContent = pathPoints.length ? `清空路径 (${pathPoints.length})` : '清空路径'
+    elements.clearPathBtn.disabled = pathPoints.length === 0
+    elements.fitPathBtn.disabled = pathPoints.length < 2
+
+    elements.playPathBtn.textContent = state.playbackRunning ? '回放中' : '开始回放'
+    elements.playPathBtn.classList.toggle('is-active', state.playbackRunning)
+    elements.playPathBtn.disabled = pathPoints.length < 2 || state.playbackRunning
+    elements.pausePathBtn.disabled = !state.playbackRunning
+
+    elements.fitCourseBtn.disabled = !state.loadedCourse
+    elements.clearCourseBtn.disabled = !state.loadedCourse
+    elements.loadConfigBtn.textContent = state.resourceLoading ? '载入中...' : '载入配置'
+    elements.loadConfigBtn.disabled = state.resourceLoading
+    elements.loadCourseBtn.textContent = state.resourceLoading ? '载入中...' : '载入控制点'
+    elements.loadCourseBtn.disabled = state.resourceLoading
+    elements.applyTilesBtn.disabled = state.resourceLoading
+    elements.resetTilesBtn.disabled = state.resourceLoading
+    elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}`
+    elements.resourceDetail.textContent = state.lastResourceDetailText
+
+    if (state.connected && state.streaming) {
+      elements.realtimeStatus.textContent = `桥接已连接,正在以 ${elements.hzSelect.value} Hz 连续发送`
+    } else if (state.connected) {
+      elements.realtimeStatus.textContent = '桥接已连接,待命中'
+    } else if (state.socketConnecting) {
+      elements.realtimeStatus.textContent = '桥接连接中'
+    } else {
+      elements.realtimeStatus.textContent = '桥接未连接'
+    }
+
+    if (state.playbackRunning) {
+      elements.playbackStatus.textContent = `路径回放中,速度 ${elements.speedInput.value} km/h`
+    } else if (state.pathEditMode) {
+      elements.playbackStatus.textContent = '路径编辑中,点击地图追加路径点'
+    } else if (pathPoints.length >= 2) {
+      elements.playbackStatus.textContent = `${state.lastTrackSourceText},共 ${pathPoints.length} 个路径点`
+    } else {
+      elements.playbackStatus.textContent = '路径待命'
+    }
+  }
+
+  function connectSocket() {
+    if (state.socket && (state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING)) {
+      return
+    }
+
+    const socket = new WebSocket(WS_URL)
+    state.socket = socket
+    state.socketConnecting = true
+    setSocketBadge(false)
+    updateUiState()
+    log(`连接 ${WS_URL}`)
+
+    socket.addEventListener('open', () => {
+      state.connected = true
+      state.socketConnecting = false
+      setSocketBadge(true)
+      updateUiState()
+      log('桥接已连接')
+    })
+
+    socket.addEventListener('close', () => {
+      state.connected = false
+      state.socketConnecting = false
+      setSocketBadge(false)
+      updateUiState()
+      log('桥接已断开')
+    })
+
+    socket.addEventListener('error', () => {
+      state.connected = false
+      state.socketConnecting = false
+      setSocketBadge(false)
+      updateUiState()
+      log('桥接连接失败')
+    })
+  }
+
+  function proxyUrl(targetUrl) {
+    return `${PROXY_BASE_URL}${encodeURIComponent(targetUrl)}`
+  }
+
+  async function fetchJson(targetUrl) {
+    const response = await fetch(proxyUrl(targetUrl), { cache: 'no-store' })
+    if (!response.ok) {
+      throw new Error(`载入失败: ${response.status} ${targetUrl}`)
+    }
+
+    const text = await response.text()
+    return parseJsonWithFallback(text)
+  }
+
+  async function fetchText(targetUrl) {
+    const response = await fetch(proxyUrl(targetUrl), { cache: 'no-store' })
+    if (!response.ok) {
+      throw new Error(`载入失败: ${response.status} ${targetUrl}`)
+    }
+    return response.text()
+  }
+
+  function parseJsonWithFallback(text) {
+    try {
+      return JSON.parse(text)
+    } catch (_error) {
+      const sanitized = text
+        .replace(/,\s*"center"\s*:\s*\[[^\]]*\]\s*(?=[}\r\n])/g, '')
+        .replace(/"center"\s*:\s*\[[^\]]*\]\s*,/g, '')
+        .replace(/,\s*([}\]])/g, '$1')
+      return JSON.parse(sanitized)
+    }
+  }
+
+  function resolveUrl(baseUrl, relativePath) {
+    const trimmed = String(relativePath || '').trim()
+    if (!trimmed) {
+      return ''
+    }
+
+    if (/^https?:\/\//i.test(trimmed)) {
+      return trimmed
+    }
+
+    const url = new URL(baseUrl)
+    if (trimmed.startsWith('/')) {
+      return `${url.origin}${trimmed}`
+    }
+
+    const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
+    return `${baseDir}${trimmed.replace(/^\.\//, '')}`
+  }
+
+  function joinUrl(rootUrl, relativePath) {
+    const normalizedRoot = String(rootUrl || '').replace(/\/+$/, '')
+    const normalizedPath = String(relativePath || '').replace(/^\/+/, '')
+    return `${normalizedRoot}/${normalizedPath}`
+  }
+
+  function webMercatorToLatLng(x, y) {
+    const lon = x / 20037508.34 * 180
+    let lat = y / 20037508.34 * 180
+    lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2)
+    return L.latLng(lat, lon)
+  }
+
+  function applyTileTemplate(tileUrl, options) {
+    const trimmed = String(tileUrl || '').trim()
+    if (!trimmed) {
+      throw new Error('瓦片模板不能为空')
+    }
+
+    if (tileLayer) {
+      map.removeLayer(tileLayer)
+    }
+
+    tileLayer = createTileLayer(trimmed, options || {}).addTo(map)
+    elements.tileUrlInput.value = trimmed
+  }
+
+  function fitBoundsFromMercator(bounds) {
+    if (!Array.isArray(bounds) || bounds.length !== 4) {
+      return
+    }
+
+    const southWest = webMercatorToLatLng(Number(bounds[0]), Number(bounds[1]))
+    const northEast = webMercatorToLatLng(Number(bounds[2]), Number(bounds[3]))
+    map.fitBounds(L.latLngBounds(southWest, northEast), { padding: [24, 24] })
+  }
+
+  function parseCoordinateTuple(rawValue) {
+    const parts = rawValue.trim().split(',')
+    if (parts.length < 2) {
+      return null
+    }
+
+    const lon = Number(parts[0])
+    const lat = Number(parts[1])
+    if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
+      return null
+    }
+
+    return { lat, lon }
+  }
+
+  function extractPointCoordinates(block) {
+    const pointMatch = block.match(/<Point\b[\s\S]*?<coordinates>([\s\S]*?)<\/coordinates>[\s\S]*?<\/Point>/i)
+    if (!pointMatch) {
+      return null
+    }
+
+    const coordinateMatch = pointMatch[1].trim().match(/-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?)?/)
+    return coordinateMatch ? parseCoordinateTuple(coordinateMatch[0]) : null
+  }
+
+  function decodeXmlEntities(text) {
+    return text
+      .replace(/&lt;/g, '<')
+      .replace(/&gt;/g, '>')
+      .replace(/&quot;/g, '"')
+      .replace(/&#39;/g, "'")
+      .replace(/&apos;/g, "'")
+      .replace(/&amp;/g, '&')
+  }
+
+  function stripXml(text) {
+    return decodeXmlEntities(String(text || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim()
+  }
+
+  function extractTagText(block, tagName) {
+    const match = block.match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'))
+    return match ? stripXml(match[1]) : ''
+  }
+
+  function normalizeCourseLabel(label) {
+    return String(label || '').trim().replace(/\s+/g, ' ')
+  }
+
+  function inferExplicitKind(label, placemarkBlock) {
+    const normalized = normalizeCourseLabel(label).toUpperCase().replace(/[^A-Z0-9]/g, '')
+    const styleHint = String(placemarkBlock || '').toUpperCase()
+
+    if (
+      normalized === 'S'
+      || normalized.startsWith('START')
+      || /^S\d+$/.test(normalized)
+      || styleHint.includes('START')
+      || styleHint.includes('TRIANGLE')
+    ) {
+      return 'start'
+    }
+
+    if (
+      normalized === 'F'
+      || normalized === 'M'
+      || normalized.startsWith('FINISH')
+      || normalized.startsWith('GOAL')
+      || /^F\d+$/.test(normalized)
+      || styleHint.includes('FINISH')
+      || styleHint.includes('GOAL')
+    ) {
+      return 'finish'
+    }
+
+    return null
+  }
+
+  function extractPlacemarkPoints(kmlText) {
+    const placemarkBlocks = kmlText.match(/<Placemark\b[\s\S]*?<\/Placemark>/gi) || []
+    const points = []
+
+    placemarkBlocks.forEach((placemarkBlock) => {
+      const point = extractPointCoordinates(placemarkBlock)
+      if (!point) {
+        return
+      }
+
+      const label = normalizeCourseLabel(extractTagText(placemarkBlock, 'name'))
+      points.push({
+        label,
+        point,
+        explicitKind: inferExplicitKind(label, placemarkBlock),
+      })
+    })
+
+    return points
+  }
+
+  function classifyOrderedNodes(points) {
+    if (!points.length) {
+      return []
+    }
+
+    const startIndex = points.findIndex((point) => point.explicitKind === 'start')
+    let finishIndex = -1
+    for (let index = points.length - 1; index >= 0; index -= 1) {
+      if (points[index].explicitKind === 'finish') {
+        finishIndex = index
+        break
+      }
+    }
+
+    return points.map((point, index) => {
+      let kind = point.explicitKind
+      if (!kind) {
+        if (startIndex === -1 && index === 0) {
+          kind = 'start'
+        } else if (finishIndex === -1 && points.length > 1 && index === points.length - 1) {
+          kind = 'finish'
+        } else {
+          kind = 'control'
+        }
+      }
+
+      return {
+        label: point.label,
+        point: point.point,
+        kind,
+      }
+    })
+  }
+
+  function parseCourseKml(kmlText) {
+    const points = extractPlacemarkPoints(kmlText)
+    if (!points.length) {
+      throw new Error('KML 中没有可用的 Point 控制点')
+    }
+
+    const nodes = classifyOrderedNodes(points)
+    const starts = []
+    const controls = []
+    const finishes = []
+    let controlSequence = 1
+
+    nodes.forEach((node) => {
+      if (node.kind === 'start') {
+        starts.push({
+          label: node.label || 'Start',
+          point: node.point,
+        })
+        return
+      }
+
+      if (node.kind === 'finish') {
+        finishes.push({
+          label: node.label || 'Finish',
+          point: node.point,
+        })
+        return
+      }
+
+      controls.push({
+        label: node.label || String(controlSequence),
+        sequence: controlSequence,
+        point: node.point,
+      })
+      controlSequence += 1
+    })
+
+    return {
+      title: extractTagText(kmlText, 'name') || 'Orienteering Course',
+      starts,
+      controls,
+      finishes,
+    }
+  }
+
+  function buildDivIcon(className, html, size) {
+    return L.divIcon({
+      className,
+      html,
+      iconSize: size,
+      iconAnchor: [size[0] / 2, size[1] / 2],
+    })
+  }
+
+  function setCurrentPosition(lat, lon) {
+    state.currentLatLng = L.latLng(lat, lon)
+    updateReadout()
+  }
+
+  function jumpToPoint(lat, lon, zoom) {
+    setCurrentPosition(lat, lon)
+    map.flyTo([lat, lon], zoom || Math.max(map.getZoom(), 18), {
+      duration: 0.6,
+    })
+  }
+
+  function buildJumpChip(label, point, className) {
+    const button = document.createElement('button')
+    button.type = 'button'
+    button.className = `jump-chip ${className || ''}`.trim()
+    button.textContent = label
+    button.addEventListener('click', () => {
+      jumpToPoint(point.lat, point.lon, 19)
+      log(`跳转到 ${label}`)
+    })
+    return button
+  }
+
+  function refreshCourseJumpList(course) {
+    elements.courseJumpList.innerHTML = ''
+    if (!course) {
+      return
+    }
+
+    course.starts.forEach((item) => {
+      elements.courseJumpList.appendChild(buildJumpChip('开始点', item.point, 'jump-chip--start'))
+    })
+    course.controls.forEach((item) => {
+      elements.courseJumpList.appendChild(buildJumpChip(String(item.sequence), item.point, ''))
+    })
+    course.finishes.forEach((item) => {
+      elements.courseJumpList.appendChild(buildJumpChip('结束点', item.point, 'jump-chip--finish'))
+    })
+  }
+
+  function renderCourse(course) {
+    courseLayer.clearLayers()
+    state.loadedCourse = course
+    refreshCourseJumpList(course)
+
+    course.starts.forEach((item) => {
+      const marker = L.marker([item.point.lat, item.point.lon], {
+        icon: buildDivIcon('course-marker', '<div class="course-marker__start"></div>', [36, 36]),
+      })
+      marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
+      marker.addTo(courseLayer)
+    })
+
+    course.controls.forEach((item) => {
+      const marker = L.marker([item.point.lat, item.point.lon], {
+        icon: buildDivIcon(
+          'course-marker',
+          `<div class="course-marker__control">${item.sequence}</div>`,
+          [40, 40],
+        ),
+      })
+      marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
+      marker.addTo(courseLayer)
+    })
+
+    course.finishes.forEach((item) => {
+      const marker = L.marker([item.point.lat, item.point.lon], {
+        icon: buildDivIcon('course-marker', '<div class="course-marker__finish"></div>', [40, 40]),
+      })
+      marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
+      marker.addTo(courseLayer)
+    })
+
+    fitCourseBounds()
+    updateUiState()
+  }
+
+  function clearCourse() {
+    state.loadedCourse = null
+    courseLayer.clearLayers()
+    refreshCourseJumpList(null)
+    setResourceStatus('已清空控制点', 'warn')
+    state.lastResourceDetailText = '已清空控制点'
+    updateUiState()
+  }
+
+  function fitCourseBounds() {
+    if (!state.loadedCourse) {
+      return
+    }
+
+    const latLngs = []
+    state.loadedCourse.starts.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
+    state.loadedCourse.controls.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
+    state.loadedCourse.finishes.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
+    if (!latLngs.length) {
+      return
+    }
+
+    map.fitBounds(L.latLngBounds(latLngs), { padding: [30, 30] })
+  }
+
+  async function loadCourseFromUrl(courseUrl, shouldFit) {
+    const trimmed = String(courseUrl || '').trim()
+    if (!trimmed) {
+      throw new Error('KML 地址不能为空')
+    }
+
+    const kmlText = await fetchText(trimmed)
+    const course = parseCourseKml(kmlText)
+    renderCourse(course)
+    elements.courseUrlInput.value = trimmed
+    if (shouldFit !== false) {
+      fitCourseBounds()
+    }
+    setResourceStatus(`已载入控制点: ${course.title}`, 'ok')
+    state.lastResourceDetailText = `最近资源: 控制点 ${course.title} (${formatClockTime(Date.now())})`
+    log(`已载入 KML: ${trimmed}`)
+    updateUiState()
+  }
+
+  async function loadConfigResources() {
+    const configUrl = String(elements.configUrlInput.value || '').trim()
+    if (!configUrl) {
+      setResourceStatus('请先填写 game.json 地址', 'warn')
+      return
+    }
+
+    state.resourceLoading = true
+    updateUiState()
+    setResourceStatus('正在载入配置...', null)
+    try {
+      const config = await fetchJson(configUrl)
+      let mapStatus = '未找到瓦片配置'
+      if (config.map && config.mapmeta) {
+        const mapRootUrl = resolveUrl(configUrl, config.map)
+        const mapMetaUrl = resolveUrl(configUrl, config.mapmeta)
+        const mapMeta = await fetchJson(mapMetaUrl)
+        const tilePathTemplate = mapMeta.tilePathTemplate || `{z}/{x}/{y}.${mapMeta.tileFormat || 'png'}`
+        const tileTemplateUrl = /^https?:\/\//i.test(tilePathTemplate)
+          ? tilePathTemplate
+          : joinUrl(mapRootUrl, tilePathTemplate)
+        applyTileTemplate(tileTemplateUrl, {
+          minZoom: Number.isFinite(mapMeta.minZoom) ? mapMeta.minZoom : 16,
+          maxZoom: Number.isFinite(mapMeta.maxZoom) ? mapMeta.maxZoom : 20,
+          attribution: 'Custom Map',
+        })
+        mapStatus = '已载入瓦片'
+        if (Array.isArray(mapMeta.bounds) && mapMeta.bounds.length === 4) {
+          fitBoundsFromMercator(mapMeta.bounds)
+        }
+      }
+
+      let courseStatus = '未找到 KML 配置'
+      if (config.course) {
+        const courseUrl = resolveUrl(configUrl, config.course)
+        elements.courseUrlInput.value = courseUrl
+        await loadCourseFromUrl(courseUrl, false)
+        courseStatus = '已载入控制点'
+      }
+
+      setResourceStatus(`配置已载入: ${mapStatus} / ${courseStatus}`, 'ok')
+      state.lastResourceDetailText = `最近资源: 配置 ${formatClockTime(Date.now())}`
+      log(`已载入配置: ${configUrl}`)
+    } catch (error) {
+      const message = error && error.message ? error.message : '未知错误'
+      setResourceStatus(`配置载入失败: ${message}`, 'warn')
+      log(`配置载入失败: ${message}`)
+    } finally {
+      state.resourceLoading = false
+      updateUiState()
+    }
+  }
+
+  function getAccuracy() {
+    return Math.max(1, Number(elements.accuracyInput.value) || 6)
+  }
+
+  function getSpeedMps() {
+    return Math.max(0.2, (Number(elements.speedInput.value) || 6) / 3.6)
+  }
+
+  function sendCurrentPoint() {
+    if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
+      log('未连接桥接,无法发送')
+      return
+    }
+
+    const payload = {
+      type: 'mock_gps',
+      timestamp: Date.now(),
+      lat: Number(state.currentLatLng.lat.toFixed(6)),
+      lon: Number(state.currentLatLng.lng.toFixed(6)),
+      accuracyMeters: getAccuracy(),
+      speedMps: Number(getSpeedMps().toFixed(2)),
+      headingDeg: Number(state.headingDeg.toFixed(1)),
+    }
+    state.socket.send(JSON.stringify(payload))
+    state.lastSentText = `${formatClockTime(payload.timestamp)} @ ${payload.lat.toFixed(6)}, ${payload.lon.toFixed(6)}`
+    updateUiState()
+  }
+
+  function startStream() {
+    stopStream()
+    state.streaming = true
+    const intervalMs = Math.max(80, 1000 / (Number(elements.hzSelect.value) || 5))
+    sendCurrentPoint()
+    state.streamTimer = window.setInterval(sendCurrentPoint, intervalMs)
+    updateUiState()
+    log(`开始连续发送 (${Math.round(1000 / intervalMs)} Hz)`)
+  }
+
+  function stopStream() {
+    state.streaming = false
+    if (state.streamTimer) {
+      window.clearInterval(state.streamTimer)
+      state.streamTimer = 0
+      log('已停止连续发送')
+    }
+    updateUiState()
+  }
+
+  function syncPathLine() {
+    pathLine.setLatLngs(pathPoints)
+    elements.pathCountText.textContent = String(pathPoints.length)
+    updateUiState()
+  }
+
+  function clearPathMarkers() {
+    while (pathMarkers.length) {
+      map.removeLayer(pathMarkers.pop())
+    }
+  }
+
+  function refreshPathMarkers() {
+    clearPathMarkers()
+    pathPoints.forEach((point, index) => {
+      const marker = L.circleMarker(point, {
+        radius: 5,
+        color: '#ffffff',
+        weight: 2,
+        fillColor: index === 0 ? '#0ea5a4' : '#0b625b',
+        fillOpacity: 0.95,
+      }).addTo(map)
+      pathMarkers.push(marker)
+    })
+  }
+
+  function addPathPoint(latlng) {
+    pathPoints.push(L.latLng(latlng.lat, latlng.lng))
+    state.lastTrackSourceText = '手工路径'
+    syncPathLine()
+    refreshPathMarkers()
+  }
+
+  function fitPathBounds() {
+    if (pathPoints.length < 2) {
+      return
+    }
+
+    map.fitBounds(L.latLngBounds(pathPoints), { padding: [30, 30] })
+  }
+
+  function replacePathPoints(nextPoints, sourceLabel) {
+    pathPoints.splice(0, pathPoints.length)
+    nextPoints.forEach((point) => {
+      pathPoints.push(L.latLng(point.lat, point.lng))
+    })
+    state.lastTrackSourceText = sourceLabel
+    stopPlayback()
+    syncPathLine()
+    refreshPathMarkers()
+    if (pathPoints.length) {
+      state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
+      updateReadout()
+    }
+    if (pathPoints.length >= 2) {
+      fitPathBounds()
+    }
+  }
+
+  function parseGeoJsonTrack(rawValue) {
+    const latLngs = []
+
+    function pushLngLat(coords) {
+      if (!Array.isArray(coords) || coords.length < 2) {
+        return
+      }
+      const lng = Number(coords[0])
+      const lat = Number(coords[1])
+      if (Number.isFinite(lat) && Number.isFinite(lng)) {
+        latLngs.push({ lat, lng })
+      }
+    }
+
+    function walk(node) {
+      if (!node || typeof node !== 'object') {
+        return
+      }
+
+      if (node.type === 'FeatureCollection' && Array.isArray(node.features)) {
+        node.features.forEach(walk)
+        return
+      }
+
+      if (node.type === 'Feature' && node.geometry) {
+        walk(node.geometry)
+        return
+      }
+
+      if (node.type === 'LineString' && Array.isArray(node.coordinates)) {
+        node.coordinates.forEach(pushLngLat)
+        return
+      }
+
+      if (node.type === 'MultiLineString' && Array.isArray(node.coordinates)) {
+        node.coordinates.forEach((line) => {
+          if (Array.isArray(line)) {
+            line.forEach(pushLngLat)
+          }
+        })
+      }
+    }
+
+    if (Array.isArray(rawValue)) {
+      rawValue.forEach((item) => {
+        if (Array.isArray(item)) {
+          pushLngLat(item)
+          return
+        }
+
+        if (item && typeof item === 'object') {
+          const lat = Number(item.lat)
+          const lng = Number(item.lng !== undefined ? item.lng : item.lon)
+          if (Number.isFinite(lat) && Number.isFinite(lng)) {
+            latLngs.push({ lat, lng })
+          }
+        }
+      })
+      return latLngs
+    }
+
+    walk(rawValue)
+    return latLngs
+  }
+
+  function parseGpxTrack(text) {
+    const xml = new DOMParser().parseFromString(text, 'application/xml')
+    const latLngs = []
+    const trackPoints = Array.from(xml.querySelectorAll('trkpt'))
+    const routePoints = trackPoints.length ? [] : Array.from(xml.querySelectorAll('rtept'))
+    const nodes = trackPoints.length ? trackPoints : routePoints
+
+    nodes.forEach((node) => {
+      const lat = Number(node.getAttribute('lat'))
+      const lng = Number(node.getAttribute('lon'))
+      if (Number.isFinite(lat) && Number.isFinite(lng)) {
+        latLngs.push({ lat, lng })
+      }
+    })
+
+    return latLngs
+  }
+
+  function parseKmlTrack(text) {
+    const xml = new DOMParser().parseFromString(text, 'application/xml')
+    const latLngs = []
+    const lineStrings = Array.from(xml.querySelectorAll('LineString coordinates'))
+
+    lineStrings.forEach((node) => {
+      String(node.textContent || '')
+        .trim()
+        .split(/\s+/)
+        .forEach((tuple) => {
+          const parsed = parseCoordinateTuple(tuple)
+          if (parsed) {
+            latLngs.push({ lat: parsed.lat, lng: parsed.lon })
+          }
+        })
+    })
+
+    return latLngs
+  }
+
+  function parseTrackFile(fileName, text) {
+    const lowerName = String(fileName || '').toLowerCase()
+    if (lowerName.endsWith('.gpx')) {
+      return parseGpxTrack(text)
+    }
+    if (lowerName.endsWith('.kml')) {
+      return parseKmlTrack(text)
+    }
+    if (lowerName.endsWith('.geojson') || lowerName.endsWith('.json')) {
+      return parseGeoJsonTrack(parseJsonWithFallback(text))
+    }
+    if (text.includes('<gpx')) {
+      return parseGpxTrack(text)
+    }
+    if (text.includes('<kml') || text.includes('<LineString')) {
+      return parseKmlTrack(text)
+    }
+    return parseGeoJsonTrack(parseJsonWithFallback(text))
+  }
+
+  async function handleTrackFileSelected(file) {
+    if (!file) {
+      return
+    }
+
+    try {
+      const text = await file.text()
+      const latLngs = parseTrackFile(file.name, text)
+      if (!latLngs || latLngs.length < 2) {
+        throw new Error('轨迹文件中没有可回放的路径点')
+      }
+
+      replacePathPoints(latLngs, `轨迹文件 ${file.name}`)
+      log(`已导入轨迹文件: ${file.name} (${latLngs.length} 点)`)
+    } catch (error) {
+      const message = error && error.message ? error.message : '轨迹文件导入失败'
+      log(message)
+      alert(message)
+    } finally {
+      elements.trackFileInput.value = ''
+    }
+  }
+
+  function toRad(value) {
+    return value * Math.PI / 180
+  }
+
+  function toDeg(value) {
+    return value * 180 / Math.PI
+  }
+
+  function getDistanceMeters(from, to) {
+    const earth = 6371000
+    const lat1 = toRad(from.lat)
+    const lat2 = toRad(to.lat)
+    const dLat = lat2 - lat1
+    const dLon = toRad(to.lng - from.lng)
+    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+      + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2)
+    return earth * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+  }
+
+  function getHeadingDeg(from, to) {
+    const lat1 = toRad(from.lat)
+    const lat2 = toRad(to.lat)
+    const dLon = toRad(to.lng - from.lng)
+    const y = Math.sin(dLon) * Math.cos(lat2)
+    const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
+    const bearing = (toDeg(Math.atan2(y, x)) + 360) % 360
+    return bearing
+  }
+
+  function interpolateLatLng(from, to, t) {
+    return L.latLng(
+      from.lat + (to.lat - from.lat) * t,
+      from.lng + (to.lng - from.lng) * t,
+    )
+  }
+
+  function tickPlayback() {
+    if (!state.playbackRunning || pathPoints.length < 2) {
+      return
+    }
+
+    const now = performance.now()
+    if (!state.lastPlaybackAt) {
+      state.lastPlaybackAt = now
+    }
+    const deltaSeconds = (now - state.lastPlaybackAt) / 1000
+    state.lastPlaybackAt = now
+
+    let remainingTravel = getSpeedMps() * deltaSeconds
+    while (remainingTravel > 0 && state.currentSegmentIndex < pathPoints.length - 1) {
+      const from = pathPoints[state.currentSegmentIndex]
+      const to = pathPoints[state.currentSegmentIndex + 1]
+      const segmentDistance = getDistanceMeters(from, to)
+      if (!segmentDistance) {
+        state.currentSegmentIndex += 1
+        state.currentSegmentProgress = 0
+        continue
+      }
+
+      const remainingSegment = segmentDistance * (1 - state.currentSegmentProgress)
+      if (remainingTravel >= remainingSegment) {
+        remainingTravel -= remainingSegment
+        state.currentSegmentIndex += 1
+        state.currentSegmentProgress = 0
+        state.currentLatLng = L.latLng(to.lat, to.lng)
+        state.headingDeg = getHeadingDeg(from, to)
+      } else {
+        state.currentSegmentProgress += remainingTravel / segmentDistance
+        state.currentLatLng = interpolateLatLng(from, to, state.currentSegmentProgress)
+        state.headingDeg = getHeadingDeg(from, to)
+        remainingTravel = 0
+      }
+    }
+
+    if (state.currentSegmentIndex >= pathPoints.length - 1) {
+      if (elements.loopPathInput.checked) {
+        state.currentSegmentIndex = 0
+        state.currentSegmentProgress = 0
+        state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
+      } else {
+        stopPlayback()
+      }
+    }
+
+    updateReadout()
+    if (state.streaming) {
+      sendCurrentPoint()
+    }
+
+    if (state.playbackRunning) {
+      state.playbackTimer = window.requestAnimationFrame(tickPlayback)
+    }
+  }
+
+  function startPlayback() {
+    if (pathPoints.length < 2) {
+      log('至少需要两个路径点')
+      return
+    }
+
+    stopPlayback()
+    state.playbackRunning = true
+    state.currentSegmentIndex = 0
+    state.currentSegmentProgress = 0
+    state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
+    state.lastPlaybackAt = 0
+    updateReadout()
+    updateUiState()
+    log('开始路径回放')
+    state.playbackTimer = window.requestAnimationFrame(tickPlayback)
+  }
+
+  function stopPlayback() {
+    state.playbackRunning = false
+    state.lastPlaybackAt = 0
+    if (state.playbackTimer) {
+      window.cancelAnimationFrame(state.playbackTimer)
+      state.playbackTimer = 0
+    }
+    updateUiState()
+  }
+
+  map.on('click', (event) => {
+    if (state.pathEditMode) {
+      addPathPoint(event.latlng)
+      return
+    }
+
+    setCurrentPosition(event.latlng.lat, event.latlng.lng)
+  })
+
+  liveMarker.on('mousedown', () => {
+    map.dragging.disable()
+  })
+
+  map.on('mousemove', (event) => {
+    if (event.originalEvent.buttons !== 1) {
+      return
+    }
+
+    if (state.pathEditMode) {
+      return
+    }
+
+    setCurrentPosition(event.latlng.lat, event.latlng.lng)
+  })
+
+  map.on('mouseup', () => {
+    map.dragging.enable()
+  })
+
+  elements.connectBtn.addEventListener('click', connectSocket)
+  elements.importTrackBtn.addEventListener('click', () => {
+    elements.trackFileInput.click()
+  })
+  elements.trackFileInput.addEventListener('change', (event) => {
+    const input = event.target
+    const file = input && input.files && input.files[0] ? input.files[0] : null
+    handleTrackFileSelected(file)
+  })
+  elements.loadConfigBtn.addEventListener('click', loadConfigResources)
+  elements.fitCourseBtn.addEventListener('click', fitCourseBounds)
+  elements.applyTilesBtn.addEventListener('click', () => {
+    try {
+      applyTileTemplate(elements.tileUrlInput.value, { attribution: 'Custom Map' })
+      setResourceStatus('已应用自定义瓦片', 'ok')
+      state.lastResourceDetailText = `最近资源: 自定义瓦片 ${formatClockTime(Date.now())}`
+      updateUiState()
+    } catch (error) {
+      setResourceStatus(error && error.message ? error.message : '瓦片应用失败', 'warn')
+    }
+  })
+  elements.resetTilesBtn.addEventListener('click', () => {
+    applyTileTemplate(DEFAULT_TILE_URL, {
+      maxZoom: 20,
+      attribution: '&copy; OpenStreetMap',
+    })
+    setResourceStatus('已恢复 OSM 底图', 'ok')
+    state.lastResourceDetailText = `最近资源: OSM 底图 ${formatClockTime(Date.now())}`
+    updateUiState()
+  })
+  elements.loadCourseBtn.addEventListener('click', async () => {
+    try {
+      await loadCourseFromUrl(elements.courseUrlInput.value, true)
+    } catch (error) {
+      const message = error && error.message ? error.message : 'KML 载入失败'
+      setResourceStatus(message, 'warn')
+      log(message)
+    }
+  })
+  elements.clearCourseBtn.addEventListener('click', clearCourse)
+  elements.fitPathBtn.addEventListener('click', fitPathBounds)
+  elements.sendOnceBtn.addEventListener('click', () => {
+    sendCurrentPoint()
+    log('已发送当前位置')
+  })
+  elements.streamBtn.addEventListener('click', startStream)
+  elements.stopStreamBtn.addEventListener('click', stopStream)
+  elements.togglePathModeBtn.addEventListener('click', () => {
+    state.pathEditMode = !state.pathEditMode
+    elements.pathHint.textContent = state.pathEditMode
+      ? '地图点击将按顺序追加路径点。'
+      : '点击“开启路径编辑”后,在地图上逐点添加路径。'
+    updateUiState()
+  })
+  elements.clearPathBtn.addEventListener('click', () => {
+    pathPoints.splice(0, pathPoints.length)
+    state.lastTrackSourceText = '路径待命'
+    syncPathLine()
+    clearPathMarkers()
+    stopPlayback()
+    log('已清空路径')
+  })
+  elements.playPathBtn.addEventListener('click', startPlayback)
+  elements.pausePathBtn.addEventListener('click', () => {
+    stopPlayback()
+    log('已暂停回放')
+  })
+
+  updateReadout()
+  setSocketBadge(false)
+  setResourceStatus('支持直接载入 game.json,也支持单独填瓦片模板和 KML 地址。', null)
+  updateUiState()
+  connectSocket()
+})()

+ 278 - 0
tools/mock-gps-sim/public/style.css

@@ -0,0 +1,278 @@
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  font-family: "Segoe UI", "PingFang SC", sans-serif;
+  background: #edf3ea;
+  color: #163126;
+}
+
+.layout {
+  display: grid;
+  grid-template-columns: 400px 1fr;
+  min-height: 100vh;
+}
+
+.panel {
+  padding: 20px;
+  background: rgba(250, 252, 248, 0.96);
+  border-right: 1px solid rgba(22, 49, 38, 0.08);
+  overflow-y: auto;
+}
+
+.panel__header h1 {
+  margin: 8px 0 10px;
+  font-size: 28px;
+}
+
+.panel__eyebrow {
+  font-weight: 800;
+  letter-spacing: 0.18em;
+  font-size: 12px;
+  color: #557266;
+}
+
+.badge {
+  display: inline-flex;
+  align-items: center;
+  min-height: 30px;
+  padding: 0 12px;
+  border-radius: 999px;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.badge--muted {
+  background: #e5ece5;
+  color: #4f6458;
+}
+
+.badge--ok {
+  background: #d8f7e3;
+  color: #0a7a3d;
+}
+
+.group {
+  margin-top: 18px;
+  padding: 16px;
+  border-radius: 18px;
+  background: #ffffff;
+  box-shadow: 0 10px 30px rgba(34, 63, 49, 0.07);
+}
+
+.group__title {
+  font-size: 14px;
+  font-weight: 800;
+  letter-spacing: 0.08em;
+  color: #5d786c;
+  margin-bottom: 12px;
+}
+
+.group__status {
+  min-height: 18px;
+  margin: -4px 0 12px;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #5e786d;
+}
+
+.row {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 10px;
+}
+
+.btn {
+  flex: 1;
+  min-height: 40px;
+  border: 0;
+  border-radius: 12px;
+  background: #ebf0ea;
+  color: #193226;
+  font-weight: 700;
+  cursor: pointer;
+  transition: transform 120ms ease, background 120ms ease, color 120ms ease, opacity 120ms ease;
+}
+
+.btn--primary {
+  background: #103f2f;
+  color: #fff;
+}
+
+.btn--accent {
+  background: #0ea5a4;
+  color: #fff;
+}
+
+.btn.is-active {
+  outline: 2px solid #ffb300;
+}
+
+.btn:disabled {
+  opacity: 0.56;
+  cursor: not-allowed;
+}
+
+.file-input-hidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  opacity: 0;
+  pointer-events: none;
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  margin-bottom: 10px;
+  font-size: 13px;
+  color: #557266;
+}
+
+.field input,
+.field select {
+  min-height: 38px;
+  border: 1px solid rgba(22, 49, 38, 0.12);
+  border-radius: 10px;
+  padding: 0 12px;
+  font: inherit;
+}
+
+.field--check {
+  flex-direction: row;
+  align-items: center;
+}
+
+.hint {
+  font-size: 12px;
+  color: #678276;
+  line-height: 1.5;
+}
+
+.hint--ok {
+  color: #0a7a3d;
+}
+
+.hint--warn {
+  color: #8d4b08;
+}
+
+.stat {
+  display: flex;
+  justify-content: space-between;
+  padding: 8px 0;
+  border-bottom: 1px solid rgba(22, 49, 38, 0.06);
+}
+
+.stat:last-child {
+  border-bottom: 0;
+}
+
+.stat span {
+  color: #668073;
+  font-size: 13px;
+}
+
+.stat strong {
+  font-size: 14px;
+}
+
+.log {
+  min-height: 140px;
+  max-height: 220px;
+  overflow-y: auto;
+  padding: 10px 12px;
+  border-radius: 12px;
+  background: #f3f7f1;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #486257;
+  white-space: pre-wrap;
+}
+
+.jump-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 12px;
+}
+
+.jump-chip {
+  min-height: 32px;
+  padding: 0 12px;
+  border: 0;
+  border-radius: 999px;
+  background: #eef6ea;
+  color: #244132;
+  font-size: 12px;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.jump-chip--start {
+  background: #fff0c9;
+}
+
+.jump-chip--finish {
+  background: #ffe2b8;
+}
+
+.map-shell {
+  min-height: 100vh;
+}
+
+#map {
+  width: 100%;
+  height: 100vh;
+}
+
+.leaflet-container {
+  background: #dfeadb;
+}
+
+.course-marker {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.course-marker__control {
+  width: 34px;
+  height: 34px;
+  border-radius: 999px;
+  border: 3px solid #cc0077;
+  color: #cc0077;
+  background: rgba(255, 255, 255, 0.9);
+  font-size: 16px;
+  font-weight: 800;
+  line-height: 1;
+}
+
+.course-marker__start {
+  width: 0;
+  height: 0;
+  border-left: 16px solid transparent;
+  border-right: 16px solid transparent;
+  border-bottom: 28px solid #cc0077;
+  filter: drop-shadow(0 3px 10px rgba(22, 49, 38, 0.22));
+}
+
+.course-marker__finish {
+  position: relative;
+  width: 36px;
+  height: 36px;
+  border-radius: 999px;
+  border: 4px solid #cc0077;
+  background: rgba(255, 255, 255, 0.76);
+}
+
+.course-marker__finish::after {
+  content: "";
+  position: absolute;
+  inset: 6px;
+  border-radius: 999px;
+  border: 3px solid #cc0077;
+}

+ 152 - 0
tools/mock-gps-sim/server.js

@@ -0,0 +1,152 @@
+const http = require('http')
+const fs = require('fs')
+const path = require('path')
+const { WebSocketServer } = require('ws')
+
+const HOST = '0.0.0.0'
+const PORT = 17865
+const WS_PATH = '/mock-gps'
+const PROXY_PATH = '/proxy'
+const PUBLIC_DIR = path.join(__dirname, 'public')
+
+function getContentType(filePath) {
+  const ext = path.extname(filePath).toLowerCase()
+  if (ext === '.html') {
+    return 'text/html; charset=utf-8'
+  }
+  if (ext === '.css') {
+    return 'text/css; charset=utf-8'
+  }
+  if (ext === '.js') {
+    return 'application/javascript; charset=utf-8'
+  }
+  if (ext === '.json') {
+    return 'application/json; charset=utf-8'
+  }
+  if (ext === '.svg') {
+    return 'image/svg+xml'
+  }
+  return 'text/plain; charset=utf-8'
+}
+
+function serveStatic(requestPath, response) {
+  const safePath = requestPath === '/' ? '/index.html' : requestPath
+  const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath))
+  if (!resolvedPath.startsWith(PUBLIC_DIR)) {
+    response.writeHead(403)
+    response.end('Forbidden')
+    return
+  }
+
+  fs.readFile(resolvedPath, (error, content) => {
+    if (error) {
+      response.writeHead(404)
+      response.end('Not Found')
+      return
+    }
+
+    response.writeHead(200, {
+      'Content-Type': getContentType(resolvedPath),
+      'Cache-Control': 'no-store',
+    })
+    response.end(content)
+  })
+}
+
+function isMockGpsPayload(payload) {
+  return payload
+    && payload.type === 'mock_gps'
+    && Number.isFinite(payload.lat)
+    && Number.isFinite(payload.lon)
+}
+
+async function handleProxyRequest(request, response) {
+  const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
+  const targetUrl = requestUrl.searchParams.get('url')
+  if (!targetUrl) {
+    response.writeHead(400, {
+      'Content-Type': 'text/plain; charset=utf-8',
+      'Access-Control-Allow-Origin': '*',
+    })
+    response.end('Missing url')
+    return
+  }
+
+  try {
+    const upstream = await fetch(targetUrl)
+    const body = Buffer.from(await upstream.arrayBuffer())
+    response.writeHead(upstream.status, {
+      'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream',
+      'Cache-Control': 'no-store',
+      'Access-Control-Allow-Origin': '*',
+    })
+    response.end(body)
+  } catch (error) {
+    response.writeHead(502, {
+      'Content-Type': 'text/plain; charset=utf-8',
+      'Access-Control-Allow-Origin': '*',
+    })
+    response.end(error && error.message ? error.message : 'Proxy request failed')
+  }
+}
+
+const server = http.createServer((request, response) => {
+  if ((request.url || '').startsWith(PROXY_PATH)) {
+    handleProxyRequest(request, response)
+    return
+  }
+
+  serveStatic(request.url || '/', response)
+})
+
+const wss = new WebSocketServer({ noServer: true })
+
+wss.on('connection', (socket) => {
+  socket.on('message', (rawMessage) => {
+    const text = String(rawMessage)
+    let parsed
+    try {
+      parsed = JSON.parse(text)
+    } catch (_error) {
+      return
+    }
+
+    if (!isMockGpsPayload(parsed)) {
+      return
+    }
+
+    const serialized = JSON.stringify({
+      type: 'mock_gps',
+      timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
+      lat: Number(parsed.lat),
+      lon: Number(parsed.lon),
+      accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
+      speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
+      headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
+    })
+
+    wss.clients.forEach((client) => {
+      if (client.readyState === client.OPEN) {
+        client.send(serialized)
+      }
+    })
+  })
+})
+
+server.on('upgrade', (request, socket, head) => {
+  if (!request.url || !request.url.startsWith(WS_PATH)) {
+    socket.destroy()
+    return
+  }
+
+  wss.handleUpgrade(request, socket, head, (ws) => {
+    wss.emit('connection', ws, request)
+  })
+})
+
+server.listen(PORT, HOST, () => {
+  console.log(`Mock GPS simulator running:`)
+  console.log(`  UI: http://127.0.0.1:${PORT}/`)
+  console.log(`  WS: ws://127.0.0.1:${PORT}${WS_PATH}`)
+  console.log(`  Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=<remote-url>`)
+})