gmweb:游戏内一键换装、方案配置与全局热键说明

This commit is contained in:
hanfuit committed 2026-11-05 18:19:24 +08:00
1 parent 3b240ec176
commit cc546031b0
5 files changed
+586 -3

No files matched your search

+3
View File
@@ -67,6 +67,9 @@
panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : '');
}
try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { }
if (name === 'loadout' && typeof window.LoadoutTab === 'function') {
window.LoadoutTab();
}
}
function bindTabs() {
+39
View File
@@ -11,6 +11,7 @@
<div id="tabs">
<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="loadout" 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>
</div>
@@ -129,6 +130,43 @@
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</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="about-inner">
<div class="about-card">
@@ -193,5 +231,6 @@
<div id="log"></div>
<script src="/app.js?v=20261105C"></script>
<script src="/loadout.js?v=20261105A"></script>
</body>
</html>
+386
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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
View File
@@ -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"
# --------------------------------------------------------------------- 换装配置
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
class Handler(BaseHTTPRequestHandler):
@@ -146,18 +265,41 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream"))
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/"):
code, data, ctype = proxy("GET", self.path[4:], None)
self._send(code, data, ctype)
return
self._static(self.path.split("?")[0])
self._static(route)
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/"):
self._send(404, b"not found", "text/plain")
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)
self._send(code, data, ctype)
+13
View File
@@ -112,6 +112,19 @@ select.inp { padding: 3px 2px; }
#log .tip { color: #e8c979; }
.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 {
font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px;