- 删除服务端工具、支援兵、插件三个页面(侧边栏入口 + 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: 支援兵页面完整导出为独立模板 - 侧边栏「更新日志」更名为「游戏更新」
405 lines
19 KiB
Markdown
405 lines
19 KiB
Markdown
# 支援兵页面模板(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. 重新打包
|
||
|
||
---
|
||
|
||
(完)
|