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,全部通过。
这个提交包含在:
+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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户