feat: load remote map config and constrain tile bounds
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { createTileGrid, type TileItem } from '../../utils/tile'
|
import { createTileGrid, type TileItem } from '../../utils/tile'
|
||||||
|
import { isTileWithinBounds } from '../../utils/remoteMapConfig'
|
||||||
import { getTileSizePx, type CameraState } from '../camera/camera'
|
import { getTileSizePx, type CameraState } from '../camera/camera'
|
||||||
import { type MapScene } from '../renderer/mapRenderer'
|
import { type MapScene } from '../renderer/mapRenderer'
|
||||||
import { type TileStore } from '../tile/tileStore'
|
import { type TileStore } from '../tile/tileStore'
|
||||||
@@ -52,7 +53,7 @@ export class TileLayer implements MapLayer {
|
|||||||
viewportHeight: scene.viewportHeight,
|
viewportHeight: scene.viewportHeight,
|
||||||
tileSize,
|
tileSize,
|
||||||
overdraw: scene.overdraw,
|
overdraw: scene.overdraw,
|
||||||
})
|
}).filter((tile) => isTileWithinBounds(scene.tileBoundsByZoom, scene.zoom, tile.x, tile.y))
|
||||||
}
|
}
|
||||||
|
|
||||||
tileStore.queueVisibleTiles(this.cachedTiles, scene, gridKey)
|
tileStore.queueVisibleTiles(this.cachedTiles, scene, gridKey)
|
||||||
@@ -122,3 +123,4 @@ export class TileLayer implements MapLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { CompassHeadingController } from '../sensor/compassHeadingController'
|
|||||||
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
||||||
import { type MapRendererStats } from '../renderer/mapRenderer'
|
import { type MapRendererStats } from '../renderer/mapRenderer'
|
||||||
import { worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
|
import { worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
|
||||||
|
import { type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||||||
|
|
||||||
const RENDER_MODE = 'Single WebGL Pipeline'
|
const RENDER_MODE = 'Single WebGL Pipeline'
|
||||||
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
|
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
|
||||||
const MAP_NORTH_OFFSET_DEG = 0
|
const MAP_NORTH_OFFSET_DEG = 0
|
||||||
const MAGNETIC_DECLINATION_DEG = -6.91
|
let MAGNETIC_DECLINATION_DEG = -6.91
|
||||||
const MAGNETIC_DECLINATION_TEXT = '6.91° W'
|
let MAGNETIC_DECLINATION_TEXT = '6.91° W'
|
||||||
const MIN_ZOOM = 15
|
const MIN_ZOOM = 15
|
||||||
const MAX_ZOOM = 20
|
const MAX_ZOOM = 20
|
||||||
const DEFAULT_ZOOM = 17
|
const DEFAULT_ZOOM = 17
|
||||||
@@ -70,6 +71,7 @@ export interface MapEngineViewState {
|
|||||||
mapReady: boolean
|
mapReady: boolean
|
||||||
mapReadyText: string
|
mapReadyText: string
|
||||||
mapName: string
|
mapName: string
|
||||||
|
configStatusText: string
|
||||||
zoom: number
|
zoom: number
|
||||||
rotationDeg: number
|
rotationDeg: number
|
||||||
rotationText: string
|
rotationText: string
|
||||||
@@ -119,6 +121,7 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'mapReady',
|
'mapReady',
|
||||||
'mapReadyText',
|
'mapReadyText',
|
||||||
'mapName',
|
'mapName',
|
||||||
|
'configStatusText',
|
||||||
'zoom',
|
'zoom',
|
||||||
'rotationDeg',
|
'rotationDeg',
|
||||||
'rotationText',
|
'rotationText',
|
||||||
@@ -383,6 +386,12 @@ export class MapEngine {
|
|||||||
autoRotateSourceMode: AutoRotateSourceMode
|
autoRotateSourceMode: AutoRotateSourceMode
|
||||||
autoRotateCalibrationOffsetDeg: number | null
|
autoRotateCalibrationOffsetDeg: number | null
|
||||||
autoRotateCalibrationPending: boolean
|
autoRotateCalibrationPending: boolean
|
||||||
|
minZoom: number
|
||||||
|
maxZoom: number
|
||||||
|
defaultZoom: number
|
||||||
|
defaultCenterTileX: number
|
||||||
|
defaultCenterTileY: number
|
||||||
|
tileBoundsByZoom: Record<number, TileZoomBounds> | null
|
||||||
|
|
||||||
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
||||||
this.buildVersion = buildVersion
|
this.buildVersion = buildVersion
|
||||||
@@ -405,6 +414,12 @@ export class MapEngine {
|
|||||||
this.handleCompassError(message)
|
this.handleCompassError(message)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
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.state = {
|
this.state = {
|
||||||
buildVersion: this.buildVersion,
|
buildVersion: this.buildVersion,
|
||||||
renderMode: RENDER_MODE,
|
renderMode: RENDER_MODE,
|
||||||
@@ -412,6 +427,7 @@ export class MapEngine {
|
|||||||
mapReady: false,
|
mapReady: false,
|
||||||
mapReadyText: 'BOOTING',
|
mapReadyText: 'BOOTING',
|
||||||
mapName: 'LCX 测试地图',
|
mapName: 'LCX 测试地图',
|
||||||
|
configStatusText: '远程配置待加载',
|
||||||
zoom: DEFAULT_ZOOM,
|
zoom: DEFAULT_ZOOM,
|
||||||
rotationDeg: 0,
|
rotationDeg: 0,
|
||||||
rotationText: formatRotationText(0),
|
rotationText: formatRotationText(0),
|
||||||
@@ -526,6 +542,55 @@ export class MapEngine {
|
|||||||
this.compassController.start()
|
this.compassController.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyRemoteMapConfig(config: RemoteMapConfig): void {
|
||||||
|
MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
|
||||||
|
MAGNETIC_DECLINATION_TEXT = config.magneticDeclinationText
|
||||||
|
this.minZoom = config.minZoom
|
||||||
|
this.maxZoom = config.maxZoom
|
||||||
|
this.defaultZoom = config.defaultZoom
|
||||||
|
this.defaultCenterTileX = config.initialCenterTileX
|
||||||
|
this.defaultCenterTileY = config.initialCenterTileY
|
||||||
|
this.tileBoundsByZoom = config.tileBoundsByZoom
|
||||||
|
|
||||||
|
const statePatch: Partial<MapEngineViewState> = {
|
||||||
|
configStatusText: '远程配置已载入',
|
||||||
|
projectionMode: config.projectionModeText,
|
||||||
|
tileSource: config.tileSource,
|
||||||
|
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
|
||||||
|
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||||||
|
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||||||
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||||
|
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.stageWidth || !this.state.stageHeight) {
|
||||||
|
this.setState({
|
||||||
|
...statePatch,
|
||||||
|
zoom: this.defaultZoom,
|
||||||
|
centerTileX: this.defaultCenterTileX,
|
||||||
|
centerTileY: this.defaultCenterTileY,
|
||||||
|
centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
|
||||||
|
statusText: `远程地图配置已载入 (${this.buildVersion})`,
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.commitViewport({
|
||||||
|
...statePatch,
|
||||||
|
zoom: this.defaultZoom,
|
||||||
|
centerTileX: this.defaultCenterTileX,
|
||||||
|
centerTileY: this.defaultCenterTileY,
|
||||||
|
tileTranslateX: 0,
|
||||||
|
tileTranslateY: 0,
|
||||||
|
}, `远程地图配置已载入 (${this.buildVersion})`, true, () => {
|
||||||
|
this.resetPreviewState()
|
||||||
|
this.syncRenderer()
|
||||||
|
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||||||
|
this.scheduleAutoRotate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
|
handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
|
||||||
this.clearInertiaTimer()
|
this.clearInertiaTimer()
|
||||||
this.clearPreviewResetTimer()
|
this.clearPreviewResetTimer()
|
||||||
@@ -696,9 +761,9 @@ export class MapEngine {
|
|||||||
this.renderer.setAnimationPaused(false)
|
this.renderer.setAnimationPaused(false)
|
||||||
this.commitViewport(
|
this.commitViewport(
|
||||||
{
|
{
|
||||||
zoom: DEFAULT_ZOOM,
|
zoom: this.defaultZoom,
|
||||||
centerTileX: DEFAULT_CENTER_TILE_X,
|
centerTileX: this.defaultCenterTileX,
|
||||||
centerTileY: DEFAULT_CENTER_TILE_Y,
|
centerTileY: this.defaultCenterTileY,
|
||||||
tileTranslateX: 0,
|
tileTranslateX: 0,
|
||||||
tileTranslateY: 0,
|
tileTranslateY: 0,
|
||||||
},
|
},
|
||||||
@@ -1196,6 +1261,7 @@ export class MapEngine {
|
|||||||
zoom: this.state.zoom,
|
zoom: this.state.zoom,
|
||||||
centerTileX: this.state.centerTileX,
|
centerTileX: this.state.centerTileX,
|
||||||
centerTileY: this.state.centerTileY,
|
centerTileY: this.state.centerTileY,
|
||||||
|
tileBoundsByZoom: this.tileBoundsByZoom,
|
||||||
viewportWidth: this.state.stageWidth,
|
viewportWidth: this.state.stageWidth,
|
||||||
viewportHeight: this.state.stageHeight,
|
viewportHeight: this.state.stageHeight,
|
||||||
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
||||||
@@ -1482,7 +1548,7 @@ export class MapEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
|
zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
|
||||||
const nextZoom = clamp(this.state.zoom + zoomDelta, MIN_ZOOM, MAX_ZOOM)
|
const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom)
|
||||||
const appliedDelta = nextZoom - this.state.zoom
|
const appliedDelta = nextZoom - this.state.zoom
|
||||||
if (!appliedDelta) {
|
if (!appliedDelta) {
|
||||||
this.animatePreviewToRest()
|
this.animatePreviewToRest()
|
||||||
@@ -1584,3 +1650,4 @@ export class MapEngine {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { type CameraState } from '../camera/camera'
|
import { type CameraState } from '../camera/camera'
|
||||||
import { type TileStoreStats } from '../tile/tileStore'
|
import { type TileStoreStats } from '../tile/tileStore'
|
||||||
import { type LonLatPoint } from '../../utils/projection'
|
import { type LonLatPoint } from '../../utils/projection'
|
||||||
|
import { type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||||||
|
|
||||||
export interface MapScene {
|
export interface MapScene {
|
||||||
tileSource: string
|
tileSource: string
|
||||||
zoom: number
|
zoom: number
|
||||||
centerTileX: number
|
centerTileX: number
|
||||||
centerTileY: number
|
centerTileY: number
|
||||||
|
tileBoundsByZoom: Record<number, TileZoomBounds> | null
|
||||||
viewportWidth: number
|
viewportWidth: number
|
||||||
viewportHeight: number
|
viewportHeight: number
|
||||||
visibleColumns: number
|
visibleColumns: number
|
||||||
@@ -42,3 +44,5 @@ export function buildCamera(scene: MapScene): CameraState {
|
|||||||
rotationRad: scene.rotationRad,
|
rotationRad: scene.rotationRad,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { buildTileUrl, type TileItem } from '../../utils/tile'
|
import { buildTileUrl, type TileItem } from '../../utils/tile'
|
||||||
|
import { isTileWithinBounds, type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||||||
import { getTilePersistentCache, type TilePersistentCache } from '../renderer/tilePersistentCache'
|
import { getTilePersistentCache, type TilePersistentCache } from '../renderer/tilePersistentCache'
|
||||||
|
|
||||||
const MAX_PARENT_FALLBACK_DEPTH = 2
|
const MAX_PARENT_FALLBACK_DEPTH = 2
|
||||||
@@ -51,6 +52,7 @@ export interface TileStoreScene {
|
|||||||
viewportHeight: number
|
viewportHeight: number
|
||||||
translateX: number
|
translateX: number
|
||||||
translateY: number
|
translateY: number
|
||||||
|
tileBoundsByZoom: Record<number, TileZoomBounds> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TileStoreCallbacks {
|
export interface TileStoreCallbacks {
|
||||||
@@ -241,6 +243,9 @@ export class TileStore {
|
|||||||
const scale = Math.pow(2, depth)
|
const scale = Math.pow(2, depth)
|
||||||
const fallbackX = Math.floor(tile.x / scale)
|
const fallbackX = Math.floor(tile.x / scale)
|
||||||
const fallbackY = Math.floor(tile.y / scale)
|
const fallbackY = Math.floor(tile.y / scale)
|
||||||
|
if (!isTileWithinBounds(scene.tileBoundsByZoom, fallbackZoom, fallbackX, fallbackY)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
const fallbackUrl = buildTileUrl(scene.tileSource, fallbackZoom, fallbackX, fallbackY)
|
const fallbackUrl = buildTileUrl(scene.tileSource, fallbackZoom, fallbackX, fallbackY)
|
||||||
const fallbackPriority = priority / (depth + 1)
|
const fallbackPriority = priority / (depth + 1)
|
||||||
const existingPriority = parentPriorityMap.get(fallbackUrl)
|
const existingPriority = parentPriorityMap.get(fallbackUrl)
|
||||||
@@ -486,7 +491,10 @@ export class TileStore {
|
|||||||
const scale = Math.pow(2, depth)
|
const scale = Math.pow(2, depth)
|
||||||
const fallbackX = Math.floor(tile.x / scale)
|
const fallbackX = Math.floor(tile.x / scale)
|
||||||
const fallbackY = Math.floor(tile.y / scale)
|
const fallbackY = Math.floor(tile.y / scale)
|
||||||
const fallbackUrl = buildTileUrl(scene.tileSource, fallbackZoom, fallbackX, fallbackY)
|
if (!isTileWithinBounds(scene.tileBoundsByZoom, fallbackZoom, fallbackX, fallbackY)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const fallbackUrl = buildTileUrl(scene.tileSource, fallbackZoom, fallbackX, fallbackY)
|
||||||
const fallbackEntry = this.tileCache.get(fallbackUrl)
|
const fallbackEntry = this.tileCache.get(fallbackUrl)
|
||||||
|
|
||||||
if (fallbackEntry && fallbackEntry.status === 'ready' && fallbackEntry.image) {
|
if (fallbackEntry && fallbackEntry.status === 'ready' && fallbackEntry.image) {
|
||||||
@@ -540,6 +548,9 @@ export class TileStore {
|
|||||||
for (let offsetX = 0; offsetX < division; offsetX += 1) {
|
for (let offsetX = 0; offsetX < division; offsetX += 1) {
|
||||||
const childX = tile.x * division + offsetX
|
const childX = tile.x * division + offsetX
|
||||||
const childY = tile.y * division + offsetY
|
const childY = tile.y * division + offsetY
|
||||||
|
if (!isTileWithinBounds(scene.tileBoundsByZoom, childZoom, childX, childY)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
const childUrl = buildTileUrl(scene.tileSource, childZoom, childX, childY)
|
const childUrl = buildTileUrl(scene.tileSource, childZoom, childX, childY)
|
||||||
const childEntry = this.tileCache.get(childUrl)
|
const childEntry = this.tileCache.get(childUrl)
|
||||||
if (!childEntry || childEntry.status !== 'ready' || !childEntry.image) {
|
if (!childEntry || childEntry.status !== 'ready' || !childEntry.image) {
|
||||||
@@ -565,3 +576,5 @@ export class TileStore {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { MapEngine, type MapEngineStageRect, type MapEngineViewState } from '../../engine/map/mapEngine'
|
import { MapEngine, type MapEngineStageRect, type MapEngineViewState } from '../../engine/map/mapEngine'
|
||||||
|
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
|
||||||
|
|
||||||
type MapPageData = MapEngineViewState & {
|
type MapPageData = MapEngineViewState & {
|
||||||
showDebugPanel: boolean
|
showDebugPanel: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTERNAL_BUILD_VERSION = 'map-build-75'
|
const INTERNAL_BUILD_VERSION = 'map-build-82'
|
||||||
|
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/qyds-001/game.json'
|
||||||
|
|
||||||
let mapEngine: MapEngine | null = null
|
let mapEngine: MapEngine | null = null
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ Page({
|
|||||||
|
|
||||||
onReady() {
|
onReady() {
|
||||||
this.measureStageAndCanvas()
|
this.measureStageAndCanvas()
|
||||||
|
this.loadMapConfigFromRemote()
|
||||||
},
|
},
|
||||||
|
|
||||||
onUnload() {
|
onUnload() {
|
||||||
@@ -45,6 +48,33 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadMapConfigFromRemote() {
|
||||||
|
const currentEngine = mapEngine
|
||||||
|
if (!currentEngine) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRemoteMapConfig(REMOTE_GAME_CONFIG_URL)
|
||||||
|
.then((config) => {
|
||||||
|
if (mapEngine !== currentEngine) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEngine.applyRemoteMapConfig(config)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (mapEngine !== currentEngine) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = error && error.message ? error.message : '未知错误'
|
||||||
|
this.setData({
|
||||||
|
configStatusText: `载入失败: ${errorMessage}`,
|
||||||
|
statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
measureStageAndCanvas() {
|
measureStageAndCanvas() {
|
||||||
const page = this
|
const page = this
|
||||||
const applyStage = (rawRect?: Partial<WechatMiniprogram.BoundingClientRectCallbackResult>) => {
|
const applyStage = (rawRect?: Partial<WechatMiniprogram.BoundingClientRectCallbackResult>) => {
|
||||||
@@ -179,6 +209,18 @@ Page({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,14 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view class="info-panel" scroll-y enhanced show-scrollbar="true">
|
<scroll-view class="info-panel" scroll-y enhanced show-scrollbar="true">
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Build</text>
|
||||||
|
<text class="info-panel__value">{{buildVersion}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">Config</text>
|
||||||
|
<text class="info-panel__value">{{configStatusText}}</text>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">Heading Mode</text>
|
<text class="info-panel__label">Heading Mode</text>
|
||||||
<text class="info-panel__value">{{orientationModeText}}</text>
|
<text class="info-panel__value">{{orientationModeText}}</text>
|
||||||
@@ -73,16 +81,11 @@
|
|||||||
<text class="info-panel__label">Status</text>
|
<text class="info-panel__label">Status</text>
|
||||||
<text class="info-panel__value">{{statusText}}</text>
|
<text class="info-panel__value">{{statusText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleToggleDebugPanel">{{showDebugPanel ? '隐藏调试' : '查看调试'}}</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleToggleDebugPanel">{{showDebugPanel ? '隐藏调试' : '查看调试'}}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<block wx:if="{{showDebugPanel}}">
|
<block wx:if="{{showDebugPanel}}">
|
||||||
<view class="info-panel__row">
|
|
||||||
<text class="info-panel__label">Build</text>
|
|
||||||
<text class="info-panel__value">{{buildVersion}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">Renderer</text>
|
<text class="info-panel__label">Renderer</text>
|
||||||
<text class="info-panel__value">{{renderMode}}</text>
|
<text class="info-panel__value">{{renderMode}}</text>
|
||||||
@@ -161,3 +164,5 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
404
miniprogram/utils/remoteMapConfig.ts
Normal file
404
miniprogram/utils/remoteMapConfig.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
|
||||||
|
|
||||||
|
export interface TileZoomBounds {
|
||||||
|
minX: number
|
||||||
|
maxX: number
|
||||||
|
minY: number
|
||||||
|
maxY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteMapConfig {
|
||||||
|
tileSource: string
|
||||||
|
minZoom: number
|
||||||
|
maxZoom: number
|
||||||
|
defaultZoom: number
|
||||||
|
initialCenterTileX: number
|
||||||
|
initialCenterTileY: number
|
||||||
|
projection: string
|
||||||
|
projectionModeText: string
|
||||||
|
magneticDeclinationDeg: number
|
||||||
|
magneticDeclinationText: string
|
||||||
|
tileFormat: string
|
||||||
|
tileSize: number
|
||||||
|
bounds: [number, number, number, number] | null
|
||||||
|
tileBoundsByZoom: Record<number, TileZoomBounds>
|
||||||
|
mapMetaUrl: string
|
||||||
|
mapRootUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedGameConfig {
|
||||||
|
mapRoot: string
|
||||||
|
mapMeta: string
|
||||||
|
declinationDeg: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedMapMeta {
|
||||||
|
tileSize: number
|
||||||
|
minZoom: number
|
||||||
|
maxZoom: number
|
||||||
|
projection: string
|
||||||
|
tileFormat: string
|
||||||
|
tilePathTemplate: string
|
||||||
|
bounds: [number, number, number, number] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestTextViaRequest(url: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.request({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'text' as any,
|
||||||
|
success: (response) => {
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`request失败: ${response.statusCode} ${url}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response.data === 'string') {
|
||||||
|
resolve(response.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(JSON.stringify(response.data))
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
reject(new Error(`request失败: ${url}`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestTextViaDownload(url: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fileSystemManager = wx.getFileSystemManager()
|
||||||
|
wx.downloadFile({
|
||||||
|
url,
|
||||||
|
success: (response) => {
|
||||||
|
if (response.statusCode !== 200 || !response.tempFilePath) {
|
||||||
|
reject(new Error(`download失败: ${response.statusCode} ${url}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSystemManager.readFile({
|
||||||
|
filePath: response.tempFilePath,
|
||||||
|
encoding: 'utf8',
|
||||||
|
success: (readResult) => {
|
||||||
|
if (typeof readResult.data === 'string') {
|
||||||
|
resolve(readResult.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(`read失败: ${url}`))
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
reject(new Error(`read失败: ${url}`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
reject(new Error(`download失败: ${url}`))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestText(url: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await requestTextViaRequest(url)
|
||||||
|
} catch (requestError) {
|
||||||
|
try {
|
||||||
|
return await requestTextViaDownload(url)
|
||||||
|
} catch (downloadError) {
|
||||||
|
const requestMessage = requestError instanceof Error ? requestError.message : 'request失败'
|
||||||
|
const downloadMessage = downloadError instanceof Error ? downloadError.message : 'download失败'
|
||||||
|
throw new Error(`${requestMessage}; ${downloadMessage}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrl(baseUrl: string, relativePath: string): string {
|
||||||
|
if (/^https?:\/\//i.test(relativePath)) {
|
||||||
|
return relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
const originMatch = baseUrl.match(/^(https?:\/\/[^/]+)/i)
|
||||||
|
const origin = originMatch ? originMatch[1] : ''
|
||||||
|
if (relativePath.startsWith('/')) {
|
||||||
|
return `${origin}${relativePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
|
||||||
|
const normalizedRelativePath = relativePath.replace(/^\.\//, '')
|
||||||
|
return `${baseDir}${normalizedRelativePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeclinationText(declinationDeg: number): string {
|
||||||
|
const suffix = declinationDeg < 0 ? 'W' : 'E'
|
||||||
|
return `${Math.abs(declinationDeg).toFixed(2)}° ${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeclinationValue(rawValue: unknown): number {
|
||||||
|
const numericValue = Number(rawValue)
|
||||||
|
return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGameConfigFromJson(text: string): ParsedGameConfig {
|
||||||
|
let parsed: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
throw new Error('game.json 解析失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: Record<string, unknown> = {}
|
||||||
|
const keys = Object.keys(parsed)
|
||||||
|
for (const key of keys) {
|
||||||
|
normalized[key.toLowerCase()] = parsed[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
|
||||||
|
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
|
||||||
|
if (!mapRoot || !mapMeta) {
|
||||||
|
throw new Error('game.json 缺少 map 或 mapmeta 字段')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapRoot,
|
||||||
|
mapMeta,
|
||||||
|
declinationDeg: parseDeclinationValue(normalized.declination),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGameConfigFromYaml(text: string): ParsedGameConfig {
|
||||||
|
const config: Record<string, string> = {}
|
||||||
|
const lines = text.split(/\r?\n/)
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = line.match(/^([A-Za-z0-9_-]+)\s*(?:=|:)\s*(.+)$/)
|
||||||
|
if (!match) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
config[match[1].trim().toLowerCase()] = match[2].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapRoot = config.map
|
||||||
|
const mapMeta = config.mapmeta
|
||||||
|
if (!mapRoot || !mapMeta) {
|
||||||
|
throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapRoot,
|
||||||
|
mapMeta,
|
||||||
|
declinationDeg: parseDeclinationValue(config.declination),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig {
|
||||||
|
const trimmedText = text.trim()
|
||||||
|
const isJson =
|
||||||
|
trimmedText.startsWith('{') ||
|
||||||
|
trimmedText.startsWith('[') ||
|
||||||
|
/\.json(?:[?#].*)?$/i.test(gameConfigUrl)
|
||||||
|
|
||||||
|
return isJson ? parseGameConfigFromJson(trimmedText) : parseGameConfigFromYaml(trimmedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractStringField(text: string, key: string): string | null {
|
||||||
|
const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)
|
||||||
|
const match = text.match(pattern)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNumberField(text: string, key: string): number | null {
|
||||||
|
const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`)
|
||||||
|
const match = text.match(pattern)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(match[1])
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNumberArrayField(text: string, key: string): number[] | null {
|
||||||
|
const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`)
|
||||||
|
const match = text.match(pattern)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g)
|
||||||
|
if (!numberMatches || !numberMatches.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = numberMatches
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((item) => Number.isFinite(item))
|
||||||
|
|
||||||
|
return values.length ? values : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMapMeta(text: string): ParsedMapMeta {
|
||||||
|
const tileSizeField = extractNumberField(text, 'tileSize')
|
||||||
|
const tileSize = tileSizeField === null ? 256 : tileSizeField
|
||||||
|
const minZoom = extractNumberField(text, 'minZoom')
|
||||||
|
const maxZoom = extractNumberField(text, 'maxZoom')
|
||||||
|
const projectionField = extractStringField(text, 'projection')
|
||||||
|
const projection = projectionField === null ? 'EPSG:3857' : projectionField
|
||||||
|
const tilePathTemplate = extractStringField(text, 'tilePathTemplate')
|
||||||
|
const tileFormatFromField = extractStringField(text, 'tileFormat')
|
||||||
|
const boundsValues = extractNumberArrayField(text, 'bounds')
|
||||||
|
|
||||||
|
if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) {
|
||||||
|
throw new Error('meta.json 缺少必要字段')
|
||||||
|
}
|
||||||
|
|
||||||
|
let tileFormat = tileFormatFromField || ''
|
||||||
|
if (!tileFormat) {
|
||||||
|
const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/)
|
||||||
|
tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tileSize,
|
||||||
|
minZoom: minZoom as number,
|
||||||
|
maxZoom: maxZoom as number,
|
||||||
|
projection,
|
||||||
|
tileFormat,
|
||||||
|
tilePathTemplate,
|
||||||
|
bounds: boundsValues && boundsValues.length >= 4
|
||||||
|
? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]]
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoundsCorners(
|
||||||
|
bounds: [number, number, number, number],
|
||||||
|
projection: string,
|
||||||
|
): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } {
|
||||||
|
if (projection === 'EPSG:3857') {
|
||||||
|
const minX = bounds[0]
|
||||||
|
const minY = bounds[1]
|
||||||
|
const maxX = bounds[2]
|
||||||
|
const maxY = bounds[3]
|
||||||
|
return {
|
||||||
|
northWest: webMercatorToLonLat({ x: minX, y: maxY }),
|
||||||
|
southEast: webMercatorToLonLat({ x: maxX, y: minY }),
|
||||||
|
center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projection === 'EPSG:4326') {
|
||||||
|
const minLon = bounds[0]
|
||||||
|
const minLat = bounds[1]
|
||||||
|
const maxLon = bounds[2]
|
||||||
|
const maxLat = bounds[3]
|
||||||
|
return {
|
||||||
|
northWest: { lon: minLon, lat: maxLat },
|
||||||
|
southEast: { lon: maxLon, lat: minLat },
|
||||||
|
center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`暂不支持的投影: ${projection}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTileBoundsByZoom(
|
||||||
|
bounds: [number, number, number, number] | null,
|
||||||
|
projection: string,
|
||||||
|
minZoom: number,
|
||||||
|
maxZoom: number,
|
||||||
|
): Record<number, TileZoomBounds> {
|
||||||
|
const boundsByZoom: Record<number, TileZoomBounds> = {}
|
||||||
|
if (!bounds) {
|
||||||
|
return boundsByZoom
|
||||||
|
}
|
||||||
|
|
||||||
|
const corners = getBoundsCorners(bounds, projection)
|
||||||
|
for (let zoom = minZoom; zoom <= maxZoom; zoom += 1) {
|
||||||
|
const northWestWorld = lonLatToWorldTile(corners.northWest, zoom)
|
||||||
|
const southEastWorld = lonLatToWorldTile(corners.southEast, zoom)
|
||||||
|
const minX = Math.floor(Math.min(northWestWorld.x, southEastWorld.x))
|
||||||
|
const maxX = Math.ceil(Math.max(northWestWorld.x, southEastWorld.x)) - 1
|
||||||
|
const minY = Math.floor(Math.min(northWestWorld.y, southEastWorld.y))
|
||||||
|
const maxY = Math.ceil(Math.max(northWestWorld.y, southEastWorld.y)) - 1
|
||||||
|
|
||||||
|
boundsByZoom[zoom] = {
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return boundsByZoom
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectionModeText(projection: string): string {
|
||||||
|
return `${projection} -> XYZ Tile -> Camera -> Screen`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTileWithinBounds(
|
||||||
|
tileBoundsByZoom: Record<number, TileZoomBounds> | null | undefined,
|
||||||
|
zoom: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): boolean {
|
||||||
|
if (!tileBoundsByZoom) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = tileBoundsByZoom[zoom]
|
||||||
|
if (!bounds) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<RemoteMapConfig> {
|
||||||
|
const gameConfigText = await requestText(gameConfigUrl)
|
||||||
|
const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
|
||||||
|
const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
|
||||||
|
const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
|
||||||
|
const mapMetaText = await requestText(mapMetaUrl)
|
||||||
|
const mapMeta = parseMapMeta(mapMetaText)
|
||||||
|
|
||||||
|
const defaultZoom = clamp(17, mapMeta.minZoom, mapMeta.maxZoom)
|
||||||
|
const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
|
||||||
|
const centerWorldTile = boundsCorners
|
||||||
|
? lonLatToWorldTile(boundsCorners.center, defaultZoom)
|
||||||
|
: { x: 0, y: 0 }
|
||||||
|
|
||||||
|
return {
|
||||||
|
tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
|
||||||
|
minZoom: mapMeta.minZoom,
|
||||||
|
maxZoom: mapMeta.maxZoom,
|
||||||
|
defaultZoom,
|
||||||
|
initialCenterTileX: Math.round(centerWorldTile.x),
|
||||||
|
initialCenterTileY: Math.round(centerWorldTile.y),
|
||||||
|
projection: mapMeta.projection,
|
||||||
|
projectionModeText: getProjectionModeText(mapMeta.projection),
|
||||||
|
magneticDeclinationDeg: gameConfig.declinationDeg,
|
||||||
|
magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg),
|
||||||
|
tileFormat: mapMeta.tileFormat,
|
||||||
|
tileSize: mapMeta.tileSize,
|
||||||
|
bounds: mapMeta.bounds,
|
||||||
|
tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
|
||||||
|
mapMetaUrl,
|
||||||
|
mapRootUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -46,5 +46,5 @@
|
|||||||
"ignore": [],
|
"ignore": [],
|
||||||
"include": []
|
"include": []
|
||||||
},
|
},
|
||||||
"appid": "wx9d42aa29805ded5d"
|
"appid": "wx9cca5c5a219a4f9c"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user