分享和讨论Javascript相关的话题
回复
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

debug之後找到問題:當MCTS搜尋到一方已經達到6分時,並沒有停止搜尋並判其獲勝,而是繼續往下搜尋。解決這個問題。

代码: 全选

這確實是一個非常好的發現!這正是我們加入 Debug 樹狀圖的價值所在。

這個 Bug 存在的原因在於:`generateAllValidTurns` 函式在生成下一步合法行動時,只顧著計算棋盤上的物理碰撞與路徑(例如有沒有撞到圓柱體或對手),卻**沒有檢查遊戲是否已經因為達到 6 分而結束**。這導致 MCTS 會在某一方已經獲勝的狀態下,繼續衍生出「幽靈回合」,從而稀釋了必勝節點的勝率,甚至讓 AI 誤判後續的風險。

我們只需要在生成合法行動的最開頭加上分數判定,一旦有一方達到 6 分,就立刻回傳空陣列 `[]`。這樣一來,該節點就會被正確標記為「終端節點 (Terminal Node)」,MCTS 就不會再往下展開,並且能把必勝或必敗的極值正確反向傳播。

請找到 `generateAllValidTurns` 函式,並在最前方加入勝利條件的攔截:

**請替換這段程式碼:**

```javascript
      // 生成給定狀態下「一整回合」的所有合法路線組合
      function generateAllValidTurns(state) {
        const moves = [];
        const myC = state.player === currentPlayer ? state.aiPos.c : state.oppPos.c;
        const myR = state.player === currentPlayer ? state.aiPos.r : state.oppPos.r;

```

**改為以下這段程式碼:**

```javascript
      // 生成給定狀態下「一整回合」的所有合法路線組合
      function generateAllValidTurns(state) {
        const moves = [];

        // 【修正】:檢查是否已經達到獲勝條件 (6分),若是則遊戲結束,不再產生後續合法步
        if (state.aiScore >= 6 || state.oppScore >= 6) {
          return moves;
        }

        const myC = state.player === currentPlayer ? state.aiPos.c : state.oppPos.c;
        const myR = state.player === currentPlayer ? state.aiPos.r : state.oppPos.r;

```

這樣修改後,一旦節點狀態的 `aiScore` 或 `oppScore` 達到 6 分,`unexpandedMoves` 就會是空的,MCTS 的 `isTerminal()` 也會回傳 `true`,搜尋就會完美停止在分出勝負的那一刻,並準確回報勝率!
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

目前MCTS的思考深度是如何定義的?現在為AI增加一項MCTS思考深度選項,其默認值為「簡單12、困難14、專家16、自訂18」。

gemini3.1pro要睡到20:52

代码: 全选

### 目前 MCTS 的思考深度是如何定義的?

在標準的蒙地卡洛樹搜尋(MCTS)中,整棵決策樹的生長深度通常是沒有硬性限制的,它會根據你給予的「思考時間」不斷向下擴展。

但在評估一個未知節點到底好不好的時候,MCTS 會進行**隨機模擬(Playout / Simulation)**。目前的程式碼中,這個隨機模擬的深度是**寫死為 3** 的。也就是說,AI 只會隨機往後推演 3 步來評估當前局勢:

```javascript
// 目前寫死為 3 步
const simScore = simulateRandomPlayout(node.state, aiColor, 3); 

```

為了讓 AI 能看得更遠,我們可以將這個寫死的數值抽離出來,變成你可以從 UI 調整的「MCTS 模擬深度」,並將預設值對應到你的需求:簡單 12、困難 14、專家 16、自訂 18。

---

### 替換與新增程式碼指南

這項修改會牽涉到 UI 選單、設置面板以及核心的 MCTS 邏輯。請依照以下步驟進行替換:

#### 1. 新增全域變數

請在程式碼最上方尋找 `let SURE_KILL_DEPTH = 6;`,並在下方加入 MCTS 的變數:

**替換這段:**

```javascript
      let aiStrength = 4000; // 預設困難
      let SURE_KILL_DEPTH = 6; // 預設必殺深度
      let currentTurnColor = ''; // 追蹤目前是誰的回合

```

**改為:**

```javascript
      let aiStrength = 4000; // 預設困難
      let SURE_KILL_DEPTH = 6; // 預設必殺深度
      let MCTS_DEPTH = 14; // 預設 MCTS 模擬深度
      let currentTurnColor = ''; // 追蹤目前是誰的回合

```

#### 2. 更新主畫面下拉選單 (HTML)

請找到 `<div id="ai-strength-options" class="custom-select-options">` 這整個區塊,替換為包含 `data-mcts` 屬性與新文字的版本:

**替換為這段:**

```html
        <div id="ai-strength-options" class="custom-select-options">
          <div class="csinger-option strength-option" id="opt-ai-easy" data-value="1000" data-depth="4" data-mcts="12">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
              <rect x="5" y="7" width="14" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="2" />
              <line x1="12" y1="2" x2="12" y2="6" stroke="currentColor" stroke-width="2" />
              <circle cx="9" cy="12" r="1.2" fill="currentColor" />
              <circle cx="15" cy="12" r="1.2" fill="currentColor" />
            </svg>
            簡單 (1s, 必殺 4, 模擬 12)
          </div>
          <div class="csinger-option strength-option" id="opt-ai-hard" data-value="4000" data-depth="6" data-mcts="14">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
              <rect x="2" y="9" width="2" height="6" rx="1" />
              <rect x="20" y="9" width="2" height="6" rx="1" />
              <rect x="5" y="6" width="14" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="2" />
              <circle cx="9" cy="11" r="1.2" />
              <circle cx="15" cy="11" r="1.2" />
              <rect x="9" y="14" width="6" height="2" />
            </svg>
            困難 (4s, 必殺 6, 模擬 14)
          </div>
          <div class="csinger-option strength-option" id="opt-ai-expert" data-value="7000" data-depth="8" data-mcts="16">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
              <rect x="7" y="7" width="10" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
              <path d="M3 9h4M3 15h4M17 9h4M17 15h4M9 3v4M15 3v4M9 17v4M15 17v4" stroke="currentColor" stroke-width="2" />
            </svg>
            專家 (7s, 必殺 8, 模擬 16)
          </div>
          <div class="csinger-option strength-option" id="opt-custom-ai" data-value="10000" data-depth="10" data-mcts="18">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
              <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
            </svg>
            自定義 (10s, 必殺 10, 模擬 18)
          </div>
        </div>

```

#### 3. 更新設置面板的預設按鈕與輸入框 (HTML)

請找到 `<div id="modal-ai-presets"` 區塊內的四個 `<button>`,為它們加上對應的 `data-mcts` 屬性:

* `modal-preset-easy` 加上 `data-mcts="12"`
* `modal-preset-hard` 加上 `data-mcts="14"`
* `modal-preset-expert` 加上 `data-mcts="16"`
* `modal-preset-custom` 加上 `data-mcts="18"`

然後往下找到 `lbl-sure-kill` 所在的 `<div class="setting-row">`。在其**下方**插入新的 MCTS 輸入框:

```html
      <div class="setting-row">
        <label id="lbl-mcts-depth">MCTS 模擬深度:</label>
        <div style="display: flex; align-items: center; gap: 5px">
          <button id="btn-mcts-minus" style="padding: 4px 10px; cursor: pointer; background: #34495e; color: white; border: 1px solid #7f8c8d; border-radius: 4px;">-</button>
          <input type="number" id="inp-mcts-depth" step="1" min="1" max="50" style="text-align: center" />
          <button id="btn-mcts-plus" style="padding: 4px 10px; cursor: pointer; background: #34495e; color: white; border: 1px solid #7f8c8d; border-radius: 4px;">+</button>
        </div>
      </div>

```

