Browse Source

feat: add background gps tracking support

zhangyan 2 weeks ago
parent
commit
a4c426df8b

+ 10 - 0
miniprogram/app.json

@@ -9,6 +9,16 @@
     "navigationBarTitleText": "CMR Mini",
     "navigationBarBackgroundColor": "#ffffff"
   },
+  "permission": {
+    "scope.userLocation": {
+      "desc": "用于获取当前位置并为后台持续定位授权"
+    },
+    "scope.userLocationBackground": {
+      "desc": "用于后台持续获取当前位置并在地图上显示GPS点"
+    }
+  },
+  "requiredBackgroundModes": ["location"],
+  "requiredPrivateInfos": ["startLocationUpdateBackground", "onLocationChange"],
   "style": "v2",
   "componentFramework": "glass-easel",
   "lazyCodeLoading": "requiredComponents"

+ 124 - 4
miniprogram/engine/map/mapEngine.ts

@@ -1,9 +1,10 @@
 import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
 import { CompassHeadingController } from '../sensor/compassHeadingController'
+import { LocationController } from '../sensor/locationController'
 import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
 import { type MapRendererStats } from '../renderer/mapRenderer'
-import { worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
-import { type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
+import { gcj02ToWgs84, lonLatToWorldTile, worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
+import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
 
 const RENDER_MODE = 'Single WebGL Pipeline'
 const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
@@ -35,6 +36,8 @@ const AUTO_ROTATE_DEADZONE_DEG = 4
 const AUTO_ROTATE_MAX_STEP_DEG = 0.75
 const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
 const COMPASS_NEEDLE_SMOOTHING = 0.12
+const GPS_TRACK_MAX_POINTS = 200
+const GPS_TRACK_MIN_STEP_METERS = 3
 
 const SAMPLE_TRACK_WGS84: LonLatPoint[] = [
   worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X - 0.72, y: DEFAULT_CENTER_TILE_Y + 0.44 }, DEFAULT_ZOOM),
@@ -108,6 +111,9 @@ export interface MapEngineViewState {
   stageLeft: number
   stageTop: number
   statusText: string
+  gpsTracking: boolean
+  gpsTrackingText: string
+  gpsCoordText: string
 }
 
 export interface MapEngineCallbacks {
@@ -149,6 +155,9 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'cacheHitRateText',
   'tileSizePx',
   'statusText',
+  'gpsTracking',
+  'gpsTrackingText',
+  'gpsCoordText',
 ]
 
 function buildCenterText(zoom: number, x: number, y: number): string {
@@ -349,10 +358,32 @@ function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networ
   const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
   return `${Math.round(hitRate)}%`
 }
+
+function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
+  if (!point) {
+    return '--'
+  }
+
+  const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
+  if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
+    return base
+  }
+
+  return `${base} / ±${Math.round(accuracyMeters)}m`
+}
+
+function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
+  const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
+  const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
+  const dy = (b.lat - a.lat) * 110540
+  return Math.sqrt(dx * dx + dy * dy)
+}
+
 export class MapEngine {
   buildVersion: string
   renderer: WebGLMapRenderer
   compassController: CompassHeadingController
+  locationController: LocationController
   onData: (patch: Partial<MapEngineViewState>) => void
   state: MapEngineViewState
   previewScale: number
@@ -392,6 +423,10 @@ export class MapEngine {
   defaultCenterTileX: number
   defaultCenterTileY: number
   tileBoundsByZoom: Record<number, TileZoomBounds> | null
+  currentGpsPoint: LonLatPoint
+  currentGpsTrack: LonLatPoint[]
+  currentGpsAccuracyMeters: number | null
+  hasGpsCenteredOnce: boolean
 
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
     this.buildVersion = buildVersion
@@ -414,12 +449,34 @@ export class MapEngine {
         this.handleCompassError(message)
       },
     })
+    this.locationController = new LocationController({
+      onLocation: (update) => {
+        this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
+      },
+      onStatus: (message) => {
+        this.setState({
+          gpsTracking: this.locationController.listening,
+          gpsTrackingText: message,
+        }, true)
+      },
+      onError: (message) => {
+        this.setState({
+          gpsTracking: false,
+          gpsTrackingText: message,
+          statusText: `${message} (${this.buildVersion})`,
+        }, true)
+      },
+    })
     this.minZoom = MIN_ZOOM
     this.maxZoom = MAX_ZOOM
     this.defaultZoom = DEFAULT_ZOOM
     this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
     this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
     this.tileBoundsByZoom = null
