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 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 { 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 { 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 { 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 try { parsed = JSON.parse(text) } catch { throw new Error('game.json 解析失败') } const normalized: Record = {} 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 = {} 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 { const boundsByZoom: Record = {} 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 | 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 { 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, } }