diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 952a9e4..82bc560 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/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 = [ '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 { + 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 diff --git a/miniprogram/engine/sensor/locationController.ts b/miniprogram/engine/sensor/locationController.ts index c17ee92..4d3389d 100644 --- a/miniprogram/engine/sensor/locationController.ts +++ b/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 - return !!authSettings['scope.userLocation'] +function formatSourceModeText(mode: LocationSourceMode): string { + return mode === 'mock' ? '模拟定位' : '真实定位' } -function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean { - const authSettings = settings as Record - return !!authSettings['scope.userLocationBackground'] +function formatMockCoordText(sample: LocationSample | null): string { + if (!sample) { + return '--' + } + + return `${sample.latitude.toFixed(6)}, ${sample.longitude.toFixed(6)}` +} + +function formatMockSpeedText(sample: LocationSample | null): string { + if (!sample || !Number.isFinite(sample.speed)) { + return '--' + } + + return `${(Number(sample.speed) * 3.6).toFixed(1)} km/h` +} + +function normalizeMockBridgeUrl(rawUrl: string): string { + const trimmed = rawUrl.trim() + if (!trimmed) { + return DEFAULT_MOCK_LOCATION_BRIDGE_URL + } + + let normalized = trimmed + if (!/^wss?:\/\//i.test(normalized)) { + normalized = `ws://${normalized.replace(/^\/+/, '')}` + } + + if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) { + normalized = normalized.replace(/\/+$/, '') + normalized = `${normalized}/mock-gps` + } + + return normalized } export class LocationController { callbacks: LocationControllerCallbacks - listening: boolean - boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null + realSource: RealLocationSource + mockSource: MockLocationSource + mockBridge: MockLocationBridge + sourceMode: LocationSourceMode + mockBridgeStatusText: string + mockBridgeUrl: string constructor(callbacks: LocationControllerCallbacks) { this.callbacks = callbacks - this.listening = false - this.boundLocationHandler = null + this.sourceMode = 'real' + this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL + this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})` + + const sourceCallbacks: LocationSourceCallbacks = { + onLocation: (sample) => { + this.callbacks.onLocation(sample) + this.emitDebugState() + }, + onStatus: (message) => { + this.callbacks.onStatus(message) + this.emitDebugState() + }, + onError: (message) => { + this.callbacks.onError(message) + this.emitDebugState() + }, + } + + this.realSource = new RealLocationSource(sourceCallbacks) + this.mockSource = new MockLocationSource(sourceCallbacks) + this.mockBridge = new MockLocationBridge({ + onOpen: () => { + this.mockBridgeStatusText = `已连接 (${this.mockBridge.url})` + this.callbacks.onStatus('模拟定位源已连接') + this.emitDebugState() + }, + onClose: () => { + this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})` + this.callbacks.onStatus('模拟定位源已断开') + this.emitDebugState() + }, + onError: (message) => { + this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})` + this.callbacks.onError(`模拟定位源错误: ${message}`) + this.emitDebugState() + }, + onSample: (sample) => { + this.mockSource.pushSample(sample) + this.emitDebugState() + }, + }) + } + + get listening(): boolean { + return this.sourceMode === 'mock' ? this.mockSource.active : this.realSource.active + } + + getDebugState(): LocationControllerDebugState { + return { + sourceMode: this.sourceMode, + sourceModeText: formatSourceModeText(this.sourceMode), + listening: this.listening, + mockBridgeConnected: this.mockBridge.connected, + mockBridgeStatusText: this.mockBridgeStatusText, + mockBridgeUrlText: this.mockBridgeUrl, + mockCoordText: formatMockCoordText(this.mockSource.lastSample), + mockSpeedText: formatMockSpeedText(this.mockSource.lastSample), + } } start(): void { - if (this.listening) { - this.callbacks.onStatus('后台持续定位进行中') - return - } - - wx.getSetting({ - success: (result) => { - const settings = result.authSetting || {} - if (hasBackgroundLocationPermission(settings)) { - this.startBackgroundLocation() - return - } - - if (hasLocationPermission(settings)) { - this.requestBackgroundPermissionInSettings() - return - } - - wx.authorize({ - scope: 'scope.userLocation', - success: () => { - this.requestBackgroundPermissionInSettings() - }, - fail: () => { - this.requestBackgroundPermissionInSettings() - }, - }) - }, - fail: (error) => { - const message = error && error.errMsg ? error.errMsg : 'getSetting 失败' - this.callbacks.onError(`GPS授权检查失败: ${message}`) - }, - }) - } - - requestBackgroundPermissionInSettings(): void { - this.callbacks.onStatus('请在授权面板开启后台定位') - wx.openSetting({ - success: (result) => { - const settings = result.authSetting || {} - if (hasBackgroundLocationPermission(settings)) { - this.startBackgroundLocation() - return - } - - this.callbacks.onError('GPS启动失败: 未授予后台定位权限') - }, - fail: (error) => { - const message = error && error.errMsg ? error.errMsg : 'openSetting 失败' - this.callbacks.onError(`GPS启动失败: ${message}`) - }, - }) - } - - startBackgroundLocation(): void { - wx.startLocationUpdateBackground({ - type: 'wgs84', - success: () => { - this.bindLocationListener() - this.listening = true - this.callbacks.onStatus('后台持续定位已启动') - }, - fail: (error) => { - const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败' - this.callbacks.onError(`GPS启动失败: ${message}`) - }, - }) + this.getActiveSource().start() + this.emitDebugState() } stop(): void { - if (!this.listening) { - this.callbacks.onStatus('后台持续定位未启动') - return - } - - if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) { - wx.offLocationChange(this.boundLocationHandler) - } - this.boundLocationHandler = null - - wx.stopLocationUpdate({ - complete: () => { - this.listening = false - this.callbacks.onStatus('后台持续定位已停止') - }, - }) + this.getActiveSource().stop() + this.emitDebugState() } destroy(): void { - if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) { - wx.offLocationChange(this.boundLocationHandler) - } - this.boundLocationHandler = null - - if (this.listening) { - wx.stopLocationUpdate({ complete: () => {} }) - this.listening = false - } + this.realSource.destroy() + this.mockSource.destroy() + this.mockBridge.destroy() + this.emitDebugState() } - bindLocationListener(): void { - if (this.boundLocationHandler) { + setSourceMode(mode: LocationSourceMode): void { + if (this.sourceMode === mode) { + this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`) + this.emitDebugState() return } - this.boundLocationHandler = (result) => { - this.callbacks.onLocation({ - latitude: result.latitude, - longitude: result.longitude, - accuracy: result.accuracy, - speed: result.speed, - }) + const wasListening = this.listening + if (wasListening) { + this.getActiveSource().stop() + } + this.sourceMode = mode + + if (wasListening) { + this.getActiveSource().start() + } else { + this.callbacks.onStatus(`已切换到${formatSourceModeText(mode)}`) } - wx.onLocationChange(this.boundLocationHandler) + this.emitDebugState() + } + + setMockBridgeUrl(url: string): void { + this.mockBridgeUrl = normalizeMockBridgeUrl(url) + + if (this.mockBridge.connected || this.mockBridge.connecting) { + this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})` + this.callbacks.onStatus('模拟定位源地址已更新,重连后生效') + } else { + this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})` + this.callbacks.onStatus('模拟定位源地址已更新') + } + + this.emitDebugState() + } + + connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void { + if (this.mockBridge.connected || this.mockBridge.connecting) { + this.callbacks.onStatus('模拟定位源已连接') + this.emitDebugState() + return + } + + const targetUrl = normalizeMockBridgeUrl(url === DEFAULT_MOCK_LOCATION_BRIDGE_URL ? this.mockBridgeUrl : url) + this.mockBridgeUrl = targetUrl + this.mockBridgeStatusText = `连接中 (${targetUrl})` + this.emitDebugState() + this.callbacks.onStatus('模拟定位源连接中') + this.mockBridge.connect(targetUrl) + } + + disconnectMockBridge(): void { + if (!this.mockBridge.connected && !this.mockBridge.connecting) { + this.callbacks.onStatus('模拟定位源未连接') + this.emitDebugState() + return + } + + this.mockBridge.disconnect() + this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})` + this.callbacks.onStatus('模拟定位源已断开') + this.emitDebugState() + } + + getActiveSource(): RealLocationSource | MockLocationSource { + return this.sourceMode === 'mock' ? this.mockSource : this.realSource + } + + emitDebugState(): void { + if (this.callbacks.onDebugStateChange) { + this.callbacks.onDebugStateChange(this.getDebugState()) + } } } - diff --git a/miniprogram/engine/sensor/locationSource.ts b/miniprogram/engine/sensor/locationSource.ts new file mode 100644 index 0000000..17bb3e8 --- /dev/null +++ b/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 +} diff --git a/miniprogram/engine/sensor/mockLocationBridge.ts b/miniprogram/engine/sensor/mockLocationBridge.ts new file mode 100644 index 0000000..4b6f236 --- /dev/null +++ b/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() + } +} diff --git a/miniprogram/engine/sensor/mockLocationSource.ts b/miniprogram/engine/sensor/mockLocationSource.ts new file mode 100644 index 0000000..b868db3 --- /dev/null +++ b/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) + } +} diff --git a/miniprogram/engine/sensor/realLocationSource.ts b/miniprogram/engine/sensor/realLocationSource.ts new file mode 100644 index 0000000..9dca595 --- /dev/null +++ b/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 + return !!authSettings['scope.userLocation'] +} + +function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean { + const authSettings = settings as Record + 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) + } +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 9828b56..9d85833 100644 --- a/miniprogram/pages/map/map.ts +++ b/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 + 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() diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 661d1f4..080b760 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -285,10 +285,42 @@ GPS {{gpsTrackingText}} + + Location Source + {{locationSourceText}} + GPS Coord {{gpsCoordText}} + + Mock Bridge + {{mockBridgeStatusText}} + + + Mock URL + + + + 保存地址 + 连接模拟源 + 断开模拟源 + + + + + Mock Coord + {{mockCoordText}} + + + Mock Speed + {{mockSpeedText}} + Heart Rate {{heartRateStatusText}} @@ -313,6 +345,10 @@ {{gpsTracking ? '停止定位' : '开启定位'}} {{heartRateConnected ? '心率带已连接' : '连接心率带'}} + + 真实定位 + 模拟定位 + 断开心率带 {{northReferenceButtonText}} diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index b9dbc19..bca96bf 100644 --- a/miniprogram/pages/map/map.wxss +++ b/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; } diff --git a/package-lock.json b/package-lock.json index 89a82fe..4334689 100644 --- a/package-lock.json +++ b/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 + } + } } } } diff --git a/package.json b/package.json index 8191c54..5c8e2e0 100644 --- a/package.json +++ b/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" diff --git a/tools/mock-gps-sim/README.md b/tools/mock-gps-sim/README.md new file mode 100644 index 0000000..04f9a94 --- /dev/null +++ b/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=` + +## 当前能力 + +- 直接载入 `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/ +``` diff --git a/tools/mock-gps-sim/public/index.html b/tools/mock-gps-sim/public/index.html new file mode 100644 index 0000000..08fd6b6 --- /dev/null +++ b/tools/mock-gps-sim/public/index.html @@ -0,0 +1,125 @@ + + + + + + Mock GPS Simulator + + + + +
+ + +
+
+
+
+ + + + + diff --git a/tools/mock-gps-sim/public/simulator.js b/tools/mock-gps-sim/public/simulator.js new file mode 100644 index 0000000..5deaa45 --- /dev/null +++ b/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: '© 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(/([\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(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/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(//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', '
', [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', + `
${item.sequence}
`, + [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', '
', [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(' 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: '© 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() +})() diff --git a/tools/mock-gps-sim/public/style.css b/tools/mock-gps-sim/public/style.css new file mode 100644 index 0000000..bcbec7f --- /dev/null +++ b/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; +} diff --git a/tools/mock-gps-sim/server.js b/tools/mock-gps-sim/server.js new file mode 100644 index 0000000..ee67821 --- /dev/null +++ b/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=`) +})