gmweb:游戏内一键换装、方案配置与全局热键说明
This commit is contained in:
1 parent
3b240ec176
commit
cc546031b0
5 files changed
+586
-3
No files matched your search
@@ -67,6 +67,9 @@
|
|||||||
panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : '');
|
panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : '');
|
||||||
}
|
}
|
||||||
try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { }
|
try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { }
|
||||||
|
if (name === 'loadout' && typeof window.LoadoutTab === 'function') {
|
||||||
|
window.LoadoutTab();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindTabs() {
|
function bindTabs() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<div id="tabs">
|
<div id="tabs">
|
||||||
<a class="tab on" data-tab="home" href="javascript:void(0)">状态</a>
|
<a class="tab on" data-tab="home" href="javascript:void(0)">状态</a>
|
||||||
<a class="tab" data-tab="char" href="javascript:void(0)">角色</a>
|
<a class="tab" data-tab="char" href="javascript:void(0)">角色</a>
|
||||||
|
<a class="tab" data-tab="loadout" href="javascript:void(0)">一键换装</a>
|
||||||
<a class="tab" data-tab="item" href="javascript:void(0)">发物品</a>
|
<a class="tab" data-tab="item" href="javascript:void(0)">发物品</a>
|
||||||
<a class="tab" data-tab="about" href="javascript:void(0)">关于</a>
|
<a class="tab" data-tab="about" href="javascript:void(0)">关于</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,6 +130,43 @@
|
|||||||
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div>
|
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pane" id="pane-loadout">
|
||||||
|
<div class="row">
|
||||||
|
<label class="lb">角色</label>
|
||||||
|
<select id="lo-char" class="inp sm"></select>
|
||||||
|
<button id="lo-refresh" class="btn">刷新</button>
|
||||||
|
<span id="lo-msg" class="dim"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lo-wrap">
|
||||||
|
<div class="lo-col lo-left">
|
||||||
|
<div class="sec">换装页</div>
|
||||||
|
<div class="row">
|
||||||
|
<select id="lo-page" class="inp sm"></select>
|
||||||
|
<button id="lo-page-add" class="btn sm">新增</button>
|
||||||
|
<button id="lo-page-ren" class="btn sm">重命名</button>
|
||||||
|
<button id="lo-page-del" class="btn sm">删除</button>
|
||||||
|
</div>
|
||||||
|
<div class="sec">方案</div>
|
||||||
|
<div id="lo-schemes" class="list fixed"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="lo-save" class="btn pri">保存当前装备为方案</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">保存后会给当前角色创建一套换装方案。</div>
|
||||||
|
<div id="lo-hotkeys" class="hint"></div>
|
||||||
|
</div>
|
||||||
|
<div class="lo-col lo-right">
|
||||||
|
<div class="sec">方案详情</div>
|
||||||
|
<div id="lo-detail" class="cur">未选择方案</div>
|
||||||
|
<div id="lo-slots" class="lo-slots"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="lo-apply" class="btn pri">应用方案</button>
|
||||||
|
<span id="lo-cooldown" class="dim"></span>
|
||||||
|
</div>
|
||||||
|
<div class="hint">每次换装完成后进入 <b style="color:#e8c979">10 秒冷却</b>;冷却期间点击按钮或按全局热键都会被服务端拒绝。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pane" id="pane-about">
|
<div class="pane" id="pane-about">
|
||||||
<div class="about-inner">
|
<div class="about-inner">
|
||||||
<div class="about-card">
|
<div class="about-card">
|
||||||
@@ -193,5 +231,6 @@
|
|||||||
<div id="log"></div>
|
<div id="log"></div>
|
||||||
|
|
||||||
<script src="/app.js?v=20261105C"></script>
|
<script src="/app.js?v=20261105C"></script>
|
||||||
|
<script src="/loadout.js?v=20261105A"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
/* 一键换装(游戏内 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 = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detail.innerHTML = '<b>' + esc(sc.Name || '') + '</b>' +
|
||||||
|
'<span class="lo-scheme-meta">' + esc(sc.CharacterName || '未绑定角色') + '</span>';
|
||||||
|
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 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;
|
||||||
|
renderSchemes();
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
+145
-3
@@ -115,6 +115,125 @@ def proxy(method: str, path: str, body: bytes | None) -> tuple[int, bytes, str]:
|
|||||||
return 502, msg, "application/json; charset=utf-8"
|
return 502, msg, "application/json; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- 换装配置
|
||||||
|
|
||||||
|
def _loadout_config_path() -> Path:
|
||||||
|
"""LoadoutTool/config.json,优先部署根目录,其次运行目录。"""
|
||||||
|
roots = [HERE.parent]
|
||||||
|
env = os.environ.get("DNF90_SERVER_ROOT")
|
||||||
|
if env:
|
||||||
|
roots.insert(0, Path(env))
|
||||||
|
for root in roots:
|
||||||
|
p = root / "LoadoutTool" / "config.json"
|
||||||
|
if p.is_file():
|
||||||
|
return p
|
||||||
|
return roots[0] / "LoadoutTool" / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_loadout_config() -> dict:
|
||||||
|
return {
|
||||||
|
"ServerUrl": "http://127.0.0.1:18111",
|
||||||
|
"AdminToken": "",
|
||||||
|
"Pages": [{"Name": "默认页", "Schemes": []}],
|
||||||
|
"ActivePage": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_loadout_config() -> dict:
|
||||||
|
p = _loadout_config_path()
|
||||||
|
try:
|
||||||
|
if p.is_file():
|
||||||
|
data = json.loads(p.read_text(encoding="utf-8-sig"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if not isinstance(data.get("Pages"), list) or not data["Pages"]:
|
||||||
|
data["Pages"] = [{"Name": "默认页", "Schemes": []}]
|
||||||
|
data["ActivePage"] = max(0, min(int(data.get("ActivePage") or 0), len(data["Pages"]) - 1))
|
||||||
|
# AdminToken 只留在本地文件,网页端永远拿不到。
|
||||||
|
data["AdminToken"] = ""
|
||||||
|
return data
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return _default_loadout_config()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_loadout_config(data: dict) -> tuple[int, bytes, str]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return 400, b'{"ok":false,"error":"config must be an object"}', "application/json"
|
||||||
|
pages = data.get("Pages")
|
||||||
|
if not isinstance(pages, list) or not pages:
|
||||||
|
return 400, b'{"ok":false,"error":"Pages must be a non-empty list"}', "application/json"
|
||||||
|
p = _loadout_config_path()
|
||||||
|
try:
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# 网页提交的配置里没有 token;保留磁盘上原来的 token。
|
||||||
|
existing = {}
|
||||||
|
if p.is_file():
|
||||||
|
try:
|
||||||
|
existing = json.loads(p.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
existing = {}
|
||||||
|
data = dict(data)
|
||||||
|
data["AdminToken"] = existing.get("AdminToken", "") if isinstance(existing, dict) else ""
|
||||||
|
tmp = p.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(p)
|
||||||
|
except OSError as exc:
|
||||||
|
return 500, json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False).encode(), "application/json"
|
||||||
|
return 200, b'{"ok":true}', "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
def _loadout_hotkeys() -> dict:
|
||||||
|
"""Return the active page's configured global hotkeys for the UI hint."""
|
||||||
|
cfg = _read_loadout_config()
|
||||||
|
pages = cfg.get("Pages") or []
|
||||||
|
try:
|
||||||
|
page = pages[int(cfg.get("ActivePage") or 0)]
|
||||||
|
except (IndexError, TypeError, ValueError):
|
||||||
|
page = pages[0] if pages else {}
|
||||||
|
out = []
|
||||||
|
for scheme in (page.get("Schemes") or []):
|
||||||
|
keycode = int(scheme.get("KeyCode") or 0)
|
||||||
|
if keycode <= 0:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"name": scheme.get("Name") or "",
|
||||||
|
"character": scheme.get("CharacterName") or "",
|
||||||
|
"keycode": keycode,
|
||||||
|
"modifiers": int(scheme.get("Modifiers") or 0),
|
||||||
|
})
|
||||||
|
return {"page": page.get("Name") or "", "schemes": out}
|
||||||
|
|
||||||
|
|
||||||
|
def _loadout_characters() -> tuple[int, bytes, str]:
|
||||||
|
"""Character list for the loadout page.
|
||||||
|
|
||||||
|
The admin endpoint without a selector is bound to the logged-in account and
|
||||||
|
can be empty when the game client is still at the login screen. The
|
||||||
|
launcher's local DB API already exposes the full local character list, so
|
||||||
|
reuse it here and keep the loadout page usable before entering the game.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
req = request.Request("http://127.0.0.1:19527/api/characters",
|
||||||
|
headers={"Accept": "application/json"})
|
||||||
|
with OPENER.open(req, timeout=8) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
chars = data.get("data") if isinstance(data, dict) else None
|
||||||
|
out = []
|
||||||
|
for c in chars or []:
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"character_id": str(c.get("character_id") or ""),
|
||||||
|
"name": c.get("name") or "",
|
||||||
|
"level": c.get("level") or 0,
|
||||||
|
"online": bool(c.get("online")),
|
||||||
|
})
|
||||||
|
return 200, json.dumps({"ok": True, "data": out}, ensure_ascii=False).encode(), "application/json; charset=utf-8"
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return 502, json.dumps({"ok": False, "error": "读取角色失败:%s" % exc},
|
||||||
|
ensure_ascii=False).encode(), "application/json; charset=utf-8"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- HTTP
|
# --------------------------------------------------------------------- HTTP
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@@ -146,18 +265,41 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream"))
|
self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream"))
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
|
route = self.path.split("?", 1)[0]
|
||||||
|
if route == "/api/loadout/config":
|
||||||
|
payload = json.dumps({"ok": True, "data": _read_loadout_config()}, ensure_ascii=False).encode()
|
||||||
|
self._send(200, payload, "application/json; charset=utf-8")
|
||||||
|
return
|
||||||
|
if route == "/api/loadout/characters":
|
||||||
|
code, payload, ctype = _loadout_characters()
|
||||||
|
self._send(code, payload, ctype)
|
||||||
|
return
|
||||||
|
if route == "/api/loadout/hotkeys":
|
||||||
|
payload = json.dumps({"ok": True, "data": _loadout_hotkeys()}, ensure_ascii=False).encode()
|
||||||
|
self._send(200, payload, "application/json; charset=utf-8")
|
||||||
|
return
|
||||||
if self.path.startswith("/api/"):
|
if self.path.startswith("/api/"):
|
||||||
code, data, ctype = proxy("GET", self.path[4:], None)
|
code, data, ctype = proxy("GET", self.path[4:], None)
|
||||||
self._send(code, data, ctype)
|
self._send(code, data, ctype)
|
||||||
return
|
return
|
||||||
self._static(self.path.split("?")[0])
|
self._static(route)
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
|
route = self.path.split("?", 1)[0]
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = self.rfile.read(length) if length else b"{}"
|
||||||
|
if route == "/api/loadout/config":
|
||||||
|
try:
|
||||||
|
data = json.loads(body.decode("utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
self._send(400, b'{"ok":false,"error":"invalid json"}', "application/json")
|
||||||
|
return
|
||||||
|
code, payload, ctype = _write_loadout_config(data)
|
||||||
|
self._send(code, payload, ctype)
|
||||||
|
return
|
||||||
if not self.path.startswith("/api/"):
|
if not self.path.startswith("/api/"):
|
||||||
self._send(404, b"not found", "text/plain")
|
self._send(404, b"not found", "text/plain")
|
||||||
return
|
return
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
body = self.rfile.read(length) if length else b"{}"
|
|
||||||
code, data, ctype = proxy("POST", self.path[4:], body)
|
code, data, ctype = proxy("POST", self.path[4:], body)
|
||||||
self._send(code, data, ctype)
|
self._send(code, data, ctype)
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,19 @@ select.inp { padding: 3px 2px; }
|
|||||||
#log .tip { color: #e8c979; }
|
#log .tip { color: #e8c979; }
|
||||||
.hint { color: #666e80; font-size: 11px; margin-top: 6px; }
|
.hint { color: #666e80; font-size: 11px; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* 一键换装:IE11 兼容,float 两栏 */
|
||||||
|
.lo-wrap { margin-top: 4px; overflow: hidden; }
|
||||||
|
.lo-col { float: left; box-sizing: border-box; }
|
||||||
|
.lo-left { width: 42%; padding-right: 10px; }
|
||||||
|
.lo-right { width: 58%; border-left: 1px solid #2b3040; padding-left: 10px; min-height: 260px; }
|
||||||
|
.lo-slots { margin-top: 6px; border: 1px solid #2b3040; background: #101319; max-height: 166px; overflow: auto; }
|
||||||
|
.lo-slot { padding: 3px 8px; border-bottom: 1px solid #1c212b; font-size: 11px; color: #9aa2b4; }
|
||||||
|
.lo-slot b { color: #dfe3ea; font-weight: normal; }
|
||||||
|
.lo-slot .id { color: #e8c979; margin-left: 8px; }
|
||||||
|
.lo-scheme-meta { color: #7c8496; font-size: 11px; margin-left: 6px; }
|
||||||
|
.lo-empty { padding: 10px; color: #666e80; text-align: center; }
|
||||||
|
#lo-cooldown { margin-left: 8px; }
|
||||||
|
|
||||||
/* 背包页:刚发进去那一行的「新」标记 */
|
/* 背包页:刚发进去那一行的「新」标记 */
|
||||||
.li .new {
|
.li .new {
|
||||||
font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px;
|
font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px;
|
||||||
|
|||||||
Reference in new issue
Block a user