#### 4. 套用至 MCTS 核心邏輯 (JS)

請找到 `planFullTurnActionsMCTS` 函式中呼叫 `simulateRandomPlayout` 的地方:

**替換這段:**

```javascript
          if (!node.isFullyExpanded() && !node.isTerminal()) {
            node = node.expand();
          }

          const simScore = simulateRandomPlayout(node.state, aiColor, 3);
          node.backpropagate(simScore);

```

**改為:**

```javascript
          if (!node.isFullyExpanded() && !node.isTerminal()) {
            node = node.expand();
          }

          // 接入玩家設定的 MCTS_DEPTH
          const simScore = simulateRandomPlayout(node.state, aiColor, MCTS_DEPTH);
          node.backpropagate(simScore);

```

#### 5. 同步所有 UI 操作與語言切換 (JS)

在程式碼尾部的事件綁定區塊,找到以下幾處並進行更新以同步 `MCTS_DEPTH`:

**A. 處理主畫面下拉選單點擊:**
找到 `.strength-option` 的迴圈:

```javascript
      document.querySelectorAll('.strength-option').forEach(opt => {
        opt.addEventListener('click', () => {
          aiStrength = parseInt(opt.getAttribute('data-value')) || 4000;
          SURE_KILL_DEPTH = parseInt(opt.getAttribute('data-depth')) || 6; 
          MCTS_DEPTH = parseInt(opt.getAttribute('data-mcts')) || 14; // 新增這行

          aiStrengthTrigger.innerHTML = opt.querySelector('svg').outerHTML;
          aiStrengthTrigger.setAttribute('data-value', aiStrength);
          aiStrengthTrigger.setAttribute('data-depth', SURE_KILL_DEPTH);
          aiStrengthTrigger.setAttribute('data-mcts', MCTS_DEPTH); // 新增這行

          aiStrengthOptions.classList.remove('open');
        });
      });

```

**B. 語言切換文字更新 (`updateUIAfterLangChange` 函式內部):**
加入 `lbl-mcts-depth` 的翻譯,並更新 `csinger-option` 的顯示文字:

```javascript
        document.getElementById('lbl-think-time').innerText = window.currentLang === 'zh' ? '思考時間 (秒):' : 'Think Time (s):';
        document.getElementById('lbl-sure-kill').innerText = window.currentLang === 'zh' ? '必殺深度:' : 'Sure-kill Depth:';
        const lblMcts = document.getElementById('lbl-mcts-depth'); // 新增
        if (lblMcts) lblMcts.innerText = window.currentLang === 'zh' ? 'MCTS 模擬深度:' : 'MCTS Sim Depth:'; // 新增

```

並且修改下方四個選項的字串替換,補上模擬深度的文字:

```javascript
        const easyOpt = document.getElementById('opt-ai-easy');
        if (easyOpt) easyOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 簡單 (1s, 必殺 4, 模擬 12)' : ' Easy (1s, Kill 4, Sim 12)';
        const hardOpt = document.getElementById('opt-ai-hard');
        if (hardOpt) hardOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 困難 (4s, 必殺 6, 模擬 14)' : ' Hard (4s, Kill 6, Sim 14)';
        const expertOpt = document.getElementById('opt-ai-expert');
        if (expertOpt) expertOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 專家 (7s, 必殺 8, 模擬 16)' : ' Expert (7s, Kill 8, Sim 16)';

```

**C. 同步 Custom 選項文字 (`syncCustomOption` 函式內部):**

```javascript
      function syncCustomOption() {
        const msTime = parseInt(document.getElementById('inp-think-time').value) * 1000;
        const currentDepth = parseInt(document.getElementById('inp-sure-kill').value);
        const currentMcts = parseInt(document.getElementById('inp-mcts-depth').value); // 新增

        let customOpt = document.getElementById('opt-custom-ai');
        if (customOpt) {
          customOpt.setAttribute('data-value', msTime);
          customOpt.setAttribute('data-depth', currentDepth);
          customOpt.setAttribute('data-mcts', currentMcts); // 新增
          const langDepth = window.currentLang === 'zh' ? '必殺' : 'Kill';
          const langMcts = window.currentLang === 'zh' ? '模擬' : 'Sim';
          const langCustom = window.currentLang === 'zh' ? '自定義' : 'Custom';
          customOpt.innerHTML = `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg> ${langCustom} (${msTime / 1000}s, ${langDepth} ${currentDepth}, ${langMcts} ${currentMcts})`;
        }
      }

```

**D. 面板預設按鈕高亮邏輯 (`updateAIPresetsHighlight` 函式內部):**

```javascript
      function updateAIPresetsHighlight() {
        const t = parseInt(document.getElementById('inp-think-time').value) * 1000;
        const d = parseInt(document.getElementById('inp-sure-kill').value);
        const m = parseInt(document.getElementById('inp-mcts-depth').value); // 新增

        let matched = false;
        let selectedBtn = null;

        presetBtns.forEach(btn => {
          btn.style.backgroundColor = '#34495e';
          const val = parseInt(btn.getAttribute('data-val'));
          const dep = parseInt(btn.getAttribute('data-dep'));
          const mcts = parseInt(btn.getAttribute('data-mcts')); // 新增
          
          // 加入 m === mcts 判斷
          if (val === t && dep === d && m === mcts && btn.id !== 'modal-preset-custom') {
            btn.style.backgroundColor = '#2ecc71';
            matched = true;
            selectedBtn = btn;
          }
        });

        // ... 省略中間 ...

        // 同步主按鈕 UI
        const trigger = document.getElementById('ai-strength-trigger');
        if (trigger && selectedBtn) {
          trigger.setAttribute('data-value', t);
          trigger.setAttribute('data-depth', d);
          trigger.setAttribute('data-mcts', m); // 新增
          aiStrength = t;
          SURE_KILL_DEPTH = d;
          MCTS_DEPTH = m; // 新增
          trigger.innerHTML = selectedBtn.querySelector('svg').outerHTML;
        }
      }

```

**E. 面板開啟與點擊邏輯:**
在 `btn-settings` 開啟 Modal 的事件中,初始化輸入框的值:

```javascript
        document.getElementById('inp-think-time').value = aiStrength / 1000;
        document.getElementById('inp-sure-kill').value = SURE_KILL_DEPTH;
        document.getElementById('inp-mcts-depth').value = MCTS_DEPTH; // 新增這行

```

在 `presetBtns.forEach` 點擊事件中,套用數值:

```javascript
          if (isCustom) {
            if (isActive) return; 
            document.getElementById('inp-think-time').value = 10;
            document.getElementById('inp-sure-kill').value = 10;
            document.getElementById('inp-mcts-depth').value = 18; // 新增這行
          } else {
            const val = parseInt(btn.getAttribute('data-val'));
            const dep = parseInt(btn.getAttribute('data-dep'));
            const mcts = parseInt(btn.getAttribute('data-mcts')); // 新增
            document.getElementById('inp-think-time').value = val / 1000;
            document.getElementById('inp-sure-kill').value = dep;
            document.getElementById('inp-mcts-depth').value = mcts; // 新增這行
          }

```

在 `btn-modal-search` 點擊事件中,確保點擊「搜尋最佳行動」時抓取到新深度:

