按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。 - 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、 出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行) - 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标 - 新增目标区块租约表(targetLease)避免多机器人撞同一块 - 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier) - 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索 顺带修掉两个影响部署的细节: - 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 —— 在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全; 显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。 - package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0, 与 CHANGELOG 的语义化版本声明脱节)。 测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
515 行
19 KiB
JavaScript
515 行
19 KiB
JavaScript
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
/**
|
||
* 大地图瓦片服务:爬取 squaremap 站点(bgjq.simpfun.cn)的地图图片并本地缓存,
|
||
* 供控制台在一张大地图上展示所有机器人的坐标与领土标记。
|
||
*
|
||
* ## 坐标系(已对 source 站点实测标定)
|
||
*
|
||
* squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
|
||
* `tiles/<world>/<z>/<x>_<y>.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/<world>/<z>/<x>_<y>.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<string, Buffer>} 内存 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<Buffer|null>} 瓦片 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<object>} 爬取结果统计
|
||
*/
|
||
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<object>} 预热结果(跳过关时返回 `{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
|
||
};
|