+    this.currentGpsPoint = SAMPLE_GPS_WGS84
+    this.currentGpsTrack = []
+    this.currentGpsAccuracyMeters = null
+    this.hasGpsCenteredOnce = false
     this.state = {
       buildVersion: this.buildVersion,
       renderMode: RENDER_MODE,
@@ -464,6 +521,9 @@ export class MapEngine {
       stageLeft: 0,
       stageTop: 0,
       statusText: `单 WebGL 管线已准备接入方向传感器 (${this.buildVersion})`,
+      gpsTracking: false,
+      gpsTrackingText: '持续定位待启动',
+      gpsCoordText: '--',
     }
     this.previewScale = 1
     this.previewOriginX = 0
@@ -508,10 +568,58 @@ export class MapEngine {
     this.clearViewSyncTimer()
     this.clearAutoRotateTimer()
     this.compassController.destroy()
+    this.locationController.destroy()
     this.renderer.destroy()
     this.mounted = false
   }
 
+
+  handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
+    const nextPoint: LonLatPoint = gcj02ToWgs84({ lon: longitude, lat: latitude })
+    const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
+    if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
+      this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
+    }
+
+    this.currentGpsPoint = nextPoint
+    this.currentGpsAccuracyMeters = accuracyMeters
+
+    const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
+    const gpsTileX = Math.floor(gpsWorldPoint.x)
+    const gpsTileY = Math.floor(gpsWorldPoint.y)
+    const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
+
+    if (gpsInsideMap && !this.hasGpsCenteredOnce) {
+      this.hasGpsCenteredOnce = true
+      this.commitViewport({
+        centerTileX: gpsWorldPoint.x,
+        centerTileY: gpsWorldPoint.y,
+        tileTranslateX: 0,
+        tileTranslateY: 0,
+        gpsTracking: true,
+        gpsTrackingText: '持续定位进行中',
+        gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
+      }, `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
+      return
+    }
+
+    this.setState({
+      gpsTracking: true,
+      gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
+      gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
+      statusText: gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`,
+    }, true)
+    this.syncRenderer()
+  }
+
+  handleToggleGpsTracking(): void {
+    if (this.locationController.listening) {
+      this.locationController.stop()
+      return
+    }
+
+    this.locationController.start()
+  }
   setStage(rect: MapEngineStageRect): void {
     this.previewScale = 1
     this.previewOriginX = rect.width / 2
@@ -1272,8 +1380,8 @@ export class MapEngine {
       previewScale: this.previewScale || 1,
       previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
       previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
-      track: SAMPLE_TRACK_WGS84,
-      gpsPoint: SAMPLE_GPS_WGS84,
+      track: this.currentGpsTrack.length ? this.currentGpsTrack : SAMPLE_TRACK_WGS84,
+      gpsPoint: this.currentGpsPoint,
     }
   }
 
@@ -1643,6 +1751,18 @@ export class MapEngine {
 
 
 
+
+
+
+
+
+
+
+
+
+
+
+
 
 
 

+ 151 - 0
miniprogram/engine/sensor/locationController.ts

@@ -0,0 +1,151 @@
+export interface LocationUpdate {
+  latitude: number
+  longitude: number
+  accuracy?: number
+  speed?: number
+}
+
+export interface LocationControllerCallbacks {
+  onLocation: (update: LocationUpdate) => void
+  onStatus: (message: string) => void
+  onError: (message: string) => void
+}
+
+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 LocationController {
+  callbacks: LocationControllerCallbacks
+  listening: boolean
+  boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null
+
+  constructor(callbacks: LocationControllerCallbacks) {
+    this.callbacks = callbacks
+    this.listening = false
+    this.boundLocationHandler = null
+  }
+
+  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({
+      success: () => {
+        this.bindLocationListener()
+        this.listening = true
+        this.callbacks.onStatus('后台持续定位已启动')
+      },
+      fail: (error) => {
+        const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败'
+        this.callbacks.onError(`GPS启动失败: ${message}`)
+      },
+    })
+  }
+
+  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('后台持续定位已停止')
+      },
+    })
+  }
+
+  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
+    }
+  }
+
+  bindLocationListener(): void {
+    if (this.boundLocationHandler) {
+      return
+    }
+
+    this.boundLocationHandler = (result) => {
+      this.callbacks.onLocation({
+        latitude: result.latitude,
+        longitude: result.longitude,
+        accuracy: result.accuracy,
+        speed: result.speed,
+      })
+    }
+
+    wx.onLocationChange(this.boundLocationHandler)
+  }
+}

