remoteMapConfig.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
  2. export interface TileZoomBounds {
  3. minX: number
  4. maxX: number
  5. minY: number
  6. maxY: number
  7. }
  8. export interface RemoteMapConfig {
  9. tileSource: string
  10. minZoom: number
  11. maxZoom: number
  12. defaultZoom: number
  13. initialCenterTileX: number
  14. initialCenterTileY: number
  15. projection: string
  16. projectionModeText: string
  17. magneticDeclinationDeg: number
  18. magneticDeclinationText: string
  19. tileFormat: string
  20. tileSize: number
  21. bounds: [number, number, number, number] | null
  22. tileBoundsByZoom: Record<number, TileZoomBounds>
  23. mapMetaUrl: string
  24. mapRootUrl: string
  25. }
  26. interface ParsedGameConfig {
  27. mapRoot: string
  28. mapMeta: string
  29. declinationDeg: number
  30. }
  31. interface ParsedMapMeta {
  32. tileSize: number
  33. minZoom: number
  34. maxZoom: number
  35. projection: string
  36. tileFormat: string
  37. tilePathTemplate: string
  38. bounds: [number, number, number, number] | null
  39. }
  40. function requestTextViaRequest(url: string): Promise<string> {
  41. return new Promise((resolve, reject) => {
  42. wx.request({
  43. url,
  44. method: 'GET',
  45. responseType: 'text' as any,
  46. success: (response) => {
  47. if (response.statusCode !== 200) {
  48. reject(new Error(`request失败: ${response.statusCode} ${url}`))
  49. return
  50. }
  51. if (typeof response.data === 'string') {
  52. resolve(response.data)
  53. return
  54. }
  55. resolve(JSON.stringify(response.data))
  56. },
  57. fail: () => {
  58. reject(new Error(`request失败: ${url}`))
  59. },
  60. })
  61. })
  62. }
  63. function requestTextViaDownload(url: string): Promise<string> {
  64. return new Promise((resolve, reject) => {
  65. const fileSystemManager = wx.getFileSystemManager()
  66. wx.downloadFile({
  67. url,
  68. success: (response) => {
  69. if (response.statusCode !== 200 || !response.tempFilePath) {
  70. reject(new Error(`download失败: ${response.statusCode} ${url}`))
  71. return
  72. }
  73. fileSystemManager.readFile({
  74. filePath: response.tempFilePath,
  75. encoding: 'utf8',
  76. success: (readResult) => {
  77. if (typeof readResult.data === 'string') {
  78. resolve(readResult.data)
  79. return
  80. }
  81. reject(new Error(`read失败: ${url}`))
  82. },
  83. fail: () => {
  84. reject(new Error(`read失败: ${url}`))
  85. },
  86. })
  87. },
  88. fail: () => {
  89. reject(new Error(`download失败: ${url}`))
  90. },
  91. })
  92. })
  93. }
  94. async function requestText(url: string): Promise<string> {
  95. try {
  96. return await requestTextViaRequest(url)
  97. } catch (requestError) {
  98. try {
  99. return await requestTextViaDownload(url)
  100. } catch (downloadError) {
  101. const requestMessage = requestError instanceof Error ? requestError.message : 'request失败'
  102. const downloadMessage = downloadError instanceof Error ? downloadError.message : 'download失败'
  103. throw new Error(`${requestMessage}; ${downloadMessage}`)
  104. }
  105. }
  106. }
  107. function clamp(value: number, min: number, max: number): number {
  108. return Math.max(min, Math.min(max, value))
  109. }
  110. function resolveUrl(baseUrl: string, relativePath: string): string {
  111. if (/^https?:\/\//i.test(relativePath)) {
  112. return relativePath
  113. }
  114. const originMatch = baseUrl.match(/^(https?:\/\/[^/]+)/i)
  115. const origin = originMatch ? originMatch[1] : ''
  116. if (relativePath.startsWith('/')) {
  117. return `${origin}${relativePath}`
  118. }
  119. const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
  120. const normalizedRelativePath = relativePath.replace(/^\.\//, '')
  121. return `${baseDir}${normalizedRelativePath}`
  122. }
  123. function formatDeclinationText(declinationDeg: number): string {
  124. const suffix = declinationDeg < 0 ? 'W' : 'E'
  125. return `${Math.abs(declinationDeg).toFixed(2)}° ${suffix}`
  126. }
  127. function parseDeclinationValue(rawValue: unknown): number {
  128. const numericValue = Number(rawValue)
  129. return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
  130. }
  131. function parseGameConfigFromJson(text: string): ParsedGameConfig {
  132. let parsed: Record<string, unknown>
  133. try {
  134. parsed = JSON.parse(text)
  135. } catch {
  136. throw new Error('game.json 解析失败')
  137. }
  138. const normalized: Record<string, unknown> = {}
  139. const keys = Object.keys(parsed)
  140. for (const key of keys) {
  141. normalized[key.toLowerCase()] = parsed[key]
  142. }
  143. const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
  144. const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
  145. if (!mapRoot || !mapMeta) {
  146. throw new Error('game.json 缺少 map 或 mapmeta 字段')
  147. }
  148. return {
  149. mapRoot,
  150. mapMeta,
  151. declinationDeg: parseDeclinationValue(normalized.declination),
  152. }
  153. }
  154. function parseGameConfigFromYaml(text: string): ParsedGameConfig {
  155. const config: Record<string, string> = {}
  156. const lines = text.split(/\r?\n/)
  157. for (const rawLine of lines) {
  158. const line = rawLine.trim()
  159. if (!line || line.startsWith('#')) {
  160. continue
  161. }
  162. const match = line.match(/^([A-Za-z0-9_-]+)\s*(?:=|:)\s*(.+)$/)
  163. if (!match) {
  164. continue
  165. }
  166. config[match[1].trim().toLowerCase()] = match[2].trim()
  167. }
  168. const mapRoot = config.map
  169. const mapMeta = config.mapmeta
  170. if (!mapRoot || !mapMeta) {
  171. throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
  172. }
  173. return {
  174. mapRoot,
  175. mapMeta,
  176. declinationDeg: parseDeclinationValue(config.declination),
  177. }
  178. }
  179. function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig {
  180. const trimmedText = text.trim()
  181. const isJson =
  182. trimmedText.startsWith('{') ||
  183. trimmedText.startsWith('[') ||
  184. /\.json(?:[?#].*)?$/i.test(gameConfigUrl)
  185. return isJson ? parseGameConfigFromJson(trimmedText) : parseGameConfigFromYaml(trimmedText)
  186. }
  187. function extractStringField(text: string, key: string): string | null {
  188. const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)
  189. const match = text.match(pattern)
  190. return match ? match[1] : null
  191. }
  192. function extractNumberField(text: string, key: string): number | null {
  193. const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`)
  194. const match = text.match(pattern)
  195. if (!match) {
  196. return null
  197. }
  198. const value = Number(match[1])
  199. return Number.isFinite(value) ? value : null
  200. }
  201. function extractNumberArrayField(text: string, key: string): number[] | null {
  202. const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`)
  203. const match = text.match(pattern)
  204. if (!match) {
  205. return null
  206. }
  207. const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g)
  208. if (!numberMatches || !numberMatches.length) {
  209. return null
  210. }
  211. const values = numberMatches
  212. .map((item) => Number(item))
  213. .filter((item) => Number.isFinite(item))
  214. return values.length ? values : null
  215. }
  216. function parseMapMeta(text: string): ParsedMapMeta {
  217. const tileSizeField = extractNumberField(text, 'tileSize')
  218. const tileSize = tileSizeField === null ? 256 : tileSizeField
  219. const minZoom = extractNumberField(text, 'minZoom')
  220. const maxZoom = extractNumberField(text, 'maxZoom')
  221. const projectionField = extractStringField(text, 'projection')
  222. const projection = projectionField === null ? 'EPSG:3857' : projectionField
  223. const tilePathTemplate = extractStringField(text, 'tilePathTemplate')
  224. const tileFormatFromField = extractStringField(text, 'tileFormat')
  225. const boundsValues = extractNumberArrayField(text, 'bounds')
  226. if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) {
  227. throw new Error('meta.json 缺少必要字段')
  228. }
  229. let tileFormat = tileFormatFromField || ''
  230. if (!tileFormat) {
  231. const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/)
  232. tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png'
  233. }
  234. return {
  235. tileSize,
  236. minZoom: minZoom as number,
  237. maxZoom: maxZoom as number,
  238. projection,
  239. tileFormat,
  240. tilePathTemplate,
  241. bounds: boundsValues && boundsValues.length >= 4
  242. ? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]]
  243. : null,
  244. }
  245. }
  246. function getBoundsCorners(
  247. bounds: [number, number, number, number],
  248. projection: string,
  249. ): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } {
  250. if (projection === 'EPSG:3857') {
  251. const minX = bounds[0]
  252. const minY = bounds[1]
  253. const maxX = bounds[2]
  254. const maxY = bounds[3]
  255. return {
  256. northWest: webMercatorToLonLat({ x: minX, y: maxY }),
  257. southEast: webMercatorToLonLat({ x: maxX, y: minY }),
  258. center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }),
  259. }
  260. }
  261. if (projection === 'EPSG:4326') {
  262. const minLon = bounds[0]
  263. const minLat = bounds[1]
  264. const maxLon = bounds[2]
  265. const maxLat = bounds[3]
  266. return {
  267. northWest: { lon: minLon, lat: maxLat },
  268. southEast: { lon: maxLon, lat: minLat },
  269. center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 },
  270. }
  271. }
  272. throw new Error(`暂不支持的投影: ${projection}`)
  273. }
  274. function buildTileBoundsByZoom(
  275. bounds: [number, number, number, number] | null,
  276. projection: string,
  277. minZoom: number,
  278. maxZoom: number,
  279. ): Record<number, TileZoomBounds> {
  280. const boundsByZoom: Record<number, TileZoomBounds> = {}
  281. if (!bounds) {
  282. return boundsByZoom
  283. }
  284. const corners = getBoundsCorners(bounds, projection)
  285. for (let zoom = minZoom; zoom <= maxZoom; zoom += 1) {
  286. const northWestWorld = lonLatToWorldTile(corners.northWest, zoom)
  287. const southEastWorld = lonLatToWorldTile(corners.southEast, zoom)
  288. const minX = Math.floor(Math.min(northWestWorld.x, southEastWorld.x))
  289. const maxX = Math.ceil(Math.max(northWestWorld.x, southEastWorld.x)) - 1
  290. const minY = Math.floor(Math.min(northWestWorld.y, southEastWorld.y))
  291. const maxY = Math.ceil(Math.max(northWestWorld.y, southEastWorld.y)) - 1
  292. boundsByZoom[zoom] = {
  293. minX,
  294. maxX,
  295. minY,
  296. maxY,
  297. }
  298. }
  299. return boundsByZoom
  300. }
  301. function getProjectionModeText(projection: string): string {
  302. return `${projection} -> XYZ Tile -> Camera -> Screen`
  303. }
  304. export function isTileWithinBounds(
  305. tileBoundsByZoom: Record<number, TileZoomBounds> | null | undefined,
  306. zoom: number,
  307. x: number,
  308. y: number,
  309. ): boolean {
  310. if (!tileBoundsByZoom) {
  311. return true
  312. }
  313. const bounds = tileBoundsByZoom[zoom]
  314. if (!bounds) {
  315. return true
  316. }
  317. return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY
  318. }
  319. export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<RemoteMapConfig> {
  320. const gameConfigText = await requestText(gameConfigUrl)
  321. const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
  322. const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
  323. const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
  324. const mapMetaText = await requestText(mapMetaUrl)
  325. const mapMeta = parseMapMeta(mapMetaText)
  326. const defaultZoom = clamp(17, mapMeta.minZoom, mapMeta.maxZoom)
  327. const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
  328. const centerWorldTile = boundsCorners
  329. ? lonLatToWorldTile(boundsCorners.center, defaultZoom)
  330. : { x: 0, y: 0 }
  331. return {
  332. tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
  333. minZoom: mapMeta.minZoom,
  334. maxZoom: mapMeta.maxZoom,
  335. defaultZoom,
  336. initialCenterTileX: Math.round(centerWorldTile.x),
  337. initialCenterTileY: Math.round(centerWorldTile.y),
  338. projection: mapMeta.projection,
  339. projectionModeText: getProjectionModeText(mapMeta.projection),
  340. magneticDeclinationDeg: gameConfig.declinationDeg,
  341. magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg),
  342. tileFormat: mapMeta.tileFormat,
  343. tileSize: mapMeta.tileSize,
  344. bounds: mapMeta.bounds,
  345. tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
  346. mapMetaUrl,
  347. mapRootUrl,
  348. }
  349. }