```javascript
        aiStrength = parseInt(document.getElementById('inp-think-time').value) * 1000 || 4000;
        SURE_KILL_DEPTH = parseInt(document.getElementById('inp-sure-kill').value) || 6;
        MCTS_DEPTH = parseInt(document.getElementById('inp-mcts-depth').value) || 14; // 新增這行

```

最後,別忘了在程式尾端將按鈕行為綁定到這個新輸入框:

```javascript
      setupNumberInput('inp-think-time', 'btn-time-minus', 'btn-time-plus', 1, 24);
      setupNumberInput('inp-sure-kill', 'btn-depth-minus', 'btn-depth-plus', 1, 18);
      setupNumberInput('inp-mcts-depth', 'btn-mcts-minus', 'btn-mcts-plus', 1, 50); // 新增這行

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

在debug時發現,某些可行的路線只走過一次,這樣得到的期望值是不準確的。為每個AI增加一個「最少測試局數」,不同AI等級的默認數量分別為「簡單60、困難120、專家180、自訂240」,用這個最少測試局數來得出當前節點的期望值。

另解釋下期望值的算法,以及還可以怎樣改進。

代码: 全选

這是一個非常精準的觀察!在標準的 MCTS 中,如果一個節點只被隨機模擬(Playout)了一次,它得到的「期望值」充滿了隨機噪音。如果那一次隨機模擬剛好讓 AI 走進死胡同,這個原本極佳的路線就會被嚴重低估而遭到拋棄。

為剛擴展的節點強制進行「最少測試局數」(Multiple Rollouts per Node),可以有效抹平單次模擬的極端誤差,讓初始期望值更具參考價值。

以下是針對你的需求進行的程式碼修改,以及對期望值算法的解析。

---

### 一、 替換與新增程式碼

#### 1. 新增全域變數

請在程式碼最上方尋找 `let MCTS_DEPTH = 14;`,在其下方加入 `MIN_SIMULATIONS`:

**替換這段:**

```javascript
      let aiStrength = 4000; // 預設困難
      let SURE_KILL_DEPTH = 6; // 預設必殺深度
      let MCTS_DEPTH = 14; // 預設 MCTS 模擬深度
      let currentTurnColor = ''; // 追蹤目前是誰的回合

```

**改為:**

```javascript
      let aiStrength = 4000; // 預設困難
      let SURE_KILL_DEPTH = 6; // 預設必殺深度
      let MCTS_DEPTH = 14; // 預設 MCTS 模擬深度
      let MIN_SIMULATIONS = 120; // 預設最少測試局數
      let currentTurnColor = ''; // 追蹤目前是誰的回合

```

#### 2. 更新主畫面下拉選單 (HTML)

請找到 `<div id="ai-strength-options" class="custom-select-options">` 區塊,替換為包含 `data-sim` 屬性與新文字的版本:

**替換為這段:**

```html
        <div id="ai-strength-options" class="custom-select-options">
          <div class="csinger-option strength-option" id="opt-ai-easy" data-value="1000" data-depth="4" data-mcts="12" data-sim="60">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
              <rect x="5" y="7" width="14" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="2" />
              <line x1="12" y1="2" x2="12" y2="6" stroke="currentColor" stroke-width="2" />
              <circle cx="9" cy="12" r="1.2" fill="currentColor" />
              <circle cx="15" cy="12" r="1.2" fill="currentColor" />
            </svg>
            簡單 (1s, 必殺 4, 模擬 12, 測試 60)
          </div>
          <div class="csinger-option strength-option" id="opt-ai-hard" data-value="4000" data-depth="6" data-mcts="14" data-sim="120">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
              <rect x="2" y="9" width="2" height="6" rx="1" />
              <rect x="20" y="9" width="2" height="6" rx="1" />
              <rect x="5" y="6" width="14" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="2" />
              <circle cx="9" cy="11" r="1.2" />
              <circle cx="15" cy="11" r="1.2" />
              <rect x="9" y="14" width="6" height="2" />
            </svg>
            困難 (4s, 必殺 6, 模擬 14, 測試 120)
          </div>
          <div class="csinger-option strength-option" id="opt-ai-expert" data-value="7000" data-depth="8" data-mcts="16" data-sim="180">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
              <rect x="7" y="7" width="10" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
              <path d="M3 9h4M3 15h4M17 9h4M17 15h4M9 3v4M15 3v4M9 17v4M15 17v4" stroke="currentColor" stroke-width="2" />
            </svg>
            專家 (7s, 必殺 8, 模擬 16, 測試 180)
          </div>
          <div class="csinger-option strength-option" id="opt-custom-ai" data-value="10000" data-depth="10" data-mcts="18" data-sim="240">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
              <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
            </svg>
            自定義 (10s, 必殺 10, 模擬 18, 測試 240)
          </div>
        </div>

```

#### 3. 更新設置面板 (HTML)

請找到 `<div id="modal-ai-presets"` 區塊內的四個 `<button>`,為它們加上 `data-sim` 屬性:

* `modal-preset-easy` 加上 `data-sim="60"`
* `modal-preset-hard` 加上 `data-sim="120"`
* `modal-preset-expert` 加上 `data-sim="180"`
* `modal-preset-custom` 加上 `data-sim="240"`

接著,找到 `lbl-mcts-depth` 所在的 `<div class="setting-row">`,在它**下方**插入新的輸入框:

```html
      <div class="setting-row">
        <label id="lbl-min-sim">最少測試局數:</label>
        <div style="display: flex; align-items: center; gap: 5px">
          <button id="btn-sim-minus" style="padding: 4px 10px; cursor: pointer; background: #34495e; color: white; border: 1px solid #7f8c8d; border-radius: 4px;">-</button>
          <input type="number" id="inp-min-sim" step="10" min="10" max="1000" style="text-align: center" />
          <button id="btn-sim-plus" style="padding: 4px 10px; cursor: pointer; background: #34495e; color: white; border: 1px solid #7f8c8d; border-radius: 4px;">+</button>
        </div>
      </div>

```

#### 4. 套用至 MCTS 核心邏輯 (JS)

請找到 `planFullTurnActionsMCTS` 函式中展開節點與模擬的地方:

**替換這段:**

```javascript
          if (!node.isFullyExpanded() && !node.isTerminal()) {
            node = node.expand();
          }

          // 接入玩家設定的 MCTS_DEPTH
          const simScore = simulateRandomPlayout(node.state, aiColor, MCTS_DEPTH);
          node.backpropagate(simScore);

```

**改為:**

```javascript
          if (!node.isFullyExpanded() && !node.isTerminal()) {
            node = node.expand();
            // 對新擴展的節點,強制執行 N 次模擬以穩定初始期望值
            for (let i = 0; i < MIN_SIMULATIONS; i++) {
              const simScore = simulateRandomPlayout(node.state, aiColor, MCTS_DEPTH);
              node.backpropagate(simScore);
            }
            iterations += MIN_SIMULATIONS; // 校正迭代次數的顯示
          } else {
            // 如果是已完全擴展的終端節點,執行單次模擬即可
            const simScore = simulateRandomPlayout(node.state, aiColor, MCTS_DEPTH);
            node.backpropagate(simScore);
            iterations++;
          }