+ 14 - 1
miniprogram/pages/map/map.ts

@@ -5,7 +5,7 @@ type MapPageData = MapEngineViewState & {
   showDebugPanel: boolean
 }
 
-const INTERNAL_BUILD_VERSION = 'map-build-82'
+const INTERNAL_BUILD_VERSION = 'map-build-88'
 const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/qyds-001/game.json'
 
 let mapEngine: MapEngine | null = null
@@ -195,6 +195,12 @@ Page({
     }
   },
 
+  handleToggleGpsTracking() {
+    if (mapEngine) {
+      mapEngine.handleToggleGpsTracking()
+    }
+  },
+
   handleToggleDebugPanel() {
     this.setData({
       showDebugPanel: !this.data.showDebugPanel,
@@ -221,6 +227,13 @@ Page({
 
 
 
+
+
+
+
+
+
+
 
 
 

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

@@ -81,6 +81,14 @@
       <text class="info-panel__label">Status</text>
       <text class="info-panel__value">{{statusText}}</text>
     </view>
+    <view class="info-panel__row">
+      <text class="info-panel__label">GPS</text>
+      <text class="info-panel__value">{{gpsTrackingText}}</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="control-row">
       <view class="control-chip control-chip--secondary" bindtap="handleToggleDebugPanel">{{showDebugPanel ? '隐藏调试' : '查看调试'}}</view>
     </view>
@@ -148,6 +156,9 @@
       <view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
       <view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
     </view>
+    <view class="control-row">
+      <view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
+    </view>
     <view class="control-row control-row--triple">
       <view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>
       <view class="control-chip {{orientationMode === 'north-up' ? 'control-chip--active' : ''}}" bindtap="handleSetNorthUpMode">北朝上</view>
@@ -166,3 +177,4 @@
 </view>
 
 
+

+ 42 - 0
miniprogram/utils/projection.ts

@@ -59,3 +59,45 @@ export function worldTileToLonLat(point: WorldTilePoint, zoom: number): LonLatPo
     lat: latRad * 180 / Math.PI,
   }
 }
+
+const CHINA_AXIS = 6378245
+const CHINA_EE = 0.00669342162296594323
+
+function isOutsideChina(point: LonLatPoint): boolean {
+  return point.lon < 72.004 || point.lon > 137.8347 || point.lat < 0.8293 || point.lat > 55.8271
+}
+
+function transformLat(x: number, y: number): number {
+  let result = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x))
+  result += (20 * Math.sin(6 * x * Math.PI) + 20 * Math.sin(2 * x * Math.PI)) * 2 / 3
+  result += (20 * Math.sin(y * Math.PI) + 40 * Math.sin(y / 3 * Math.PI)) * 2 / 3
+  result += (160 * Math.sin(y / 12 * Math.PI) + 320 * Math.sin(y * Math.PI / 30)) * 2 / 3
+  return result
+}
+
+function transformLon(x: number, y: number): number {
+  let result = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x))
+  result += (20 * Math.sin(6 * x * Math.PI) + 20 * Math.sin(2 * x * Math.PI)) * 2 / 3
+  result += (20 * Math.sin(x * Math.PI) + 40 * Math.sin(x / 3 * Math.PI)) * 2 / 3
+  result += (150 * Math.sin(x / 12 * Math.PI) + 300 * Math.sin(x / 30 * Math.PI)) * 2 / 3
+  return result
+}
+
+export function gcj02ToWgs84(point: LonLatPoint): LonLatPoint {
+  if (isOutsideChina(point)) {
+    return point
+  }
+
+  const dLat = transformLat(point.lon - 105, point.lat - 35)
+  const dLon = transformLon(point.lon - 105, point.lat - 35)
+  const radLat = point.lat / 180 * Math.PI
+  const magic = Math.sin(radLat)
+  const sqrtMagic = Math.sqrt(1 - CHINA_EE * magic * magic)
+  const latOffset = (dLat * 180) / ((CHINA_AXIS * (1 - CHINA_EE)) / (sqrtMagic * sqrtMagic * sqrtMagic) * Math.PI)
+  const lonOffset = (dLon * 180) / (CHINA_AXIS / sqrtMagic * Math.cos(radLat) * Math.PI)
+
+  return {
+    lon: point.lon - lonOffset,
+    lat: point.lat - latOffset,
+  }
+}