由 Fabric 客户端模组 AutoStore v1.3.1 重构为独立 Node.js 机器人,无需游戏客户端。 核心实现: - 领土数据:Dynmap markers.json 拉取解析(奇偶填充识别国中飞地)、SimpCore 开放 API 第四数据源、 带缓存击穿的时间戳请求与内容未变化跳过解析 - 扩地规划:四种策略(方形外扩 / 区域 S 形扫描 / 玩家周围 / 绕国一圈)逐行对照移植, 接壤判定只算己方领土,飞地优先填补 - 扩地状态机:规划→寻路→等待开垦→成功/失败→下一目标→完成,含超时重试、 往边上走两步自救、连续超时回城、耐久保护回城 - 生存辅助:自动进食、自动攻击(豁免中立生物)、自动游泳防溺水、自动用船(默认关闭) - 寻路:Baritone → mineflayer-pathfinder,高度偏好用包装 Goal 等价移植 Grim 反作弊合规层(新增,核心差异点): - 旋转不超前:接管 bot.look 按 ≤8.6°/tick 推进朝向,消除 mineflayer 「本地朝向瞬间跳变、数据包滞后补发」导致的 Prediction 分歧 - 攻击三重校验:朝向已同步 + 距离 ≤3.0 格 + 视线可达 + 攻击冷却 + 非骑乘/进食 - 动作互斥:拦下疾跑+潜行、疾跑+使用物品等原版不可能出现的组合 - 物理参数只读自检 + 寻路约束(禁挖/禁放/禁跑酷/禁搭塔/限下落/绕开水域) 网页控制台(新增): - 状态面板、Canvas 领土地图(缩放/平移/图层)、分组配置表单、实时日志流 - REST + WebSocket 接口,支持 WEB_TOKEN 令牌鉴权 修复: - containsFuzzy 只取固定长度前缀的末行编辑距离,导致完全相同的子串也匹配不上 - 占领去重依赖 Set.add 返回值(JS 中恒为真),导致同一区块被重复计数 - 非法枚举值不再钳制到最近边界,改为回退默认值 测试:154 个用例,npm test 全绿;npm run check 语法自检通过。
854 行
31 KiB
JavaScript
854 行
31 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* AutoStore Bot Web 控制台前端逻辑。
|
||
*
|
||
* 三块职责:
|
||
* 1. 通过 WebSocket 接收实时状态与日志,渲染左侧状态面板;
|
||
* 2. 根据配置 schema 动态生成右侧配置表单,读写 `/api/config`;
|
||
* 3. 用 Canvas 绘制领土地图(支持缩放 / 平移 / 图层开关)。
|
||
*/
|
||
|
||
// ==================== 工具 ====================
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/** 取 URL 上的 token 参数,后续所有请求都带上 */
|
||
const TOKEN = new URLSearchParams(location.search).get('token') || '';
|
||
|
||
function api(path, options) {
|
||
const opts = options || {};
|
||
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
|
||
if (TOKEN) headers['X-Auth-Token'] = TOKEN;
|
||
return fetch(path, Object.assign({}, opts, { headers })).then(async (res) => {
|
||
let body = null;
|
||
try {
|
||
body = await res.json();
|
||
} catch {
|
||
body = { ok: false, message: 'HTTP ' + res.status };
|
||
}
|
||
if (!res.ok || body.ok === false) {
|
||
throw new Error(body.message || ('HTTP ' + res.status));
|
||
}
|
||
return body;
|
||
});
|
||
}
|
||
|
||
let toastTimer = null;
|
||
function toast(message, isError) {
|
||
const el = $('toast');
|
||
el.textContent = message;
|
||
el.className = 'toast show' + (isError ? ' error' : '');
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { el.className = 'toast'; }, 3500);
|
||
}
|
||
|
||
function fmtTime(ts) {
|
||
if (!ts) return '-';
|
||
const d = new Date(ts);
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||
}
|
||
|
||
function fmtAgo(ts) {
|
||
if (!ts) return '从未';
|
||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||
if (seconds < 5) return '刚刚';
|
||
if (seconds < 60) return seconds + ' 秒前';
|
||
if (seconds < 3600) return Math.floor(seconds / 60) + ' 分钟前';
|
||
return fmtTime(ts);
|
||
}
|
||
|
||
// ==================== 配置 schema ====================
|
||
|
||
const STRATEGY_OPTIONS = [
|
||
{ value: 1, label: '1 - 方形外扩(优先填洞)' },
|
||
{ value: 2, label: '2 - 区域扩地(S 形扫描)' },
|
||
{ value: 3, label: '3 - 玩家周围扩地' },
|
||
{ value: 4, label: '4 - 绕国一圈' }
|
||
];
|
||
|
||
const WATER_MODE_OPTIONS = [
|
||
{ value: 'avoid', label: 'avoid - 绕开水域(推荐)' },
|
||
{ value: 'swim', label: 'swim - 游泳通过' },
|
||
{ value: 'boat', label: 'boat - 放船渡水(Grim 风险高)' }
|
||
];
|
||
|
||
/**
|
||
* 配置表单 schema:每个 tab 下若干分组,每组若干字段。
|
||
* path 为配置对象里的点分路径。
|
||
*/
|
||
const CONFIG_SCHEMA = {
|
||
strategy: [
|
||
{
|
||
title: '扩地策略',
|
||
fields: [
|
||
{ path: 'strategy', label: '策略', type: 'select', options: STRATEGY_OPTIONS },
|
||
{ path: 'stateName', label: '目标邦国', type: 'text', hint: '留空则自动识别机器人所在邦国' },
|
||
{ path: 'targetCount', label: '目标扩地数量', type: 'number', min: -1, hint: '-1 表示持续扩地直到无可用区块' },
|
||
{ path: 'maxRings', label: '扩圈上限', type: 'number', min: 0, hint: '0 表示不限制' }
|
||
]
|
||
},
|
||
{
|
||
title: '区域扩地范围(仅策略 2)',
|
||
fields: [
|
||
{ path: 'regionMinX', label: '矩形 minX(区块)', type: 'number' },
|
||
{ path: 'regionMaxX', label: '矩形 maxX(区块)', type: 'number', hint: 'maxX < minX 表示未圈选,改用中心 + 半径' },
|
||
{ path: 'regionMinZ', label: '矩形 minZ(区块)', type: 'number' },
|
||
{ path: 'regionMaxZ', label: '矩形 maxZ(区块)', type: 'number' },
|
||
{ path: 'regionCenterX', label: '中心 X(区块)', type: 'number' },
|
||
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
|
||
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
|
||
]
|
||
}
|
||
],
|
||
timing: [
|
||
{
|
||
title: '超时与重试',
|
||
fields: [
|
||
{ path: 'waitTimeoutSeconds', label: '等待 / 寻路超时(秒)', type: 'number', min: 5 },
|
||
{ path: 'retries', label: '超时后最大重试次数', type: 'number', min: 1 },
|
||
{ path: 'returnThresholdSeconds', label: '连续超时回城阈值(秒)', type: 'number', min: 0 },
|
||
{ path: 'returnWaitSeconds', label: '回城等待传送(秒)', type: 'number', min: 1 },
|
||
{ path: 'sideStepMaxRounds', label: '往边上走两步最大轮数', type: 'number', min: 0 }
|
||
]
|
||
},
|
||
{
|
||
title: '开垦时段限制',
|
||
fields: [
|
||
{ path: 'enforceClaimTime', label: '启用时段限制', type: 'checkbox' },
|
||
{ path: 'claimStartHour', label: '起始小时(含)', type: 'number', min: 0, max: 23 },
|
||
{ path: 'claimEndHour', label: '结束小时(不含)', type: 'number', min: 1, max: 24 }
|
||
]
|
||
}
|
||
],
|
||
pathing: [
|
||
{
|
||
title: '寻路行为',
|
||
fields: [
|
||
{ path: 'allowSprint', label: '允许疾跑', type: 'checkbox' },
|
||
{ path: 'allowSwim', label: '允许游泳', type: 'checkbox' },
|
||
{ path: 'allowJump', label: '允许跳跃', type: 'checkbox' },
|
||
{ path: 'allowBreak', label: '允许破坏方块', type: 'checkbox', hint: 'Grim 对破坏速度检查严格,默认关闭' },
|
||
{ path: 'allowPlace', label: '允许放置方块', type: 'checkbox', hint: 'Grim 对放置检查严格,默认关闭' },
|
||
{ path: 'altitudePreference', label: '高度偏好(优先走高处)', type: 'checkbox' },
|
||
{ path: 'altitudeWeight', label: '高度偏好权重', type: 'number', step: 0.01, min: 0 }
|
||
]
|
||
},
|
||
{
|
||
title: '寻路器专属',
|
||
fields: [
|
||
{ path: 'pathing.allowParkour', label: '允许跑酷跳跃', type: 'checkbox', hint: 'mineflayer 起跳时机与原版有偏差,默认关闭' },
|
||
{ path: 'pathing.allow1by1towers', label: '允许 1×1 搭塔', type: 'checkbox' },
|
||
{ path: 'pathing.maxDropDown', label: '最大下落高度(格)', type: 'number', min: 0 },
|
||
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
|
||
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' }
|
||
]
|
||
}
|
||
],
|
||
survival: [
|
||
{
|
||
title: '生存辅助',
|
||
fields: [
|
||
{ path: 'avoidMobs', label: '避开怪物', type: 'checkbox' },
|
||
{ path: 'autoEat', label: '自动进食', type: 'checkbox' },
|
||
{ path: 'eatThreshold', label: '进食饥饿阈值', type: 'number', min: 0, max: 20 },
|
||
{ path: 'autoAttack', label: '自动攻击敌对生物', type: 'checkbox' },
|
||
{ path: 'durabilityGuard', label: '耐久保护', type: 'checkbox' },
|
||
{ path: 'durabilityThreshold', label: '耐久保护阈值(%)', type: 'number', min: 1, max: 99 }
|
||
]
|
||
},
|
||
{
|
||
title: '战斗筛选',
|
||
fields: [
|
||
{ path: 'combat.scanRange', label: '索敌半径(格)', type: 'number', step: 0.5, min: 1 },
|
||
{ path: 'combat.attackEnderman', label: '攻击末影人', type: 'checkbox', hint: 'mineflayer 读不到「是否被激怒」,默认不打' },
|
||
{ path: 'combat.attackSpiders', label: '攻击蜘蛛', type: 'checkbox', hint: 'mineflayer 读不到环境亮度,默认不打' },
|
||
{ path: 'combat.attackPiglins', label: '攻击猪灵(未穿金甲时)', type: 'checkbox' },
|
||
{ path: 'combat.avoidDangerousMobs', label: '跳过监守者 / 末影龙 / 凋灵', type: 'checkbox' }
|
||
]
|
||
},
|
||
{
|
||
title: '水域处理',
|
||
fields: [
|
||
{ path: 'waterMode', label: '水域策略', type: 'select', options: WATER_MODE_OPTIONS },
|
||
{ path: 'autoBoat', label: '启用自动用船', type: 'checkbox', hint: 'mineflayer 无载具物理,Grim 的 Vehicle 检查风险高,默认关闭' },
|
||
{ path: 'autoBoatMinWidth', label: '触发用船的最小水面宽度(格)', type: 'number', min: 1 },
|
||
{ path: 'autoBoatMinLength', label: '触发用船的最短水域长度(格)', type: 'number', min: 1 }
|
||
]
|
||
}
|
||
],
|
||
data: [
|
||
{
|
||
title: '地图数据源',
|
||
fields: [
|
||
{ path: 'refreshIntervalSeconds', label: '地图刷新间隔(秒)', type: 'number', min: 10 }
|
||
]
|
||
},
|
||
{
|
||
title: 'SimpCore 开放 API',
|
||
fields: [
|
||
{ path: 'apiEnabled', label: '启用 API 数据源', type: 'checkbox' },
|
||
{ path: 'apiKey', label: 'API Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_KEY' },
|
||
{ path: 'apiRefreshIntervalSeconds', label: 'API 轮询间隔(秒)', type: 'number', min: 60 }
|
||
]
|
||
}
|
||
],
|
||
grim: [
|
||
{
|
||
title: 'Grim 反作弊合规层',
|
||
fields: [
|
||
{ path: 'grim.enabled', label: '启用合规层', type: 'checkbox', hint: '仅在本地测试服才建议关闭' },
|
||
{ path: 'grim.maxRotationPerTickDeg', label: '每 tick 最大旋转(度)', type: 'number', step: 0.1, min: 1, max: 180, hint: 'mineflayer 物理限速约 8.6°/tick,实际取两者较小值' },
|
||
{ path: 'grim.attackReach', label: '攻击距离(格)', type: 'number', step: 0.1, min: 1, max: 6 },
|
||
{ path: 'grim.requireLineOfSight', label: '攻击前校验视线', type: 'checkbox' },
|
||
{ path: 'grim.respectAttackCooldown', label: '遵守攻击冷却', type: 'checkbox' },
|
||
{ path: 'grim.stopMovementWhileEating', label: '进食时停止移动', type: 'checkbox' },
|
||
{ path: 'grim.blockSprintJump', label: '禁止疾跑跳跃', type: 'checkbox', hint: '疾跑跳本身是原版合法动作,仅在实测被标记时开启' },
|
||
{ path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' },
|
||
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
|
||
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' }
|
||
]
|
||
}
|
||
],
|
||
server: [
|
||
{
|
||
title: '服务器交互',
|
||
fields: [
|
||
{ path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' },
|
||
{ path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_PASSWORD' },
|
||
{ path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' },
|
||
{ path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 },
|
||
{ path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' },
|
||
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 }
|
||
]
|
||
}
|
||
]
|
||
};
|
||
|
||
// ==================== 配置读写 ====================
|
||
|
||
let currentConfig = null;
|
||
|
||
function getByPath(obj, path) {
|
||
return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
|
||
}
|
||
|
||
function setByPath(obj, path, value) {
|
||
const keys = path.split('.');
|
||
let cursor = obj;
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
if (cursor[keys[i]] == null || typeof cursor[keys[i]] !== 'object') cursor[keys[i]] = {};
|
||
cursor = cursor[keys[i]];
|
||
}
|
||
cursor[keys[keys.length - 1]] = value;
|
||
}
|
||
|
||
let activeTab = 'strategy';
|
||
|
||
function renderConfig() {
|
||
const body = $('configBody');
|
||
body.innerHTML = '';
|
||
const groups = CONFIG_SCHEMA[activeTab] || [];
|
||
for (const group of groups) {
|
||
const title = document.createElement('div');
|
||
title.className = 'group-title';
|
||
title.textContent = group.title;
|
||
body.appendChild(title);
|
||
for (const field of group.fields) {
|
||
body.appendChild(buildField(field));
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildField(field) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'field';
|
||
const label = document.createElement('label');
|
||
label.textContent = field.label;
|
||
wrap.appendChild(label);
|
||
|
||
const value = getByPath(currentConfig, field.path);
|
||
|
||
if (field.type === 'checkbox') {
|
||
const inline = document.createElement('div');
|
||
inline.className = 'field-inline';
|
||
const input = document.createElement('input');
|
||
input.type = 'checkbox';
|
||
input.checked = value === true;
|
||
input.dataset.path = field.path;
|
||
input.dataset.type = 'checkbox';
|
||
inline.appendChild(input);
|
||
wrap.appendChild(inline);
|
||
} else if (field.type === 'select') {
|
||
const select = document.createElement('select');
|
||
select.className = 'input';
|
||
select.dataset.path = field.path;
|
||
select.dataset.type = 'select';
|
||
for (const option of field.options) {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(option.value);
|
||
opt.textContent = option.label;
|
||
if (String(value) === String(option.value)) opt.selected = true;
|
||
select.appendChild(opt);
|
||
}
|
||
wrap.appendChild(select);
|
||
} else {
|
||
const input = document.createElement('input');
|
||
input.className = 'input';
|
||
input.type = field.type === 'password' ? 'password' : (field.type === 'number' ? 'number' : 'text');
|
||
if (field.min !== undefined) input.min = field.min;
|
||
if (field.max !== undefined) input.max = field.max;
|
||
if (field.step !== undefined) input.step = field.step;
|
||
input.value = value === undefined || value === null ? '' : String(value);
|
||
input.dataset.path = field.path;
|
||
input.dataset.type = field.type;
|
||
wrap.appendChild(input);
|
||
}
|
||
|
||
if (field.hint) {
|
||
const hint = document.createElement('div');
|
||
hint.className = 'hint';
|
||
hint.textContent = field.hint;
|
||
wrap.appendChild(hint);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
function collectConfig() {
|
||
const result = JSON.parse(JSON.stringify(currentConfig || {}));
|
||
for (const input of $('configBody').querySelectorAll('[data-path]')) {
|
||
const path = input.dataset.path;
|
||
const type = input.dataset.type;
|
||
let value;
|
||
if (type === 'checkbox') value = input.checked;
|
||
else if (type === 'select') {
|
||
const raw = input.value;
|
||
value = /^-?\d+$/.test(raw) ? Number(raw) : raw;
|
||
} else if (type === 'number') value = input.value === '' ? 0 : Number(input.value);
|
||
else value = input.value;
|
||
setByPath(result, path, value);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function loadConfig() {
|
||
try {
|
||
const res = await api('/api/config');
|
||
currentConfig = res.data;
|
||
renderConfig();
|
||
} catch (err) {
|
||
toast('读取配置失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
async function saveConfig() {
|
||
try {
|
||
const config = collectConfig();
|
||
const res = await api('/api/config', { method: 'POST', body: JSON.stringify(config) });
|
||
currentConfig = res.data;
|
||
renderConfig();
|
||
toast('配置已保存并应用');
|
||
} catch (err) {
|
||
toast('保存配置失败:' + err.message, true);
|
||
}
|
||
}
|
||
|
||
// ==================== 状态渲染 ====================
|
||
|
||
let lastStatus = null;
|
||
|
||
function renderStatus(status) {
|
||
lastStatus = status;
|
||
const bot = status.bot || {};
|
||
const task = status.task || {};
|
||
const data = status.data || {};
|
||
|
||
// ===== 顶栏徽标 =====
|
||
const connBadge = $('connBadge');
|
||
const stateText = { online: '已连接', connecting: '连接中', offline: '已断开', idle: '未连接' };
|
||
connBadge.textContent = stateText[bot.state] || bot.state || '未知';
|
||
connBadge.className = 'badge ' + (bot.state === 'online' ? 'badge-online' : 'badge-offline');
|
||
|
||
const taskBadge = $('taskBadge');
|
||
taskBadge.textContent = task.state || 'IDLE';
|
||
taskBadge.className = 'badge ' + (task.running ? 'badge-running' : (task.state === 'COMPLETE' ? 'badge-online' : 'badge-idle'));
|
||
|
||
// ===== 连接卡片 =====
|
||
$('kServer').textContent = bot.host || '-';
|
||
$('kUsername').textContent = bot.username || '-';
|
||
$('kVersion').textContent = bot.version || '-';
|
||
$('kPos').textContent = bot.position
|
||
? `${bot.position.x} / ${bot.position.y} / ${bot.position.z}`
|
||
: '-';
|
||
$('kChunk').textContent = bot.chunk ? `${bot.chunk.x}, ${bot.chunk.z}` : '-';
|
||
$('kHealth').textContent = bot.health === null || bot.health === undefined
|
||
? '-'
|
||
: `${bot.health.toFixed(1)} / ${bot.food === null || bot.food === undefined ? '-' : bot.food}`;
|
||
$('kBotState').textContent = (bot.loggedIn ? '已登录 · ' : '') + (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地');
|
||
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
|
||
|
||
// ===== 任务卡片 =====
|
||
$('kTaskState').textContent = task.state || 'IDLE';
|
||
$('kTaskStatus').textContent = task.status || '-';
|
||
$('kTarget').textContent = task.target ? `${task.target.x}, ${task.target.z}` : '-';
|
||
$('kQueue').textContent = String(task.queuedTargets || 0);
|
||
$('kCompleted').textContent = String(task.completedClaims || 0);
|
||
$('kRetry').textContent = String(task.retryCount || 0);
|
||
$('kProgress').textContent = (task.lastProgress || 0) + '%';
|
||
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
|
||
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
|
||
$('progressBar').style.width = (task.lastProgress || 0) + '%';
|
||
|
||
// ===== 数据源卡片 =====
|
||
$('kMarkersUrl').textContent = data.markersUrl || '-';
|
||
$('kLastFetch').textContent = data.lastSuccessfulFetchMillis ? fmtAgo(data.lastSuccessfulFetchMillis) : '从未';
|
||
$('kDataError').textContent = data.lastError || '无';
|
||
$('kDataError').style.color = data.lastError ? 'var(--red)' : '';
|
||
$('kStateCount').textContent = String(data.stateCount || 0);
|
||
$('kClaimed').textContent = String(data.claimedChunks || 0);
|
||
$('kSession').textContent = String(data.sessionClaims || 0);
|
||
$('kFlying').textContent = String(data.flyingChunks || 0);
|
||
$('kApi').textContent = data.apiEnabled
|
||
? `已启用(${data.apiTerritoryCount || 0} 国,最近成功 ${data.apiLastSuccessMillis ? fmtAgo(data.apiLastSuccessMillis) : '从未'})`
|
||
: '未启用';
|
||
if (data.apiError) $('kApi').textContent += ' · ' + data.apiError;
|
||
|
||
// ===== Grim 卡片 =====
|
||
const grim = bot.grim;
|
||
if (grim) {
|
||
$('kGrimEnabled').textContent = grim.enabled ? '已启用' : '已关闭';
|
||
$('kGrimRot').textContent = `${grim.maxRotationPerTickDeg}°(实际 ${grim.effectiveStepDeg}°/tick)`;
|
||
$('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否';
|
||
$('kGrimReach').textContent = grim.attackReach + ' 格';
|
||
$('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪';
|
||
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
|
||
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
|
||
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
|
||
const issues = $('grimIssues');
|
||
issues.innerHTML = '';
|
||
for (const issue of grim.issues || []) {
|
||
const li = document.createElement('li');
|
||
li.textContent = issue;
|
||
issues.appendChild(li);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 日志 ====================
|
||
|
||
const MAX_LOG_LINES = 800;
|
||
|
||
function appendLog(entry) {
|
||
const view = $('logView');
|
||
const line = document.createElement('span');
|
||
line.className = 'log-line log-' + (entry.level || 'info');
|
||
const time = document.createElement('span');
|
||
time.className = 'log-time';
|
||
time.textContent = '[' + entry.time + ']';
|
||
line.appendChild(time);
|
||
line.appendChild(document.createTextNode(entry.message));
|
||
view.appendChild(line);
|
||
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
|
||
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||
}
|
||
|
||
function renderLogs(entries) {
|
||
$('logView').innerHTML = '';
|
||
for (const entry of entries) appendLog(entry);
|
||
}
|
||
|
||
// ==================== 地图 ====================
|
||
|
||
const mapView = {
|
||
centerX: 0,
|
||
centerZ: 0,
|
||
scale: 2,
|
||
dragging: false,
|
||
lastX: 0,
|
||
lastY: 0,
|
||
data: null,
|
||
/** 是否已经按玩家位置做过首次居中 */
|
||
centered: false
|
||
};
|
||
|
||
function initMap() {
|
||
const canvas = $('mapCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const resize = () => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
|
||
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
drawMap();
|
||
};
|
||
window.addEventListener('resize', resize);
|
||
|
||
canvas.addEventListener('mousedown', (e) => {
|
||
mapView.dragging = true;
|
||
mapView.lastX = e.clientX;
|
||
mapView.lastY = e.clientY;
|
||
});
|
||
window.addEventListener('mouseup', () => { mapView.dragging = false; });
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!mapView.dragging) return;
|
||
const dx = e.clientX - mapView.lastX;
|
||
const dy = e.clientY - mapView.lastY;
|
||
mapView.lastX = e.clientX;
|
||
mapView.lastY = e.clientY;
|
||
mapView.centerX -= dx / mapView.scale;
|
||
mapView.centerZ -= dy / mapView.scale;
|
||
drawMap();
|
||
});
|
||
canvas.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||
mapView.scale = Math.min(24, Math.max(0.15, mapView.scale * factor));
|
||
drawMap();
|
||
}, { passive: false });
|
||
|
||
setTimeout(resize, 0);
|
||
window.addEventListener('resize', resize);
|
||
}
|
||
|
||
function chunkToScreen(cx, cz, canvas) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const w = rect.width;
|
||
const h = rect.height;
|
||
const scale = mapView.scale;
|
||
return {
|
||
x: w / 2 + (cx - mapView.centerX) * scale,
|
||
y: h / 2 + (cz - mapView.centerZ) * scale,
|
||
scale
|
||
};
|
||
}
|
||
|
||
function drawMap() {
|
||
const canvas = $('mapCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const rect = canvas.getBoundingClientRect();
|
||
const w = rect.width;
|
||
const h = rect.height;
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = '#05070a';
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
const data = mapView.data;
|
||
const scale = mapView.scale;
|
||
const cell = Math.max(1, scale);
|
||
|
||
const drawChunks = (flat, color) => {
|
||
ctx.fillStyle = color;
|
||
for (let i = 0; i < flat.length; i += 2) {
|
||
const p = chunkToScreen(flat[i], flat[i + 1], canvas);
|
||
if (p.x < -cell || p.y < -cell || p.x > w + cell || p.y > h + cell) continue;
|
||
ctx.fillRect(p.x, p.y, cell, cell);
|
||
}
|
||
};
|
||
|
||
// 网格
|
||
if ($('chkGrid').checked && scale >= 1.2) {
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.045)';
|
||
ctx.lineWidth = 1;
|
||
const halfX = w / 2 / scale;
|
||
const halfZ = h / 2 / scale;
|
||
const startX = Math.floor(mapView.centerX - halfX);
|
||
const endX = Math.ceil(mapView.centerX + halfX);
|
||
const startZ = Math.floor(mapView.centerZ - halfZ);
|
||
const endZ = Math.ceil(mapView.centerZ + halfZ);
|
||
ctx.beginPath();
|
||
for (let x = startX; x <= endX; x++) {
|
||
const sx = Math.round(chunkToScreen(x, 0, canvas).x) + 0.5;
|
||
ctx.moveTo(sx, 0); ctx.lineTo(sx, h);
|
||
}
|
||
for (let z = startZ; z <= endZ; z++) {
|
||
const sy = Math.round(chunkToScreen(0, z, canvas).y) + 0.5;
|
||
ctx.moveTo(0, sy); ctx.lineTo(w, sy);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
if (!data) {
|
||
ctx.fillStyle = '#48505e';
|
||
ctx.font = '13px "Microsoft YaHei", sans-serif';
|
||
ctx.fillText('暂无地图数据,等待 markers.json 拉取…', 16, 24);
|
||
return;
|
||
}
|
||
|
||
// 全部邦国(按需加载)
|
||
if ($('chkFull').checked && data.otherChunks) {
|
||
for (const state of data.otherChunks) {
|
||
drawChunks(state.chunks, hexToRgba(state.color, 0.55));
|
||
}
|
||
}
|
||
|
||
drawChunks(data.self || [], 'rgba(47,129,247,0.85)');
|
||
drawChunks(data.session || [], 'rgba(63,185,80,0.95)');
|
||
if ($('chkFlying').checked) drawChunks(data.flying || [], 'rgba(248,81,73,0.95)');
|
||
|
||
// 王城
|
||
for (const state of data.states || []) {
|
||
if (!state.capital) continue;
|
||
const p = chunkToScreen(state.capital.x, state.capital.z, canvas);
|
||
if (p.x < -20 || p.y < -20 || p.x > w + 20 || p.y > h + 20) continue;
|
||
ctx.fillStyle = '#bc8cff';
|
||
ctx.beginPath();
|
||
ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.7), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
if (scale >= 1) {
|
||
ctx.fillStyle = 'rgba(188,140,255,0.85)';
|
||
ctx.font = '11px "Microsoft YaHei", sans-serif';
|
||
ctx.fillText(state.name, p.x + 6, p.y + 4);
|
||
}
|
||
}
|
||
|
||
// 当前目标
|
||
if (data.target) {
|
||
const p = chunkToScreen(data.target.x, data.target.z, canvas);
|
||
ctx.strokeStyle = '#d29922';
|
||
ctx.lineWidth = 2;
|
||
const size = Math.max(8, scale * 2);
|
||
ctx.strokeRect(p.x - size / 2 + scale / 2, p.y - size / 2 + scale / 2, size, size);
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x - size, p.y + scale / 2);
|
||
ctx.lineTo(p.x + size, p.y + scale / 2);
|
||
ctx.moveTo(p.x + scale / 2, p.y - size);
|
||
ctx.lineTo(p.x + scale / 2, p.y + size);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 机器人位置
|
||
if (data.playerChunk) {
|
||
const p = chunkToScreen(data.playerChunk.x, data.playerChunk.z, canvas);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.beginPath();
|
||
ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.6), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#0d1117';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
}
|
||
|
||
$('mapInfo').textContent = `缩放 ${scale.toFixed(2)} px/区块 · 中心 ${Math.round(mapView.centerX)}, ${Math.round(mapView.centerZ)}`;
|
||
}
|
||
|
||
function hexToRgba(hex, alpha) {
|
||
const value = String(hex).replace('#', '');
|
||
const r = parseInt(value.slice(0, 2), 16) || 0;
|
||
const g = parseInt(value.slice(2, 4), 16) || 0;
|
||
const b = parseInt(value.slice(4, 6), 16) || 0;
|
||
return `rgba(${r},${g},${b},${alpha})`;
|
||
}
|
||
|
||
let mapLoading = false;
|
||
async function loadMap() {
|
||
if (mapLoading) return;
|
||
mapLoading = true;
|
||
try {
|
||
const full = $('chkFull').checked ? '1' : '0';
|
||
const res = await api('/api/map?full=' + full);
|
||
mapView.data = res.data;
|
||
if (!mapView.centered && res.data.playerChunk) {
|
||
mapView.centerX = res.data.playerChunk.x;
|
||
mapView.centerZ = res.data.playerChunk.z;
|
||
mapView.centered = true;
|
||
}
|
||
drawMap();
|
||
} catch (err) {
|
||
toast('读取地图失败:' + err.message, true);
|
||
} finally {
|
||
mapLoading = false;
|
||
}
|
||
}
|
||
|
||
// ==================== WebSocket ====================
|
||
|
||
let ws = null;
|
||
let wsRetry = 0;
|
||
|
||
function connectWs() {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const query = TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '';
|
||
ws = new WebSocket(`${proto}://${location.host}/ws${query}`);
|
||
ws.onopen = () => { wsRetry = 0; };
|
||
ws.onmessage = (event) => {
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(event.data);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (payload.type === 'status') renderStatus(payload.data);
|
||
else if (payload.type === 'log') appendLog(payload.data);
|
||
else if (payload.type === 'logs') renderLogs(payload.data);
|
||
};
|
||
ws.onclose = () => {
|
||
wsRetry++;
|
||
const delay = Math.min(15000, 1000 * wsRetry);
|
||
setTimeout(connectWs, delay);
|
||
};
|
||
ws.onerror = () => { try { ws.close(); } catch { /* 忽略 */ } };
|
||
}
|
||
|
||
// ==================== 事件绑定 ====================
|
||
|
||
function bindEvents() {
|
||
$('btnStart').addEventListener('click', async () => {
|
||
try {
|
||
const res = await api('/api/task/start', { method: 'POST', body: JSON.stringify(collectConfig()) });
|
||
toast(res.message || '任务已启动');
|
||
renderStatus(res.data);
|
||
loadMap();
|
||
} catch (err) {
|
||
toast('启动失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnStop').addEventListener('click', async () => {
|
||
try {
|
||
await api('/api/task/stop', { method: 'POST' });
|
||
toast('已请求停止');
|
||
} catch (err) {
|
||
toast('停止失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnReconnect').addEventListener('click', async () => {
|
||
try {
|
||
await api('/api/bot/reconnect', { method: 'POST' });
|
||
toast('正在重连');
|
||
} catch (err) {
|
||
toast('重连失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnRefreshMap').addEventListener('click', async () => {
|
||
try {
|
||
await api('/api/map/refresh', { method: 'POST' });
|
||
toast('已触发地图刷新');
|
||
} catch (err) {
|
||
toast(err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnFetchAll').addEventListener('click', async () => {
|
||
toast('正在拉取全服领土(可能较慢)…');
|
||
try {
|
||
const res = await api('/api/api/fetch-all', { method: 'POST' });
|
||
toast(res.message);
|
||
loadMap();
|
||
} catch (err) {
|
||
toast('拉取失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnSaveConfig').addEventListener('click', saveConfig);
|
||
$('btnReloadConfig').addEventListener('click', loadConfig);
|
||
|
||
$('configTabs').addEventListener('click', (e) => {
|
||
const tab = e.target.closest('.tab');
|
||
if (!tab) return;
|
||
activeTab = tab.dataset.tab;
|
||
for (const el of $('configTabs').querySelectorAll('.tab')) {
|
||
el.classList.toggle('active', el === tab);
|
||
}
|
||
renderConfig();
|
||
});
|
||
|
||
$('chkFull').addEventListener('change', loadMap);
|
||
$('chkFlying').addEventListener('change', drawMap);
|
||
$('chkGrid').addEventListener('change', drawMap);
|
||
|
||
$('btnMapCenter').addEventListener('click', () => {
|
||
if (lastStatus && lastStatus.task && lastStatus.task.playerChunk) {
|
||
mapView.centerX = lastStatus.task.playerChunk.x;
|
||
mapView.centerZ = lastStatus.task.playerChunk.z;
|
||
drawMap();
|
||
}
|
||
});
|
||
|
||
$('btnMapFit').addEventListener('click', () => {
|
||
const data = mapView.data;
|
||
if (!data) return;
|
||
let minX = Infinity; let maxX = -Infinity; let minZ = Infinity; let maxZ = -Infinity;
|
||
const absorb = (flat) => {
|
||
for (let i = 0; i < flat.length; i += 2) {
|
||
if (flat[i] < minX) minX = flat[i];
|
||
if (flat[i] > maxX) maxX = flat[i];
|
||
if (flat[i + 1] < minZ) minZ = flat[i + 1];
|
||
if (flat[i + 1] > maxZ) maxZ = flat[i + 1];
|
||
}
|
||
};
|
||
absorb(data.self || []);
|
||
absorb(data.session || []);
|
||
if (!Number.isFinite(minX)) {
|
||
for (const state of data.states || []) {
|
||
if (!state.bounds) continue;
|
||
minX = Math.min(minX, state.bounds.minX);
|
||
maxX = Math.max(maxX, state.bounds.maxX);
|
||
minZ = Math.min(minZ, state.bounds.minZ);
|
||
maxZ = Math.max(maxZ, state.bounds.maxZ);
|
||
}
|
||
}
|
||
if (!Number.isFinite(minX)) return;
|
||
mapView.centerX = (minX + maxX) / 2;
|
||
mapView.centerZ = (minZ + maxZ) / 2;
|
||
const canvas = $('mapCanvas');
|
||
const rect = canvas.getBoundingClientRect();
|
||
const scaleX = rect.width / Math.max(1, maxX - minX + 2);
|
||
const scaleZ = rect.height / Math.max(1, maxZ - minZ + 2);
|
||
mapView.scale = Math.min(24, Math.max(0.15, Math.min(scaleX, scaleZ)));
|
||
drawMap();
|
||
});
|
||
|
||
$('btnClearLog').addEventListener('click', () => { $('logView').innerHTML = ''; });
|
||
$('btnExportLog').addEventListener('click', async () => {
|
||
try {
|
||
const res = await api('/api/logs/export', { method: 'POST' });
|
||
toast('已导出到 ' + res.data.file);
|
||
} catch (err) {
|
||
toast('导出失败:' + err.message, true);
|
||
}
|
||
});
|
||
|
||
$('btnSendCmd').addEventListener('click', sendCommand);
|
||
$('cmdInput').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') sendCommand();
|
||
});
|
||
for (const btn of document.querySelectorAll('[data-cmd]')) {
|
||
btn.addEventListener('click', () => {
|
||
$('cmdInput').value = btn.dataset.cmd;
|
||
sendCommand();
|
||
});
|
||
}
|
||
|
||
async function sendCommand() {
|
||
const command = $('cmdInput').value.trim();
|
||
if (!command) return;
|
||
try {
|
||
await api('/api/command', { method: 'POST', body: JSON.stringify({ command }) });
|
||
toast('已发送:' + command);
|
||
} catch (err) {
|
||
toast('发送失败:' + err.message, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 启动 ====================
|
||
|
||
async function main() {
|
||
initMap();
|
||
bindEvents();
|
||
await loadConfig();
|
||
await loadMap();
|
||
connectWs();
|
||
// 地图数据每 20 秒自动刷新一次(服务端 markers 刷新间隔通常为 30 秒)
|
||
setInterval(loadMap, 20000);
|
||
// 相对时间("x 秒前")需要定期重绘
|
||
setInterval(() => { if (lastStatus) renderStatus(lastStatus); }, 5000);
|
||
}
|
||
|
||
main();
|