浏览代码

feat: load remote map config and constrain tile bounds

zhangyan 4 周之前
父节点
当前提交
1ecb4809df

+ 3 - 1
miniprogram/engine/layer/tileLayer.ts

@@ -1,4 +1,5 @@
 import { createTileGrid, type TileItem } from '../../utils/tile'
+import { isTileWithinBounds } from '../../utils/remoteMapConfig'
 import { getTileSizePx, type CameraState } from '../camera/camera'
 import { type MapScene } from '../renderer/mapRenderer'
 import { type TileStore } from '../tile/tileStore'
@@ -52,7 +53,7 @@ export class TileLayer implements MapLayer {
         viewportHeight: scene.viewportHeight,
         tileSize,
         overdraw: scene.overdraw,
-      })
+      }).filter((tile) => isTileWithinBounds(scene.tileBoundsByZoom, scene.zoom, tile.x, tile.y))
     }
 
     tileStore.queueVisibleTiles(this.cachedTiles, scene, gridKey)
@@ -122,3 +123,4 @@ export class TileLayer implements MapLayer {
     }
   }
 }
+

+ 73 - 6
miniprogram/engine/map/mapEngine.ts

@@ -3,12 +3,13 @@ import { CompassHeadingController } from '../sensor/compassHeadingController'
 import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
 import { type MapRendererStats } from '../renderer/mapRenderer'
 import { worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
+import { type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
 
 const RENDER_MODE = 'Single WebGL Pipeline'
 const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
 const MAP_NORTH_OFFSET_DEG = 0
-const MAGNETIC_DECLINATION_DEG = -6.91
-const MAGNETIC_DECLINATION_TEXT = '6.91° W'
+let MAGNETIC_DECLINATION_DEG = -6.91
+let MAGNETIC_DECLINATION_TEXT = '6.91° W'
 const MIN_ZOOM = 15
 const MAX_ZOOM = 20
 const DEFAULT_ZOOM = 17
@@ -70,6 +71,7 @@ export interface MapEngineViewState {
   mapReady: boolean
   mapReadyText: string
   mapName: string
+  configStatusText: string
   zoom: number
   rotationDeg: number
   rotationText: string
@@ -119,6 +121,7 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'mapReady',
   'mapReadyText',
   'mapName',
+  'configStatusText',
   'zoom',
   'rotationDeg',
   'rotationText',
@@ -383,6 +386,12 @@ export class MapEngine {
   autoRotateSourceMode: AutoRotateSourceMode
   autoRotateCalibrationOffsetDeg: number | null
   autoRotateCalibrationPending: boolean
+  minZoom: number
+  maxZoom: number
+  defaultZoom: number
+  defaultCenterTileX: number
+  defaultCenterTileY: number
+  tileBoundsByZoom: Record<number, TileZoomBounds> | null
 
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
     this.buildVersion = buildVersion
@@ -405,6 +414,12 @@ export class MapEngine {
         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 = {
       buildVersion: this.buildVersion,
       renderMode: RENDER_MODE,
@@ -412,6 +427,7 @@ export class MapEngine {
       mapReady: false,
       mapReadyText: 'BOOTING',
       mapName: 'LCX 测试地图',
+      configStatusText: '远程配置待加载',
       zoom: DEFAULT_ZOOM,
       rotationDeg: 0,
       rotationText: formatRotationText(0),
@@ -526,6 +542,55 @@ export class MapEngine {
     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 {
     this.clearInertiaTimer()
     this.clearPreviewResetTimer()
@@ -696,9 +761,9 @@ export class MapEngine {
     this.renderer.setAnimationPaused(false)
     this.commitViewport(
       {
-        zoom: DEFAULT_ZOOM,
-        centerTileX: DEFAULT_CENTER_TILE_X,
-        centerTileY: DEFAULT_CENTER_TILE_Y,
+        zoom: this.defaultZoom,
+        centerTileX: this.defaultCenterTileX,
+        centerTileY: this.defaultCenterTileY,
         tileTranslateX: 0,
         tileTranslateY: 0,
       },
@@ -1196,6 +1261,7 @@ export class MapEngine {
       zoom: this.state.zoom,
       centerTileX: this.state.centerTileX,
       centerTileY: this.state.centerTileY,
+      tileBoundsByZoom: this.tileBoundsByZoom,
       viewportWidth: this.state.stageWidth,
       viewportHeight: this.state.stageHeight,
       visibleColumns: DESIRED_VISIBLE_COLUMNS,
@@ -1482,7 +1548,7 @@ export class MapEngine {
   }
 
   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
     if (!appliedDelta) {
       this.animatePreviewToRest()
@@ -1584,3 +1650,4 @@ export class MapEngine {
 
 
 
+

+ 4 - 0
miniprogram/engine/renderer/mapRenderer.ts

@@ -1,12 +1,14 @@
 import { type CameraState } from '../camera/camera'
 import { type TileStoreStats } from '../tile/tileStore'
 import { type LonLatPoint } from '../../utils/projection'
+import { type TileZoomBounds } from '../../utils/remoteMapConfig'
 
 export interface MapScene {
   tileSource: string
   zoom: number
   centerTileX: number
   centerTileY: number
+  tileBoundsByZoom: Record<number, TileZoomBounds> | null
   viewportWidth: number
   viewportHeight: number
   visibleColumns: number
@@ -42,3 +44,5 @@ export function buildCamera(scene: MapScene): CameraState {
     rotationRad: scene.rotationRad,
   }
 }
+
+

+ 14 - 1
miniprogram/engine/tile/tileStore.ts

@@ -1,4 +1,5 @@
 import { buildTileUrl, type TileItem } from '../../utils/tile'
+import { isTileWithinBounds, type TileZoomBounds } from '../../utils/remoteMapConfig'
 import { getTilePersistentCache, type TilePersistentCache } from '../renderer/tilePersistentCache'
 
 const MAX_PARENT_FALLBACK_DEPTH = 2
@@ -51,6 +52,7 @@ export interface TileStoreScene {
   viewportHeight: number
   translateX: number
   translateY: number
+  tileBoundsByZoom: Record<number, TileZoomBounds> | null
 }
 
 export interface TileStoreCallbacks {
@@ -241,6 +243,9 @@ export class TileStore {
         const scale = Math.pow(2, depth)
         const fallbackX = Math.floor(tile.x / 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 fallbackPriority = priority / (depth + 1)
         const existingPriority = parentPriorityMap.get(fallbackUrl)
@@ -486,7 +491,10 @@ export class TileStore {
       const scale = Math.pow(2, depth)
       const fallbackX = Math.floor(tile.x / 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)
 
       if (fallbackEntry && fallbackEntry.status === 'ready' && fallbackEntry.image) {
@@ -540,6 +548,9 @@ export class TileStore {
         for (let offsetX = 0; offsetX < division; offsetX += 1) {
           const childX = tile.x * division + offsetX
           const childY = tile.y * division + offsetY
+          if (!isTileWithinBounds(scene.tileBoundsByZoom, childZoom, childX, childY)) {
+            continue
+          }
           const childUrl = buildTileUrl(scene.tileSource, childZoom, childX, childY)
           const childEntry = this.tileCache.get(childUrl)
           if (!childEntry || childEntry.status !== 'ready' || !childEntry.image) {
@@ -565,3 +576,5 @@ export class TileStore {
     return null
   }
 }
+
+

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

@@ -1,10 +1,12 @@
 import { MapEngine, type MapEngineStageRect, type MapEngineViewState } from '../../engine/map/mapEngine'
+import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
 
 type MapPageData = MapEngineViewState & {
   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
 
@@ -36,6 +38,7 @@ Page({
 
   onReady() {
     this.measureStageAndCanvas()
+    this.loadMapConfigFromRemote()
   },
 
   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() {
     const page = this
     const applyStage = (rawRect?: Partial<WechatMiniprogram.BoundingClientRectCallbackResult>) => {
@@ -179,6 +209,18 @@ Page({
 
 
 
+
+
+
+
+
+
+
+
+
+
+
+
 
 
 

+ 10 - 5
miniprogram/pages/map/map.wxml

@@ -49,6 +49,14 @@
   </view>
 
   <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">
       <text class="info-panel__label">Heading Mode</text>
       <text class="info-panel__value">{{orientationModeText}}</text>
@@ -73,16 +81,11 @@
       <text class="info-panel__label">Status</text>
       <text class="info-panel__value">{{statusText}}</text>
     </view>
-
     <view class="control-row">
       <view class="control-chip control-chip--secondary" bindtap="handleToggleDebugPanel">{{showDebugPanel ? '隐藏调试' : '查看调试'}}</view>
     </view>
 
     <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">
         <text class="info-panel__label">Renderer</text>
         <text class="info-panel__value">{{renderMode}}</text>
@@ -161,3 +164,5 @@
     </view>
   </scroll-view>
 </view>
+
+

+ 404 - 0
miniprogram/utils/remoteMapConfig.ts

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

+ 1 - 1
project.config.json

@@ -46,5 +46,5 @@
     "ignore": [],
     "include": []
   },
-  "appid": "wx9d42aa29805ded5d"
+  "appid": "wx9cca5c5a219a4f9c"
 }