分享和讨论Javascript相关的话题
回复
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6021
注册时间: 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
一枝独秀
一枝独秀
帖子: 6021
注册时间: 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
一枝独秀
一枝独秀
帖子: 6021
注册时间: 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
一枝独秀
一枝独秀
帖子: 6021
注册时间: 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/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

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