feat: 大地图跟随机器人并支持可调图幅,新增定时消息,控制台界面重做并适配移动端

大地图跟随机器人 + 可调图幅
- 爬取与显示区域改为以机器人当前位置为中心(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),
  导致地图永远停在世界原点附近。现在按「机器人所在区块 → 包含它的邦国 →
  该邦国王城/外接矩形」定位

定时重复发送消息(src/bot/scheduledMessenger.js)
- 每个机器人可配多条定时任务,按固定间隔重复发送同一内容,如每 300 秒 /u restore confirm
- 以 / 开头按游戏指令发送,否则按公屏消息发送
- 只在已连接且通过登录验证时发送;未就绪的周期顺延且不补发,避免重连后一次性连发
- 与指令节流队列共用,间隔下限 5 秒、单机器人最多 20 条
- 控制台新增「定时消息」卡片(新增/删除/启停 + 剩余秒数倒计时)

控制台界面重做 + 移动端适配
- 重新设计的深色主题:统一圆角、阴影、焦点态与悬停态,卡片式分层
- 响应式三档:≥1200px 三栏;≤1200px 两栏;≤820px 单栏 + 底部导航
- 移动端:触控目标 ≥36px、地图/日志高度按视口自适应、避让底部安全区、
  横屏压矮地图、尊重「减少动效」偏好、Toast 全宽
- 「添加玩家」折叠进 details;顶栏窄屏自动换行且按钮等分

修复
- POST /api/bigmap/crawl 响应把中心与图幅字段传错(实际爬取正确,返回值显示成原点)
- 定时任务保存后接口返回原始列表而非归一化结果,前端拿不到剩余秒数

测试 308 -> 325,全部通过。
这个提交包含在:
lyizai
2026-09-28 00:27:58 +08:00
父节点 fcd5fb2bd9
当前提交 a83dea9677
共修改 14 个文件,包含 1579 行新增和 343 行删除
+304 -32
查看文件
@@ -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<object>} 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);