```

*(注意:你也需要將原本下方的 `iterations++;` 刪除,因為我已經將其整合進 `if/else` 區塊中)*

#### 5. 同步 UI 操作邏輯 (JS)

在程式碼尾端,更新對應的事件與語言切換:

**A. 下拉選單點擊:**

```javascript
      document.querySelectorAll('.strength-option').forEach(opt => {
        opt.addEventListener('click', () => {
          aiStrength = parseInt(opt.getAttribute('data-value')) || 4000;
          SURE_KILL_DEPTH = parseInt(opt.getAttribute('data-depth')) || 6; 
          MCTS_DEPTH = parseInt(opt.getAttribute('data-mcts')) || 14; 
          MIN_SIMULATIONS = parseInt(opt.getAttribute('data-sim')) || 120; // 新增這行

          aiStrengthTrigger.innerHTML = opt.querySelector('svg').outerHTML;
          aiStrengthTrigger.setAttribute('data-value', aiStrength);
          aiStrengthTrigger.setAttribute('data-depth', SURE_KILL_DEPTH);
          aiStrengthTrigger.setAttribute('data-mcts', MCTS_DEPTH); 
          aiStrengthTrigger.setAttribute('data-sim', MIN_SIMULATIONS); // 新增這行

          aiStrengthOptions.classList.remove('open');
        });
      });

```

**B. 語言切換 (`updateUIAfterLangChange`):**
加入 `lbl-min-sim` 的翻譯:

```javascript
        const lblSim = document.getElementById('lbl-min-sim'); // 新增
        if (lblSim) lblSim.innerText = window.currentLang === 'zh' ? '最少測試局數:' : 'Min Simulations:'; // 新增

```

更新選項文字:

```javascript
        const easyOpt = document.getElementById('opt-ai-easy');
        if (easyOpt) easyOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 簡單 (1s, 必殺 4, 模擬 12, 測試 60)' : ' Easy (1s, Kill 4, Sim 12, Test 60)';
        const hardOpt = document.getElementById('opt-ai-hard');
        if (hardOpt) hardOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 困難 (4s, 必殺 6, 模擬 14, 測試 120)' : ' Hard (4s, Kill 6, Sim 14, Test 120)';
        const expertOpt = document.getElementById('opt-ai-expert');
        if (expertOpt) expertOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 專家 (7s, 必殺 8, 模擬 16, 測試 180)' : ' Expert (7s, Kill 8, Sim 16, Test 180)';

```

**C. 同步 Custom 選項 (`syncCustomOption`):**

```javascript
        const currentSim = parseInt(document.getElementById('inp-min-sim').value); // 新增
        // ...
        if (customOpt) {
          customOpt.setAttribute('data-value', msTime);
          customOpt.setAttribute('data-depth', currentDepth);
          customOpt.setAttribute('data-mcts', currentMcts); 
          customOpt.setAttribute('data-sim', currentSim); // 新增
          const langDepth = window.currentLang === 'zh' ? '必殺' : 'Kill';
          const langMcts = window.currentLang === 'zh' ? '模擬' : 'Sim';
          const langSim = window.currentLang === 'zh' ? '測試' : 'Test';
          const langCustom = window.currentLang === 'zh' ? '自定義' : 'Custom';
          customOpt.innerHTML = `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg> ${langCustom} (${msTime / 1000}s, ${langDepth} ${currentDepth}, ${langMcts} ${currentMcts}, ${langSim} ${currentSim})`;
        }

