feat: load remote map config and constrain tile bounds

This commit is contained in:
2026-03-20 16:19:12 +08:00
parent 8e6291885d
commit 1ecb4809df
8 changed files with 552 additions and 15 deletions

View 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,
}
}