启动器:移除服务端工具/支援兵/插件页面,支援兵模板独立保存
- 删除服务端工具、支援兵、插件三个页面(侧边栏入口 + HTML section) - server.py: 删除 servertool(9个)、companion/basic-attack 相关接口及常量 - native.py: 删除无人引用的 get_server_features/save_server_features/ companion_state/companion_save(保留 GM 工具依赖的 gameplay 方法) - shell.js: 删除 loadServerTool/loadCompanion 全部逻辑及状态变量 - shell.css: 清理 st-/cp- 相关样式 - templates/支援兵页面模板.md: 支援兵页面完整导出为独立模板 - 侧边栏「更新日志」更名为「游戏更新」
This commit is contained in:
1 parent
56236496a0
commit
977a4a1196
6 files changed
+410
-869
No files matched your search
+2
-81
@@ -28,12 +28,9 @@
|
||||
<button class="shell-nav-item active" data-shell-page="start"><span class="nav-ico">▶</span>开始</button>
|
||||
<button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button>
|
||||
<button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button>
|
||||
<button class="shell-nav-item" data-shell-page="servertool"><span class="nav-ico">🧰</span>服务端工具</button>
|
||||
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>
|
||||
<button class="shell-nav-item" data-shell-page="plugins"><span class="nav-ico">🧩</span>插件</button>
|
||||
<div class="shell-nav-gap"></div>
|
||||
<button class="shell-nav-item" data-shell-page="settings"><span class="nav-ico">⚙</span>设置</button>
|
||||
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>更新日志</button>
|
||||
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>游戏更新</button>
|
||||
</nav>
|
||||
<div class="shell-win-controls">
|
||||
<button class="tb-btn" id="shellWinMin" title="最小化">—</button>
|
||||
@@ -111,82 +108,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-servertool">
|
||||
<div class="page-pad">
|
||||
<div class="page-heading"><div><h2>服务端工具</h2><p class="muted">按原 ServerTool 页签组织,配置写入 server-features.json / activity.json。</p></div><span class="tag" id="stSource">文件配置</span></div>
|
||||
<div class="st-tabs" id="stTabs">
|
||||
<button class="st-tab active" data-st="global">全局游戏参数</button>
|
||||
<button class="st-tab" data-st="activity">在线活动</button>
|
||||
<button class="st-tab" data-st="gameplay">游戏参数</button>
|
||||
<button class="st-tab" data-st="growth">疲劳/成长</button>
|
||||
<button class="st-tab" data-st="raid">团本配置</button>
|
||||
<button class="st-tab" data-st="launch">启动控制</button>
|
||||
</div>
|
||||
<div class="st-subtabs" id="stSubTabs"></div>
|
||||
<div class="st-toolbar"><button class="shell-btn primary" id="stSave">保存当前页</button><button class="shell-btn" id="stReload">重新读取</button><span id="stStatus" class="muted"></span></div>
|
||||
<div class="st-body" id="stBody"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-companion">
|
||||
<div class="page-pad">
|
||||
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
|
||||
<div class="cp-toolbar">
|
||||
<label>账号<select id="cpAccount" class="shell-input"></select></label>
|
||||
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
|
||||
<button class="shell-btn" id="cpReload">刷新</button>
|
||||
<span id="cpStatus" class="muted"></span>
|
||||
</div>
|
||||
<div class="cp-flags">
|
||||
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
|
||||
</div>
|
||||
<h3 class="cp-title">选择支援兵角色</h3>
|
||||
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
|
||||
<div class="cp-ai">
|
||||
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
|
||||
<div class="st-form">
|
||||
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
|
||||
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
|
||||
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
|
||||
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
|
||||
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
|
||||
</div>
|
||||
<details class="st-json">
|
||||
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
|
||||
<div class="st-form" id="cpAITuning">
|
||||
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
|
||||
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
|
||||
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
|
||||
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
|
||||
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
|
||||
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
|
||||
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
|
||||
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
|
||||
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
|
||||
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
|
||||
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
|
||||
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
|
||||
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
|
||||
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
|
||||
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
|
||||
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
|
||||
</div>
|
||||
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-plugins">
|
||||
<div class="page-pad"><h2>插件</h2><div class="empty-box">插件目录:启动器扩展位,后续接入 LoadoutTool / ServerTool 之外的模块。</div></div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-settings">
|
||||
<div class="page-pad">
|
||||
<div class="page-heading"><div><h2>设置</h2><p class="muted">当前启动器实际绑定的服务端、数据库和游戏端。</p></div><button class="shell-btn" id="setRefresh">刷新</button></div>
|
||||
@@ -202,7 +123,7 @@
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-changelog">
|
||||
<div class="page-pad"><h2>更新日志</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
|
||||
<div class="page-pad"><h2>游戏更新</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
|
||||
</section>
|
||||
|
||||
<section class="shell-page" id="page-gm">
|
||||
|
||||
@@ -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))
|
||||
+1
-203
@@ -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):
|
||||
"""返回账号列表(含默认标记)。"""
|
||||
|
||||
+1
-76
@@ -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}
|
||||
|
||||
+2
-496
@@ -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 => `<option value="${x.account_id || x.username || ''}">${x.username || x.account_id}</option>`).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 => `<option value="${c.character_id}">${c.name} · Lv${c.level}</option>`).join('');
|
||||
if (cpCharacters.length) await loadCompanionState();
|
||||
else { $('cpGrid').innerHTML = '<div class="empty-box">当前账号没有角色</div>'; }
|
||||
}
|
||||
|
||||
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 = '<div class="empty-box">当前账号没有其他角色可作为支援兵</div>'; renderCpAI(); return; }
|
||||
grid.innerHTML = list.map(c => {
|
||||
const id = String(c.character_id), selected = cpSelected.has(id);
|
||||
return `<label class="cp-card ${selected?'selected':''}">
|
||||
<input type="checkbox" data-cp-source="${id}" ${selected?'checked':''}>
|
||||
<b>${c.name || id}</b><small>Lv${c.level ?? '-'} · 职业 ${c.job ?? '-'}</small>
|
||||
</label>`;
|
||||
}).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 `<option value="${id}">${c ? c.name : id}</option>`;
|
||||
}).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 = '<div class="empty-box">正在读取服务端配置…</div>';
|
||||
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 = '<div class="empty-box">读取失败:' + (e && e.message ? e.message : e) + '</div>';
|
||||
$('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 = `<div class="st-section"><h3>启动控制</h3><p class="st-note">配置文件:server-features.json / activity.json。服务端运行状态请以开始页为准。</p><div class="lo-toolbar"><button class="shell-btn primary" id="stStart">开始游戏</button><button class="shell-btn" id="stStop">关闭服务端</button></div><div class="st-note" id="stLaunchInfo"></div></div>`;
|
||||
$('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 =>
|
||||
`<button class="st-subtab ${stRaidTab===k?'active':''}" data-raid="${k}">${names[k]}</button>`).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 = `<div class="st-section"><h3>${tabTitle(stTab)}</h3>`;
|
||||
if (stTab === 'features') {
|
||||
const info = window.__stStatusInfo || {};
|
||||
const rawNames = ['instance.json','dnfbridge.toml','logic.toml','plan.json'];
|
||||
$('stSubTabs').innerHTML = rawNames.map(n =>
|
||||
`<button class="st-subtab ${stFeatureTab===n?'active':''}" data-raw="${n}">${n}</button>`).join('');
|
||||
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
|
||||
stFeatureTab = b.dataset.raw; renderServerTool();
|
||||
});
|
||||
html += `<div class="st-form">
|
||||
<label>服务端目录<input value="${(info.server_root || '').replace(/"/g,'"')}" readonly></label>
|
||||
<label>Admin 端口<input value="18111" readonly></label>
|
||||
<label>数据库<input value="${(info.db || '').replace(/"/g,'"')}" readonly></label>
|
||||
<label>客户端目录<input value="${(info.client_dir || '').replace(/"/g,'"')}" readonly></label>
|
||||
</div>`;
|
||||
html += `<div class="st-note">当前配置文件:${stFeatureTab}</div>`;
|
||||
html += `<textarea id="stRaw" class="st-raw" spellcheck="false">${stRawText.replace(/</g,'<')}</textarea>`;
|
||||
html += `<div class="st-note">服务端参数页保留实例与配置文件;下方列出未归入其他页的字段。</div>`;
|
||||
}
|
||||
html += `<div class="st-form">${groupFields(stFeatures, stTab === 'gameplay' ? keys.filter(k => k !== 'allowFreeRevive') : keys)}</div>`;
|
||||
if (stTab === 'gameplay') {
|
||||
html += `<div class="st-form">
|
||||
<label>深渊史诗掉率(%)<small class="st-hint">100 = 原版,250 = 2.5 倍</small><input data-game-key="hellEpicDropRatePercent" type="number" value="${stGameplay.hell_epic_drop_rate_percent ?? 0}"></label>
|
||||
<label>史诗药剂测试倍率(%)<small class="st-hint">0 = 关闭测试倍率</small><input data-game-key="epicPotionMultiplierPercent" type="number" value="${stGameplay.epic_drop_rate_potion_test_multiplier_percent ?? 0}"></label>
|
||||
</div>`;
|
||||
html += `<div class="st-check-row">
|
||||
<label class="st-check"><input type="checkbox" data-st-key="allowFreeRevive" ${stFeatures.allowFreeRevive === true ? 'checked' : ''}>允许无复活币免费复活</label>
|
||||
<label class="st-check"><input type="checkbox" data-batch-pickup ${stBatchPickup ? 'checked' : ''}>服务端允许一键拾取</label>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<details class="st-json"><summary>高级:完整 JSON 配置</summary><textarea id="stJson" spellcheck="false"></textarea><div class="lo-toolbar"><button class="shell-btn" id="stJsonApply">应用 JSON</button><span class="muted">可直接编辑全部服务端字段</span></div></details>`;
|
||||
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 `<label class="st-check"><input type="checkbox" data-st-key="${k}" ${val===true?'checked':''}>${label}</label>`;
|
||||
}
|
||||
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] ? `<small class="st-hint">${ST_DESC[k]}</small>` : '';
|
||||
return `<label>${label}${hint}<input data-st-key="${k}" data-type="${type}" type="${type}" value="${shown.replace(/"/g,'"')}"></label>`;
|
||||
}).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 = `<div class="st-section"><h3>在线活动开关</h3><div class="st-form">${keys.map(k => {
|
||||
const v = !!stActivity[k]; const label = ({daily:'每日签到',weekly:'每周签到',monthly:'每月签到',marble:'签到弹珠',returnee:'回归活动',returneeGift:'回归赠礼',lucky:'幸运活动',levelAiming:'升级预约',laborDay:'劳动节',legacyLevelup:'旧版升级',pcRoom:'网吧时间点',spendTime:'在线时长'})[k] || k;
|
||||
return `<label class="st-check"><input type="checkbox" data-act-key="${k}" ${v?'checked':''}>${label}</label>`;
|
||||
}).join('')}</div></div>`;
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
# 支援兵页面模板(Companion Page Template)
|
||||
|
||||
> 从 DNF90 启动器 v2.1.0 中完整导出的「支援兵」页面模板。
|
||||
> 导出日期:2026-11-04。
|
||||
> 用途:日后如需恢复「支援兵」功能,按本文档把 HTML / JS / CSS / 后端接口 四个部分分别粘贴回对应文件即可。
|
||||
|
||||
---
|
||||
|
||||
## 一、HTML(index.html 中的 section)
|
||||
|
||||
插入位置:`<section class="shell-page" id="page-changelog">` 之前(或任意 shell-page 之间)。
|
||||
|
||||
```html
|
||||
<section class="shell-page" id="page-companion">
|
||||
<div class="page-pad">
|
||||
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
|
||||
<div class="cp-toolbar">
|
||||
<label>账号<select id="cpAccount" class="shell-input"></select></label>
|
||||
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
|
||||
<button class="shell-btn" id="cpReload">刷新</button>
|
||||
<span id="cpStatus" class="muted"></span>
|
||||
</div>
|
||||
<div class="cp-flags">
|
||||
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
|
||||
</div>
|
||||
<h3 class="cp-title">选择支援兵角色</h3>
|
||||
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
|
||||
<div class="cp-ai">
|
||||
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
|
||||
<div class="st-form">
|
||||
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
|
||||
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
|
||||
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
|
||||
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
|
||||
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
|
||||
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
|
||||
</div>
|
||||
<details class="st-json">
|
||||
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
|
||||
<div class="st-form" id="cpAITuning">
|
||||
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
|
||||
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
|
||||
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
|
||||
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
|
||||
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
|
||||
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
|
||||
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
|
||||
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
|
||||
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
|
||||
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
|
||||
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
|
||||
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
|
||||
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
|
||||
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
|
||||
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
|
||||
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
|
||||
</div>
|
||||
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、侧边栏导航入口(index.html 的 nav 内)
|
||||
|
||||
```html
|
||||
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、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 => `<option value="${x.account_id || x.username || ''}">${x.username || x.account_id}</option>`).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 => `<option value="${c.character_id}">${c.name} · Lv${c.level}</option>`).join('');
|
||||
if (cpCharacters.length) await loadCompanionState();
|
||||
else { $('cpGrid').innerHTML = '<div class="empty-box">当前账号没有角色</div>'; }
|
||||
}
|
||||
|
||||
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 = '<div class="empty-box">当前账号没有其他角色可作为支援兵</div>'; renderCpAI(); return; }
|
||||
grid.innerHTML = list.map(c => {
|
||||
const id = String(c.character_id), selected = cpSelected.has(id);
|
||||
return `<label class="cp-card ${selected?'selected':''}">
|
||||
<input type="checkbox" data-cp-source="${id}" ${selected?'checked':''}>
|
||||
<b>${c.name || id}</b><small>Lv${c.level ?? '-'} · 职业 ${c.job ?? '-'}</small>
|
||||
</label>`;
|
||||
}).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 `<option value="${id}">${c ? c.name : id}</option>`;
|
||||
}).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. 重新打包
|
||||
|
||||
---
|
||||
|
||||
(完)
|
||||
Reference in new issue
Block a user