diff --git a/miniprogram/app.json b/miniprogram/app.json index 8b2af6d..b5e6e9e 100644 --- a/miniprogram/app.json +++ b/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" diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 0028a34..91256af 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/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 = [ '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) => void state: MapEngineViewState previewScale: number @@ -392,6 +423,10 @@ export class MapEngine { defaultCenterTileX: number defaultCenterTileY: number tileBoundsByZoom: Record | 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 { + + + + + + + + + + + + diff --git a/miniprogram/engine/sensor/locationController.ts b/miniprogram/engine/sensor/locationController.ts new file mode 100644 index 0000000..0742c41 --- /dev/null +++ b/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 + return !!authSettings['scope.userLocation'] +} + +function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean { + const authSettings = settings as Record + 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) + } +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 3b34fff..a8a7a69 100644 --- a/miniprogram/pages/map/map.ts +++ b/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({ + + + + + + + diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 8d18eb3..f696ddd 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -81,6 +81,14 @@ Status {{statusText}} + + GPS + {{gpsTrackingText}} + + + GPS Coord + {{gpsCoordText}} + {{showDebugPanel ? '隐藏调试' : '查看调试'}} @@ -148,6 +156,9 @@ 回到首屏 旋转归零 + + {{gpsTracking ? '停止定位' : '开启定位'}} + 手动 北朝上 @@ -166,3 +177,4 @@ + diff --git a/miniprogram/utils/projection.ts b/miniprogram/utils/projection.ts index cc106e4..26ccf62 100644 --- a/miniprogram/utils/projection.ts +++ b/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, + } +}