diff --git a/CHANGELOG.md b/CHANGELOG.md index 447888d..f1f261c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ 本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.4.0] - 2026-09-27 + +大地图改为跟随机器人、支持可调图幅;新增定时重复发送消息;控制台界面重做并适配移动端。 + +### 新增 + +- **定时重复发送消息 / 指令**(`src/bot/scheduledMessenger.js`) + - 每个机器人可配置多条定时任务,按固定间隔重复发送同一条内容, + 例如每 300 秒发一次 `/u restore confirm` 维持疆土民心 + - 以 `/` 开头的内容自动按游戏指令发送,否则按公屏消息发送 + - 只在「已连接 + 已通过登录验证」时才发;未就绪的周期直接顺延、**不补发**, + 避免重连后一次性连发多条 + - 与指令节流队列共用,不会绕过 `server.commandCooldownMs` 刷屏 + - 控制台新增「定时消息」卡片:新增 / 删除 / 启用停用,并显示距下次发送的剩余秒数 + - 接口:`GET|POST /api/bots/:id/schedules` +- **大地图跟随机器人 + 可调图幅** + - 爬取与显示区域改为**以机器人当前位置为中心**(`bigMap.followBot`,默认开启), + 不再傻傻地爬世界原点周边 + - 界面新增「图幅」下拉框(±512 / ±1024 / ±2048 / ±4096 / ±8192 / 全世界), + 切换后即时重新对焦并拉取对应范围 + - 新增「爬取周边」按钮:只爬当前图幅内的瓦片。 + 层级 0 每张瓦片覆盖 4096 格,所以 ±4096 只需 **9 张**,而全世界要 100 张 + - `POST /api/bigmap/crawl` 支持 `centerX` / `centerZ` / `rangeBlocks`; + `GET /api/bigmap` 支持 `rangeBlocks` 与 `bot` 查询参数 + - 启动预爬取会先等机器人进入世界,以它的位置为中心;等不到才退回世界原点 +- **领土地图以机器人所在邦国为中心** + - 之前初始视图用的是控制器的 `playerChunk`,而任务启动前它是 (0,0), + 导致地图永远停在世界原点附近 —— 完全不是用户关心的位置 + - 现在按「机器人所在区块 → 包含它的邦国 → 该邦国王城 / 外接矩形」定位 +- **控制台界面重做 + 移动端适配** + - 重新设计的深色主题:统一圆角、阴影、焦点态与悬停态,卡片式分层 + - 响应式三档:≥1200px 三栏;≤1200px 两栏(配置栏落到下方并自动分列);≤820px 单栏 + - 移动端:底部导航(状态 / 地图 / 日志 / 配置)一键定位、触控目标放大到 ≥36px、 + 地图与日志高度按视口自适应、避让底部安全区、Toast 全宽 + - 横屏手机压矮地图高度;尊重系统「减少动效」偏好 + - 「添加玩家」折叠进 details,减少左栏长度 + - 顶栏在窄屏下自动换行,按钮等分排布 + +### 变更 + +- 大地图默认图幅从「全世界 ±20480」改为「±4096 格」,默认预爬取瓦片数从 100 张降到 9 张 +- 「回到机器人」保留并修正:没有坐标时给出明确提示,而不是静默无反应 + +### 修复 + +- `POST /api/bigmap/crawl` 的响应把中心与图幅字段传错(实际爬取是对的,但返回值显示成世界原点) +- 定时任务保存后接口返回的是原始列表而非归一化结果,导致前端拿不到「剩余秒数」 + ## [1.3.0] - 2026-09-27 按线上实测结果修复「机器人走不动」,并移植旧版机器人已验证的关键补包。 diff --git a/README.md b/README.md index 5996737..0bd6cc8 100644 --- a/README.md +++ b/README.md @@ -437,13 +437,59 @@ npm start 打开控制台后: - **顶栏**:机器人下拉选择、连接 / 任务徽标、控制台登录状态、开始扩地 / 停止 / 重连 -- **左栏**:机器人列表(状态 / 坐标 / 任务 / 已开拓数)、连接、任务、领土数据源、Grim 合规 +- **左栏**:机器人列表、连接、任务、**定时消息**、领土数据源、Grim 合规 - **中栏**: - 地图页签 —— **大地图**(瓦片 + 领土多边形 + 全部机器人坐标)与 **领土地图**(区块级) - 日志页签 —— **运行日志** / **公屏聊天** / **开拓日志** -- **右栏**:8 个标签页的配置表单(可切换「仅对当前机器人生效」)+ 控制台 / 聊天框 +- **右栏**:控制台 / 聊天框(含移动测试)+ 8 个标签页的配置表单 -地图图层:己方领土(蓝)、本次占领(绿)、飞地疏漏(红)、当前目标(黄)、机器人(白点 / 黄点)、王城(紫)。 +### 响应式与移动端 + +| 宽度 | 布局 | +| --- | --- | +| ≥1200px | 三栏(状态 / 地图日志 / 配置) | +| ≤1200px | 两栏,配置栏落到下方并自动分列 | +| ≤820px | 单栏 + 底部导航(状态 / 地图 / 日志 / 配置一键定位) | + +移动端额外处理:触控目标放大到 ≥36px、地图与日志高度按视口自适应、 +避让底部安全区(`env(safe-area-inset-bottom)`)、横屏时压矮地图高度、 +尊重系统「减少动效」偏好。 + +### 定时重复发送消息 + +挂机时常需要周期性发同一条指令(例如每 300 秒 `/u restore confirm` 维持疆土民心)。 +左栏「定时消息」卡片里点「+ 新增」,填内容与间隔即可;以 `/` 开头按游戏指令发送,否则按公屏消息发送。 + +实现上刻意**不用**朴素的 `setInterval`: + +- 只在「已连接 + 已通过登录验证」时才发送; +- 未就绪的周期直接顺延,**不补发** —— 否则重连后会把漏掉的几条一次性连发出去; +- 与指令节流队列共用,不会绕过 `server.commandCooldownMs` 变成刷屏; +- 间隔下限 5 秒、单个机器人最多 20 条,防止误配置把账号玩进黑名单。 + +--- + +## 大地图 + +`src/core/bigMapService.js` 爬取服务器地图站点(本项目部署的是 +[squaremap](https://github.com/jpenilla/squaremap))的瓦片图片与领土标记, +把所有机器人画在同一张图上。 + +### 以机器人为中心 + 可调图幅 + +大地图的用途是看**自己机器人周边**,所以默认以机器人当前位置为中心(`bigMap.followBot`)。 +界面上的「图幅」下拉框决定半径: + +| 图幅 | 层级 0 需要的瓦片数 | +| --- | --- | +| ±512 格 | 1 | +| ±1024 格 | 1~4 | +| ±4096 格(默认) | 9 | +| ±8192 格 | 25 | +| 全世界(±20480) | 100 | + +层级 0 每张瓦片覆盖 4096 格,所以「只看周边」比「拉全世界」省一到两个数量级。 +点「爬取周边」只会爬当前图幅内的瓦片;「回到机器人」把视图拉回机器人所在位置。 ### REST / WebSocket 接口 @@ -456,6 +502,8 @@ npm start | GET | `/api/bots/:id/status` | 单个机器人状态 | | GET | `/api/bots/:id/settings` | 单个机器人的设置与生效配置 | | POST | `/api/bots/:id/settings` | 保存备注名 / 启用 / 自动开始 / 配置覆盖 | +| GET | `/api/bots/:id/schedules` | 该机器人的定时消息列表(含距下次发送的秒数) | +| POST | `/api/bots/:id/schedules` | 整体替换该机器人的定时消息列表 | | POST | `/api/bots/:id/task/start` | 启动该机器人的扩地(body 为配置覆盖) | | POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 | | POST | `/api/bots/:id/reconnect` | 重连该机器人 | @@ -469,10 +517,10 @@ npm start | GET | `/api/expansion-log` | 开拓日志(`limit` / `botId` / `state` / `since`)+ 统计 | | POST | `/api/expansion-log/export` | 导出开拓日志 CSV 到 `data/` | | POST | `/api/expansion-log/clear` | 清空内存中的开拓日志(磁盘保留) | -| GET | `/api/bigmap` | 大地图元信息(坐标系、瓦片范围、缓存进度、机器人坐标、领土标记) | +| GET | `/api/bigmap` | 大地图元信息(坐标系、图幅、瓦片范围、缓存进度、机器人坐标、领土标记);支持 `rangeBlocks` / `bot` 参数 | | GET | `/api/bigmap/markers` | 大地图领土标记(多边形 + 王城) | | GET | `/api/bigmap/tile/:z/:x/:y` | 瓦片代理(缓存优先,避免跨域) | -| POST | `/api/bigmap/crawl` | 手动触发批量爬取瓦片 | +| POST | `/api/bigmap/crawl` | 爬取瓦片;默认爬机器人周边,支持 `centerX` / `centerZ` / `rangeBlocks` | | POST | `/api/bigmap/cache/clear` | 清空瓦片缓存 | | GET | `/api/config` | 全局配置 | | POST | `/api/config` | 保存并立即应用全局配置 | @@ -499,8 +547,8 @@ npm start | 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 | | 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 | | 数据 | 地图刷新间隔、SimpCore API | -| 大地图 | 开关、世界名、预爬取层级、最大层级、覆盖半径、启动预爬取、下载并发数 | -| Grim | 合规层全部阈值(见上) | +| 大地图 | 开关、世界名、预爬取层级、最大层级、**图幅半径、是否跟随机器人**、启动预爬取、下载并发数 | +| Grim | 合规层全部阈值 + 协议补包开关(见上) | | 服务器 | 开垦模式指令、登录 / 注册模板、登录确认超时、指令节流、重连 | --- @@ -534,9 +582,10 @@ npm run dev # 文件变更自动重启 确认兜底超时、公屏消息类型映射 - 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出 - 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出 -- 大地图:坐标系换算(含对源站实测标定的王城用例)、瓦片范围、缓存命中、批量爬取、缓存清理 -- Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、瓦片代理、 - 令牌与登录鉴权、WebSocket 握手鉴权 +- 大地图:坐标系换算(含对源站实测标定的王城用例)、**以机器人为中心的图幅换算**、瓦片范围、缓存命中、批量爬取、缓存清理 +- 定时消息:内容归一化与间隔钳制、到点发送与按周期重复、未就绪时顺延不补发、多任务独立计时、整体替换重置计时 +- Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、定时任务接口、 + 图幅与中心计算、瓦片代理、令牌与登录鉴权、WebSocket 握手鉴权 ### 目录结构 @@ -561,7 +610,8 @@ src/ ├── bot/ │ ├── botPool.js ★ 机器人池:同时挂多个玩家 │ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送 -│ ├── grimCompat.js ★ Grim 反作弊合规层 +│ ├── scheduledMessenger.js ★ 定时重复发送消息 / 指令 +│ ├── grimCompat.js ★ Grim 反作弊合规层 + 协议补包(player_input / tick_end) │ ├── pathingService.js 寻路服务(替代 Baritone) │ ├── autoCombat.js 战斗筛选与耐久检查 │ ├── autoEat.js 自动进食 diff --git a/src/bot/botPool.js b/src/bot/botPool.js index 39c4dcb..c796dd0 100644 --- a/src/bot/botPool.js +++ b/src/bot/botPool.js @@ -3,6 +3,7 @@ const { EventEmitter } = require('events'); const { BotManager } = require('./botManager'); +const { ScheduledMessenger } = require('./scheduledMessenger'); const { ExpansionController } = require('../controller/expansionController'); const { normalizeConfig } = require('../config/expansionConfig'); const { sanitizeId } = require('../config/botsConfig'); @@ -130,11 +131,16 @@ class BotPool extends EventEmitter { manager.on('spawn', () => this.emit('botSpawn', id)); manager.on('end', () => this.emit('botEnd', id)); + // 定时消息器:按固定间隔重复发送同一条内容(如每 300 秒 /u restore confirm) + const messenger = new ScheduledMessenger(manager, this.logger); + messenger.setTasks(setting.schedules || []); + this.entries.set(id, { id, connection, manager, controller, + messenger, /** 账号来源:env(.env 配置)/ store(控制台新增) */ source: connection.source || 'env', /** 该机器人实际生效的配置(全局 + 覆盖) */ @@ -190,7 +196,10 @@ class BotPool extends EventEmitter { const entry = this._addBot(connection); if (!this.defaultId) this.defaultId = id; this._log(`已添加玩家 ${username}(密码保存在服务器本地凭据文件,权限 0600)`); - if (opts.connect !== false && this._settingOf(id).enabled) entry.manager.connect(); + if (opts.connect !== false && this._settingOf(id).enabled) { + entry.messenger.start(); + entry.manager.connect(); + } return entry; } @@ -209,6 +218,7 @@ class BotPool extends EventEmitter { throw badRequest(`${entry.connection.username} 是在 .env 的 MC_BOTS 里配置的,` + '请修改 .env 后重启;控制台只能移除在这里添加的玩家'); } + entry.messenger.stop(); entry.manager.disconnect(); this.entries.delete(id); delete this.settings[id]; @@ -234,6 +244,7 @@ class BotPool extends EventEmitter { this._log(`${entry.connection.username} 已在控制台禁用,跳过连接`); continue; } + entry.messenger.start(); entry.manager.connect(); } } @@ -246,6 +257,7 @@ class BotPool extends EventEmitter { } catch { // 忽略 } + entry.messenger.stop(); entry.manager.disconnect(); } } @@ -301,13 +313,14 @@ class BotPool extends EventEmitter { // ==================== 配置 ==================== - /** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 配置覆盖) */ + /** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 定时任务 / 配置覆盖) */ _settingOf(id) { const raw = this.settings[id] || {}; return { enabled: raw.enabled !== false, autoStart: raw.autoStart === true, label: raw.label || '', + schedules: Array.isArray(raw.schedules) ? raw.schedules : [], config: raw.config && typeof raw.config === 'object' ? raw.config : {} }; } @@ -356,6 +369,13 @@ class BotPool extends EventEmitter { entry.config = this.getConfigFor(id); entry.manager.applyConfig(entry.config); entry.controller.config = entry.config; + // 定时任务列表整体替换:把归一化结果写回设置, + // 保证「接口返回值 / 落盘内容 / 运行时状态」三者一致 + if (patch && patch.schedules !== undefined) { + const normalized = entry.messenger.setTasks(this._settingOf(id).schedules); + this.settings[id] = Object.assign({}, this.settings[id], { schedules: normalized }); + this._persistSettings(); + } return this._settingOf(id); } @@ -368,10 +388,20 @@ class BotPool extends EventEmitter { enabled: setting.enabled, autoStart: setting.autoStart, hasOverrides: Object.keys(setting.config).length > 0, - config: this.getConfigFor(id) + config: this.getConfigFor(id), + schedules: this.entry(id) ? this.entry(id).messenger.snapshot() : [] }; } + /** + * 取运行时条目(不存在时返回 null,不抛异常)。 + * @param {string} id + * @returns {object|null} + */ + entry(id) { + return this.entries.get(id) || null; + } + _persistSettings() { if (!this.botStore) return; this.botStore.save({ bots: this.settings }); @@ -486,7 +516,8 @@ class BotPool extends EventEmitter { enabled: setting.enabled, autoStart: setting.autoStart, source: entry.source, - task: entry.controller.snapshot() + task: entry.controller.snapshot(), + schedules: entry.messenger.snapshot() })); } return bots; diff --git a/src/bot/scheduledMessenger.js b/src/bot/scheduledMessenger.js new file mode 100644 index 0000000..f8cf0b7 --- /dev/null +++ b/src/bot/scheduledMessenger.js @@ -0,0 +1,166 @@ +'use strict'; + +/** 单个任务的间隔下限(秒):再短就近似刷屏,容易被服务器或反作弊盯上 */ +const MIN_INTERVAL_SECONDS = 5; +/** 单个机器人最多允许的定时任务数 */ +const MAX_TASKS = 20; +/** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */ +const TICK_INTERVAL_MS = 1000; + +/** + * 定时消息器:按固定间隔重复发送同一条内容(公屏消息或游戏指令)。 + * + * 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm` + * 来维持疆土民心。这类「心跳式」指令是挂机机器人的刚需, + * 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发, + * 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。 + * + * 因此这里做成: + * - 每个任务独立计时,按「上次成功发送时间 + 间隔」判断是否到点; + * - 只在 `canOperate()`(已连接 + 已通过登录验证)时才发; + * - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条); + * - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。 + */ +class ScheduledMessenger { + /** + * @param {import('./botManager').BotManager} manager + * @param {import('../util/logger').Logger} [logger] + */ + constructor(manager, logger) { + this.manager = manager; + this.logger = logger || null; + /** @type {Array} 定时任务列表 */ + this.tasks = []; + /** @type {Map} 任务 id → 下次应发送的时间戳 */ + this._nextDueAt = new Map(); + this._timer = null; + } + + /** + * 设置任务列表(整体替换),并重置各任务的计时。 + * + * @param {Array} tasks 原始任务定义 + * @param {number} [now] 当前时间戳(默认取系统时间;测试里注入固定值即可不必真等) + * @returns {Array} 归一化后的任务列表 + */ + setTasks(tasks, now) { + this.tasks = normalizeTasks(tasks); + this._nextDueAt.clear(); + const current = Number.isFinite(now) ? now : Date.now(); + for (const task of this.tasks) { + // 首次不立即发送:给机器人留出进入世界与登录的时间 + this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + } + return this.tasks; + } + + /** 启动调度心跳 */ + start() { + if (this._timer) return; + this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS); + if (this._timer.unref) this._timer.unref(); + } + + /** 停止调度心跳 */ + stop() { + if (this._timer) clearInterval(this._timer); + this._timer = null; + } + + /** + * 心跳:把所有到点的任务发出去。 + * + * 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间, + * 不必真的等 300 秒。 + * + * @param {number} [now] 当前时间戳(默认取系统时间) + * @returns {number} 本次实际发出的条数 + */ + tick(now) { + const current = Number.isFinite(now) ? now : Date.now(); + let sent = 0; + for (const task of this.tasks) { + if (!task.enabled) continue; + const due = this._nextDueAt.get(task.id); + if (due === undefined) { + this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + continue; + } + if (current < due) continue; + + // 未就绪就顺延到下一个周期,不补发 + if (!this.manager.canSendChat()) { + this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + continue; + } + const ok = this.manager.sendChat(task.content); + this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + if (ok) { + sent++; + this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`); + } + } + return sent; + } + + /** @returns {object} 供控制台展示的状态快照 */ + snapshot() { + const now = Date.now(); + return this.tasks.map((task) => { + const due = this._nextDueAt.get(task.id); + return Object.assign({}, task, { + nextDueAt: due || 0, + nextInSeconds: due ? Math.max(0, Math.round((due - now) / 1000)) : null + }); + }); + } + + _log(message) { + if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`); + } +} + +/** + * 归一化任务列表:丢弃非法项、补齐默认值、限制数量。 + * + * @param {Array} tasks + * @returns {Array} + */ +function normalizeTasks(tasks) { + if (!Array.isArray(tasks)) return []; + const result = []; + const seen = new Set(); + for (let i = 0; i < tasks.length && result.length < MAX_TASKS; i++) { + const raw = tasks[i]; + if (!raw || typeof raw !== 'object') continue; + const content = String(raw.content === undefined || raw.content === null ? '' : raw.content).trim(); + if (!content) continue; + let id = String(raw.id || '').trim(); + if (!id || seen.has(id)) id = `task-${i + 1}`; + while (seen.has(id)) id += '_'; + seen.add(id); + result.push({ + id, + label: String(raw.label || '').trim(), + content, + enabled: raw.enabled !== false, + intervalSeconds: clampInterval(raw.intervalSeconds), + /** 以 `/` 开头的内容会被 sendChat 自动转成指令 */ + kind: content.startsWith('/') ? 'command' : 'chat' + }); + } + return result; +} + +/** + * 把间隔钳制到合理范围。 + * @param {*} value + * @returns {number} + */ +function clampInterval(value) { + const n = Number(value); + if (!Number.isFinite(n)) return 300; + return Math.min(86400, Math.max(MIN_INTERVAL_SECONDS, Math.trunc(n))); +} + +module.exports = { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS }; diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index 3534d63..6e3b684 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -231,8 +231,20 @@ function defaultConfig() { zoom: 0, /** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */ maxZoom: 3, - /** 以原点为中心的覆盖半径(方块),默认与服务器世界边界 ±20480 一致 */ - rangeBlocks: 20480, + /** + * 爬取半径(方块),**以机器人为中心**(见 followBot)。 + * + * 层级 0 每张瓦片覆盖 4096 格,所以: + * 1024 格半径 = 1 张瓦片,4096 格 = 4 张,8192 格 = 9 张,20480 格 = 100 张。 + * 只关心机器人周边时把半径调小,爬取和传输开销会差一个数量级。 + */ + rangeBlocks: 4096, + /** + * 是否以机器人当前位置为中心爬取与显示大地图(默认开启)。 + * + * 关闭时以世界原点 (0,0) 为中心 —— 只有在想看整张世界地图时才需要。 + */ + followBot: true, /** 启动时是否自动预爬取 */ crawlOnStart: true, /** 瓦片下载并发数(对源站友好,默认 4) */ @@ -390,9 +402,10 @@ function validateConfig(config) { config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3); // 预爬取层级不可能超过可浏览的最大层级,否则会爬到源站不存在的瓦片 if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom; - config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 20480); + config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096); config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4); if (typeof config.bigMap.world !== 'string') config.bigMap.world = ''; + if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true; config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200); config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000); diff --git a/src/core/bigMapService.js b/src/core/bigMapService.js index d348bf3..e731b42 100644 --- a/src/core/bigMapService.js +++ b/src/core/bigMapService.js @@ -86,22 +86,40 @@ class BigMapService { } /** - * 计算覆盖 `[-rangeBlocks, rangeBlocks)` 区间所需的瓦片下标范围(闭区间)。 + * 计算覆盖以 `center` 为中心、半径 `rangeBlocks` 的方形区域所需的瓦片下标范围(闭区间)。 + * + * 默认中心是原点(全世界),但实际使用时通常只关心**机器人所在位置周边**的图幅 —— + * 全世界层级 0 是 100 张瓦片,而 1024 格半径只需 1 张、4096 格半径只需 4 张, + * 爬取和传输的开销差一个数量级。 * * @param {number} zoom 层级 * @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置 - * @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number}} + * @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) { + 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(-radius / perTile); - const maxX = Math.ceil(radius / perTile) - 1; - const minZ = minX; - const maxZ = maxX; - return { minX, maxX, minZ, maxZ, count: (maxX - minX + 1) * (maxZ - minZ + 1) }; + 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 + }; } /** @@ -210,19 +228,22 @@ class BigMapService { // ==================== 批量爬取 ==================== /** - * 批量爬取某一层级覆盖范围内的全部瓦片(已缓存则跳过)。 + * 批量爬取某一层级、某一区域内的全部瓦片(已缓存则跳过)。 + * + * 区域默认以原点为中心;实际使用时通常传机器人所在坐标,只爬它周边那几平方公里, + * 这样图幅可以自由调整而不用把整个世界都拉下来。 * * 重复调用时若已有爬取在途则直接返回当前进度,不会并发起多轮。 * * @param {number} [zoom] 层级,缺省取配置里的 bigMap.zoom - * @param {{force?: boolean}} [options] force=true 时忽略磁盘缓存重新下载 + * @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 z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom); const opts = options || {}; - const range = this.tileRange(z); + 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, @@ -232,10 +253,14 @@ class BigMapService { failed: 0, startedAt: Date.now(), finishedAt: 0, - lastError: '' + lastError: '', + centerBlockX: range.centerBlockX, + centerBlockZ: range.centerBlockZ, + rangeBlocks: range.rangeBlocks }; if (this.logger) { - this.logger.log(`开始爬取大地图瓦片:层级 ${z},范围 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`); + this.logger.log(`开始爬取大地图瓦片:层级 ${z},中心 ${range.centerBlockX},${range.centerBlockZ},` + + `半径 ${range.rangeBlocks} 格,瓦片 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`); } const tasks = []; @@ -285,11 +310,15 @@ class BigMapService { // ==================== 状态 ==================== /** + * @param {{centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options] * @returns {object} 大地图状态快照(供控制台与前端渲染使用) */ - snapshot() { + snapshot(options) { + const opts = options || {}; const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0; - const range = this.tileRange(zoom); + 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, @@ -298,7 +327,8 @@ class BigMapService { maxZoom: this.maxZoom, zoom, blocksPerTile: this.blocksPerTile(zoom), - rangeBlocks: this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS, + rangeBlocks: range.rangeBlocks, + followBot: !!(this.config && this.config.bigMap && this.config.bigMap.followBot), tiles: range, cached: this.countCached(zoom), source: { diff --git a/src/index.js b/src/index.js index e210bf5..f58627b 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,8 @@ const { WebConsole } = require('./web/server'); /** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */ const TICK_INTERVAL_MS = 50; +/** 启动时等待机器人进入世界的上限(毫秒),超时后大地图退回以世界原点为中心 */ +const WAIT_FOR_BOT_MS = 60000; async function main() { const logger = new Logger({ echo: true }); @@ -116,13 +118,10 @@ async function main() { dataService.start(config.refreshIntervalSeconds); pool.start(); - // 大地图:启动时预爬取低层级瓦片(全世界一张大图),保证控制台打开就能看到 + // 大地图:启动时预爬取「机器人所在位置周边」的瓦片。 + // 机器人还没进世界时先等一会儿 —— 以世界原点为中心爬是没意义的。 if (config.bigMap.enabled && config.bigMap.crawlOnStart) { - setTimeout(() => { - bigMap.crawlAll(config.bigMap.zoom).catch((err) => { - logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn'); - }); - }, 3000).unref?.(); + scheduleInitialCrawl(pool, bigMap, config, logger); } const tickTimer = setInterval(() => { @@ -161,6 +160,39 @@ async function main() { }); } +/** + * 启动时预爬取大地图瓦片:优先等机器人进入世界,以它的位置为中心。 + * + * 大地图的用途是看自己机器人周边,所以「以世界原点为中心爬」几乎总是爬错地方。 + * 这里最多等 `WAIT_FOR_BOT_MS`,等到任意一个机器人有坐标就用它当中心; + * 等不到(比如机器人被禁用)再退回世界原点,保证控制台至少不是空白。 + * + * @param {import('./bot/botPool').BotPool} pool + * @param {import('./core/bigMapService').BigMapService} bigMap + * @param {object} config + * @param {import('./util/logger').Logger} logger + */ +function scheduleInitialCrawl(pool, bigMap, config, logger) { + const deadline = Date.now() + WAIT_FOR_BOT_MS; + const attempt = () => { + const position = pool.positions().find((bot) => bot.position) || null; + if (!position && Date.now() < deadline) { + setTimeout(attempt, 3000).unref?.(); + return; + } + const center = position + ? { centerBlockX: position.position.x, centerBlockZ: position.position.z } + : { centerBlockX: 0, centerBlockZ: 0 }; + if (!position) { + logger.log('等待机器人进入世界超时,大地图改以世界原点为中心预爬取', 'warn'); + } + bigMap.crawlAll(config.bigMap.zoom, center).catch((err) => { + logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn'); + }); + }; + setTimeout(attempt, 3000).unref?.(); +} + // 仅在作为入口运行时启动(被 require 时只导出工厂,便于测试与语法自检) if (require.main === module) { main().catch((err) => { @@ -169,4 +201,4 @@ if (require.main === module) { }); } -module.exports = { main }; +module.exports = { main, scheduleInitialCrawl }; diff --git a/src/web/public/app.js b/src/web/public/app.js index b30ec27..e4f8b31 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -19,6 +19,28 @@ const $ = (id) => document.getElementById(id); /** 控制台凭证:优先 URL 上的 ?token=,其次浏览器本地保存(登录框写入) */ let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('autostore_token') || ''; +/** 当前选中的地图图幅(半径,方块) */ +function selectedRangeBlocks() { + const el = $('bigMapRange'); + const n = Number(el && el.value); + return Number.isFinite(n) && n > 0 ? n : 4096; +} + +/** + * 大地图 / 领土地图应该聚焦的位置:当前选中机器人的坐标。 + * + * 这两张图的用途都是「看自己的机器人」,所以默认必须跟着机器人走, + * 而不是永远停在某个固定坐标上。 + * + * @returns {{x: number, z: number}|null} + */ +function botFocusPosition() { + const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position); + if (bots.length === 0) return null; + const target = bots.find((b) => b.id === selectedBotId) || bots[0]; + return { x: target.position.x, z: target.position.z }; +} + function setToken(value) { TOKEN = value || ''; try { @@ -628,6 +650,9 @@ function selectBot(id, silent) { const changed = id !== selectedBotId; selectedBotId = id; botListSignature = ''; + scheduleSignature = ''; + mapView.centered = false; + bigMapView.fitted = false; $('botSelect').value = id; $('consoleTarget').textContent = id; for (const item of document.querySelectorAll('.bot-item')) { @@ -724,11 +749,14 @@ function renderStatus(status) { // ===== 大地图爬取进度 ===== if (status.bigMap && status.bigMap.crawl) { const crawl = status.bigMap.crawl; - $('btnCrawlMap').textContent = crawl.running - ? `爬取中 ${crawl.done}/${crawl.total}` - : '爬取地图'; + const btn = $('btnCrawlAround'); + btn.textContent = crawl.running ? `爬取中 ${crawl.done}/${crawl.total}` : '爬取周边'; + btn.disabled = crawl.running === true; } + // ===== 定时消息 ===== + renderSchedules(bot.schedules || []); + // ===== Grim 卡片 ===== const grim = bot.grim; if (grim) { @@ -737,6 +765,19 @@ function renderStatus(status) { $('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否'; $('kGrimReach').textContent = grim.attackReach + ' 格'; $('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪'; + const packets = grim.protocolPackets; + if (packets) { + // 这两个补包是「机器人能不能走」的关键,直接显示发送计数最直观 + const parts = []; + if (packets.playerInputEnabled) { + parts.push(packets.playerInputSupported + ? `player_input ${packets.playerInputSent}` + : 'player_input 当前版本不支持'); + } + if (packets.tickEndEnabled) parts.push(`tick_end ${packets.tickEndSent}`); + $('kGrimPackets').textContent = parts.length > 0 ? parts.join(' · ') : '已关闭'; + $('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)'; + } $('kGrimBlocked').textContent = String(grim.blockedActions || 0); $('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常'; $('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)'; @@ -752,6 +793,130 @@ function renderStatus(status) { drawMap(); } +// ==================== 定时消息 ==================== + +/** 上次渲染定时列表用的签名,避免每秒推送都重建 DOM */ +let scheduleSignature = ''; + +/** + * 渲染当前机器人的定时发送列表。 + * + * @param {Array} schedules 服务端返回的任务快照(含剩余秒数) + */ +function renderSchedules(schedules) { + const list = $('scheduleList'); + if (!list) return; + const signature = (schedules || []) + .map((t) => [t.id, t.enabled, t.content, t.intervalSeconds, t.nextInSeconds].join(':')) + .join('|'); + if (signature === scheduleSignature) return; + scheduleSignature = signature; + + list.innerHTML = ''; + if (!schedules || schedules.length === 0) { + const empty = document.createElement('div'); + empty.className = 'schedule-empty'; + empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。'; + list.appendChild(empty); + return; + } + + for (const task of schedules) { + const item = document.createElement('div'); + item.className = 'schedule-item' + (task.enabled ? '' : ' off'); + + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.checked = task.enabled; + toggle.title = '启用 / 停用'; + toggle.addEventListener('change', () => toggleSchedule(task, toggle.checked)); + item.appendChild(toggle); + + const content = document.createElement('div'); + content.className = 'content'; + const code = document.createElement('code'); + code.textContent = task.content; + code.title = task.content; + content.appendChild(code); + const sub = document.createElement('div'); + sub.className = 'sub'; + sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒` + + (task.label ? ` · ${task.label}` : ''); + content.appendChild(sub); + item.appendChild(content); + + const next = document.createElement('div'); + next.className = 'sub next'; + next.textContent = task.enabled && task.nextInSeconds !== null && task.nextInSeconds !== undefined + ? `${task.nextInSeconds}s 后` + : '已停用'; + item.appendChild(next); + + const remove = document.createElement('button'); + remove.className = 'remove'; + remove.textContent = '×'; + remove.title = '删除该定时任务'; + remove.addEventListener('click', () => removeSchedule(task)); + item.appendChild(remove); + + list.appendChild(item); + } +} + +/** 保存整份定时任务列表 */ +async function saveSchedules(schedules) { + if (!selectedBotId) return; + try { + await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/schedules', { + method: 'POST', + body: JSON.stringify({ schedules }) + }); + scheduleSignature = ''; + toast('定时任务已保存'); + } catch (err) { + toast('保存定时任务失败:' + err.message, true); + } +} + +/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */ +function currentSchedules() { + const bot = selectedBot(lastStatus || {}); + return ((bot && bot.schedules) || []).map((t) => ({ + id: t.id, + label: t.label, + content: t.content, + intervalSeconds: t.intervalSeconds, + enabled: t.enabled + })); +} + +function toggleSchedule(task, enabled) { + const next = currentSchedules().map((t) => (t.id === task.id ? Object.assign({}, t, { enabled }) : t)); + saveSchedules(next); +} + +function removeSchedule(task) { + const next = currentSchedules().filter((t) => t.id !== task.id); + saveSchedules(next); +} + +// ==================== 移动端导航 ==================== + +/** 小屏底部导航:点击滚动到对应区块 */ +function bindMobileNav() { + const nav = $('mobileNav'); + if (!nav) return; + nav.addEventListener('click', (e) => { + const item = e.target.closest('.mobile-nav-item'); + if (!item) return; + for (const el of nav.querySelectorAll('.mobile-nav-item')) { + el.classList.toggle('active', el === item); + } + const target = $(item.dataset.scroll); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); +} + // ==================== 运行日志 ==================== const MAX_LOG_LINES = 800; @@ -1339,11 +1504,7 @@ async function loadMap(forceFull) { if (needFull) lastFullFetchAt = Date.now(); else if (cached && cached.otherChunks) next.otherChunks = cached.otherChunks; mapView.data = next; - if (!mapView.centered && next.playerChunk) { - mapView.centerX = next.playerChunk.x; - mapView.centerZ = next.playerChunk.z; - mapView.centered = true; - } + if (!mapView.centered) focusTerritoryOnBotCountry(next); drawMap(); } catch (err) { toast('读取地图失败:' + err.message, true); @@ -1352,6 +1513,51 @@ async function loadMap(forceFull) { } } +/** + * 把领土地图对准「机器人所在的邦国」。 + * + * 之前这里用的是控制器的 playerChunk,而控制器在任务启动前是 (0,0), + * 结果地图永远停在世界的原点附近 —— 而用户关心的是自己机器人所在的那个邦国。 + * 现在按「机器人所在区块 → 包含它的邦国 → 该邦国的王城/外接矩形」来定位。 + * + * @param {object} data `/api/map` 返回的数据 + */ +function focusTerritoryOnBotCountry(data) { + if (!data) return; + const focus = botFocusPosition(); + const botChunk = focus ? { x: Math.floor(focus.x / 16), z: Math.floor(focus.z / 16) } : null; + + // 1) 优先用机器人所在区块反查邦国(最准确) + let state = null; + if (botChunk) { + state = (data.states || []).find((s) => s.bounds + && botChunk.x >= s.bounds.minX && botChunk.x <= s.bounds.maxX + && botChunk.z >= s.bounds.minZ && botChunk.z <= s.bounds.maxZ); + } + // 2) 退而求其次:用服务端标记的己方邦国 + if (!state && data.selfState) state = (data.states || []).find((s) => s.name === data.selfState); + // 3) 再退:直接用机器人所在区块 + if (!state && botChunk) { + mapView.centerX = botChunk.x; + mapView.centerZ = botChunk.z; + mapView.scale = 2; + mapView.centered = true; + return; + } + if (!state) return; + + if (state.capital) { + mapView.centerX = state.capital.x; + mapView.centerZ = state.capital.z; + } else if (state.bounds) { + mapView.centerX = (state.bounds.minX + state.bounds.maxX) / 2; + mapView.centerZ = (state.bounds.minZ + state.bounds.maxZ) / 2; + } + mapView.scale = 2; + mapView.centered = true; + mapView.countryName = state.name; +} + let bigMapLoading = false; let bigMapMarkersAt = 0; @@ -1360,7 +1566,10 @@ async function loadBigMap(force) { if (bigMapLoading) return; bigMapLoading = true; try { - const res = await api('/api/bigmap'); + // 带上图幅与关注的机器人:服务端据此算出要爬/要用的瓦片范围 + const query = '?rangeBlocks=' + selectedRangeBlocks() + + (selectedBotId ? '&bot=' + encodeURIComponent(selectedBotId) : ''); + const res = await api('/api/bigmap' + query); const data = res.data; bigMapView.data = data; // 领土标记是 160KB 级别的数据,按需刷新(默认 5 分钟一次) @@ -1371,9 +1580,7 @@ async function loadBigMap(force) { // 会话新占领的区块:复用领土地图的数据 if (mapView.data) bigMapView.sessionChunks = [mapView.data.session || []]; - if (!bigMapView.fitted && data.rangeBlocks) { - fitBigMapToWorld(); - } + if (!bigMapView.fitted) fitBigMapToRegion(); drawMap(); } catch (err) { toast('读取大地图失败:' + err.message, true); @@ -1400,21 +1607,30 @@ function setBigMapMarkers(markers) { bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states }; } -/** 让全世界刚好铺满画布 */ -function fitBigMapToWorld() { - const data = bigMapView.data; +/** + * 让「选定图幅」刚好铺满画布,并以机器人所在位置为中心。 + * + * 图幅由界面上的「图幅」下拉框决定(±512 ~ 全世界), + * 这样既能看到机器人周边细节,也能一键切到全局视角。 + */ +function fitBigMapToRegion() { const rect = $('mapCanvas').getBoundingClientRect(); - const radius = (data && data.rangeBlocks) || 20480; - bigMapView.centerX = 0; - bigMapView.centerZ = 0; + const radius = selectedRangeBlocks(); + const focus = botFocusPosition(); + const data = bigMapView.data; + bigMapView.centerX = focus ? focus.x : (data && data.center ? data.center.x : 0); + bigMapView.centerZ = focus ? focus.z : (data && data.center ? data.center.z : 0); bigMapView.pxPerBlock = Math.min(rect.width, rect.height) / (2 * radius + 1); bigMapView.fitted = true; } -/** 把视图中心移到所有机器人的几何中心 */ +/** 把视图中心移到所有机器人的几何中心(「回到机器人」按钮) */ function centerBigMapOnBots() { const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position); - if (bots.length === 0) return; + if (bots.length === 0) { + toast('机器人还没有坐标(未进入世界)', true); + return; + } let x = 0; let z = 0; for (const bot of bots) { @@ -1540,6 +1756,35 @@ function bindEvents() { } }); + // ===== 定时消息编辑器 ===== + $('btnAddSchedule').addEventListener('click', () => { + $('schedContent').value = ''; + $('schedInterval').value = '300'; + $('schedLabel').value = ''; + $('scheduleEditor').classList.remove('hidden'); + $('schedContent').focus(); + }); + $('btnCancelSchedule').addEventListener('click', () => { + $('scheduleEditor').classList.add('hidden'); + }); + $('btnSaveSchedule').addEventListener('click', async () => { + const content = $('schedContent').value.trim(); + if (!content) { + toast('请填写要发送的内容', true); + return; + } + const next = currentSchedules(); + next.push({ + id: 'task-' + Date.now().toString(36), + content, + intervalSeconds: Number($('schedInterval').value) || 300, + label: $('schedLabel').value.trim(), + enabled: true + }); + $('scheduleEditor').classList.add('hidden'); + await saveSchedules(next); + }); + $('btnRefreshMap').addEventListener('click', async () => { try { await api('/api/map/refresh', { method: 'POST' }); @@ -1616,12 +1861,18 @@ function bindEvents() { $('chkBotLabels').addEventListener('change', drawMap); $('btnMapCenter').addEventListener('click', () => { - const data = mapView.data; - if (data && data.playerChunk) { - mapView.centerX = data.playerChunk.x; - mapView.centerZ = data.playerChunk.z; - drawMap(); + const focus = botFocusPosition(); + if (focus) { + mapView.centerX = Math.floor(focus.x / 16); + mapView.centerZ = Math.floor(focus.z / 16); + } else if (mapView.data && mapView.data.playerChunk) { + mapView.centerX = mapView.data.playerChunk.x; + mapView.centerZ = mapView.data.playerChunk.z; + } else { + toast('机器人还没有坐标(未进入世界)', true); + return; } + drawMap(); }); $('btnMapFit').addEventListener('click', () => { @@ -1657,17 +1908,37 @@ function bindEvents() { drawMap(); }); - $('btnMapFitWorld').addEventListener('click', () => { - fitBigMapToWorld(); - drawMap(); - }); $('btnMapCenterBots').addEventListener('click', centerBigMapOnBots); - $('btnCrawlMap').addEventListener('click', async () => { + // 图幅切换:重新对焦并拉取对应范围的瓦片信息 + $('bigMapRange').addEventListener('change', () => { + bigMapView.fitted = false; + fitBigMapToRegion(); + drawMap(); + loadBigMap(true); + }); + + // 爬取机器人周边:只爬当前图幅内的瓦片,而不是把整个世界拉下来 + $('btnCrawlAround').addEventListener('click', async () => { + const focus = botFocusPosition(); + const radius = selectedRangeBlocks(); + if (!focus) { + toast('机器人还没有坐标,无法按周边爬取;请在配置里指定中心或先让机器人进入世界', true); + return; + } try { - const res = await api('/api/bigmap/crawl', { method: 'POST', body: JSON.stringify({}) }); - toast(res.message); + const res = await api('/api/bigmap/crawl', { + method: 'POST', + body: JSON.stringify({ + centerX: focus.x, + centerZ: focus.z, + rangeBlocks: radius, + bot: selectedBotId + }) + }); + toast(`正在爬取 ${Math.round(focus.x)},${Math.round(focus.z)} 周边 ±${radius} 格的瓦片`); loadBigMap(true); + void res; } catch (err) { toast('爬取失败:' + err.message, true); } @@ -1886,6 +2157,7 @@ async function startApp() { wsEnabled = true; initMap(); bindEvents(); + bindMobileNav(); await loadConfig(); await loadMap(); await loadBigMap(true); diff --git a/src/web/public/index.html b/src/web/public/index.html index 694efd9..e8283c4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2,7 +2,8 @@ - + + AutoStore Bot 控制台 @@ -10,46 +11,62 @@
-
+

AutoStore Bot

多机器人自动扩地 · mineflayer · Grim 合规 · 大地图

-
+ +
-
+

机器人(0)

-
+
-
-
- - - - +
+ + 添加玩家 +
+
+
+ +
+
+ +
+

密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。

-
+
@@ -89,6 +106,35 @@
+
+
+

定时消息

+ +
+
+ +
+

领土数据源

@@ -101,7 +147,7 @@
己方飞地
0
SimpCore API
未启用
-
+
@@ -115,6 +161,7 @@
朝向已同步
-
攻击距离
-
攻击冷却
-
+
协议补包
-
已拦下动作
0
物理自检
-
@@ -123,26 +170,36 @@
-
+
-
+
-
+
+ - - + + -
- @@ -165,26 +222,27 @@
-
-
+
+
-
+
- +
-