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