520 lines
17 KiB
JavaScript
520 lines
17 KiB
JavaScript
/* 一键换装(游戏内 GM 窗口)
|
||
* IE11 兼容:ES5 + XMLHttpRequest,不使用箭头函数/模板字符串/flex。
|
||
* 数据:/api/loadout/config(本地 config.json),角色/装备/应用走 serve.py 代理到 18111。
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var S = {
|
||
cfg: null,
|
||
chars: [],
|
||
charName: '',
|
||
page: 0,
|
||
scheme: -1,
|
||
equipment: [],
|
||
cooldown: {},
|
||
timer: null
|
||
};
|
||
|
||
function keyText(code) {
|
||
if (code >= 112 && code <= 123) { return 'F' + (code - 111); }
|
||
if (code >= 96 && code <= 105) { return 'Num' + (code - 96); }
|
||
if (code >= 48 && code <= 57) { return String.fromCharCode(code); }
|
||
if (code >= 65 && code <= 90) { return String.fromCharCode(code); }
|
||
if (code === 13) { return 'Enter'; }
|
||
if (code === 32) { return 'Space'; }
|
||
return 'VK' + code;
|
||
}
|
||
|
||
function modText(mods) {
|
||
var out = [];
|
||
if (mods & 2) { out.push('Ctrl'); }
|
||
if (mods & 1) { out.push('Alt'); }
|
||
if (mods & 4) { out.push('Shift'); }
|
||
return out;
|
||
}
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
function esc(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function log(text, cls) {
|
||
var box = $('log');
|
||
if (!box) { return; }
|
||
var d = document.createElement('div');
|
||
if (cls) { d.className = cls; }
|
||
var t = new Date();
|
||
var p = function (n) { return (n < 10 ? '0' : '') + n; };
|
||
d.innerHTML = '[' + p(t.getHours()) + ':' + p(t.getMinutes()) + ':' + p(t.getSeconds()) + '] ' + esc(text);
|
||
box.appendChild(d);
|
||
box.scrollTop = box.scrollHeight;
|
||
while (box.childNodes.length > 120) { box.removeChild(box.firstChild); }
|
||
}
|
||
|
||
function api(path, method, body, cb) {
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open(method || 'GET', '/api' + path, true);
|
||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||
xhr.onreadystatechange = function () {
|
||
if (xhr.readyState !== 4) { return; }
|
||
if (xhr.status === 0) {
|
||
log('连不上本地代理(gmweb/serve.py 未启动?)', 'err');
|
||
if (cb) { cb(null); }
|
||
return;
|
||
}
|
||
var data = null;
|
||
try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; }
|
||
if (!data) {
|
||
log('响应不是 JSON:' + String(xhr.responseText || '').substring(0, 120), 'err');
|
||
if (cb) { cb(null); }
|
||
return;
|
||
}
|
||
if (data.ok === false || data.success === false) {
|
||
log('失败:' + (data.error || data.message || '未知错误'), 'err');
|
||
if (cb) { cb(null); }
|
||
return;
|
||
}
|
||
if (cb) { cb(data); }
|
||
};
|
||
xhr.send(body ? JSON.stringify(body) : null);
|
||
}
|
||
|
||
function msg(text) {
|
||
var el = $('lo-msg');
|
||
if (el) { el.innerHTML = esc(text || ''); }
|
||
}
|
||
|
||
function currentPage() {
|
||
if (!S.cfg || !S.cfg.Pages || !S.cfg.Pages.length) { return null; }
|
||
if (S.page < 0 || S.page >= S.cfg.Pages.length) { S.page = 0; }
|
||
return S.cfg.Pages[S.page];
|
||
}
|
||
|
||
function normalizeConfig(data) {
|
||
if (!data || !data.Pages || !data.Pages.length) {
|
||
data = { ServerUrl: 'http://127.0.0.1:18111', AdminToken: '', ActivePage: 0,
|
||
Pages: [{ Name: '默认页', Schemes: [] }] };
|
||
}
|
||
if (!data.Pages[0].Schemes) { data.Pages[0].Schemes = []; }
|
||
S.page = parseInt(data.ActivePage, 10) || 0;
|
||
if (S.page < 0 || S.page >= data.Pages.length) { S.page = 0; }
|
||
return data;
|
||
}
|
||
|
||
function loadHotkeys() {
|
||
api('/loadout/hotkeys', 'GET', null, function (d) {
|
||
var el = $('lo-hotkeys');
|
||
if (!el) { return; }
|
||
if (!d || !d.data || !(d.data.schemes || []).length) {
|
||
el.innerHTML = '全局热键:当前页没有配置快捷键';
|
||
return;
|
||
}
|
||
var parts = [];
|
||
var schemes = d.data.schemes;
|
||
for (var i = 0; i < schemes.length; i++) {
|
||
var sc = schemes[i];
|
||
var mods = modText(sc.modifiers || 0);
|
||
mods.push(keyText(sc.keycode));
|
||
parts.push(esc(sc.name || '') + ' → ' + esc(mods.join('+')));
|
||
}
|
||
el.innerHTML = '全局热键(当前页):' + parts.join(' ');
|
||
});
|
||
}
|
||
|
||
function loadConfig(cb) {
|
||
api('/loadout/config', 'GET', null, function (d) {
|
||
if (!d) { msg('读取换装配置失败'); if (cb) { cb(false); } return; }
|
||
S.cfg = normalizeConfig(d.data || {});
|
||
renderPages();
|
||
renderSchemes();
|
||
loadHotkeys();
|
||
if (cb) { cb(true); }
|
||
});
|
||
}
|
||
|
||
function saveConfig(cb) {
|
||
if (!S.cfg) { return; }
|
||
S.cfg.ActivePage = S.page;
|
||
api('/loadout/config', 'POST', S.cfg, function (d) {
|
||
if (d) { msg('换装配置已保存'); log('换装配置已保存', 'ok'); loadHotkeys(); }
|
||
if (cb) { cb(!!d); }
|
||
});
|
||
}
|
||
|
||
function renderPages() {
|
||
var sel = $('lo-page');
|
||
if (!sel) { return; }
|
||
var pages = (S.cfg && S.cfg.Pages) || [];
|
||
var h = [];
|
||
for (var i = 0; i < pages.length; i++) {
|
||
h.push('<option value="' + i + '">' + esc(pages[i].Name || ('页 ' + (i + 1))) + '</option>');
|
||
}
|
||
sel.innerHTML = h.join('');
|
||
sel.value = String(S.page);
|
||
}
|
||
|
||
function renderSchemes() {
|
||
var box = $('lo-schemes');
|
||
if (!box) { return; }
|
||
var page = currentPage();
|
||
var schemes = (page && page.Schemes) || [];
|
||
if (!schemes.length) {
|
||
box.innerHTML = '<div class="lo-empty">当前页还没有方案</div>';
|
||
S.scheme = -1;
|
||
renderDetail();
|
||
return;
|
||
}
|
||
var h = [];
|
||
for (var i = 0; i < schemes.length; i++) {
|
||
var sc = schemes[i];
|
||
h.push('<div class="li' + (i === S.scheme ? ' on' : '') + '" data-i="' + i + '">' +
|
||
'<span class="nm">' + esc(sc.Name || ('方案 ' + (i + 1))) + '</span>' +
|
||
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '') +
|
||
' · ' + ((sc.Slots || []).length) + ' 件</span></div>');
|
||
}
|
||
box.innerHTML = h.join('');
|
||
var lis = box.getElementsByClassName('li');
|
||
for (var k = 0; k < lis.length; k++) {
|
||
lis[k].onclick = function () {
|
||
S.scheme = parseInt(this.getAttribute('data-i'), 10);
|
||
renderSchemes();
|
||
};
|
||
}
|
||
if (S.scheme < 0 || S.scheme >= schemes.length) { S.scheme = 0; }
|
||
var active = box.getElementsByClassName('li')[S.scheme];
|
||
if (active) { active.className = 'li on'; }
|
||
renderDetail();
|
||
}
|
||
|
||
function renderDetail() {
|
||
var page = currentPage();
|
||
var schemes = (page && page.Schemes) || [];
|
||
var detail = $('lo-detail');
|
||
var slotsBox = $('lo-slots');
|
||
if (!detail || !slotsBox) { return; }
|
||
var sc = schemes[S.scheme];
|
||
if (!sc) {
|
||
detail.innerHTML = '未选择方案';
|
||
slotsBox.innerHTML = '';
|
||
var emptyHotkey = $('lo-hotkey-text');
|
||
if (emptyHotkey) { emptyHotkey.innerHTML = ''; }
|
||
return;
|
||
}
|
||
detail.innerHTML = '<b>' + esc(sc.Name || '') + '</b>' +
|
||
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '未绑定角色') + '</span>';
|
||
var hotkeyTextEl = $('lo-hotkey-text');
|
||
if (hotkeyTextEl) { hotkeyTextEl.innerHTML = '快捷键:' + esc(hotkeyText(sc)); }
|
||
var slots = sc.Slots || [];
|
||
if (!slots.length) {
|
||
slotsBox.innerHTML = '<div class="lo-empty">该方案没有保存装备</div>';
|
||
return;
|
||
}
|
||
var h = [];
|
||
for (var i = 0; i < slots.length; i++) {
|
||
h.push('<div class="lo-slot">部位 <b>' + esc(slots[i].slot) + '</b>' +
|
||
'<span class="id">ID ' + esc(slots[i].item_id) + '</span></div>');
|
||
}
|
||
slotsBox.innerHTML = h.join('');
|
||
}
|
||
|
||
function hotkeyText(sc) {
|
||
if (!sc || !sc.KeyCode) { return '未设置'; }
|
||
var parts = modText(sc.Modifiers || 0);
|
||
parts.push(keyText(sc.KeyCode));
|
||
return parts.join('+');
|
||
}
|
||
|
||
function currentScheme() {
|
||
var page = currentPage();
|
||
var schemes = (page && page.Schemes) || [];
|
||
return schemes[S.scheme] || null;
|
||
}
|
||
|
||
function setHotkey() {
|
||
var sc = currentScheme();
|
||
if (!sc) { msg('请先选择方案'); return; }
|
||
S.capture = true;
|
||
msg('请按下快捷键,Esc 取消');
|
||
var handler = function (ev) {
|
||
ev = ev || window.event;
|
||
var code = ev.keyCode || ev.which;
|
||
if (code === 27) {
|
||
S.capture = false;
|
||
document.removeEventListener('keydown', handler, true);
|
||
msg('已取消设置快捷键');
|
||
return false;
|
||
}
|
||
if (!code || code === 16 || code === 17 || code === 18) {
|
||
return false;
|
||
}
|
||
var mods = 0;
|
||
if (ev.ctrlKey) { mods |= 2; }
|
||
if (ev.altKey) { mods |= 1; }
|
||
if (ev.shiftKey) { mods |= 4; }
|
||
sc.KeyCode = code;
|
||
sc.Modifiers = mods;
|
||
S.capture = false;
|
||
document.removeEventListener('keydown', handler, true);
|
||
saveConfig(function (ok) {
|
||
if (ok) {
|
||
renderDetail();
|
||
loadHotkeys();
|
||
msg('快捷键已保存:' + hotkeyText(sc));
|
||
}
|
||
});
|
||
if (ev.preventDefault) { ev.preventDefault(); }
|
||
return false;
|
||
};
|
||
document.addEventListener('keydown', handler, true);
|
||
}
|
||
|
||
function clearHotkey() {
|
||
var sc = currentScheme();
|
||
if (!sc) { msg('请先选择方案'); return; }
|
||
sc.KeyCode = 0;
|
||
sc.Modifiers = 0;
|
||
saveConfig(function (ok) {
|
||
if (ok) {
|
||
renderDetail();
|
||
loadHotkeys();
|
||
msg('快捷键已清除');
|
||
}
|
||
});
|
||
}
|
||
|
||
function addScheme() {
|
||
var page = currentPage();
|
||
if (!page) { return; }
|
||
var name = window.prompt('方案名称', '方案' + ((page.Schemes || []).length + 1));
|
||
if (!name) { return; }
|
||
var character = window.prompt('绑定角色游戏内名称', S.charName || '') || '';
|
||
page.Schemes = page.Schemes || [];
|
||
page.Schemes.push({
|
||
Name: String(name),
|
||
CharacterName: String(character),
|
||
Modifiers: 0,
|
||
KeyCode: 0,
|
||
Slots: []
|
||
});
|
||
S.scheme = page.Schemes.length - 1;
|
||
saveConfig(function (ok) {
|
||
if (ok) {
|
||
renderSchemes();
|
||
msg('已新增方案:' + name);
|
||
}
|
||
});
|
||
}
|
||
|
||
function renameScheme() {
|
||
var sc = currentScheme();
|
||
if (!sc) { msg('请先选择方案'); return; }
|
||
var name = window.prompt('重命名方案', sc.Name || '');
|
||
if (!name) { return; }
|
||
sc.Name = String(name);
|
||
saveConfig(function (ok) {
|
||
if (ok) {
|
||
renderSchemes();
|
||
msg('方案已重命名');
|
||
}
|
||
});
|
||
}
|
||
|
||
function deleteScheme() {
|
||
var page = currentPage();
|
||
var sc = currentScheme();
|
||
if (!page || !sc) { msg('请先选择方案'); return; }
|
||
if (!window.confirm('删除方案“' + (sc.Name || '') + '”?')) { return; }
|
||
page.Schemes.splice(S.scheme, 1);
|
||
S.scheme = -1;
|
||
saveConfig(function (ok) {
|
||
if (ok) {
|
||
renderSchemes();
|
||
msg('方案已删除');
|
||
}
|
||
});
|
||
}
|
||
|
||
function loadChars() {
|
||
api('/loadout/characters', 'GET', null, function (d) {
|
||
if (!d) { msg('角色读取失败,确认服务端已启动'); return; }
|
||
S.chars = d.data || [];
|
||
var sel = $('lo-char');
|
||
if (!sel) { return; }
|
||
var old = S.charName;
|
||
var h = ['<option value="">选择角色</option>'];
|
||
for (var i = 0; i < S.chars.length; i++) {
|
||
var c = S.chars[i];
|
||
h.push('<option value="' + esc(c.name) + '">' + esc(c.name) +
|
||
'(Lv.' + esc(c.level) + (c.online ? ' · 在线' : '') + ')</option>');
|
||
}
|
||
sel.innerHTML = h.join('');
|
||
if (old) { sel.value = old; }
|
||
if (!sel.value && S.chars.length === 1) { sel.value = S.chars[0].name; }
|
||
S.charName = sel.value || '';
|
||
msg('角色 ' + S.chars.length + ' 个');
|
||
});
|
||
}
|
||
|
||
function fetchEquipment(characterName, cb) {
|
||
api('/local/dnf-gm/loadout/equipment?character_id=' + encodeURIComponent(characterName), 'GET', null, function (d) {
|
||
if (!d) { cb(null); return; }
|
||
cb((d.data && d.data.slots) || []);
|
||
});
|
||
}
|
||
|
||
function saveCurrent() {
|
||
if (!S.charName) { msg('请先选择角色'); return; }
|
||
if (!S.cfg) { return; }
|
||
msg('正在读取当前装备…');
|
||
fetchEquipment(S.charName, function (slots) {
|
||
if (!slots) { msg('读取当前装备失败'); return; }
|
||
if (!slots.length) { msg('当前没有可保存的装备'); return; }
|
||
var name = window.prompt('方案名称', S.charName + ' 当前装备');
|
||
if (!name) { msg('已取消'); return; }
|
||
var page = currentPage();
|
||
if (!page) { return; }
|
||
if (!page.Schemes) { page.Schemes = []; }
|
||
page.Schemes.push({
|
||
Name: String(name),
|
||
CharacterName: S.charName,
|
||
Modifiers: 0,
|
||
KeyCode: 0,
|
||
Slots: slots
|
||
});
|
||
S.scheme = page.Schemes.length - 1;
|
||
saveConfig(function (ok) {
|
||
if (ok) { renderSchemes(); msg('已保存方案:' + name); }
|
||
});
|
||
});
|
||
}
|
||
|
||
function cooldownRemaining(name) {
|
||
var until = S.cooldown[name] || 0;
|
||
return Math.max(0, Math.ceil((until - Date.now()) / 1000));
|
||
}
|
||
|
||
function tickCooldown() {
|
||
var el = $('lo-cooldown');
|
||
var page = currentPage();
|
||
var schemes = (page && page.Schemes) || [];
|
||
var sc = schemes[S.scheme];
|
||
if (!el) { return; }
|
||
if (!sc) { el.innerHTML = ''; return; }
|
||
var remain = cooldownRemaining(sc.CharacterName || '');
|
||
el.innerHTML = remain > 0 ? ('换装冷却 ' + remain + ' 秒') : '';
|
||
}
|
||
|
||
function applyScheme() {
|
||
var page = currentPage();
|
||
var schemes = (page && page.Schemes) || [];
|
||
var sc = schemes[S.scheme];
|
||
if (!sc) { msg('请先选择方案'); return; }
|
||
if (!sc.CharacterName || !(sc.Slots || []).length) { msg('方案没有角色或装备'); return; }
|
||
var remain = cooldownRemaining(sc.CharacterName);
|
||
if (remain > 0) { msg('换装冷却中,剩余 ' + remain + ' 秒'); return; }
|
||
msg('正在应用方案…');
|
||
api('/local/dnf-gm/loadout/apply', 'POST', { character_id: sc.CharacterName, slots: sc.Slots }, function (d) {
|
||
if (!d) { msg('应用失败'); return; }
|
||
var r = d.data || {};
|
||
var applied = (r.applied || []).length;
|
||
var skipped = (r.skipped || []).length;
|
||
var sec = parseInt(r.cooldown_seconds, 10) || 0;
|
||
if (sec > 0) {
|
||
S.cooldown[sc.CharacterName] = Date.now() + sec * 1000;
|
||
}
|
||
msg('应用完成:成功 ' + applied + ' 件,跳过 ' + skipped + ' 件' +
|
||
(sec > 0 ? (',冷却 ' + sec + ' 秒') : ''));
|
||
log('换装 ' + sc.Name + ':成功 ' + applied + ',跳过 ' + skipped, 'ok');
|
||
tickCooldown();
|
||
});
|
||
}
|
||
|
||
function addPage() {
|
||
var name = window.prompt('新换装页名称', '新页面');
|
||
if (!name || !S.cfg) { return; }
|
||
S.cfg.Pages.push({ Name: String(name), Schemes: [] });
|
||
S.page = S.cfg.Pages.length - 1;
|
||
S.scheme = -1;
|
||
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
|
||
}
|
||
|
||
function renamePage() {
|
||
var page = currentPage();
|
||
if (!page || !S.cfg) { return; }
|
||
var name = window.prompt('重命名换装页', page.Name || '');
|
||
if (!name) { return; }
|
||
page.Name = String(name);
|
||
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
|
||
}
|
||
|
||
function deletePage() {
|
||
if (!S.cfg || S.cfg.Pages.length <= 1) { msg('至少保留一个换装页'); return; }
|
||
var page = currentPage();
|
||
if (!page) { return; }
|
||
if (!window.confirm('删除换装页「' + page.Name + '」及其方案?')) { return; }
|
||
S.cfg.Pages.splice(S.page, 1);
|
||
S.page = Math.max(0, S.page - 1);
|
||
S.scheme = -1;
|
||
saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } });
|
||
}
|
||
|
||
function bind() {
|
||
var sel = $('lo-page');
|
||
if (sel) {
|
||
sel.onchange = function () {
|
||
S.page = parseInt(this.value, 10) || 0;
|
||
S.scheme = -1;
|
||
if (S.cfg) { S.cfg.ActivePage = S.page; }
|
||
renderSchemes();
|
||
loadHotkeys();
|
||
// The launcher hotkey worker reads ActivePage from config.json. Persist
|
||
// the page switch here, otherwise a hotkey assigned on this page never
|
||
// becomes the active page's hotkey.
|
||
saveConfig(function (ok) {
|
||
if (ok) { loadHotkeys(); }
|
||
});
|
||
};
|
||
}
|
||
var ch = $('lo-char');
|
||
if (ch) { ch.onchange = function () { S.charName = this.value; }; }
|
||
var b;
|
||
if ((b = $('lo-refresh'))) { b.onclick = function () { loadChars(); }; }
|
||
if ((b = $('lo-save'))) { b.onclick = saveCurrent; }
|
||
if ((b = $('lo-apply'))) { b.onclick = applyScheme; }
|
||
if ((b = $('lo-page-add'))) { b.onclick = addPage; }
|
||
if ((b = $('lo-page-ren'))) { b.onclick = renamePage; }
|
||
if ((b = $('lo-page-del'))) { b.onclick = deletePage; }
|
||
if ((b = $('lo-scheme-add'))) { b.onclick = addScheme; }
|
||
if ((b = $('lo-scheme-ren'))) { b.onclick = renameScheme; }
|
||
if ((b = $('lo-scheme-del'))) { b.onclick = deleteScheme; }
|
||
if ((b = $('lo-hotkey'))) { b.onclick = setHotkey; }
|
||
if ((b = $('lo-hotkey-clear'))) { b.onclick = clearHotkey; }
|
||
}
|
||
|
||
function loadTab() {
|
||
msg('正在读取换装配置…');
|
||
loadConfig(function (ok) {
|
||
if (!ok) { return; }
|
||
loadChars();
|
||
if (!S.timer) { S.timer = setInterval(tickCooldown, 500); }
|
||
});
|
||
}
|
||
|
||
window.LoadoutTab = loadTab;
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', bind);
|
||
} else {
|
||
bind();
|
||
}
|
||
|
||
// 通过 #loadout 直接打开时,app.js 可能已经在 loadout.js 之前切过页
|
||
if ((location.hash || '').replace('#', '') === 'loadout') {
|
||
setTimeout(loadTab, 0);
|
||
}
|
||
})();
|