'use strict'; const fs = require('fs'); const path = require('path'); /** * 大地图瓦片服务:爬取 squaremap 站点(bgjq.simpfun.cn)的地图图片并本地缓存, * 供控制台在一张大地图上展示所有机器人的坐标与领土标记。 * * ## 坐标系(已对 source 站点实测标定) * * squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为 * `tiles///_.png`,并且: * * - 瓦片图片固定 512×512 像素; * - 最大原生层级 `zoom.max`(本站为 3),在该层级 1 方块 = 1 像素; * - 因此层级 z 的每张瓦片覆盖 `512 * 2^(maxZoom - z)` 个方块, * 瓦片 (x, y) 覆盖方块区间 `[x*B, (x+1)*B) × [y*B, (y+1)*B)`(B 即上式)。 * * 本站实测:王城「现代大陆」(10722, 1547) 落在 z=3 的瓦片 (20, 3) 内, * 与上述换算一致(20*512=10240 ≤ 10722 < 10752,3*512=1536 ≤ 1547 < 2048)。 * * ## 缓存策略 * * - 磁盘缓存 `data/mapcache///_.png`,命中即直接返回,不再回源; * - 低层级(默认 z=0,全世界 10×10=100 张)在启动时批量爬取,保证大地图秒开; * - 高层级按需穿透拉取(浏览器缩放时才请求),拉过就永久缓存。 */ /** 瓦片图片边长(像素) */ const TILE_PIXELS = 512; /** 本站 squaremap 的最大原生层级(settings.json 的 zoom.max) */ const DEFAULT_MAX_ZOOM = 3; /** 默认覆盖半径(方块):与服务器世界边界 ±20480 一致 */ const DEFAULT_RANGE_BLOCKS = 20480; /** 单张瓦片下载超时(毫秒) */ const TILE_TIMEOUT_MS = 20000; class BigMapService { /** * @param {object} config 完整配置对象 * @param {import('../util/logger').Logger} [logger] * @param {{baseUrl?: string, world?: string, cacheDir?: string, fetchImpl?: Function}} [options] */ constructor(config, logger, options) { const opts = options || {}; this.config = config; this.logger = logger || null; this.baseUrl = String(opts.baseUrl || process.env.DYNMAP_BASE || 'http://bgjq.simpfun.cn').replace(/\/+$/, ''); this.world = opts.world || (config && config.bigMap && config.bigMap.world) || process.env.DYNMAP_WORLD || 'minecraft_overworld'; this.cacheDir = opts.cacheDir || path.join(__dirname, '..', '..', 'data', 'mapcache'); this.fetchImpl = opts.fetchImpl || globalThis.fetch; /** @type {Map} 内存 LRU(瓦片体积小,按层级分目录缓存到磁盘为主) */ this._memory = new Map(); this._memoryLimit = 256; /** 爬取进度 */ this.crawl = { running: false, zoom: 0, total: 0, done: 0, failed: 0, startedAt: 0, finishedAt: 0, lastError: '' }; this._crawlAbort = false; /** * 后台预热状态(机器人移动到新区域后顺带拉取周边高层级瓦片)。 * * `lastCenterX/lastCenterZ` 用来判断「机器人是否移动够远」, * `lastAt` 用来兜底「很久没预热就再预热一次」。 */ this.prewarm = { running: false, lastAt: 0, lastCenterX: null, lastCenterZ: null, /** 最近一次预热覆盖的层级范围(展示用) */ minZoom: 0, maxZoom: 0, total: 0, done: 0, failed: 0, finishedAt: 0 }; this._prewarmAbort = false; } // ==================== 坐标系 ==================== /** @returns {number} 当前允许浏览的最大层级 */ get maxZoom() { const configured = this.config && this.config.bigMap ? this.config.bigMap.maxZoom : undefined; return Number.isFinite(configured) ? configured : DEFAULT_MAX_ZOOM; } /** * 层级 z 下每张瓦片覆盖的方块数。 * @param {number} zoom * @returns {number} */ blocksPerTile(zoom) { return TILE_PIXELS * Math.pow(2, this.maxZoom - clampZoom(zoom, this.maxZoom)); } /** * 计算覆盖以 `center` 为中心、半径 `rangeBlocks` 的方形区域所需的瓦片下标范围(闭区间)。 * * 默认中心是原点(全世界),但实际使用时通常只关心**机器人所在位置周边**的图幅 —— * 全世界层级 0 是 100 张瓦片,而 1024 格半径只需 1 张、4096 格半径只需 4 张, * 爬取和传输的开销差一个数量级。 * * @param {number} zoom 层级 * @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置 * @param {number} [centerBlockX] 中心方块 X,缺省 0 * @param {number} [centerBlockZ] 中心方块 Z,缺省 0 * @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number, * centerBlockX: number, centerBlockZ: number, rangeBlocks: number}} */ tileRange(zoom, rangeBlocks, centerBlockX, centerBlockZ) { const radius = Number.isFinite(rangeBlocks) ? Math.abs(rangeBlocks) : (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS); const centerX = Number.isFinite(centerBlockX) ? centerBlockX : 0; const centerZ = Number.isFinite(centerBlockZ) ? centerBlockZ : 0; const perTile = this.blocksPerTile(zoom); const minX = Math.floor((centerX - radius) / perTile); const maxX = Math.ceil((centerX + radius) / perTile) - 1; const minZ = Math.floor((centerZ - radius) / perTile); const maxZ = Math.ceil((centerZ + radius) / perTile) - 1; return { minX, maxX, minZ, maxZ, count: (maxX - minX + 1) * (maxZ - minZ + 1), centerBlockX: centerX, centerBlockZ: centerZ, rangeBlocks: radius }; } /** * 瓦片覆盖的方块区间(闭开区间 `[min, max)`)。 * @param {number} zoom * @param {number} tileX * @param {number} tileZ * @returns {{minBlockX: number, maxBlockX: number, minBlockZ: number, maxBlockZ: number}} */ tileBlockBounds(zoom, tileX, tileZ) { const perTile = this.blocksPerTile(zoom); return { minBlockX: tileX * perTile, maxBlockX: (tileX + 1) * perTile, minBlockZ: tileZ * perTile, maxBlockZ: (tileZ + 1) * perTile }; } /** @returns {string} 某个瓦片的回源地址 */ tileUrl(zoom, tileX, tileZ) { return `${this.baseUrl}/tiles/${encodeURIComponent(this.world)}/${clampZoom(zoom, this.maxZoom)}/${tileX}_${tileZ}.png`; } /** @returns {string} 某个瓦片的磁盘缓存路径 */ tilePath(zoom, tileX, tileZ) { return path.join(this.cacheDir, sanitize(this.world), String(clampZoom(zoom, this.maxZoom)), `${tileX}_${tileZ}.png`); } // ==================== 瓦片读取 ==================== /** * 读取一张瓦片:内存 → 磁盘 → 回源。 * * @param {number} zoom * @param {number} tileX * @param {number} tileZ * @returns {Promise} 瓦片 PNG 数据;失败返回 null */ async getTile(zoom, tileX, tileZ) { const z = clampZoom(zoom, this.maxZoom); const key = `${z}/${tileX}_${tileZ}`; const cached = this._memory.get(key); if (cached) return cached; const file = this.tilePath(z, tileX, tileZ); try { const data = fs.readFileSync(file); if (data.length > 0) { this._remember(key, data); return data; } } catch { // 未缓存,继续回源 } return this._download(z, tileX, tileZ); } /** 从源站下载并落盘;失败返回 null(控制台会画占位色块) */ async _download(zoom, tileX, tileZ) { const url = this.tileUrl(zoom, tileX, tileZ); const controller = typeof AbortController === 'function' ? new AbortController() : null; const timer = controller ? setTimeout(() => controller.abort(), TILE_TIMEOUT_MS) : null; try { const response = await this.fetchImpl(url, { signal: controller ? controller.signal : undefined, headers: { 'User-Agent': 'Mozilla/5.0 autostore-node/1.0', Accept: 'image/png,image/*,*/*' } }); if (!response.ok) return null; const buffer = Buffer.from(await response.arrayBuffer()); if (buffer.length === 0) return null; this._remember(`${zoom}/${tileX}_${tileZ}`, buffer); this._writeCache(zoom, tileX, tileZ, buffer); return buffer; } catch { return null; } finally { if (timer) clearTimeout(timer); } } /** 落盘缓存(失败静默:缓存只是加速手段,不影响功能) */ _writeCache(zoom, tileX, tileZ, buffer) { try { const file = this.tilePath(zoom, tileX, tileZ); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, buffer); } catch { // 忽略 } } /** 写入内存 LRU(简单实现:超限时丢掉最早插入的若干项) */ _remember(key, buffer) { this._memory.set(key, buffer); while (this._memory.size > this._memoryLimit) { const oldest = this._memory.keys().next(); if (oldest.done) break; this._memory.delete(oldest.value); } } // ==================== 批量爬取 ==================== /** * 批量爬取某一层级、某一区域内的全部瓦片(已缓存则跳过)。 * * 区域默认以原点为中心;实际使用时通常传机器人所在坐标,只爬它周边那几平方公里, * 这样图幅可以自由调整而不用把整个世界都拉下来。 * * 重复调用时若已有爬取在途则直接返回当前进度,不会并发起多轮。 * * @param {number} [zoom] 层级,缺省取配置里的 bigMap.zoom * @param {{force?: boolean, centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options] * @returns {Promise} 爬取结果统计 */ async crawlAll(zoom, options) { if (this.crawl.running) return this.snapshot().crawl; const opts = options || {}; const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom); const range = this.tileRange(z, opts.rangeBlocks, opts.centerBlockX, opts.centerBlockZ); this._crawlAbort = false; this.crawl = { running: true, zoom: z, total: range.count, done: 0, failed: 0, startedAt: Date.now(), finishedAt: 0, lastError: '', centerBlockX: range.centerBlockX, centerBlockZ: range.centerBlockZ, rangeBlocks: range.rangeBlocks }; if (this.logger) { this.logger.log(`开始爬取大地图瓦片:层级 ${z},中心 ${range.centerBlockX},${range.centerBlockZ},` + `半径 ${range.rangeBlocks} 格,瓦片 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`); } const tasks = []; for (let x = range.minX; x <= range.maxX; x++) { for (let y = range.minZ; y <= range.maxZ; y++) tasks.push([x, y]); } const concurrency = Math.max(1, Math.min(16, (this.config.bigMap && this.config.bigMap.concurrency) || 4)); let cursor = 0; const worker = async () => { while (cursor < tasks.length && !this._crawlAbort) { const task = tasks[cursor++]; const buffer = await this._fetchForCrawl(z, task[0], task[1], opts.force === true); if (buffer) this.crawl.done++; else this.crawl.failed++; } }; const workers = []; for (let i = 0; i < concurrency; i++) workers.push(worker()); await Promise.all(workers); this.crawl.running = false; this.crawl.finishedAt = Date.now(); if (this.logger) { const seconds = ((this.crawl.finishedAt - this.crawl.startedAt) / 1000).toFixed(1); this.logger.log(`大地图瓦片爬取完成:成功 ${this.crawl.done} / 失败 ${this.crawl.failed},用时 ${seconds} 秒`, 'success'); } return this.snapshot().crawl; } /** 爬取单张瓦片:非强制模式下已缓存则直接计入成功 */ async _fetchForCrawl(zoom, tileX, tileZ, force) { if (!force) { try { if (fs.statSync(this.tilePath(zoom, tileX, tileZ)).size > 0) return true; } catch { // 未缓存 } } return (await this._download(zoom, tileX, tileZ)) !== null; } /** 中断进行中的爬取 */ abortCrawl() { this._crawlAbort = true; } // ==================== 后台预热 ==================== /** * 后台低优先级预热:机器人移动到新区域后,顺带把周边的高层级瓦片拉下来。 * * 只有「启动预爬取层级」(默认 0,全世界 10×10 张)会批量爬,层级 1~3 原本 * 要靠用户缩放时按需拉取 —— 首次浏览某个区域要等好几秒。预热就是用空闲带宽 * 把「以机器人为中心、半径 prewarmRangeBlocks」的较高层级补下来。 * * 节流规则(避免频繁骚扰源站): * - 已有批量爬取或预热在途 → 直接跳过; * - 机器人移动不足 `prewarmMinMoveBlocks` 且距上次预热不足 `prewarmIntervalMs` → 跳过; * - 并发取批量爬取的一半(低优先级),已缓存的瓦片只会做一次 statSync。 * * @param {number} centerBlockX * @param {number} centerBlockZ * @returns {Promise} 预热结果(跳过关时返回 `{skipped: 原因}`) */ async prewarmAround(centerBlockX, centerBlockZ) { const mapConfig = (this.config && this.config.bigMap) || {}; if (mapConfig.prewarm === false) return { skipped: 'disabled' }; if (!Number.isFinite(centerBlockX) || !Number.isFinite(centerBlockZ)) return { skipped: 'no-position' }; if (this.crawl.running || this.prewarm.running) return { skipped: 'busy' }; const now = Date.now(); const minMove = Number.isFinite(mapConfig.prewarmMinMoveBlocks) ? mapConfig.prewarmMinMoveBlocks : 512; const interval = Number.isFinite(mapConfig.prewarmIntervalMs) ? mapConfig.prewarmIntervalMs : 600000; const moved = this.prewarm.lastCenterX === null ? Infinity : Math.hypot(centerBlockX - this.prewarm.lastCenterX, centerBlockZ - this.prewarm.lastCenterZ); if (moved < minMove && now - this.prewarm.lastAt < interval) return { skipped: 'recent' }; const baseZoom = clampZoom(mapConfig.zoom || 0, this.maxZoom); const levels = Number.isFinite(mapConfig.prewarmLevels) ? Math.max(0, mapConfig.prewarmLevels) : 3; const lastZoom = Math.min(this.maxZoom, baseZoom + levels); if (lastZoom <= baseZoom) return { skipped: 'no-levels' }; const radius = Number.isFinite(mapConfig.prewarmRangeBlocks) ? mapConfig.prewarmRangeBlocks : 1024; const batchConcurrency = Math.max(1, Math.min(16, mapConfig.concurrency || 4)); const concurrency = Math.max(1, Math.floor(batchConcurrency / 2)); this.prewarm.running = true; this.prewarm.lastAt = now; this.prewarm.lastCenterX = centerBlockX; this.prewarm.lastCenterZ = centerBlockZ; this.prewarm.minZoom = baseZoom + 1; this.prewarm.maxZoom = lastZoom; this._prewarmAbort = false; let done = 0; let failed = 0; let total = 0; const startedAt = Date.now(); try { for (let z = baseZoom + 1; z <= lastZoom; z++) { const range = this.tileRange(z, radius, centerBlockX, centerBlockZ); total += range.count; const tasks = []; for (let x = range.minX; x <= range.maxX; x++) { for (let y = range.minZ; y <= range.maxZ; y++) tasks.push([x, y]); } let cursor = 0; const worker = async () => { while (cursor < tasks.length && !this._prewarmAbort) { const task = tasks[cursor++]; if (await this._fetchForCrawl(z, task[0], task[1], false)) done++; else failed++; } }; const workers = []; for (let i = 0; i < concurrency; i++) workers.push(worker()); await Promise.all(workers); } } finally { this.prewarm.running = false; this.prewarm.total = total; this.prewarm.done = done; this.prewarm.failed = failed; this.prewarm.finishedAt = Date.now(); } if (this.logger && total > 0) { const seconds = ((this.prewarm.finishedAt - startedAt) / 1000).toFixed(1); this.logger.log(`大地图预热完成:层级 ${baseZoom + 1}~${lastZoom} 周边 ${total} 张,` + `成功 ${done} / 失败 ${failed},用时 ${seconds} 秒`); } return Object.assign({}, this.prewarm); } /** 中断进行中的预热 */ abortPrewarm() { this._prewarmAbort = true; } // ==================== 状态 ==================== /** * @param {{centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options] * @returns {object} 大地图状态快照(供控制台与前端渲染使用) */ snapshot(options) { const opts = options || {}; const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0; const centerX = Number.isFinite(opts.centerBlockX) ? opts.centerBlockX : 0; const centerZ = Number.isFinite(opts.centerBlockZ) ? opts.centerBlockZ : 0; const range = this.tileRange(zoom, opts.rangeBlocks, centerX, centerZ); return { enabled: !!(this.config && this.config.bigMap && this.config.bigMap.enabled), baseUrl: this.baseUrl, world: this.world, tilePixels: TILE_PIXELS, maxZoom: this.maxZoom, zoom, blocksPerTile: this.blocksPerTile(zoom), rangeBlocks: range.rangeBlocks, followBot: !!(this.config && this.config.bigMap && this.config.bigMap.followBot), tiles: range, cached: this.countCached(zoom), source: { tileUrlTemplate: `${this.baseUrl}/tiles/${this.world}/{z}/{x}_{y}.png`, markersUrl: `${this.baseUrl}/tiles/${this.world}/markers.json` }, crawl: Object.assign({}, this.crawl), /** 后台预热状态:最近一次覆盖的层级、成功 / 失败数与上次位置 */ prewarm: Object.assign({}, this.prewarm) }; } /** * 统计某层级已缓存的瓦片数量。 * @param {number} zoom * @returns {number} */ countCached(zoom) { const dir = path.join(this.cacheDir, sanitize(this.world), String(clampZoom(zoom, this.maxZoom))); try { return fs.readdirSync(dir).filter((name) => name.endsWith('.png')).length; } catch { return 0; } } /** * 清空磁盘与内存缓存。 * @returns {number} 删除的瓦片数 */ clearCache() { let removed = 0; const root = path.join(this.cacheDir, sanitize(this.world)); try { removed = countFiles(root); fs.rmSync(root, { recursive: true, force: true }); } catch { // 忽略 } this._memory.clear(); return removed; } } /** 递归统计目录下的文件数 */ function countFiles(dir) { let total = 0; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) total += countFiles(path.join(dir, entry.name)); else total++; } return total; } /** 世界名可能含路径分隔符,落盘前统一安全化 */ function sanitize(name) { return String(name).replace(/[\\/:*?"<>|]/g, '_') || 'world'; } function clampZoom(zoom, max) { const n = Number(zoom); if (!Number.isFinite(n)) return 0; return Math.min(max, Math.max(0, Math.trunc(n))); } module.exports = { BigMapService, TILE_PIXELS, DEFAULT_MAX_ZOOM, DEFAULT_RANGE_BLOCKS, TILE_TIMEOUT_MS };