diff --git a/launcher/index.html b/launcher/index.html index 116e574..caf86bc 100644 --- a/launcher/index.html +++ b/launcher/index.html @@ -28,12 +28,9 @@ - - -
- +
@@ -111,82 +108,6 @@
-
-
-

服务端工具

按原 ServerTool 页签组织,配置写入 server-features.json / activity.json。

文件配置
-
- - - - - - -
-
-
-
-
-
- -
-
-

支援兵

按账号配置支援兵;配置只影响下一次进入副本。

-
- - - - -
-
- - - - - -
-

选择支援兵角色

-
选择出战角色后加载支援兵列表
-
-

AI 策略

-
- - - - - - -
-
- 高级调参:距离、追击、视野、重试和治疗阈值 -
- - - - - - - - - - - - - - - - -
-
-
AI 策略按角色保存;保存后下一次进入副本生效。
-
-
支援兵按账号隔离,只能选择同一账号下的其他角色。
-
-
- -
-

插件

插件目录:启动器扩展位,后续接入 LoadoutTool / ServerTool 之外的模块。
-
-

设置

当前启动器实际绑定的服务端、数据库和游戏端。

@@ -202,7 +123,7 @@
-

更新日志

从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。

+

游戏更新

从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。

diff --git a/launcher/native.py b/launcher/native.py index 2e24cbe..fe4bf20 100644 --- a/launcher/native.py +++ b/launcher/native.py @@ -622,16 +622,3 @@ class NativeClient(object): def loadout_apply(self, character_id, slots): return self._unwrap(self._post("/local/dnf-gm/loadout/apply", { "character_id": str(character_id), "slots": slots})) - - def get_server_features(self): - return self._unwrap(self._get("/dnf/gm/features")) - - def save_server_features(self, features): - return self._unwrap(self._post("/dnf/gm/features", features)) - - def companion_state(self, character_id): - qs = urlencode({"characterId": str(character_id)}) - return self._unwrap(self._get("/local/companion?" + qs)) - - def companion_save(self, payload): - return self._unwrap(self._post("/local/companion", payload)) diff --git a/launcher/server.py b/launcher/server.py index d60e2af..bc1fab5 100644 --- a/launcher/server.py +++ b/launcher/server.py @@ -111,12 +111,8 @@ LOADOUT_EXE = os.path.join(LOADOUT_DIR, "换装工具.exe") if not os.path.exists(LOADOUT_EXE): LOADOUT_EXE = os.path.join(LOADOUT_DIR, "DNFLoadout.exe") -# 一键换装 / 服务端工具的持久化配置路径 +# 一键换装的持久化配置路径 LOADOUT_CONFIG = os.path.join(LOADOUT_DIR, "config.json") -SERVER_CONFIG_DIR = os.path.join(SERVER_ROOT, "Server", "runtime", "config") -SERVER_FEATURES_PATH = os.path.join(SERVER_CONFIG_DIR, "server-features.json") -ACTIVITY_CONFIG_PATH = os.path.join(SERVER_CONFIG_DIR, "activity.json") -RAW_CONFIG_NAMES = ("instance.json", "dnfbridge.toml", "logic.toml", "plan.json") # 赞助二维码图片路径(放启动器目录下 sponsor.png) SPONSOR_IMG = os.path.join(BASE_DIR, "sponsor.png") @@ -547,23 +543,6 @@ class Handler(BaseHTTPRequestHandler): self._api_loadout_characters() elif path == "/api/loadout/equipment": self._api_loadout_equipment(qs) - elif path == "/api/servertool/features": - self._api_servertool_features() - elif path == "/api/servertool/activity": - self._api_servertool_activity() - elif path == "/api/servertool/info": - self._api_servertool_info() - elif path == "/api/servertool/raw": - self._api_servertool_raw(qs) - elif path == "/api/servertool/gameplay": - self._api_servertool_gameplay() - elif path == "/api/servertool/batch-pickup": - self._api_servertool_batch_pickup() - elif path == "/api/companion": - self._api_companion(qs) - elif path == "/api/basic-attack": - self._json({"ok": True, "enabled": not os.path.exists( - os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))}) elif path == "/api/logs": self._api_logs() elif path == "/api/gm/health": @@ -629,32 +608,6 @@ class Handler(BaseHTTPRequestHandler): self._api_save_loadout_config(data) elif path == "/api/loadout/apply": self._api_loadout_apply(data) - elif path == "/api/servertool/features": - self._api_save_servertool_features(data) - elif path == "/api/servertool/activity": - self._api_save_servertool_activity(data) - elif path == "/api/servertool/raw": - self._api_save_servertool_raw(data) - elif path == "/api/servertool/gameplay": - self._api_save_servertool_gameplay(data) - elif path == "/api/servertool/batch-pickup": - self._api_save_servertool_batch_pickup(data) - elif path == "/api/companion": - self._api_save_companion(data) - elif path == "/api/basic-attack": - enabled = bool(data.get("enabled", True)) - flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled") - try: - if enabled: - if os.path.exists(flag): - os.remove(flag) - else: - os.makedirs(CLIENT_DIR, exist_ok=True) - with open(flag, "w", encoding="utf-8") as f: - f.write("disabled\n") - self._json({"ok": True, "enabled": enabled}) - except Exception as e: - self._json({"ok": False, "error": str(e)}, 500) elif path == "/api/accounts-config": self._api_accounts_config_post(data) elif path == "/api/source": @@ -868,161 +821,6 @@ class Handler(BaseHTTPRequestHandler): except Exception as e: self._json({"ok": False, "error": str(e)}, 502) - # ==================== 服务端工具 ==================== - def _api_servertool_features(self): - if NATIVE is not None and server_running(): - try: - self._json({"ok": True, "source": "native", - "data": NATIVE.get_server_features()}) - return - except Exception: - pass - cfg = _read_json_file(SERVER_FEATURES_PATH, {}) - self._json({"ok": True, "source": "file", "data": cfg}) - - def _api_save_servertool_features(self, data): - if NATIVE is not None and server_running(): - try: - self._json({"ok": True, "source": "native", - "data": NATIVE.save_server_features(data)}) - return - except Exception as e: - fallback_error = str(e) - else: - fallback_error = "Native API 不可用" - _write_json_file(SERVER_FEATURES_PATH, data) - self._json({"ok": True, "source": "file", - "warning": fallback_error, "data": data}) - - def _api_servertool_activity(self): - self._json({"ok": True, - "data": _read_json_file(ACTIVITY_CONFIG_PATH, {})}) - - def _api_save_servertool_activity(self, data): - _write_json_file(ACTIVITY_CONFIG_PATH, data) - self._json({"ok": True, "data": data}) - - def _api_servertool_info(self): - self._json({ - "ok": True, - "features_path": SERVER_FEATURES_PATH, - "activity_path": ACTIVITY_CONFIG_PATH, - "config_dir": SERVER_CONFIG_DIR, - "raw_names": list(RAW_CONFIG_NAMES), - "server_running": server_running(), - }) - - def _api_servertool_raw(self, qs): - name = (qs.get("name") or [""])[0] - if name not in RAW_CONFIG_NAMES: - self._json({"error": "invalid config name"}, 400) - return - path = os.path.join(SERVER_CONFIG_DIR, name) - try: - text = "" - if os.path.exists(path): - with open(path, "r", encoding="utf-8-sig") as f: - text = f.read() - self._json({"ok": True, "name": name, "path": path, "text": text}) - except Exception as e: - self._json({"error": str(e)}, 500) - - def _api_save_servertool_raw(self, data): - name = str(data.get("name", "")) - text = str(data.get("text", "")) - if name not in RAW_CONFIG_NAMES: - self._json({"error": "invalid config name"}, 400) - return - path = os.path.join(SERVER_CONFIG_DIR, name) - try: - os.makedirs(SERVER_CONFIG_DIR, exist_ok=True) - if os.path.exists(path): - shutil.copy2(path, path + ".bak_tool") - with open(path, "w", encoding="utf-8", newline="\n") as f: - f.write(text) - self._json({"ok": True, "name": name, "path": path}) - except Exception as e: - self._json({"error": str(e)}, 500) - - def _api_servertool_gameplay(self): - if NATIVE is not None and server_running(): - try: - self._json({"ok": True, "source": "native", - "data": NATIVE.get_gameplay_settings()}) - return - except Exception: - pass - self._json({"ok": True, "source": "file", "data": {}}) - - def _api_save_servertool_gameplay(self, data): - if NATIVE is None or not server_running(): - self._json({"error": "服务端未运行,无法写入玩法参数"}, 400) - return - try: - if data.get("hellEpicDropRatePercent") is not None: - NATIVE.set_hell_drop_rate(int(data["hellEpicDropRatePercent"])) - if data.get("epicPotionMultiplierPercent") is not None: - NATIVE.set_epic_potion_multiplier(int(data["epicPotionMultiplierPercent"])) - self._json({"ok": True, "data": data}) - except Exception as e: - self._json({"error": str(e)}, 502) - - def _batch_pickup_path(self): - return INSTANCE_PATH if INSTANCE_PATH else os.path.join( - SERVER_CONFIG_DIR, "instance.json") - - def _api_servertool_batch_pickup(self): - path = self._batch_pickup_path() - cfg = _read_json_file(path, {}) - value = True - try: - value = bool(cfg.get("game", {}).get("dungeonBatchPickupEnabled", True)) - except Exception: - value = True - self._json({"ok": True, "data": {"enabled": value}, "path": path}) - - def _api_save_servertool_batch_pickup(self, data): - path = self._batch_pickup_path() - cfg = _read_json_file(path, None) - if not isinstance(cfg, dict): - self._json({"error": "instance.json 不存在或格式错误"}, 400) - return - game = cfg.get("game") - if not isinstance(game, dict): - game = {} - cfg["game"] = game - game["dungeonBatchPickupEnabled"] = bool(data.get("enabled", True)) - try: - if os.path.exists(path): - shutil.copy2(path, path + ".bak_tool") - _write_json_file(path, cfg) - self._json({"ok": True, "data": {"enabled": game["dungeonBatchPickupEnabled"]}}) - except Exception as e: - self._json({"error": str(e)}, 500) - - # ==================== 支援兵 ==================== - def _api_companion(self, qs): - if NATIVE is None or not server_running(): - self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400) - return - character = (qs.get("character") or qs.get("id") or [""])[0].strip() - if not character: - self._json({"ok": False, "error": "缺少角色 ID"}, 400) - return - try: - self._json({"ok": True, "data": NATIVE.companion_state(character)}) - except Exception as e: - self._json({"ok": False, "error": str(e)}, 502) - - def _api_save_companion(self, data): - if NATIVE is None or not server_running(): - self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400) - return - try: - self._json({"ok": True, "data": NATIVE.companion_save(data)}) - except Exception as e: - self._json({"ok": False, "error": str(e)}, 502) - # ==================== 自定义账号配置 ==================== def _api_accounts_config(self): """返回账号列表(含默认标记)。""" diff --git a/launcher/shell.css b/launcher/shell.css index 15c7e68..425c92a 100644 --- a/launcher/shell.css +++ b/launcher/shell.css @@ -165,32 +165,6 @@ button,input,select{font:inherit} .lo-slot b{display:block;color:#d5ece0;font-weight:400;font-family:Consolas,monospace} /* server tool */ -.st-tabs{display:flex;flex-wrap:wrap;gap:2px;border-bottom:1px solid #244435;margin-bottom:10px} -.st-tab{background:none;border:1px solid transparent;border-bottom:0;color:#8dac9c;padding:8px 13px;cursor:pointer;font-size:13px} -.st-tab:hover{color:#d8eee3} -.st-tab.active{background:#10251c;border-color:#2a503d;color:#8fe5b8} -.st-subtabs{display:flex;gap:4px;margin:-2px 0 10px;padding:6px 0;border-bottom:1px solid #1d3a2d} -.st-subtab{background:transparent;border:1px solid transparent;color:#8dac9c;padding:3px 9px;cursor:pointer;font-size:11px;border-radius:5px} -.st-subtab.active{background:rgba(124,92,255,.16);border-color:rgba(124,92,255,.35);color:#dcd4ff} -.st-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px} -.st-body{border:1px solid #1d3a2d;background:#0b1712;padding:14px;min-height:300px} -.st-section{margin-bottom:16px} -.st-section h3{margin:0 0 8px;color:#9cd8b8;font-size:15px;font-weight:400;border-left:2px solid #4d9f77;padding-left:8px} -.st-form{display:grid;grid-template-columns:repeat(2,minmax(260px,1fr));gap:12px 18px} -.st-form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:#83a393} -.st-hint{font-size:11.5px;color:#687f73;line-height:1.4;margin-bottom:1px} -.st-form input,.st-form select{background:#08130f;border:1px solid #254636;color:#d2e7db;padding:8px 9px;border-radius:2px;font-size:13px;height:34px} -.st-check{display:flex;align-items:center;gap:6px;font-size:13px;color:#b7d4c5;padding:5px 0} -.st-check-row{display:flex;gap:28px;flex-wrap:wrap;border:1px solid #1d3a2d;background:#0b1712;padding:8px 12px;margin-bottom:10px;border-radius:3px} -.st-check-row .st-check{color:#d5e8de;font-size:13px} -.st-check input{width:15px;height:15px} -.st-note{font-size:12.5px;color:#638373;margin:6px 0} -.st-json{margin-top:14px;border-top:1px solid #1d3a2d;padding-top:10px;color:#8eae9d;font-size:12px} -.st-raw{width:100%;height:300px;background:#07110e;border:1px solid #254636;color:#cfe5d9; - font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:3px;margin:6px 0} -.st-json summary{cursor:pointer;color:#a9d8bf;margin-bottom:8px} -.st-json textarea{width:100%;height:220px;background:#07110e;border:1px solid #254636;color:#cfe5d9; - font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:2px} /* GM embedded */ #page-gm{padding:0} @@ -210,7 +184,6 @@ button,input,select{font:inherit} @media (max-width:1000px){ .stat-grid{grid-template-columns:repeat(2,1fr)} .loadout-layout{grid-template-columns:1fr} - .st-form{grid-template-columns:repeat(2,1fr)} } /* ================= 沿用原启动器配色与图标 ================= */ @@ -277,7 +250,7 @@ button,input,select{font:inherit} .shell-input:focus{outline:1px solid var(--primary);border-color:var(--primary)} .tag{border-color:rgba(124,92,255,.45);color:#c9beff;background:rgba(124,92,255,.1)} .empty-box{border-color:rgba(255,255,255,.12);color:#70748d;background:rgba(20,20,36,.45)} -.loadout-side,.loadout-main,.st-body{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px} +.loadout-side,.loadout-main{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px} .lo-page{border-color:rgba(255,255,255,.12);color:#aeb2c8;border-radius:6px} .lo-page.active{background:rgba(124,92,255,.2);color:#eee9ff;border-color:rgba(124,92,255,.5)} .lo-list{border-top-color:rgba(255,255,255,.08)} @@ -285,38 +258,11 @@ button,input,select{font:inherit} .lo-item:hover,.lo-item.active{background:rgba(124,92,255,.16);color:#f2f3f8} .lo-slot{background:rgba(0,0,0,.18);border-color:rgba(255,255,255,.09);color:#aeb2c8} .lo-slot b{color:#eef0fb} -.st-tabs{border-bottom-color:rgba(255,255,255,.1)} -.st-tab{color:#9a9eb5}.st-tab:hover{color:#fff} -.st-tab.active{background:rgba(124,92,255,.15);border-color:rgba(124,92,255,.35);color:#dcd4ff;border-radius:8px 8px 0 0} -.st-section h3{color:#c9beff;border-left-color:var(--primary)} -.st-form label,.st-json{color:#9a9eb5} -.st-form input,.st-form select{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.13);color:#eef0fb;border-radius:6px} -.st-form input:focus,.st-form select:focus{outline:1px solid var(--primary)} -.st-check{color:#c4c8dc} -.st-json summary{color:#c9beff} -.st-json textarea{background:rgba(0,0,0,.25);border-color:rgba(255,255,255,.13);color:#e6e8f5} .shell-statusbar{background:#08080f;border-top-color:rgba(255,255,255,.08);color:#7d8198} .shell-statusbar b{color:#b8bcd0} #page-gm #gmView{background:#0b0b14} .settings-card{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px} .settings-label{color:#8d91a8}.settings-value{color:#e2e4f2} -.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px} -.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5} -.cp-toolbar select{min-width:220px} -.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px; - padding:10px 16px;margin-bottom:18px;background:rgba(20,20,36,.45)} -.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px} -.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px} -.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer} -.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)} -.cp-card b{font-size:14px;display:block;color:#eef0fb} -.cp-card small{font-size:12px;color:#9a9eb5} -.cp-card input{margin-right:7px} -.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px} -.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px} -.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff} -.cp-ai-head select{width:240px} -.cp-ai .st-form{margin-bottom:10px} /* ================= 字号整体 +2,间距放宽 ================= */ body{font-size:16px} @@ -381,26 +327,6 @@ body{font-size:16px} .lo-cooldown.active{color:#ffcf7a} .lo-slot{font-size:13px;padding:9px} .lo-slot b{font-size:13px} -.st-tabs{gap:4px;margin-bottom:14px} -.st-tab{font-size:15px;padding:9px 14px} -.st-subtabs{gap:6px;padding:8px 0;margin-bottom:12px} -.st-subtab{font-size:13px;padding:5px 11px} -.st-toolbar{gap:10px;margin-bottom:14px} -.st-body{padding:18px;min-height:380px} -.st-section{margin-bottom:20px} -.st-section h3{font-size:17px;margin-bottom:12px;padding-left:10px} -.st-form{grid-template-columns:repeat(2,minmax(280px,1fr));gap:16px 22px} -.st-form label{font-size:15px;gap:6px} -.st-form input,.st-form select{font-size:15px;padding:9px 10px;height:38px} -.st-hint{font-size:13px;line-height:1.5} -.st-note{font-size:14px;line-height:1.6} -.st-check{font-size:15px;gap:9px;padding:8px 0} -.st-check input{width:17px;height:17px} -.st-check-row{gap:34px;padding:12px 16px;margin-bottom:14px} -.st-check-row .st-check{font-size:15px} -.st-json{font-size:14px;padding-top:14px;margin-top:18px} -.st-json summary{font-size:14px;margin-bottom:10px} -.st-json textarea,.st-raw{font-size:13px;height:260px} .shell-statusbar{height:34px;font-size:13px;gap:22px;padding:0 18px} .settings-grid{gap:14px;margin-top:18px} .settings-card{padding:16px 18px} @@ -408,7 +334,6 @@ body{font-size:16px} .settings-value{font-size:13px;line-height:1.7} @media (max-width:1100px){ .stat-grid{grid-template-columns:repeat(2,1fr)} - .st-form{grid-template-columns:1fr} .hero-actions{flex-wrap:wrap;gap:10px} } .win-resize{position:fixed;z-index:10000;display:none} diff --git a/launcher/shell.js b/launcher/shell.js index 5e0a066..e519985 100644 --- a/launcher/shell.js +++ b/launcher/shell.js @@ -1,19 +1,13 @@ /* 90轮回启动器 · 单窗口页面与工具整合 */ (function () { const $ = (id) => document.getElementById(id); - const pages = ['start','gm','loadout','companion','servertool','plugins','settings','changelog']; + const pages = ['start','gm','loadout','settings','changelog']; let shellGmInit = false; let loConfig = null, loPage = 0, loScheme = -1; let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null; - let stFeatures = {}, stActivity = {}, stTab = 'global'; - let stGameplay = {}; - let stBatchPickup = true; let hotkeyCapture = false; - let stRaidTab = 'anton'; - let stFeatureTab = 'instance.json'; - let stRawText = ''; - const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',companion:'工具 / 支援兵',servertool:'工具 / 服务端工具',plugins:'插件',settings:'设置',changelog:'更新日志'}; + const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',settings:'设置',changelog:'更新日志'}; function shellGo(page) { if (!pages.includes(page)) page = 'start'; @@ -22,8 +16,6 @@ $('shellCrumb').textContent = crumb[page] || page; if (page === 'start') loadStart(); if (page === 'loadout') loadLoadout(); - if (page === 'companion') loadCompanion(); - if (page === 'servertool') loadServerTool(); if (page === 'settings') loadSettings(); if (page === 'changelog') loadChangelog(); if (page === 'gm') { @@ -483,492 +475,6 @@ saveLoadout(); }); - // ---------------- 支援兵 ---------------- - let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set(); - let cpAIProfiles = {}, cpAISource = ''; - let cpLoading = false; - const CP_AI_DEFAULTS = { - behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto', - combo_enabled:true, aoe_min_targets:3, protect_leader:true, - tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90, - chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85, - heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700, - detour_ms:900,give_up_ms:3500,follow_distance:75} - }; - - async function loadCompanion() { - if (cpLoading) return; - cpLoading = true; - $('cpStatus').textContent = '读取账号和角色…'; - try { - const a = await api('/api/accounts'); - const ba = await api('/api/basic-attack'); - $('cpBasicAttack').checked = !ba || ba.enabled !== false; - cpAccounts = (a && a.data) || []; - const sel = $('cpAccount'); - sel.innerHTML = cpAccounts.map(x => ``).join(''); - if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; } - await loadCompanionCharacters(); - $('cpStatus').textContent = ''; - } catch (e) { - $('cpStatus').textContent = '读取失败:' + e.message; - } finally { cpLoading = false; } - } - - async function loadCompanionCharacters() { - const account = $('cpAccount').value; - const r = await api('/api/characters?account=' + encodeURIComponent(account)); - cpCharacters = (r && r.data) || []; - const owner = $('cpOwner'); - owner.innerHTML = cpCharacters.map(c => ``).join(''); - if (cpCharacters.length) await loadCompanionState(); - else { $('cpGrid').innerHTML = '
当前账号没有角色
'; } - } - - async function loadCompanionState() { - const owner = $('cpOwner').value; - if (!owner) return; - $('cpStatus').textContent = '读取支援兵配置…'; - try { - const r = await api('/api/companion?character=' + encodeURIComponent(owner)); - if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; } - cpState = r.data || {}; - const cfg = (cpState.state && cpState.state.config) || {}; - $('cpEnabled').checked = !!cfg.enabled; - $('cpBalance').checked = !!cfg.balance_enabled; - $('cpProgression').checked = !!cfg.progression_enabled; - $('cpRandom').checked = !!cfg.random_equipment_enabled; - cpSelected = new Set((cfg.source_character_ids || []).map(String)); - cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {})); - cpAISource = [...cpSelected][0] || ''; - renderCompanionGrid(); - $('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取'; - } catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; } - } - - function renderCompanionGrid() { - const owner = String($('cpOwner').value || ''); - const list = cpCharacters.filter(c => String(c.character_id) !== owner); - const grid = $('cpGrid'); - if (!list.length) { grid.innerHTML = '
当前账号没有其他角色可作为支援兵
'; renderCpAI(); return; } - grid.innerHTML = list.map(c => { - const id = String(c.character_id), selected = cpSelected.has(id); - return ``; - }).join(''); - grid.querySelectorAll('input[data-cp-source]').forEach(inp => { - inp.onchange = () => { - const id = inp.dataset.cpSource; - if (inp.checked) cpSelected.add(id); else cpSelected.delete(id); - inp.closest('.cp-card').classList.toggle('selected', inp.checked); - renderCpAI(); - }; - }); - renderCpAI(); - } - - function cpProfile(id) { - const raw = cpAIProfiles[String(id)] || {}; - return { - ...CP_AI_DEFAULTS, ...raw, - tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})} - }; - } - - function renderCpAI() { - const select = $('cpAISource'); - const ids = [...cpSelected]; - if (!ids.includes(cpAISource)) cpAISource = ids[0] || ''; - select.innerHTML = ids.map(id => { - const c = cpCharacters.find(x => String(x.character_id) === id); - return ``; - }).join(''); - if (cpAISource) select.value = cpAISource; - if (!cpAISource) { - $('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first'; - $('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3; - $('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true; - document.querySelectorAll('[data-cp-tuning]').forEach(inp => { - inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0; - }); - return; - } - const p = cpProfile(cpAISource); - $('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive'; - $('cpAIBuff').value = p.buff_policy || 'leader_first'; - $('cpAIComboPreset').value = p.combo_preset || 'auto'; - $('cpAIAoe').value = Number(p.aoe_min_targets) || 3; - $('cpAIComboEnabled').checked = !!p.combo_enabled; - $('cpAIProtect').checked = !!p.protect_leader; - document.querySelectorAll('[data-cp-tuning]').forEach(inp => { - inp.value = p.tuning[inp.dataset.cpTuning] ?? 0; - }); - } - - function readCpAI() { - if (!cpAISource) return; - const p = cpProfile(cpAISource); - p.behavior = $('cpAIBehavior').value; - p.buff_policy = $('cpAIBuff').value; - p.combo_preset = $('cpAIComboPreset').value; - p.combo_enabled = $('cpAIComboEnabled').checked; - p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3)); - p.protect_leader = $('cpAIProtect').checked; - p.tuning = {...p.tuning}; - document.querySelectorAll('[data-cp-tuning]').forEach(inp => { - const n = Number(inp.value); - if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n; - }); - cpAIProfiles[String(cpAISource)] = p; - } - - - $('cpAccount').addEventListener('change', async () => { - try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; } - }); - $('cpOwner').addEventListener('change', loadCompanionState); - $('cpReload').addEventListener('click', loadCompanion); - $('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); }); - ['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => { - $(id).addEventListener('change', readCpAI); - }); - document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI)); - $('cpBasicAttack').addEventListener('change', async () => { - try { - const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked}); - if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败'; - else $('cpStatus').textContent = $('cpBasicAttack').checked - ? '已允许支援兵普攻;重启客户端后生效。' - : '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。'; - } catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; } - }); - - $('cpSave').addEventListener('click', async () => { - const owner = $('cpOwner').value; - if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; } - if ($('cpEnabled').checked && cpSelected.size === 0) { - $('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return; - } - const state = (cpState && cpState.state) || {}; - const cfg = state.config || {}; - const sources = [...cpSelected]; - readCpAI(); - const payload = { - characterId: String(owner), - expectedRevision: Number(cfg.revision || 0), - enabled: $('cpEnabled').checked, - balanceEnabled: $('cpBalance').checked, - progressionEnabled: $('cpProgression').checked, - randomEquipmentEnabled: $('cpRandom').checked, - sourceCharacterId: sources[0] || '', - sourceCharacterIds: sources, - ai: cfg.ai || {}, - aiProfiles: cpAIProfiles - }; - $('cpStatus').textContent = '保存中…'; - try { - const r = await post('/api/companion', payload); - if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; } - cpState = r.data || cpState; - $('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。'; - } catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; } - }); - - // ---------------- 服务端工具 ---------------- - async function loadServerTool() { - try { - $('stBody').innerHTML = '
正在读取服务端配置…
'; - const [f, a, s, g, b] = await Promise.all([api('/api/servertool/features'), api('/api/servertool/activity'), api('/api/status'), api('/api/servertool/gameplay'), api('/api/servertool/batch-pickup')]); - stFeatures = f.data || {}; stActivity = a.data || {}; stGameplay = g.data || {}; - stBatchPickup = !!(b.data || {}).enabled; - window.__stStatusInfo = s || {}; - $('stSource').textContent = f.source === 'native' ? 'Native API' : '文件配置'; - renderServerTool(); - } catch (e) { - $('stBody').innerHTML = '
读取失败:' + (e && e.message ? e.message : e) + '
'; - $('stStatus').textContent = '读取失败'; - } - } - - document.querySelectorAll('.st-tab').forEach(b => b.addEventListener('click', () => { - stTab = b.dataset.st; - document.querySelectorAll('.st-tab').forEach(x => x.classList.toggle('active', x === b)); - renderServerTool(); - })); - - const LABEL = { - raidReviveSeconds:'副本复活秒数', allDeadReturnSeconds:'全员死亡返回秒数', allowFreeRevive:'允许免费复活', - hellOrbChance:'深渊宝珠概率', hellDropChanceDenominator:'掉落分母', equipmentRollMultiplier:'装备倍率', - epicPieceRollMultiplier:'史诗碎片倍率', lukeEnergyRecoveryPercent:'卢克能量恢复%', fiendWarSquadCount:'超时空队伍数', - roomFatigueCost:'每房间疲劳', dailyFatigueResetHour:'每日疲劳重置点', adventureGroupBonusPercent:'冒险团加成%', - rentalMaxReinforce:'租赁最大强化', rentalMaxForge:'租赁最大锻造', soloTeleportCooldownSeconds:'单人传送冷却秒', - soloTeleportCooldownIndices:'单人传送索引', auctionStandardListingLimit:'普通拍卖上架数', - auctionPremiumListingLimit:'高级拍卖上架数', auctionStandardDurationHours:'普通拍卖时长', - auctionPremiumDurationHours:'高级拍卖时长', auctionStandardFeePercent:'普通手续费%', auctionPremiumFeePercent:'高级手续费%', - antonBlackFogRecoverySeconds:'安徒恩黑雾恢复秒', antonBlackFogActiveSeconds:'安徒恩黑雾持续秒', - antonQuakeRecoverySeconds:'安徒恩震颤恢复秒', antonQuakeActiveSeconds:'安徒恩震颤持续秒', - antonNavalOpenDelaySeconds:'安徒恩舰炮延迟秒', antonNavalRecoverySeconds:'安徒恩舰炮恢复秒', - antonNavalActiveSeconds:'安徒恩舰炮持续秒', lukeEnergyRecovery0Seconds:'卢克1阶段恢复秒', - lukeEnergyRecovery1Seconds:'卢克2阶段恢复秒', lukeEnergyRecovery2Seconds:'卢克3阶段恢复秒', - lukePhaseTimeoutPercent:'卢克阶段时限%', fiendWarAreaLimitPercent:'超时空区域限制%', - fiendWarDieImprisonPercent:'超时空死亡禁锢%', antonMemberMax:'安徒恩人数上限', - antonStartMinimumMembers:'安徒恩最少开局人数', antonSingleStartMinimumMembers:'安徒恩单人开局人数', - antonStartDelaySeconds:'安徒恩开始延迟', antonPhaseBreakSeconds:'安徒恩阶段间隔', - antonEntryCostItem:'安徒恩入场物品', antonEntryCostCount:'安徒恩入场数量', - antonWeeklyEnterCountA:'安徒恩A每周次数', antonWeeklyEnterCountB:'安徒恩B每周次数', - antonPhaseTimeoutSeconds:'安徒恩阶段时限', lukeDailyAccountEnterLimit:'卢克账号每日次数', - lukeSanctumTimerCount:'卢克圣所计时', lukeEntryCostItem:'卢克入场物品', lukeEntryCostCount:'卢克入场数量', - fiendWarDailyEnterLimit:'超时空每日次数', fiendWarMemberMax:'超时空人数上限', - fiendWarStartMinimumMembers:'超时空最少开局人数', fiendWarDisconnectWaitSeconds:'超时空掉线等待', - fiendWarEntryCostItem:'超时空入场物品', fiendWarEntryCostCount:'超时空入场数量', fiendWarRetry:'超时空重试', - reviveByDungeon:'按副本复活配置' - }; - - const ST_DESC = { - soloTeleportCooldownSeconds:'单人传送冷却时间,单位秒;服务端读取该值限制重复传送。', - soloTeleportCooldownIndices:'允许触发单人传送的技能索引,多个用逗号分隔。', - auctionStandardListingLimit:'普通拍卖行同时可上架数量。', - auctionStandardDurationHours:'普通拍卖行上架时长,单位小时。', - auctionPremiumListingLimit:'高级拍卖行同时可上架数量。', - auctionPremiumDurationHours:'高级拍卖行上架时长,单位小时。', - auctionStandardFeePercent:'普通拍卖成交手续费百分比。', - auctionPremiumFeePercent:'高级拍卖成交手续费百分比。', - raidReviveSeconds:'团本死亡后等待复活的秒数,有效范围 1-60;不是无限复活选项。', - allDeadReturnSeconds:'队伍全员死亡后返回城镇的等待秒数。', - hellOrbChance:'深渊宝珠掉落判定值。', - hellDropChanceDenominator:'深渊基础掉率分母,数值越大掉率越低。', - equipmentRollMultiplier:'史诗装备掉落 roll 次数倍率。', - epicPieceRollMultiplier:'史诗碎片掉落 roll 次数倍率。', - allowFreeRevive:'允许不使用复活币直接复活。', - roomFatigueCost:'进入每个房间消耗的疲劳值。', - dailyFatigueResetHour:'每日疲劳重置小时,0-23。', - adventureGroupBonusPercent:'冒险团经验加成百分比。', - rentalMaxReinforce:'租赁装备允许的最大强化等级。', - rentalMaxForge:'租赁装备允许的最大锻造等级。', - antonBlackFogRecoverySeconds:'安徒恩黑雾之源恢复等待秒数。', - antonBlackFogActiveSeconds:'安徒恩黑雾之源持续时间秒数。', - antonQuakeRecoverySeconds:'安徒恩震颤之地恢复等待秒数。', - antonQuakeActiveSeconds:'安徒恩震颤之地持续时间秒数。', - antonNavalOpenDelaySeconds:'安徒恩舰炮防御战开启延迟秒数。', - antonNavalRecoverySeconds:'安徒恩舰炮防御战恢复等待秒数。', - antonNavalActiveSeconds:'安徒恩舰炮防御战持续时间秒数。', - lukeEnergyRecovery0Seconds:'卢克能量控制一阶段恢复秒数。', - lukeEnergyRecovery1Seconds:'卢克能量控制二阶段恢复秒数。', - lukeEnergyRecovery2Seconds:'卢克能量控制三阶段恢复秒数。', - lukePhaseTimeoutPercent:'卢克各阶段时限百分比。', - lukeEnergyRecoveryPercent:'卢克能量恢复速度百分比。', - fiendWarSquadCount:'超时空之战小队编制数量。', - fiendWarAreaLimitPercent:'超时空区域进入限制百分比。', - fiendWarDieImprisonPercent:'超时空死亡禁锢触发百分比。' - }; - - const ST_FIELDS = { - global: [ - 'soloTeleportCooldownSeconds','soloTeleportCooldownIndices', - 'auctionStandardListingLimit','auctionStandardDurationHours', - 'auctionPremiumListingLimit','auctionPremiumDurationHours', - 'auctionStandardFeePercent','auctionPremiumFeePercent' - ], - gameplay: [ - 'allDeadReturnSeconds','hellOrbChance', - 'hellDropChanceDenominator','equipmentRollMultiplier', - 'epicPieceRollMultiplier','allowFreeRevive' - ], - growth: [ - 'roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent', - 'rentalMaxReinforce','rentalMaxForge' - ], - raid: { - anton: [ - 'antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds', - 'antonQuakeRecoverySeconds','antonQuakeActiveSeconds', - 'antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds' - ], - luke: [ - 'lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds', - 'lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent', - 'lukeEnergyRecoveryPercent' - ], - fiend: [ - 'fiendWarSquadCount','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent' - ] - } - }; - - function allAssignedKeys() { - return new Set([...ST_FIELDS.global, ...ST_FIELDS.gameplay, ...ST_FIELDS.growth, - ...ST_FIELDS.raid.anton, ...ST_FIELDS.raid.luke, ...ST_FIELDS.raid.fiend]); - } - - function renderServerTool() { - const body = $('stBody'); - if (stTab === 'activity') { renderActivity(body); return; } - if (stTab === 'launch') { - body.innerHTML = `

启动控制

配置文件:server-features.json / activity.json。服务端运行状态请以开始页为准。

`; - $('stStart').onclick = () => doAction('start'); - $('stStop').onclick = () => doAction('stop'); - api('/api/status').then(s => { - $('stLaunchInfo').textContent = `服务端:${s.server_running ? '运行中' : '未运行'} · 目录:${s.server_root || '未绑定'}`; - }).catch(() => {}); - return; - } - let sub = ''; - let keys; - if (stTab === 'features') { - keys = Object.keys(stFeatures).filter(k => !allAssignedKeys().has(k)); - } else if (stTab === 'raid') { - const names = {anton:'安徒恩机制', luke:'卢克', fiend:'超时空'}; - $('stSubTabs').innerHTML = Object.keys(names).map(k => - ``).join(''); - $('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => { - stRaidTab = b.dataset.raid; renderServerTool(); - }); - keys = ST_FIELDS.raid[stRaidTab] || []; - } else { - $('stSubTabs').innerHTML = ''; - keys = ST_FIELDS[stTab] || []; - } - let html = `

${tabTitle(stTab)}

`; - if (stTab === 'features') { - const info = window.__stStatusInfo || {}; - const rawNames = ['instance.json','dnfbridge.toml','logic.toml','plan.json']; - $('stSubTabs').innerHTML = rawNames.map(n => - ``).join(''); - $('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => { - stFeatureTab = b.dataset.raw; renderServerTool(); - }); - html += `
- - - - -
`; - html += `
当前配置文件:${stFeatureTab}
`; - html += ``; - html += `
服务端参数页保留实例与配置文件;下方列出未归入其他页的字段。
`; - } - html += `
${groupFields(stFeatures, stTab === 'gameplay' ? keys.filter(k => k !== 'allowFreeRevive') : keys)}
`; - if (stTab === 'gameplay') { - html += `
- - -
`; - html += `
- - -
`; - } - html += `
`; - html += `
高级:完整 JSON 配置
可直接编辑全部服务端字段
`; - body.innerHTML = html; - if (stTab === 'features') loadRawConfig(stFeatureTab); - const jsonBox = $('stJson'); - if (jsonBox) jsonBox.value = JSON.stringify(stFeatures, null, 2); - $('stJsonApply').onclick = () => { - try { - const obj = JSON.parse($('stJson').value); - if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('需要 JSON 对象'); - stFeatures = obj; renderServerTool(); - $('stStatus').textContent = 'JSON 已载入,点击“保存当前页”写入'; - } catch (e) { $('stStatus').textContent = 'JSON 错误:' + e.message; } - }; - bindFeatureInputs(body); - } - - async function loadRawConfig(name) { - try { - const r = await api('/api/servertool/raw?name=' + encodeURIComponent(name)); - stRawText = r.text || ''; - const box = $('stRaw'); - if (box) box.value = stRawText; - } catch (e) { /* ignore */ } - } - - function bindFeatureInputs(body) { - body.querySelectorAll('input[data-st-key]').forEach(inp => { - inp.addEventListener('change', () => { - const key = inp.dataset.stKey; - let v = inp.value; - if (inp.type === 'checkbox') v = inp.checked; - else if (inp.dataset.type === 'number') { const n = Number(v); v = Number.isFinite(n) ? n : 0; } - stFeatures[key] = v; - }); - }); - body.querySelectorAll('input[data-game-key]').forEach(inp => { - inp.addEventListener('change', () => { - stGameplay[inp.dataset.gameKey] = Number(inp.value) || 0; - }); - }); - body.querySelectorAll('input[data-batch-pickup]').forEach(inp => { - inp.addEventListener('change', () => { stBatchPickup = inp.checked; }); - }); - } - - function groupFields(obj, keys) { - return keys.map(k => { - const val = obj[k]; - const label = LABEL[k] || k; - if (typeof val === 'boolean' || /Retry|Revive|Pickup/.test(k)) { - return ``; - } - const numeric = (typeof val === 'number') || /Seconds|Percent|Count|Hour|Limit|Cost|Max|Min|Delay|Item/.test(k); - const type = numeric ? 'number' : 'text'; - const shown = val === undefined || val === null ? '' : String(val); - const hint = ST_DESC[k] ? `${ST_DESC[k]}` : ''; - return ``; - }).join(''); - } - - function tabTitle(tab) { - return ({features:'服务端参数',global:'全局游戏参数',gameplay:'游戏参数',growth:'疲劳/成长',raid:'团本配置'})[tab] || tab; - } - function tabKeys(tab) { - const all = Object.keys(stFeatures); - const match = { - features: ['raidReviveSeconds','allDeadReturnSeconds','allowFreeRevive','hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','lukeEnergyRecoveryPercent','fiendWarSquadCount'], - global: [], - gameplay: ['hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','roomFatigueCost','rentalMaxReinforce','rentalMaxForge'], - growth: ['roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent','rentalMaxReinforce','rentalMaxForge'], - raid: ['antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds','antonQuakeRecoverySeconds','antonQuakeActiveSeconds','antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds','lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds','lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent','antonMemberMax','antonStartMinimumMembers','lukeDailyAccountEnterLimit','fiendWarDailyEnterLimit'], - }; - if (tab === 'global') return all.filter(k => /soloTeleport|auction|allDead|revive/i.test(k)); - return (match[tab] || []).filter(k => all.includes(k)); - } - - function renderActivity(body) { - const keys = Object.keys(stActivity); - body.innerHTML = `

在线活动开关

${keys.map(k => { - const v = !!stActivity[k]; const label = ({daily:'每日签到',weekly:'每周签到',monthly:'每月签到',marble:'签到弹珠',returnee:'回归活动',returneeGift:'回归赠礼',lucky:'幸运活动',levelAiming:'升级预约',laborDay:'劳动节',legacyLevelup:'旧版升级',pcRoom:'网吧时间点',spendTime:'在线时长'})[k] || k; - return ``; - }).join('')}
`; - body.querySelectorAll('input[data-act-key]').forEach(inp => { - inp.addEventListener('change', () => { stActivity[inp.dataset.actKey] = inp.checked; }); - }); - } - - $('stReload').addEventListener('click', loadServerTool); - $('stSave').addEventListener('click', async () => { - try { - let r; - if (stTab === 'activity') { - r = await post('/api/servertool/activity', stActivity); - } else if (stTab === 'features' && $('stRaw')) { - r = await post('/api/servertool/raw', {name: stFeatureTab, text: $('stRaw').value}); - } else if (stTab === 'gameplay') { - r = await post('/api/servertool/features', stFeatures); - const g = await post('/api/servertool/gameplay', stGameplay); - const bp = await post('/api/servertool/batch-pickup', {enabled: stBatchPickup}); - if (!g.ok) r = g; - if (!bp.ok) r = bp; - } else { - r = await post('/api/servertool/features', stFeatures); - } - $('stStatus').textContent = r.ok ? ('已保存' + (r.warning ? '(' + r.warning + ')' : '')) : (r.error || '保存失败'); - } catch (e) { $('stStatus').textContent = '保存失败:' + e.message; } - }); - async function loadSettings() { try { const s = await api('/api/status'); diff --git a/launcher/templates/支援兵页面模板.md b/launcher/templates/支援兵页面模板.md new file mode 100644 index 0000000..3021b85 --- /dev/null +++ b/launcher/templates/支援兵页面模板.md @@ -0,0 +1,404 @@ +# 支援兵页面模板(Companion Page Template) + +> 从 DNF90 启动器 v2.1.0 中完整导出的「支援兵」页面模板。 +> 导出日期:2026-11-04。 +> 用途:日后如需恢复「支援兵」功能,按本文档把 HTML / JS / CSS / 后端接口 四个部分分别粘贴回对应文件即可。 + +--- + +## 一、HTML(index.html 中的 section) + +插入位置:`
` 之前(或任意 shell-page 之间)。 + +```html +
+
+

支援兵

按账号配置支援兵;配置只影响下一次进入副本。

+
+ + + + +
+
+ + + + + +
+

选择支援兵角色

+
选择出战角色后加载支援兵列表
+
+

AI 策略

+
+ + + + + + +
+
+ 高级调参:距离、追击、视野、重试和治疗阈值 +
+ + + + + + + + + + + + + + + + +
+
+
AI 策略按角色保存;保存后下一次进入副本生效。
+
+
支援兵按账号隔离,只能选择同一账号下的其他角色。
+
+
+``` + +--- + +## 二、侧边栏导航入口(index.html 的 nav 内) + +```html + +``` + +--- + +## 三、JavaScript(shell.js 中的支援兵模块) + +插入位置:`// ---------------- 一键换装 ----------------` 区块之后、`// ---------------- 服务端工具 ----------------` 区块之前。 + +```javascript + // ---------------- 支援兵 ---------------- + let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set(); + let cpAIProfiles = {}, cpAISource = ''; + let cpLoading = false; + const CP_AI_DEFAULTS = { + behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto', + combo_enabled:true, aoe_min_targets:3, protect_leader:true, + tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90, + chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85, + heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700, + detour_ms:900,give_up_ms:3500,follow_distance:75} + }; + + async function loadCompanion() { + if (cpLoading) return; + cpLoading = true; + $('cpStatus').textContent = '读取账号和角色…'; + try { + const a = await api('/api/accounts'); + const ba = await api('/api/basic-attack'); + $('cpBasicAttack').checked = !ba || ba.enabled !== false; + cpAccounts = (a && a.data) || []; + const sel = $('cpAccount'); + sel.innerHTML = cpAccounts.map(x => ``).join(''); + if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; } + await loadCompanionCharacters(); + $('cpStatus').textContent = ''; + } catch (e) { + $('cpStatus').textContent = '读取失败:' + e.message; + } finally { cpLoading = false; } + } + + async function loadCompanionCharacters() { + const account = $('cpAccount').value; + const r = await api('/api/characters?account=' + encodeURIComponent(account)); + cpCharacters = (r && r.data) || []; + const owner = $('cpOwner'); + owner.innerHTML = cpCharacters.map(c => ``).join(''); + if (cpCharacters.length) await loadCompanionState(); + else { $('cpGrid').innerHTML = '
当前账号没有角色
'; } + } + + async function loadCompanionState() { + const owner = $('cpOwner').value; + if (!owner) return; + $('cpStatus').textContent = '读取支援兵配置…'; + try { + const r = await api('/api/companion?character=' + encodeURIComponent(owner)); + if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; } + cpState = r.data || {}; + const cfg = (cpState.state && cpState.state.config) || {}; + $('cpEnabled').checked = !!cfg.enabled; + $('cpBalance').checked = !!cfg.balance_enabled; + $('cpProgression').checked = !!cfg.progression_enabled; + $('cpRandom').checked = !!cfg.random_equipment_enabled; + cpSelected = new Set((cfg.source_character_ids || []).map(String)); + cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {})); + cpAISource = [...cpSelected][0] || ''; + renderCompanionGrid(); + $('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取'; + } catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; } + } + + function renderCompanionGrid() { + const owner = String($('cpOwner').value || ''); + const list = cpCharacters.filter(c => String(c.character_id) !== owner); + const grid = $('cpGrid'); + if (!list.length) { grid.innerHTML = '
当前账号没有其他角色可作为支援兵
'; renderCpAI(); return; } + grid.innerHTML = list.map(c => { + const id = String(c.character_id), selected = cpSelected.has(id); + return ``; + }).join(''); + grid.querySelectorAll('input[data-cp-source]').forEach(inp => { + inp.onchange = () => { + const id = inp.dataset.cpSource; + if (inp.checked) cpSelected.add(id); else cpSelected.delete(id); + inp.closest('.cp-card').classList.toggle('selected', inp.checked); + renderCpAI(); + }; + }); + renderCpAI(); + } + + function cpProfile(id) { + const raw = cpAIProfiles[String(id)] || {}; + return { + ...CP_AI_DEFAULTS, ...raw, + tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})} + }; + } + + function renderCpAI() { + const select = $('cpAISource'); + const ids = [...cpSelected]; + if (!ids.includes(cpAISource)) cpAISource = ids[0] || ''; + select.innerHTML = ids.map(id => { + const c = cpCharacters.find(x => String(x.character_id) === id); + return ``; + }).join(''); + if (cpAISource) select.value = cpAISource; + if (!cpAISource) { + $('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first'; + $('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3; + $('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true; + document.querySelectorAll('[data-cp-tuning]').forEach(inp => { + inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0; + }); + return; + } + const p = cpProfile(cpAISource); + $('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive'; + $('cpAIBuff').value = p.buff_policy || 'leader_first'; + $('cpAIComboPreset').value = p.combo_preset || 'auto'; + $('cpAIAoe').value = Number(p.aoe_min_targets) || 3; + $('cpAIComboEnabled').checked = !!p.combo_enabled; + $('cpAIProtect').checked = !!p.protect_leader; + document.querySelectorAll('[data-cp-tuning]').forEach(inp => { + inp.value = p.tuning[inp.dataset.cpTuning] ?? 0; + }); + } + + function readCpAI() { + if (!cpAISource) return; + const p = cpProfile(cpAISource); + p.behavior = $('cpAIBehavior').value; + p.buff_policy = $('cpAIBuff').value; + p.combo_preset = $('cpAIComboPreset').value; + p.combo_enabled = $('cpAIComboEnabled').checked; + p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3)); + p.protect_leader = $('cpAIProtect').checked; + p.tuning = {...p.tuning}; + document.querySelectorAll('[data-cp-tuning]').forEach(inp => { + const n = Number(inp.value); + if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n; + }); + cpAIProfiles[String(cpAISource)] = p; + } + + $('cpAccount').addEventListener('change', async () => { + try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; } + }); + $('cpOwner').addEventListener('change', loadCompanionState); + $('cpReload').addEventListener('click', loadCompanion); + $('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); }); + ['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => { + $(id).addEventListener('change', readCpAI); + }); + document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI)); + $('cpBasicAttack').addEventListener('change', async () => { + try { + const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked}); + if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败'; + else $('cpStatus').textContent = $('cpBasicAttack').checked + ? '已允许支援兵普攻;重启客户端后生效。' + : '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。'; + } catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; } + }); + + $('cpSave').addEventListener('click', async () => { + const owner = $('cpOwner').value; + if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; } + if ($('cpEnabled').checked && cpSelected.size === 0) { + $('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return; + } + const state = (cpState && cpState.state) || {}; + const cfg = state.config || {}; + const sources = [...cpSelected]; + readCpAI(); + const payload = { + characterId: String(owner), + expectedRevision: Number(cfg.revision || 0), + enabled: $('cpEnabled').checked, + balanceEnabled: $('cpBalance').checked, + progressionEnabled: $('cpProgression').checked, + randomEquipmentEnabled: $('cpRandom').checked, + sourceCharacterId: sources[0] || '', + sourceCharacterIds: sources, + ai: cfg.ai || {}, + aiProfiles: cpAIProfiles + }; + $('cpStatus').textContent = '保存中…'; + try { + const r = await post('/api/companion', payload); + if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; } + cpState = r.data || cpState; + $('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。'; + } catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; } + }); +``` + +同时,需要在 `shellGo` 函数中加回页面切换调用: + +```javascript + if (page === 'companion') loadCompanion(); +``` + +以及在 `pages` 数组加回 `'companion'`,在 `crumb` 加回 `companion:'工具 / 支援兵'`。 + +--- + +## 四、后端接口(server.py) + +### 4.1 GET 路由注册(do_GET 内) + +```python + elif path == "/api/companion": + self._api_companion(qs) + elif path == "/api/basic-attack": + self._json({"ok": True, "enabled": not os.path.exists( + os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))}) +``` + +### 4.2 POST 路由注册(do_POST 内) + +```python + elif path == "/api/companion": + self._api_save_companion(data) + elif path == "/api/basic-attack": + enabled = bool(data.get("enabled", True)) + flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled") + try: + if enabled: + if os.path.exists(flag): + os.remove(flag) + else: + os.makedirs(CLIENT_DIR, exist_ok=True) + with open(flag, "w", encoding="utf-8") as f: + f.write("disabled\n") + self._json({"ok": True, "enabled": enabled}) + except Exception as e: + self._json({"ok": False, "error": str(e)}, 500) +``` + +### 4.3 处理函数 + +```python + # ==================== 支援兵 ==================== + def _api_companion(self, qs): + if NATIVE is None or not server_running(): + self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400) + return + character = (qs.get("character") or qs.get("id") or [""])[0].strip() + if not character: + self._json({"ok": False, "error": "缺少角色 ID"}, 400) + return + try: + self._json({"ok": True, "data": NATIVE.companion_state(character)}) + except Exception as e: + self._json({"ok": False, "error": str(e)}, 502) + + def _api_save_companion(self, data): + if NATIVE is None or not server_running(): + self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400) + return + try: + self._json({"ok": True, "data": NATIVE.companion_save(data)}) + except Exception as e: + self._json({"ok": False, "error": str(e)}, 502) +``` + +--- + +## 五、CSS 样式(shell.css 中的 cp- 相关) + +支援兵页面复用了 `.st-form`、`.st-check`、`.st-json`、`.st-note` 等「服务端工具」样式(这些在服务端工具删除后需要一并保留或从模板恢复),以及以下独立的 cp- 样式(位于 shell.css 明暗主题区域): + +```css +.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px} +.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5} +.cp-toolbar select{min-width:220px} +.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px} +.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px} +.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px} +.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer} +.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)} +.cp-card b{font-size:14px;display:block;color:#eef0fb} +.cp-card small{font-size:12px;color:#9a9eb5} +.cp-card input{margin-right:7px} +.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px} +.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px} +.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff} +.cp-ai-head select{width:240px} +.cp-ai .st-form{margin-bottom:10px} +``` + +> 注意:`cp-flags` 样式在原文件里是分成两行(续行),恢复时需合并为完整声明。 + +--- + +## 六、依赖说明 + +支援兵页面依赖以下「服务端工具」的通用样式(这些如果被一并删除,恢复支援兵时需一并恢复): + +- `.st-form` / `.st-form label` / `.st-form input` / `.st-form select` +- `.st-check` / `.st-check input` +- `.st-json` / `.st-json summary` / `.st-json textarea` +- `.st-note` + +依赖的后端接口: +- `/api/accounts`(账号列表) +- `/api/characters?account=`(角色列表) +- `/api/companion`(支援兵状态读取/保存) +- `/api/basic-attack`(普攻开关) + +--- + +## 七、恢复步骤速查 + +1. `index.html`:粘贴 section + 侧边栏按钮 +2. `shell.js`:粘贴 JS 模块,并在 `pages`/`crumb`/`shellGo` 三处加回 `companion` +3. `server.py`:粘贴路由注册 + 两个处理函数 +4. `shell.css`:粘贴 cp- 样式(若 st- 样式已删,一并恢复 st- 通用样式) +5. 重新打包 + +--- + +(完)