```

**D. 預設按鈕高亮 (`updateAIPresetsHighlight`):**

```javascript
        const s = parseInt(document.getElementById('inp-min-sim').value); // 新增
        // ...
          const sim = parseInt(btn.getAttribute('data-sim')); // 新增
          if (val === t && dep === d && m === mcts && s === sim && btn.id !== 'modal-preset-custom') { // 更新判斷

```

記得在 `btn-settings` 開啟 Modal 的事件中加入:

```javascript
        document.getElementById('inp-min-sim').value = MIN_SIMULATIONS; 

```

並在 `presetBtns.forEach` 點擊事件中套用數值:

```javascript
            document.getElementById('inp-min-sim').value = isCustom ? 240 : parseInt(btn.getAttribute('data-sim'));

```

在 `btn-modal-search` 點擊事件中抓取:

```javascript
        MIN_SIMULATIONS = parseInt(document.getElementById('inp-min-sim').value) || 120;

```

最後綁定輸入框按鈕:

```javascript
      setupNumberInput('inp-min-sim', 'btn-sim-minus', 'btn-sim-plus', 10, 1000); // 步長可根據 setupNumberInput 自行微調

```

---

### 二、 當前期望值的算法解析

目前程式碼中的期望值(Expected Value)計算邏輯可以拆分為兩個階段:**模擬計分**與**反向傳播平均**。

#### 1. 模擬計分 (Simulation Score)

在 `simulateRandomPlayout` 走到終點(或達到深度極限)時,會為當前局面打一個分數:


$$Score = ScoreDiff + (aiScore \times 500) - (oppScore \times 500)$$

* **權重極大化**:這是一種啟發式評估(Heuristic Evaluation)。AI 的分數乘上了 `500` 這個巨大權重,意味著「實際得分」的價值遠高於普通的「話筒數量優勢 (`scoreDiff`)」。
* **必勝/必敗**:如果觸發了 `aiScore >= 6`,直接回傳 $+10000$ 加上優勢;反之則是 $-10000$。

#### 2. 期望值與勝率轉換

這個 `Score` 透過 `node.backpropagate()` 一路向上累加到根節點,期望值即為:


$$Expected Score = \frac{Total Score}{Visits}$$


然後為了在 UI 上顯示為 $0\% \sim 100\%$ 的勝率,我們使用了一個 Sigmoid 函數來平滑化:


$$Win Rate = \frac{1}{1 + e^{-\frac{Expected Score}{200}}} \times 100\%$$


*(分母的 200 是溫度參數,用來將大幾百的分數縮放至合理的曲線範圍)*

---

### 三、 未來的改進方向

除了強制多次測試外,還有幾個能讓 MCTS 大幅進化的經典做法:

1. **模擬策略 (Playout Policy) 替換純隨機**:
目前的 `Math.floor(Math.random() * validMoves.length)` 是「純隨機推演」。純隨機會讓 AI 在模擬時做出極度愚蠢的行為(例如放著對手的話筒不吃,跑去撞牆)。
* **改進**:實作一個 **$\epsilon$-greedy (貪婪隨機) 策略**。在模擬時,有 70% 的機率隨機走,30% 的機率「強制選擇能得分或阻擋對手」的路線。這樣推演出來的期望值會更貼近「兩個高手對弈」的真實局勢。


2. **分數歸一化 (Score Normalization)**:
目前你的 UCT 公式是 `expectedScore + C * sqrt(ln(N)/n)`,其中 $C=150$。因為你的 `expectedScore` 是在正負幾千之間浮動的未收斂數值,這導致 $C=150$ 在某些局面下無法有效平衡「探索 (Exploration)」與「利用 (Exploitation)」。
* **改進**:在 `getBestUCTChild` 中,將所有子節點的期望分數動態對齊到 $[0, 1]$ 或 $[-1, 1]$ 之間,再使用標準的 $C = \sqrt{2}$。


3. **漸進式擴展 (Progressive Widening)**:
現在你的 `generateAllValidTurns` 會一次性把所有合法步(有時高達幾十種)全部生成並加入 `unexpandedMoves`。如果有 50 種走法,每種走法擴展時還要測試 240 次,很容易浪費算力在明顯是死路的選項上。
* **改進**:利用一個簡單的靜態評估函數,先為所有合法步打分,只取前 $K$ 個(例如前 8 名)最有可能的走法加入 MCTS 樹中進行深度模擬。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

現在的算法是,先模擬(比如120次),得到期望,然後再根據期望高低進行搜尋的是嗎?

如果是的話,現在要改成:當期望值已經定下之後,仍然要每個節點至少搜尋120次。

同時我想知道,後面的搜尋是否仍然會繼續調整期望值。能否修改期望的算法,以使期望值排在前幾名的節點都能大概搜尋次數是相等的?

gemini3.1pro要睡到00:52

代码: 全选

這是一個非常核心且專業的 MCTS(蒙地卡洛樹搜尋)問題!你的直覺非常敏銳,我們來一步步釐清並解決這個問題。

### 一、 先回答你的疑問:期望值是定死的嗎?會繼續調整嗎?

**解答:期望值不是定死的,後面的搜尋會「不斷地」調整期望值。**

MCTS 的運作是一個不斷循環的過程:

1. **選擇 (Selection):** 從樹的根節點往下,依據公式選一個最值得探索的節點。
2. **擴展 (Expansion):** 如果這個節點還沒被完全探索,就幫它長出一個新的子節點(也就是我們上次修改的地方:在這裡強制對這個新節點做 120 次隨機模擬,為它打下初始基礎)。
3. **模擬 (Simulation):** (我們已把它合併在擴展階段)
4. **反向傳播 (Backpropagation):** 將這 120 次模擬的總分,一路往上加回父節點。

當這個循環繼續跑時,由於這個節點的「初始期望值」算出來了,下次樹在做「選擇」時,如果發現它的期望值很高,就會**再次走進這個節點**,並且繼續對它底下的子路線做模擬。這些後續模擬產生的分數,會繼續加進這個節點裡,讓它的 `visits` (拜訪次數) 和 `totalScore` (總分) 不斷增加,**期望值也就隨之不斷更新、越來越精準**。

---

### 二、 為什麼現在會出現「只偏好某一個節點,其他頂尖節點搜尋次數很少」?

這正是你觀察到的痛點。MCTS 選擇節點的依據是 **UCT 公式 (Upper Confidence Bound for Trees)**:

> UCT 分數 = 期望值 + 探索係數 (Exploration)

在我們目前的程式碼中:

* **期望值** 的量級非常巨大,因為獲勝是 $+10000$,失敗是 $-10000$。
* **探索係數** 目前寫死為 `C = 150`。

這導致一個嚴重的失衡:如果節點 A 的期望值是 5000,節點 B 的期望值是 4800,兩者其實都是極佳的路線。但是因為 5000 和 4800 差距足足有 200,而探索係數的增長非常緩慢,這導致公式**永遠覺得節點 A 比較好**,從而把所有的搜尋次數 (Visits) 全砸在節點 A 上,節點 B 就被餓死了。

### 三、 如何修改算法,讓排名前幾名的節點搜尋次數大致相等?

**解決方案:分數歸一化 (Score Normalization)**

我們需要把巨大且浮動的期望值,動態壓縮到 `0 到 1` 的範圍內。

* 當前這層最好的節點分數當作 `1`。
* 最差的當作 `0`。
* 同時我們把探索係數 $C$ 改為標準的 `1.414` (即 $\sqrt{2}$)。

這樣一來,如果節點 A 和節點 B 的分數都很頂尖(歸一化後分別是 1.0 和 0.98),它們在公式中的「期望值」差距就極小。此時,**「探索係數」就能發揮強大的平衡作用**。只要節點 A 被多搜了幾次,它的探索紅利就會下降,公式就會自動轉而去搜尋節點 B,從而達成你想要的「頂尖節點能得到大致相等的搜尋次數」!

---

### 四、 替換程式碼指南

請在程式碼中找到 `class MCTSNode` 裡面的 `getBestUCTChild()` 函式,將整個函式完全替換為以下動態歸一化版本:

**請將整段 `getBestUCTChild()` 替換為:**

```javascript
        getBestUCTChild() {
          // 1. 先找出當前所有子節點中的最高分與最低分 (用以動態歸一化)
          let maxScore = -Infinity;
          let minScore = Infinity;
          for (const child of this.children) {
            if (child.visits > 0) {
              const expectedScore = child.totalScore / child.visits;
              if (expectedScore > maxScore) maxScore = expectedScore;
              if (expectedScore < minScore) minScore = expectedScore;
            }
          }

          // 避免所有節點分數一樣時發生除以零的錯誤
          const scoreRange = maxScore - minScore > 0 ? maxScore - minScore : 1;

          // 2. 標準的 UCT 探索常數 (當分數被壓縮到 0~1 時,1.414 是數學上的最佳解)
          const C = 1.414; 
          let bestValue = -Infinity;
          let bestChild = null;

          const isAI = this.state.player === currentPlayer;

          for (const child of this.children) {
            // 如果遇到完全沒拜訪過的節點,優先選擇它
            if (child.visits === 0) return child;

            const expectedScore = child.totalScore / child.visits;

            // 3. 分數歸一化 (Normalization)
            let normalizedScore;
            if (isAI) {
              // AI 回合:AI 想要分數最大化。分數越接近 maxScore,歸一化後越接近 1
              normalizedScore = (expectedScore - minScore) / scoreRange;
            } else {
              // 對手回合:對手想要讓 AI 分數最小化。分數越接近 minScore,對手眼裡的價值越接近 1
              normalizedScore = (maxScore - expectedScore) / scoreRange;
            }

            // 4. 計算探索紅利 (拜訪次數越少的節點,紅利越高)
            const exploration = C * Math.sqrt(Math.log(this.visits) / child.visits);
            
            // 5. 結合歸一化期望值與探索紅利
            const uctValue = normalizedScore + exploration;

            if (uctValue > bestValue) {
              bestValue = uctValue;
              bestChild = child;
            }
          }
          return bestChild;
        }

```

### 這樣修改後的預期效果:

現在你可以打開你的 Debug 展開按鈕觀察。你會發現,如果排名前三名的路線期望值都很接近,**它們的測試次數 (Visits) 會互相追趕,呈現階梯式的平行增長**。這能極大地提升 AI 的穩定性,避免它因為初期的隨機誤差而「鑽牛角尖」走向死胡同!
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

把分數歸一化後,仍然沒有解決問題,我希望走的那個分支,根本都沒有走。能否把期望的影響降至最低?或者有其它的解決方法?

gemini3.1pro

代码: 全选

這是一個非常經典的 MCTS 陷阱!你的觀察完全正確,這正是我們上次加入「強制 120 次測試」後引發的副作用。

### 為什麼會發生這個問題?

在標準的 MCTS 中,UCT 公式的探索項是:
$Exploration = C \times \sqrt{\frac{\ln(\text{父節點次數})}{\text{子節點次數}}}$

因為我們強制每個子節點一出生就跑了 120 次測試,這導致**子節點次數(分母)瞬間變得極大**。
當分母很大時,算出來的「探索紅利」就會趨近於 $0$。這意味著:只要某個分支在那 120 次的**隨機**測試中運氣不好,導致期望值偏低,它就失去了「探索紅利」的保護,AI 會認定它不值得再看一眼,從此把它打入冷宮。

---

### 解決方案:邏輯次數校正與權重控制

要解決這個問題,我們不需要廢棄那 120 次測試,而是要**欺騙 UCT 公式**,以及**手動調降期望值的影響力**。

1. **邏輯次數校正**:我們把 `visits / 120`,讓 UCT 公式認為「這 120 次的批量測試,只相當於 1 次的邏輯探索」。這樣探索紅利就不會被過度稀釋。
2. **加入期望值權重 (EV_WEIGHT)**:直接在公式中把期望值乘上一個小於 $1$ 的小數(例如 $0.3$ 或 $0.5$),強制降低它的話語權。
3. **調高探索常數 (C)**:將原本的 $1.414$ 調高到 $3.0$ 甚至 $5.0$,讓 AI 變得更「貪玩」,更願意去走沒走過的路。

---

### 程式碼替換

請找到 `class MCTSNode` 裡面的 `getBestUCTChild()` 函式,將整個函式替換為以下版本:

```javascript
        getBestUCTChild() {
          let maxScore = -Infinity;
          let minScore = Infinity;
          for (const child of this.children) {
            if (child.visits > 0) {
              const expectedScore = child.totalScore / child.visits;
              if (expectedScore > maxScore) maxScore = expectedScore;
              if (expectedScore < minScore) minScore = expectedScore;
            }
          }

          const scoreRange = maxScore - minScore > 0 ? maxScore - minScore : 1;

          // ======= 新增的控制參數 =======
          // 1. 探索係數 C:數值越大,AI 越愛嘗試沒走過的路 (建議 2.0 ~ 5.0)
          const C = 3.0; 
          // 2. 期望值權重:強制降低期望值的影響力 (1.0 為完全信任,0.5 為減半,0.0 為盲目亂走)
          const EV_WEIGHT = 0.5; 
          // ==============================

          let bestValue = -Infinity;
          let bestChild = null;

          const isAI = this.state.player === currentPlayer;

          for (const child of this.children) {
            if (child.visits === 0) return child;

            const expectedScore = child.totalScore / child.visits;

            let normalizedScore;
            if (isAI) {
              normalizedScore = (expectedScore - minScore) / scoreRange;
            } else {
              normalizedScore = (maxScore - expectedScore) / scoreRange;
            }

            // 【核心修正】:將拜訪次數除以 MIN_SIMULATIONS
            // 將 120 次的批量測試視為「1 次邏輯探索」,防止探索紅利被巨大分母吞噬
            const logicalParentVisits = Math.max(1, this.visits / MIN_SIMULATIONS);
            const logicalChildVisits = Math.max(1, child.visits / MIN_SIMULATIONS);
            
            const exploration = C * Math.sqrt(Math.log(logicalParentVisits) / logicalChildVisits);
            
            // 結合權重:打壓期望值,放大探索值
            const uctValue = (normalizedScore * EV_WEIGHT) + exploration;

            if (uctValue > bestValue) {
              bestValue = uctValue;
              bestChild = child;
            }
          }
          return bestChild;
        }

```

透過這段修改,你可以自由調整 `C` 和 `EV_WEIGHT` 這兩個常數。如果 AI 還是太固執,就把 `EV_WEIGHT` 調成 $0.2$,並把 `C` 調成 $5.0$。

在觀察 Debug 樹狀圖時,現在那些原本被冷落的分支,它們的測試次數有呈現平均分配的趨勢了嗎?
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

csinger2472tmp.html
附件
csinger2472tmp.html.7z
(32.92 KiB) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

csinger2472tmp2.html
附件
csinger2472tmp2.html.7z
(32.84 KiB) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

修復與改進:
一,AI觸發按鈕事件函式改進
btn-ai-blue和btn-ai-green把事件都綁到了同一個函式triggerAIIfReady(),這樣會導致事件干涉。請把這兩個按鈕事件分別綁至兩個獨立的函式,同時,當一個AI在思考和行動時,如果行動沒有結束,按鈕按下使其關閉,則思考立即停止,所有已執行的行走都撤銷。

二,布局階段的翻譯改進
布局階段的中文已經更改,但是相關的翻譯並未更改,請更改相關的英文翻譯。目標函式為function tMsg()。

三,在AI的必殺算法和結束時的文字提示,增加對平局的判斷和描述
本遊戲是可能會有平局的,當一方在行動中,拿到六分,但也因為最後一步停在自己的話筒上,使對方也加一分後得到六分,則為平局。

在AI的必殺算法中,增加對平局的判斷,當搜尋不到必勝,則應搜尋平局,如果沒有可能平局,才需要搜尋是否必敗。

第一局或第二局結束時的文字提示,增加對平局的描述。

需要明確給出所要修改的地方和所要替換的代碼。

代码: 全选

修復與改進:
一,AI觸發按鈕事件函式改進
btn-ai-blue和btn-ai-green把事件都綁到了同一個函式triggerAIIfReady(),這樣會導致事件干涉。請把這兩個按鈕事件分別綁至兩個獨立的函式,同時,當一個AI在思考和行動時,如果行動沒有結束,按鈕按下使其關閉,則思考立即停止,所有已執行的行走都撤銷。

二,布局階段的翻譯改進
布局階段的中文已經更改,但是相關的翻譯並未更改,請更改相關的英文翻譯。目標函式為function tMsg()。

三,在AI的必殺算法和結束時的文字提示,增加對平局的判斷和描述
本遊戲是可能會有平局的,當一方在行動中,拿到六分,但也因為最後一步停在自己的話筒上,使對方也加一分後得到六分,則為平局。

在AI的必殺算法中,增加對平局的判斷,當搜尋不到必勝,則應搜尋平局,如果沒有可能平局,才需要搜尋是否必敗。

第一局或第二局結束時的文字提示,增加對平局的描述。

需要明確給出所要修改的地方和所要替換的代碼。
附件
csinger2472tmp3.html.7z
(32.83 KiB) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6026
注册时间: 2022年 11月 18日 17:36
联系:

Re: 「我為歌狂」遊戲製作

帖子 ejsoon »

gemini3.1pro美國現在是白天

代码: 全选

以下是針對您的三個需求所對應的代碼修改。請在您的專案檔案中尋找對應的段落並進行替換:

### 一、 獨立 AI 按鈕事件與即時中斷機制

我們將新增一個 `aiActionTimeout` 變數來記錄 AI 延遲點擊事件,並加入 `abortAITurn` 函式來中斷思考和撤銷尚未確認的回合,同時改寫兩個 AI 按鈕的事件綁定。

**1. 增加 `aiActionTimeout` 全域變數**
尋找(大約在行號 385 附近):

```javascript
      let isBlueAI = false;
      let isGreenAI = true; // 預設綠方為 AI
      let blueAiTimeout = null; // 記錄藍隊 AI 延遲定時器
      let greenAiTimeout = null; // 記錄綠隊 AI 延遲定時器
      let aiStrength = 4000; // 預設困難

```

替換為:

```javascript
      let isBlueAI = false;
      let isGreenAI = true; // 預設綠方為 AI
      let blueAiTimeout = null; // 記錄藍隊 AI 延遲定時器
      let greenAiTimeout = null; // 記錄綠隊 AI 延遲定時器
      let aiActionTimeout = null; // 記錄 AI 動作執行的定時器
      let aiStrength = 4000; // 預設困難

```

**2. 加入中斷函數並改寫按鈕事件**
尋找(大約在行號 1520 附近):

```javascript
      btnAiBlue.addEventListener('click', () => {
        isBlueAI = !isBlueAI;
        btnAiBlue.classList.toggle('active-blue', isBlueAI);
        triggerAIIfReady();
      });

      btnAiGreen.addEventListener('click', () => {
        isGreenAI = !isGreenAI;
        btnAiGreen.classList.toggle('active-green', isGreenAI);
        triggerAIIfReady();
      });

```

替換為:

```javascript
      function abortAITurn(color) {
        if (currentPlayer !== color) return; // 僅當中斷當前行動方的 AI 時才撤銷
        
        isAIThinking = false;
        aiPlannedActions = [];
        if (aiActionTimeout) {
          clearTimeout(aiActionTimeout);
          aiActionTimeout = null;
        }
        
        const statusDiv = document.getElementById('ai-status');
        if (statusDiv) statusDiv.style.display = 'none';
        document.body.style.pointerEvents = 'auto';

        // 如果在正式對戰中,且已經走了幾步(或是正處於放話筒階段),則撤銷回回合起點
        if (gameplayActive && (walkCount > 1 || currentSubPhase !== 'walk' || (markers.length > 0 && markers[0].position.y === 6))) {
          setTimeout(() => {
             turnUndo();
          }, 100);
        }
      }

      btnAiBlue.addEventListener('click', () => {
        isBlueAI = !isBlueAI;
        btnAiBlue.classList.toggle('active-blue', isBlueAI);
        if (!isBlueAI) {
          abortAITurn('blue');
        } else {
          triggerAIIfReady();
        }
      });

      btnAiGreen.addEventListener('click', () => {
        isGreenAI = !isGreenAI;
        btnAiGreen.classList.toggle('active-green', isGreenAI);
        if (!isGreenAI) {
          abortAITurn('green');
        } else {
          triggerAIIfReady();
        }
      });

```

**3. 將原有定時器改用 `aiActionTimeout` 紀錄與中斷防護**
尋找 `checkAndRunAI()` 中的:

```javascript
          if (targetMarker) {
            setTimeout(() => {
              targetMarker.userData.onClick();
            }, 400);
          } else {

```

替換為:

```javascript
          if (targetMarker) {
            aiActionTimeout = setTimeout(() => {
              targetMarker.userData.onClick();
            }, 400);
          } else {

```

尋找 `finishAIAction()` 中的:

```javascript
        setTimeout(() => {
          document.body.style.pointerEvents = 'auto';
          isAIThinking = false;

```

替換為:

```javascript
        aiActionTimeout = setTimeout(() => {
          document.body.style.pointerEvents = 'auto';
          isAIThinking = false;

```

尋找 `doMCTSAI()` 中的:

```javascript
          // 接收新的回傳結構
          const result = await planFullTurnActionsMCTS(timeLimit);
          aiPlannedActions = result.sequence;

```

替換為:

```javascript
          // 接收新的回傳結構
          const result = await planFullTurnActionsMCTS(timeLimit);
          if (!isAIThinking) return; // 如果在中途被手動關閉,則立刻放棄後續執行
          aiPlannedActions = result.sequence;

```

---

### 二、 佈局階段英文翻譯適配修正

尋找 `tMsg(msg)` 函數內的這段舊式正則表達(大約在行號 275 附近):

```javascript
        if (msg.includes('選擇並放置角遊戲板(左上角或右上角)')) {
          return translated.replace(
            /(.+)行動:選擇並放置角遊戲板(左上角或右上角)/,
            '$1 Turn: Place corner board (Top-Left or Top-Right)'
          );
        }
        if (msg.includes('選擇並放置角遊戲板')) {
          return translated.replace(/(.+)行動:選擇並放置角遊戲板/, '$1 Turn: Select & place corner board');
        }
        if (msg.includes('放置對角遊戲板')) {
          return translated.replace(/(.+)行動:放置對角遊戲板/, '$1 Turn: Place opposite corner');
        }
        if (msg.includes('在中心板上放置一個圓柱體 (避開同行/同列/斜相鄰)')) {
          return translated.replace(
            /(.+)行動:在中心板上放置一個圓柱體 \(避開同行\/同列\/斜相鄰\)/,
            '$1 Turn: Place cylinder on center board (Avoid row/col/diag)'
          );
        }
        if (msg.includes('在中心板上放置一個圓柱體')) {
          return translated.replace(/(.+)行動:在中心板上放置一個圓柱體/, '$1 Turn: Place cylinder on center board');
        }
        if (msg.includes('放置邊遊戲板及圓柱體')) {
          return translated.replace(/(.+)行動:放置邊遊戲板及圓柱體/, '$1 Turn: Place edge board & cylinder');
        }
        if (msg.includes('放置本方歌手')) {
          return translated.replace(/(.+)行動:放置本方歌手/, '$1 Turn: Place own singer');
        }
        if (msg.includes('開局階段結束!即將進入正式對戰模式...')) {
          return 'Opening Phase Ended! Entering Battle Mode...';
        }

```

替換為:

```javascript
        if (msg.includes('布局階段:')) {
          if (msg.includes('放置角遊戲板')) {
            return translated.replace(/布局階段:(.+)放置角遊戲板/, 'Placement Phase: $1 places corner board');
          }
          if (msg.includes('在中遊戲板上放置一個圓柱體 (避免共線以及斜連)')) {
            return translated.replace(/布局階段:(.+)在中遊戲板上放置一個圓柱體 \(避免共線以及斜連\)/, 'Placement Phase: $1 places cylinder on center board (avoid colinear/diag)');
          }
          if (msg.includes('在中遊戲板上放置一個圓柱體')) {
            return translated.replace(/布局階段:(.+)在中遊戲板上放置一個圓柱體/, 'Placement Phase: $1 places cylinder on center board');
          }
          if (msg.includes('放置邊遊戲板及圓柱體')) {
            return translated.replace(/布局階段:(.+)放置邊遊戲板及圓柱體/, 'Placement Phase: $1 places edge board & cylinder');
          }
          if (msg.includes('放置本方歌手')) {
            return translated.replace(/布局階段:(.+)放置本方歌手/, 'Placement Phase: $1 places own singer');
          }
        }
        if (msg.includes('布局階段結束!即將進入行動階段...')) {
          return 'Placement Phase Ended! Entering Action Phase...';
        }

```

---

### 三、 增加對平局(Tie)的判斷與描述

**1. 讓 Minimax 和 RandomPlayout 優先識別平局**
尋找 `minimaxEndgame()` 函數的前幾行:

```javascript
      function minimaxEndgame(state, depth, isAI, alpha = -Infinity, beta = Infinity, deadline = Infinity) {
        // 如果執行時間已超過分配的截止時間,強制退出,避免卡死主執行緒
        if (performance.now() > deadline) return { val: 0, move: null, timeout: true };

        if (state.aiScore >= 6) return { val: 10000 + state.aiScore * 500 - state.oppScore * 500 + depth, move: null };
        if (state.oppScore >= 6)
          return { val: -10000 + state.aiScore * 500 - state.oppScore * 500 - depth, move: null };

```

替換為:

```javascript
      function minimaxEndgame(state, depth, isAI, alpha = -Infinity, beta = Infinity, deadline = Infinity) {
        // 如果執行時間已超過分配的截止時間,強制退出,避免卡死主執行緒
        if (performance.now() > deadline) return { val: 0, move: null, timeout: true };

        // 優先判斷平局 (雙方同時達到6分)
        if (state.aiScore >= 6 && state.oppScore >= 6) return { val: 0 + state.scoreDiff, move: null, isTie: true };
        if (state.aiScore >= 6) return { val: 10000 + state.aiScore * 500 - state.oppScore * 500 + depth, move: null };
        if (state.oppScore >= 6)
          return { val: -10000 + state.aiScore * 500 - state.oppScore * 500 - depth, move: null };

```

尋找 `simulateRandomPlayout()` 函數的前幾行:

```javascript
      function simulateRandomPlayout(state, aiColor, depthLimit) {
        let currState = state;
        for (let i = 0; i < depthLimit; i++) {
          if (currState.aiScore >= 6)
            return 10000 + currState.scoreDiff + currState.aiScore * 500 - currState.oppScore * 500;
          if (currState.oppScore >= 6)
            return -10000 + currState.scoreDiff + currState.aiScore * 500 - currState.oppScore * 500;

```

替換為:

```javascript
      function simulateRandomPlayout(state, aiColor, depthLimit) {
        let currState = state;
        for (let i = 0; i < depthLimit; i++) {
          if (currState.aiScore >= 6 && currState.oppScore >= 6)
            return 0 + currState.scoreDiff; // 優先判斷平局
          if (currState.aiScore >= 6)
            return 10000 + currState.scoreDiff + currState.aiScore * 500 - currState.oppScore * 500;
          if (currState.oppScore >= 6)
            return -10000 + currState.scoreDiff + currState.aiScore * 500 - currState.oppScore * 500;

```

**2. 將平局結果導出至 UI 文本與 Modal 顯示**
尋找 `planFullTurnActionsMCTS()`:

```javascript
        const actualSureKillDepth = SURE_KILL_DEPTH;
        let isSureLoss = false;

        await new Promise(resolve => setTimeout(resolve, 30));

        // 1. 將所有時間分配給 Minimax 必殺搜尋
        const minimaxDeadline = performance.now() + timeLimit;
        const endgameRes = minimaxEndgame(rootState, actualSureKillDepth, true, -Infinity, Infinity, minimaxDeadline);

        if (!endgameRes.timeout) {
          if (endgameRes.val > 5000 && endgameRes.move) {
            return { sequence: endgameRes.move.sequence, isSureKill: true, isSureLoss: false, val: endgameRes.val };
          } else if (endgameRes.val < -5000) {
            isSureLoss = true;
          }
        }

```

替換為:

```javascript
        const actualSureKillDepth = SURE_KILL_DEPTH;
        let isSureLoss = false;
        let isSureTie = false;

        await new Promise(resolve => setTimeout(resolve, 30));

        // 1. 將所有時間分配給 Minimax 必殺搜尋
        const minimaxDeadline = performance.now() + timeLimit;
        const endgameRes = minimaxEndgame(rootState, actualSureKillDepth, true, -Infinity, Infinity, minimaxDeadline);

        if (!endgameRes.timeout) {
          if (endgameRes.val > 5000 && endgameRes.move) {
            return { sequence: endgameRes.move.sequence, isSureKill: true, isSureTie: false, isSureLoss: false, val: endgameRes.val };
          } else if (endgameRes.isTie || (endgameRes.val === rootState.scoreDiff && endgameRes.move && Math.abs(endgameRes.val) < 1000)) {
            isSureTie = true; // 搜尋到強制平局
          } else if (endgameRes.val < -5000) {
            isSureLoss = true;
          }
        }

```

並在 `planFullTurnActionsMCTS()` 最後的回傳處將 `isSureTie` 加上:

```javascript
        return {
          sequence: bestChild ? bestChild.actionSequence : fallbackSequence,
          rootNode: rootNode,
          isSureKill: false,
          isSureTie: isSureTie,
          isSureLoss: isSureLoss
        };

```

尋找 `doMCTSAI()` 內的文本提示:

```javascript
          if (result.isSureKill) {
            winRateText = window.currentLang === 'zh' ? '100% (必勝局面)' : '100% (Sure Win)';
          } else if (result.isSureLoss) {
            winRateText = window.currentLang === 'zh' ? '0% (必敗局面)' : '0% (Sure Loss)';
          } else if (result.rootNode) {

```

替換為:

```javascript
          if (result.isSureKill) {
            winRateText = window.currentLang === 'zh' ? '100% (必勝局面)' : '100% (Sure Win)';
          } else if (result.isSureTie) {
            winRateText = window.currentLang === 'zh' ? '平局保底 (強制平手)' : 'Tie Guard (Sure Tie)';
          } else if (result.isSureLoss) {
            winRateText = window.currentLang === 'zh' ? '0% (必敗局面)' : '0% (Sure Loss)';
          } else if (result.rootNode) {

```

尋找 UI 彈窗中的搜尋最佳行動 `btn-modal-search` 結果串接邏輯:

```javascript
        if (res.isSureKill) {
          allCandidates.unshift({ seq: res.sequence, winRate: 100, isSureKill: true, isSureLoss: false });
        }

```

替換為:

```javascript
        if (res.isSureKill) {
          allCandidates.unshift({ seq: res.sequence, winRate: 100, isSureKill: true, isSureTie: false, isSureLoss: false });
        } else if (res.isSureTie) {
          allCandidates.unshift({ seq: res.sequence, winRate: 50, isSureKill: false, isSureTie: true, isSureLoss: false });
        }

```

接著在 `renderSearchResults` 中找到這行:

```javascript
          let stateTxt = item.isSureKill ? '必勝/Win' : item.isSureLoss ? '必敗/Loss' : `${item.winRate.toFixed(1)}%`;

```

替換為:

```javascript
          let stateTxt = item.isSureKill ? '必勝/Win' : item.isSureTie ? '必平/Tie' : item.isSureLoss ? '必敗/Loss' : `${item.winRate.toFixed(1)}%`;

```

**3. 更新對戰結束時 (`triggerRoundEnd`) 的單局平手文字**
尋找:

```javascript
        let roundWinner = blueFinal > greenFinal ? '藍方' : greenFinal > blueFinal ? '綠方' : '平手';
        if (window.currentLang === 'en') {
          roundWinner = roundWinner === '藍方' ? 'Blue' : roundWinner === '綠方' ? 'Green' : 'Draw';
        }

        const overlay = document.createElement('div');
        overlay.id = 'round-end-overlay';
        overlay.style.cssText =
          'position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); display:flex; flex-direction:column; justify-content:center; align-items:center; color:#fff; font-family:sans-serif; z-index:100;';

        const titleText =
          window.currentLang === 'zh'
            ? `第 ${gameRound} 局結束!本局勝者:${roundWinner}`
            : `Round ${gameRound} Ended! Winner: ${roundWinner}`;

```

替換為:

```javascript
        let roundWinner = blueFinal > greenFinal ? '藍方' : greenFinal > blueFinal ? '綠方' : '平手';
        if (window.currentLang === 'en') {
          roundWinner = roundWinner === '藍方' ? 'Blue' : roundWinner === '綠方' ? 'Green' : 'Draw';
        }

        const overlay = document.createElement('div');
        overlay.id = 'round-end-overlay';
        overlay.style.cssText =
          'position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); display:flex; flex-direction:column; justify-content:center; align-items:center; color:#fff; font-family:sans-serif; z-index:100;';

        let titleText = "";
        if (roundWinner === '平手' || roundWinner === 'Draw') {
          titleText = window.currentLang === 'zh'
            ? `第 ${gameRound} 局結束!雙方平局!`
            : `Round ${gameRound} Ended! It's a Draw!`;
        } else {
          titleText = window.currentLang === 'zh'
            ? `第 ${gameRound} 局結束!本局勝者:${roundWinner}`
            : `Round ${gameRound} Ended! Winner: ${roundWinner}`;
        }

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

正浏览此版面之用户: 没有注册用户 和 4 访客