Files
90CNServer/launcher/templates/支援兵页面模板.md
T
hanfuit 977a4a1196 启动器:移除服务端工具/支援兵/插件页面,支援兵模板独立保存
- 删除服务端工具、支援兵、插件三个页面(侧边栏入口 + 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: 支援兵页面完整导出为独立模板
- 侧边栏「更新日志」更名为「游戏更新」
2026-11-04 19:32:01 +08:00

19 KiB
Raw Blame History

支援兵页面模板(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 之间)。

    <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 内)

      <button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>

三、JavaScript(shell.js 中的支援兵模块)

插入位置:// ---------------- 一键换装 ---------------- 区块之后、// ---------------- 服务端工具 ---------------- 区块之前。

  // ---------------- 支援兵 ----------------
  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 函数中加回页面切换调用:

    if (page === 'companion') loadCompanion();

以及在 pages 数组加回 'companion',在 crumb 加回 companion:'工具 / 支援兵'。


四、后端接口(server.py)

4.1 GET 路由注册(do_GET 内)

        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 内)

        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 处理函数

    # ==================== 支援兵 ====================
    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 明暗主题区域):

.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. 重新打包

(完)