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

研究提升三菱棋的AI棋力

帖子 ejsoon »

我贏了「專家」:

代码: 全选

[(0,0),(1,0)] [(0,-2),(0,-1)] [(0,2),(0,1)] [(1,-2),(1,-1)] [(0,-3),(-1,-3)] [(2,-1),(2,0)] [(1,2),(1,1)] [(-1,3),(0,3)] [(-1,1),(-1,0)] [(-2,0),(-2,1)] [(-1,-1),(-1,-2)] [(-2,-2),(-2,-1)] [(3,0),(3,-1)] [(1,3),(2,3)] [(3,-2),(2,-2)] [(-2,2),(-1,2)] [(2,4),(2,5)] [(0,4),(1,4)] [(0,6),(0,5)] [(2,-4),(2,-3)] [(2,-5),(1,-5)] [(-1,-5),(-1,-4)] [(1,6),(1,5)] [(-2,-4),(-2,-3)] [(-1,5),(-1,4)] [(3,1),(4,1)] [(4,-1),(4,-2)] [(4,0),(5,0)] [(6,-1),(5,-1)] [(3,-4),(3,-3)] [(4,-5),(3,-5)] [(2,1),(2,2)] [(-2,4),(-2,3)] [(4,-7),(4,-6)] [(4,-4),(4,-3)] [(-3,2),(-3,3)]
專家只要在最後兩步棋走對,他就能贏。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6314
注册时间: 2022年 11月 18日 17:36
联系:

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

改進MCTS搜尋,加入minimx算法

代码: 全选

改進MCTS搜尋,加入minimx算法:
一,在MCTS搜尋時,用「近似minimax的算法」評估當前這一步的最終分差

「近似minimax的算法」為,首先獲取所有可能造成分數變化的下法(不管給哪一方加分),以及所有能阻止對方影響分數的下法(假定輪到對方,對方的某一個下法能影響分數,其所佔的兩個三角形,我方有一步棋正好佔了其中一個三角形),之後以最終分差來作為當前下法的期望值。

當剩餘棋子數量等於或少於n1時,將在MCTS搜尋中加入這個算法。

n1在不同等級為:簡單3,困難6,專家9,自訂12。將n1換成合適的英文命名,並加入AI設置窗口中。

二,在局末加入minimax算法,將只用分差來排序。

當剩餘棋子數量等於或少於n2時,將不再使用MCTS搜尋,而是改用minimax,並用分差來排序搜尋結果。

n2在不同等級為:簡單2,困難3,專家4,自訂5。將n2換成合適的英文命名,並加入AI設置窗口中。

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

代码: 全选

這是一個非常精確且能有效提升 MCTS 在局末與得分點判斷能力的改進方案!透過加入近似 Minimax 的啟發式搜尋,以及完全交由純 Minimax 收尾,可以大幅解決 MCTS 在最後幾手容易發生隨機失誤的問題。

以下是針對你提出的兩個需求,所需修改的對應代碼片段與替換說明。

### 一、修改 AI 配置與全域變數

首先,我們需要將 `n1`(`heuristicMinimaxN`)與 `n2`(`pureMinimaxN`)加入到多語言字典、預設配置與全域變數中。

**1. 在 `I18N` 字典中加入新翻譯(約在 `const I18N = { ... }` 內):**

```javascript
// 在 zh 語言包內加入:
heuristicMinimaxN: '啟發式 Minimax 閾值 (n1)',
pureMinimaxN: '純 Minimax 閾值 (局末 n2)',

// 在 en 語言包內加入:
heuristicMinimaxN: 'Heuristic Minimax Threshold (n1)',
pureMinimaxN: 'Pure Minimax Threshold (Endgame n2)',

```

**2. 替換 `aiConfigs` 與新增全域變數(約行號 660 附近):**

```javascript
      const aiConfigs = {
        simple: {
          time: 12000, c: 2.4, heuristicN: 4, heuristicChecks: 6,
          heuristicMinimaxN: 3, pureMinimaxN: 2,
          icon: ' <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" /> '
        },
        hard: {
          time: 24000, c: 3.6, heuristicN: 8, heuristicChecks: 12,
          heuristicMinimaxN: 6, pureMinimaxN: 3,
          icon: ' <rect x="2" y="9" width="2" height="6" rx="1" fill="currentColor" /> <rect x="20" y="9" width="2" height="6" rx="1" fill="currentColor" /> <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" fill="currentColor" /> <circle cx="15" cy="11" r="1.2" fill="currentColor" /> <rect x="9" y="14" width="6" height="2" fill="currentColor" /> '
        },
        expert: {
          time: 36000, c: 4.8, heuristicN: 12, heuristicChecks: 24,
          heuristicMinimaxN: 9, pureMinimaxN: 4,
          icon: '<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" fill="currentColor"/>'
        },
        custom: {
          time: 48000, c: 6.0, heuristicN: 16, heuristicChecks: 48,
          heuristicMinimaxN: 12, pureMinimaxN: 5,
          icon: '<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" fill="currentColor"/>'
        },
        human: {
          icon: '<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>'
        }
      };
      
      // 全域變數補充
      let mctsExplorationConstant = 3.6;
      let mctsHeuristicN = 8;
      let mctsHeuristicChecks = 12;
      let mctsHeuristicMinimaxN = 6;
      let pureMinimaxN = 3;

```

---

### 二、更新 AI 設置面板 UI 與事件

確保玩家可以在「自訂」模式調整新加入的 $n_1$ 與 $n_2$ 參數。

**1. 在 HTML 內 `#ai-modal` 區域中加入 UI(放在 `heuristic-checks` 的 setting-row 下方):**

```html
        <div class="setting-row">
          <label id="label-heuristic-minimax-n">啟發式 Minimax 閾值 (n1)</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('heuristic-minimax-n', -1, 0, 36, 0)">-</button>
            <input type="number" id="heuristic-minimax-n" class="num-input" value="6" step="1" onchange="updateGlobalParams()" />
            <button class="num-btn" onclick="adjustGlobalParam('heuristic-minimax-n', 1, 0, 36, 0)">+</button>
          </div>
        </div>

        <div class="setting-row">
          <label id="label-pure-minimax-n">純 Minimax 閾值 (局末 n2)</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('pure-minimax-n', -1, 0, 36, 0)">-</button>
            <input type="number" id="pure-minimax-n" class="num-input" value="3" step="1" onchange="updateGlobalParams()" />
            <button class="num-btn" onclick="adjustGlobalParam('pure-minimax-n', 1, 0, 36, 0)">+</button>
          </div>
        </div>

```

**2. 綁定多語言與自動更新數值(替換 `toggleLanguage` 與 `updateGlobalParams` 等相關片段):**

```javascript
// 在 toggleLanguage() 中加入:
if (document.getElementById('label-heuristic-minimax-n')) document.getElementById('label-heuristic-minimax-n').innerText = getText('heuristicMinimaxN');
if (document.getElementById('label-pure-minimax-n')) document.getElementById('label-pure-minimax-n').innerText = getText('pureMinimaxN');

// 在 selectAiTab() 內加入數值更新:
document.getElementById('heuristic-minimax-n').value = aiConfigs[preset].heuristicMinimaxN;
document.getElementById('pure-minimax-n').value = aiConfigs[preset].pureMinimaxN;

// 在 markCustom() 的判斷式與賦值加入:
let hMn = parseInt(document.getElementById('heuristic-minimax-n').value, 10);
let pMn = parseInt(document.getElementById('pure-minimax-n').value, 10);
// ... 判斷條件中加入 aiConfigs[k].heuristicMinimaxN === hMn && aiConfigs[k].pureMinimaxN === pMn ...
aiConfigs.custom.heuristicMinimaxN = hMn;
aiConfigs.custom.pureMinimaxN = pMn;

// 替換 updateGlobalParams():
function updateGlobalParams() {
  mctsExplorationConstant = parseFloat(document.getElementById('mcts-c').value);
  mctsHeuristicN = parseInt(document.getElementById('heuristic-n').value, 10);
  mctsHeuristicChecks = parseInt(document.getElementById('heuristic-checks').value, 10);
  mctsHeuristicMinimaxN = parseInt(document.getElementById('heuristic-minimax-n').value, 10);
  pureMinimaxN = parseInt(document.getElementById('pure-minimax-n').value, 10);
  markCustom();
}

```

---

### 三、實作 Minimax 演算法核心

將以下這兩個全新的搜尋算法函數,放在 `runMCTSBatch` 的上方。

```javascript
      // 近似 Minimax (用於 MCTS Rollout 評估)
      function selectiveMinimax(state, player, workPieces, alpha, beta) {
        let totalPieces = workPieces[1][0] + workPieces[1][1] + workPieces[1][2] + workPieces[2][0] + workPieces[2][1] + workPieces[2][2];
        if (totalPieces === 0) {
          let s = state.calculateScores();
          return s.p1Score - s.p2Score;
        }

        let validMoves = state.getValidMoves(player, workPieces);
        if (validMoves.length === 0) {
          let s = state.calculateScores();
          return s.p1Score - s.p2Score;
        }

        let candidateMoves = [];
        let baseScoreObj = state.calculateScores();
        let p1Base = baseScoreObj.p1Score;
        let p2Base = baseScoreObj.p2Score;

        let oppPlayer = player === 1 ? 2 : 1;
        let oppValidMoves = state.getValidMoves(oppPlayer, workPieces);
        let oppScoringPieces = new Set();

        // 預先找出對手會得分的三角形坐標
        for (let om of oppValidMoves) {
          let simPid = (om >> 24) & 0xff;
          let r = state.tryPlacePiece(simPid + 1, state._idxToCoord((om >> 12) & 0xfff), state._idxToCoord(om & 0xfff), false);
          if (r.success) {
            let newS = state.calculateScores();
            state.undo();
            if (newS.p1Score !== p1Base || newS.p2Score !== p2Base) {
              oppScoringPieces.add((om >> 12) & 0xfff);
              oppScoringPieces.add(om & 0xfff);
            }
          }
        }

        // 篩選會改變分數或阻擋對手的棋子
        for (let m of validMoves) {
          let simPid = (m >> 24) & 0xff;
          let idx1 = (m >> 12) & 0xfff;
          let idx2 = m & 0xfff;
          let r = state.tryPlacePiece(simPid + 1, state._idxToCoord(idx1), state._idxToCoord(idx2), false);
          if (r.success) {
            let newScoreObj = state.calculateScores();
            state.undo();
            if (newScoreObj.p1Score !== p1Base || newScoreObj.p2Score !== p2Base) {
              candidateMoves.push(m);
            } else if (oppScoringPieces.has(idx1) || oppScoringPieces.has(idx2)) {
              candidateMoves.push(m);
            }
          }
        }

        // 若無直接衝突點,代表分數暫時穩定,直接返回當前分差作為期望值
        if (candidateMoves.length === 0) {
          return p1Base - p2Base;
        }

        if (player === 1) { // 追求最大化
          let maxEval = -Infinity;
          for (let m of candidateMoves) {
            let simPid = (m >> 24) & 0xff;
            state.tryPlacePiece(simPid + 1, state._idxToCoord((m >> 12) & 0xfff), state._idxToCoord(m & 0xfff), false);
            workPieces[player][simPid]--;
            let ev = selectiveMinimax(state, oppPlayer, workPieces, alpha, beta);
            workPieces[player][simPid]++;
            state.undo();
            maxEval = Math.max(maxEval, ev);
            alpha = Math.max(alpha, ev);
            if (beta <= alpha) break;
          }
          return maxEval;
        } else { // 追求最小化 (P2 視角)
          let minEval = Infinity;
          for (let m of candidateMoves) {
            let simPid = (m >> 24) & 0xff;
            state.tryPlacePiece(simPid + 1, state._idxToCoord((m >> 12) & 0xfff), state._idxToCoord(m & 0xfff), false);
            workPieces[player][simPid]--;
            let ev = selectiveMinimax(state, oppPlayer, workPieces, alpha, beta);
            workPieces[player][simPid]++;
            state.undo();
            minEval = Math.min(minEval, ev);
            beta = Math.min(beta, ev);
            if (beta <= alpha) break;
          }
          return minEval;
        }
      }

      // 純 Minimax (用於極限局末徹底搜尋,無剪枝)
      function fullMinimax(state, player, workPieces, alpha, beta) {
        let totalPieces = workPieces[1][0] + workPieces[1][1] + workPieces[1][2] + workPieces[2][0] + workPieces[2][1] + workPieces[2][2];
        if (totalPieces === 0) {
          let s = state.calculateScores();
          return { score: s.p1Score - s.p2Score, move: null };
        }

        let validMoves = state.getValidMoves(player, workPieces);
        if (validMoves.length === 0) {
          let s = state.calculateScores();
          return { score: s.p1Score - s.p2Score, move: null };
        }

        let bestMove = null;
        if (player === 1) {
          let maxEval = -Infinity;
          for (let m of validMoves) {
            let simPid = (m >> 24) & 0xff;
            state.tryPlacePiece(simPid + 1, state._idxToCoord((m >> 12) & 0xfff), state._idxToCoord(m & 0xfff), false);
            workPieces[player][simPid]--;
            let res = fullMinimax(state, 2, workPieces, alpha, beta);
            workPieces[player][simPid]++;
            state.undo();
            if (res.score > maxEval) { maxEval = res.score; bestMove = m; }
            alpha = Math.max(alpha, maxEval);
            if (beta <= alpha) break;
          }
          return { score: maxEval, move: bestMove };
        } else {
          let minEval = Infinity;
          for (let m of validMoves) {
            let simPid = (m >> 24) & 0xff;
            state.tryPlacePiece(simPid + 1, state._idxToCoord((m >> 12) & 0xfff), state._idxToCoord(m & 0xfff), false);
            workPieces[player][simPid]--;
            let res = fullMinimax(state, 1, workPieces, alpha, beta);
            workPieces[player][simPid]++;
            state.undo();
            if (res.score < minEval) { minEval = res.score; bestMove = m; }
            beta = Math.min(beta, minEval);
            if (beta <= alpha) break;
          }
          return { score: minEval, move: bestMove };
        }
      }

```

---

### 四、將演算法整合進入遊戲流程

**1. 替換 `runMCTSBatch` 內的 Rollout(Simulation)邏輯:**
找到 `// 3. Simulation (Rollout) - 保證一直探索至局終` 段落,將內部邏輯替換:

```javascript
          // 3. Simulation (Rollout)
          let advantage;
          if (node.isTerminal) {
            advantage = node.terminalScore;
          } else {
            let remainingTotal = workPieces[1][0] + workPieces[1][1] + workPieces[1][2] + workPieces[2][0] + workPieces[2][1] + workPieces[2][2];
            
            if (remainingTotal <= mctsHeuristicMinimaxN) {
              // 觸發 n1 條件:使用近似 Minimax 直接評估
              advantage = selectiveMinimax(simState, simPlayer, workPieces, -Infinity, Infinity);
            } else {
              // 原本的隨機 Rollout 邏輯 (包含 mctsHeuristicN 介入)
              while (true) {
                let moves = simState.getValidMoves(simPlayer, workPieces);
                if (moves.length === 0) break;
                // ... 保留舊版從 let movesPlayed = 36 - (...) 一直到 simPlayer 切換結束的邏輯 ...
                
                // --- 為了保持範例整潔,這段就是你原本程式碼第 1210-1287 行的 Rollout 迴圈 ---
              }
              let scores = simState.calculateScores();
              advantage = scores.p1Score - scores.p2Score;
            }
          }

```

**2. 在 `triggerAITurn` 與 `runAISearch` 加入 $n_2$ 攔截條件:**
在 `triggerAITurn()` 中,準備執行 `let rootState = gameLogic.clone();` 之前,攔截檢查:

```javascript
        let remainingPieces = piecesLeft[1][0] + piecesLeft[1][1] + piecesLeft[1][2] + piecesLeft[2][0] + piecesLeft[2][1] + piecesLeft[2][2];
        if (remainingPieces <= pureMinimaxN) {
          let workPieces = { 1: new Int8Array(piecesLeft[1]), 2: new Int8Array(piecesLeft[2]) };
          box.innerText = `局末必勝推演中...`;
          await new Promise(r => setTimeout(r, 50));
          
          let res = fullMinimax(gameLogic.clone(), currentPlayer, workPieces, -Infinity, Infinity);
          if (res.move !== null) {
            let simPid = (res.move >> 24) & 0xff;
            let p1Coord = gameLogic._idxToCoord((res.move >> 12) & 0xfff);
            currentSelectedPiece = simPid;
            isFirstMove = false;
            let halfWidth = 31.1769;
            let isRight = gameLogic.isRight(p1Coord.x, p1Coord.y);
            let cx = isRight ? (p1Coord.x - 1) * halfWidth + 10.392 : (p1Coord.x - 1) * halfWidth + 20.784;
            let tri = { idx: p1Coord.x, N: p1Coord.y, isRight: isRight, cx: cx, cy: p1Coord.y * 18 };
            attemptDrop(tri);
          } else {
            box.innerText = `無合法著法`;
            setTimeout(() => (box.style.opacity = '0'), 2000);
          }
          isAiThinking = false;
          return;
        }
        
        // 如果還沒到 n2,則繼續往下執行正常的 MCTS 初始化與迴圈

```

在 `runAISearch()` 中做類似修改,把窮舉出來的最佳解答直接畫成樹狀節點。
上次由 ejsoon 在 2026年 7月 20日 06:39,总共编辑 1 次。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6314
注册时间: 2022年 11月 18日 17:36
联系:

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

修復MCTS的計算權重問題
當在以下局面時:

代码: 全选

[(0,0),(1,0)] [(0,-2),(0,-1)] [(0,1),(0,2)] [(1,-2),(1,-1)] [(0,-3),(-1,-3)] [(-1,-1),(-1,0)] [(-2,-1),(-2,0)] [(-1,3),(0,3)] [(1,2),(1,1)] [(-1,1),(-1,2)] [(-2,1),(-2,2)] [(-3,3),(-2,3)] [(2,-3),(1,-3)] [(-3,1),(-3,2)] [(2,1),(2,0)] [(-2,4),(-1,4)] [(-1,5),(-1,6)] [(-3,-1),(-3,0)] [(-3,5),(-3,4)] [(2,2),(3,2)] [(1,-4),(0,-4)] [(3,0),(3,1)] [(1,3),(1,4)] [(1,5),(2,5)] [(0,4),(0,5)] [(2,3),(2,4)] [(2,-1),(2,-2)] [(0,6),(1,6)] [(-2,5),(-2,6)] [(3,-2),(3,-1)] [(-2,-2),(-1,-2)] [(0,-6),(0,-5)] [(-3,-2),(-4,-2)] [(1,-6),(1,-5)] [(-4,0),(-4,-1)] [(2,-5),(2,-4)] [(0,0),(1,0)] [(1,1),(1,2)] [(1,-2),(1,-1)] [(0,1),(0,2)] [(0,-2),(0,-1)] [(2,-2),(2,-1)] [(2,0),(3,0)] [(1,-3),(2,-3)] [(3,-2),(3,-1)] [(3,1),(3,2)] [(2,-5),(2,-4)] [(2,1),(2,2)] [(1,3),(2,3)] [(0,-4),(1,-4)] [(0,-6),(0,-5)] [(-1,-2),(-1,-1)] [(1,-6),(1,-5)] [(-1,-3),(0,-3)] [(-1,-5),(-1,-4)] [(-1,0),(-1,1)] [(3,-4),(3,-3)] [(3,-5),(4,-5)] [(4,-4),(5,-4)] [(4,-3),(4,-2)] [(5,-3),(6,-3)] [(6,-2),(6,-1)] [(-2,2),(-1,2)] [(-2,1),(-3,1)]
不管怎麼設置,對方AI一定會下[(-2,-1),(-2,0)],而這時我方如果下[(5,-1),(5,-2)]會立即得3分,但是我方完全可以先下[(-3,0),(-4,0)]使對方不得分,在後面的回合中再下[(5,-1),(5,-2)]得到這3分。所以對方AI下的[(-2,-1),(-2,0)]這步棋是錯誤的。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6314
注册时间: 2022年 11月 18日 17:36
联系:

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

改進MCTS搜尋,加入minimx算法:
一,在局末加入minimax算法,將只用分差來排序。

當剩餘棋子數量等於或少於pureMinimaxN時,將不再使用MCTS搜尋,而是改用minimax,並用分差來排序搜尋結果。

pureMinimaxN在不同等級為:簡單2,困難3,專家4,自訂5,將pureMinimaxN加入AI設置窗口中。

注意當時間結束時,如果minimax沒有運算結束,停止運算,給出提示,之後緊接著採用「簡單」等級的MCTS進行搜尋。

二,在MCTS搜尋時,增加新的排序算法

之前在runMCTSBatch中,if (36 - movesPlayed <= mctsHeuristicN) {...}是一個啟發式搜尋,它會尋找「自己得分」和「防止對方得分」的下法,。這個啟發式搜尋用於從開局開始的任意階段。

現在改為,當到達mctsHeuristicN的階段時(也就是當前已經到了第N手棋,36-N等於或小於mctsHeuristicN,但在pureMinimaxN之前),用以下新的方法,替代之前的啟發式搜尋:把所有「自己得分」和「防止對方得分」的下法作暴力破解(如果某個分支不存在「使自己得分」或「防止對方得分」的下法,則這個分支的探索就結束),把絕對分差作為這些下法的排序,然後再繼續MCTS搜尋。

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

代码: 全选

改進MCTS搜尋,加入minimx算法:
一,在局末加入minimax算法,將只用分差來排序。

當剩餘棋子數量等於或少於pureMinimaxN時,將不再使用MCTS搜尋,而是改用minimax,並用分差來排序搜尋結果。

pureMinimaxN在不同等級為:簡單2,困難3,專家4,自訂5,將pureMinimaxN加入AI設置窗口中。

注意當時間結束時,如果minimax沒有運算結束,停止運算,給出提示,之後緊接著採用「簡單」等級的MCTS進行搜尋。

二,在MCTS搜尋時,增加新的排序算法

之前在runMCTSBatch中,if (36 - movesPlayed <= mctsHeuristicN) {...}是一個啟發式搜尋,它會尋找「自己得分」和「防止對方得分」的下法,。這個啟發式搜尋用於從開局開始的任意階段。

現在改為,當到達mctsHeuristicN的階段時(也就是當前已經到了第N手棋,36-N等於或小於mctsHeuristicN,但在pureMinimaxN之前),用以下新的方法,替代之前的啟發式搜尋:把所有「自己得分」和「防止對方得分」的下法作暴力破解(如果某個分支不存在「使自己得分」或「防止對方得分」的下法,則這個分支的探索就結束),把絕對分差作為這些下法的排序,然後再繼續MCTS搜尋。

需要明確給出所要修改的地方和所要替換的代碼。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6314
注册时间: 2022年 11月 18日 17:36
联系:

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這是一個非常具體且進階的演算法修改。為了達成您的需求,我們需要修改 MCTS 參數設定、在模擬(Rollout)階段替換啟發式搜尋邏輯,並在 AI 執行回合的入口處加入 Minimax 與超時降級機制。

以下是具體需要修改與替換的程式碼區塊:

### 第一部分:設定檔與全域變數更新

找到 `aiConfigs` 與全域變數宣告區域,加入 `pureMinimaxN` 參數。

**修改前的程式碼 (大約 480 行):**

```javascript
      const aiConfigs = {
        simple: {
          time: 12000,
          c: 2.4,
          heuristicN: 4,
          heuristicChecks: 6,
          // ...省略圖示
        },
        // ... (其他設定)
      };
      let isAiThinking = false;
      let abortAiSearch = false;
      let mctsExplorationConstant = 3.6;
      let mctsHeuristicN = 8;
      let mctsHeuristicChecks = 12;

```

**替換為:**

```javascript
      const aiConfigs = {
        simple: {
          time: 12000,
          c: 2.4,
          heuristicN: 4,
          heuristicChecks: 6,
          pureMinimaxN: 2,
          icon: ' <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" /> '
        },
        hard: {
          time: 24000,
          c: 3.6,
          heuristicN: 8,
          heuristicChecks: 12,
          pureMinimaxN: 3,
          icon: ' <rect x="2" y="9" width="2" height="6" rx="1" fill="currentColor" /> <rect x="20" y="9" width="2" height="6" rx="1" fill="currentColor" /> <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" fill="currentColor" /> <circle cx="15" cy="11" r="1.2" fill="currentColor" /> <rect x="9" y="14" width="6" height="2" fill="currentColor" /> '
        },
        expert: {
          time: 36000,
          c: 4.8,
          heuristicN: 12,
          heuristicChecks: 24,
          pureMinimaxN: 4,
          icon: '<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" fill="currentColor"/>'
        },
        custom: {
          time: 48000,
          c: 6.0,
          heuristicN: 16,
          heuristicChecks: 48,
          pureMinimaxN: 5,
          icon: '<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" fill="currentColor"/>'
        },
        human: {
          icon: '<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>'
        }
      };
      let isAiThinking = false;
      let abortAiSearch = false;
      let mctsExplorationConstant = 3.6;
      let mctsHeuristicN = 8;
      let mctsHeuristicChecks = 12;
      let mctsPureMinimaxN = 3; // 新增全域變數

```

---

### 第二部分:UI 面板新增 Minimax 步數設定

找到 `<div id="ai-modal"` 裡面的設定行區塊,在 `heuristic-checks` 之後加入 `pureMinimaxN` 的輸入列。

**在 `<div class="setting-row">...<label id="label-heuristic-checks">...</label>...</div>` 後方加入:**

```html
        <div class="setting-row">
          <label id="label-minimax-n">局末 Minimax 啟動步數</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('minimax-n', -1, 0, 10, 0)">-</button>
            <input
              type="number"
              id="minimax-n"
              class="num-input"
              value="3"
              step="1"
              onchange="updateGlobalParams()"
            />
            <button class="num-btn" onclick="adjustGlobalParam('minimax-n', 1, 0, 10, 0)">+</button>
          </div>
        </div>

```

---

### 第三部分:同步 UI 更新與自訂切換邏輯

修改 `selectAiTab`、`markCustom` 與 `updateGlobalParams` 函式,以包含新的變數。

**修改前的 `selectAiTab` 內容:**

```javascript
          document.getElementById('heuristic-n').value = aiConfigs[preset].heuristicN;
          document.getElementById('heuristic-checks').value = aiConfigs[preset].heuristicChecks;

```

**替換為:**

```javascript
          document.getElementById('heuristic-n').value = aiConfigs[preset].heuristicN;
          document.getElementById('heuristic-checks').value = aiConfigs[preset].heuristicChecks;
          document.getElementById('minimax-n').value = aiConfigs[preset].pureMinimaxN;

```

**修改 `markCustom` 中的判斷式:**

```javascript
        let n = parseInt(document.getElementById('heuristic-n').value, 10);
        let checks = parseInt(document.getElementById('heuristic-checks').value, 10);
        let mm = parseInt(document.getElementById('minimax-n').value, 10); // 新增

        let matchedPreset = 'custom';
        for (let k of ['simple', 'hard', 'expert']) {
          if (
            aiConfigs[k].time === t &&
            Math.abs(aiConfigs[k].c - c) < 0.01 &&
            aiConfigs[k].heuristicN === n &&
            aiConfigs[k].heuristicChecks === checks &&
            aiConfigs[k].pureMinimaxN === mm // 新增條件
          ) {
            matchedPreset = k;
            break;
          }
        }
        // ... 中略 ...
        aiConfigs.custom.heuristicChecks = checks;
        aiConfigs.custom.pureMinimaxN = mm; // 新增

```

**修改 `updateGlobalParams`:**

```javascript
      function updateGlobalParams() {
        mctsExplorationConstant = parseFloat(document.getElementById('mcts-c').value);
        mctsHeuristicN = parseInt(document.getElementById('heuristic-n').value, 10);
        mctsHeuristicChecks = parseInt(document.getElementById('heuristic-checks').value, 10);
        mctsPureMinimaxN = parseInt(document.getElementById('minimax-n').value, 10); // 新增
        markCustom();
      }

```

---

### 第四部分:替換 MCTS 模擬階段的啟發式搜尋

找到 `runMCTSBatch` 函式內部,第 3 步 Simulation (Rollout) 中的啟發式介入區塊。

**修改前的程式碼:**

```javascript
              // 啟發式探索:在局末 n 手內介入
              if (36 - movesPlayed <= mctsHeuristicN) {
                  // ... 原本的隨機洗牌與打分邏輯 ...
                  // (一直到)
                  m = moves[(Math.random() * checkCount) | 0];
                  }
                }
              } else {

```

**替換為全新的暴力破解與排序邏輯:**

```javascript
              // 啟發式探索:在局末 n 手內介入
              if (36 - movesPlayed <= mctsHeuristicN) {
                let baseScores = simState.calculateScores();
                let myBase = simPlayer === 1 ? baseScores.p1Score : baseScores.p2Score;
                let oppPlayer = simPlayer === 1 ? 2 : 1;
                let oppBase = oppPlayer === 1 ? baseScores.p1Score : baseScores.p2Score;

                let candidateMoves = [];

                // 條件一:暴力窮舉所有使自己得分的著法
                for (let i = 0; i < moves.length; i++) {
                  let move = moves[i];
                  let simPid = (move >> 24) & 0xff;
                  let res = simState.tryPlacePiece(simPid + 1, simState._idxToCoord((move >> 12) & 0xfff), simState._idxToCoord(move & 0xfff), false);
                  if (res.success) {
                    let newScore = simPlayer === 1 ? res.scores.p1Score : res.scores.p2Score;
                    let diff = newScore - myBase;
                    simState.undo();
                    if (diff > 0) {
                      candidateMoves.push({ move: move, diff: diff, type: 'score' });
                    }
                  }
                }

                // 條件二:若無法得分,暴力窮舉所有防守對方的著法
                if (candidateMoves.length === 0) {
                  let oppMoves = simState.getValidMoves(oppPlayer, workPieces);
                  let threatMoves = [];
                  for (let i = 0; i < oppMoves.length; i++) {
                    let omove = oppMoves[i];
                    let simPid = (omove >> 24) & 0xff;
                    let res = simState.tryPlacePiece(simPid + 1, simState._idxToCoord((omove >> 12) & 0xfff), simState._idxToCoord(omove & 0xfff), false);
                    if (res.success) {
                      let newScore = oppPlayer === 1 ? res.scores.p1Score : res.scores.p2Score;
                      if (newScore - oppBase > 0) threatMoves.push(omove);
                      simState.undo();
                    }
                  }

                  if (threatMoves.length > 0) {
                    for (let omove of threatMoves) {
                      let p1Idx = (omove >> 12) & 0xfff;
                      let p2Idx = omove & 0xfff;
                      for (let myMove of moves) {
                        let mp1 = (myMove >> 12) & 0xfff;
                        let mp2 = myMove & 0xfff;
                        // 檢查是否佔用了威脅步的格子(阻擋)
                        if (mp1 === p1Idx || mp1 === p2Idx || mp2 === p1Idx || mp2 === p2Idx) {
                          let bRes = simState.tryPlacePiece(((myMove >> 24) & 0xff) + 1, simState._idxToCoord(mp1), simState._idxToCoord(mp2), false);
                          if (bRes.success) {
                            let bDiff = (simPlayer === 1 ? bRes.scores.p1Score : bRes.scores.p2Score) - myBase;
                            simState.undo();
                            candidateMoves.push({ move: myMove, diff: bDiff, type: 'block' });
                          }
                        }
                      }
                    }
                  }
                }

                // 若該分支不存在得分或防守,提前結束探索此分支 (End rollout early)
                if (candidateMoves.length === 0) {
                  break; 
                } else {
                  // 依據絕對分差排序 (由大到小)
                  candidateMoves.sort((a, b) => b.diff - a.diff);
                  m = candidateMoves[0].move; // 取最佳著法繼續模擬
                }
              } else {

```

---

### 第五部分:新增 Minimax 演算法

在 `runMCTSBatch` 函式的下方(或 `triggerAITurn` 之前),加入帶有超時控制的 Minimax 函式:

```javascript
      // 帶有時間限制的 Minimax 演算法
      function runEndgameMinimax(state, player, workPieces, startTime, timeLimit) {
        let isTimeout = false;

        function minimax(currState, currPlayer, alpha, beta) {
          if (Date.now() - startTime >= timeLimit) {
            isTimeout = true;
            return 0;
          }

          let moves = currState.getValidMoves(currPlayer, workPieces);
          if (moves.length === 0) {
            let scores = currState.calculateScores();
            // 以「當前評估玩家」的分差作為回傳值
            return currPlayer === player ? (scores.p1Score - scores.p2Score) * (player === 1 ? 1 : -1) 
                                         : (scores.p2Score - scores.p1Score) * (player === 1 ? 1 : -1);
          }

          let bestVal = -Infinity;
          for (let move of moves) {
            let pid = (move >> 24) & 0xff;
            currState.tryPlacePiece(pid + 1, currState._idxToCoord((move >> 12) & 0xfff), currState._idxToCoord(move & 0xfff), false);
            workPieces[currPlayer][pid]--;
            
            let val = -minimax(currState, currPlayer === 1 ? 2 : 1, -beta, -alpha);
            
            workPieces[currPlayer][pid]++;
            currState.undo();

            if (isTimeout) return 0;
            bestVal = Math.max(bestVal, val);
            alpha = Math.max(alpha, val);
            if (alpha >= beta) break;
          }
          return bestVal;
        }

        let bestMove = null;
        let bestVal = -Infinity;
        let rootMoves = state.getValidMoves(player, workPieces);

        for (let move of rootMoves) {
          if (Date.now() - startTime >= timeLimit) {
            isTimeout = true;
            break;
          }
          let pid = (move >> 24) & 0xff;
          state.tryPlacePiece(pid + 1, state._idxToCoord((move >> 12) & 0xfff), state._idxToCoord(move & 0xfff), false);
          workPieces[player][pid]--;

          let val = -minimax(state, player === 1 ? 2 : 1, -Infinity, Infinity);
          
          workPieces[player][pid]++;
          state.undo();

          if (isTimeout) break;
          if (val > bestVal) {
            bestVal = val;
            bestMove = move;
          }
        }
        return { bestMove, isTimeout, bestVal };
      }

```

---

### 第六部分:在回合入口調用 Minimax 或 MCTS

找到 `triggerAITurn()`。在迴圈開始 MCTS 搜尋之前,加入 Minimax 的判定與降級機制。

**修改前的程式碼:**

```javascript
        let rootState = gameLogic.clone();
        let root = new MCTSNode(null, null, rootState, currentPlayer, piecesLeft);

        let startTime = Date.now();
        let lastUiTime = startTime;
        let iterations = 0;

        // 在迴圈外預先建立工作區陣列,避免大量產生垃圾物件引發 GC 卡頓
        let workPieces = { 1: new Int8Array(3), 2: new Int8Array(3) };

        while (Date.now() - startTime < timeLimit) {

```

**替換為:**

```javascript
        let rootState = gameLogic.clone();
        let root = new MCTSNode(null, null, rootState, currentPlayer, piecesLeft);

        let startTime = Date.now();
        let lastUiTime = startTime;
        let iterations = 0;

        let workPieces = { 1: new Int8Array(3), 2: new Int8Array(3) };
        workPieces[1][0] = piecesLeft[1][0]; workPieces[1][1] = piecesLeft[1][1]; workPieces[1][2] = piecesLeft[1][2];
        workPieces[2][0] = piecesLeft[2][0]; workPieces[2][1] = piecesLeft[2][1]; workPieces[2][2] = piecesLeft[2][2];

        let movesPlayed = 36 - (workPieces[1][0] + workPieces[1][1] + workPieces[1][2] + workPieces[2][0] + workPieces[2][1] + workPieces[2][2]);
        
        let targetMove = null;
        let useMinimax = (36 - movesPlayed) <= config.pureMinimaxN;

        if (useMinimax) {
          box.innerText = `AI Minimax 深度運算中...`;
          await new Promise(r => setTimeout(r, 50));
          let mmResult = runEndgameMinimax(rootState, currentPlayer, workPieces, startTime, timeLimit);
          
          if (mmResult.isTimeout) {
            box.innerText = `Minimax 運算超時,降級為 MCTS (簡單)...`;
            await new Promise(r => setTimeout(r, 50));
            // 降級套用簡單設定
            mctsExplorationConstant = aiConfigs['simple'].c;
            mctsHeuristicN = aiConfigs['simple'].heuristicN;
            useMinimax = false; 
            // 重置時間繼續 MCTS
            startTime = Date.now();
          } else {
            targetMove = mmResult.bestMove;
            let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
            box.innerText = `Minimax 完成: ${finalTime}s\n終局估值: ${mmResult.bestVal}`;
          }
        }

        if (!useMinimax) {
          while (Date.now() - startTime < timeLimit) {
            if (abortAiSearch) {
              isAiThinking = false;
              box.style.opacity = '0';
              return; 
            }

            let ran = runMCTSBatch(root, rootState, workPieces, 20);
            iterations += ran;
            if (ran === 0 || root.isFullyExplored) break; 

            let now = Date.now();
            if (now - lastUiTime > 100) {
              box.innerText = `AI 思考中...\n時間: ${((now - startTime) / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s\n次數: ${iterations}`;
              await new Promise(r => setTimeout(r, 0));
              lastUiTime = now;
            }
          }

          let bestChild = null, mostVisits = -1;
          for (let c of root.children) {
            if (c.visits > mostVisits) {
              mostVisits = c.visits;
              bestChild = c;
            }
          }
          if (bestChild) {
            targetMove = bestChild.move;
            let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
            let winRate = (((bestChild.wins / bestChild.visits + 1) / 2) * 100).toFixed(1);
            box.innerText = `思考完成: ${finalTime}s\n當前勝率: ${winRate}%`;
          }
        }

        if (targetMove !== null) {
          let pid = (targetMove >> 24) & 0xff;
          let p1Idx = (targetMove >> 12) & 0xfff;
          let p1Coord = gameLogic._idxToCoord(p1Idx);

          currentSelectedPiece = pid;
          isFirstMove = false;

          let halfWidth = 31.1769;
          let isRight = gameLogic.isRight(p1Coord.x, p1Coord.y);
          let cx = isRight ? (p1Coord.x - 1) * halfWidth + 10.392 : (p1Coord.x - 1) * halfWidth + 20.784;

          let tri = { idx: p1Coord.x, N: p1Coord.y, isRight: isRight, cx: cx, cy: p1Coord.y * 18 };
          attemptDrop(tri);
        } else {
          box.innerText = `無合法著法`;
          setTimeout(() => (box.style.opacity = '0'), 2000);
        }
        isAiThinking = false;

```

這樣修改後,遊戲會在殘局階段(剩餘步數達到設定值時)自動切換至絕對嚴謹的 Minimax 樹尋找必勝路線,並將原有的 MCTS Rollout 從局部洗牌升級為完全根據分差推導的窮舉啟發,大幅提升 AI 在防守與收官時的強度。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6314
注册时间: 2022年 11月 18日 17:36
联系:

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

改進MCTS搜尋,加入minimx算法:
一,在局末加入minimax算法,將只用分差來排序。

當剩餘棋子數量等於或少於pureMinimaxN時,將不再使用MCTS搜尋,而是改用minimax,並用分差來排序搜尋結果。

pureMinimaxN在不同等級為:簡單2,困難3,專家4,自訂5,將pureMinimaxN加入AI設置窗口中。

注意當時間結束時,如果minimax沒有運算結束,停止運算,給出提示,之後緊接著採用「簡單」等級的MCTS進行搜尋。

二,在MCTS搜尋時,把「預判檢查數」改為「保底補充數」

把目前的程式碼中的mctsHeuristicChecks(預判檢查數),改為「保底補充數(假設有5步)」:掃描全盤所有合法步(假設有 20 步),找出所有「自己得分」和「阻止對方」的步(假設只找到 2 步),發現 2 步小於設定的 預判檢查數 (5),從剩下的 18 步普通棋裡,隨機抓 3 步來補滿到 5 步。

三,在局末(pureMinimaxN前)加入最終得分驗證

當所剩棋子數量小於「智能預判步數 (局末介入)」時,將用36%的時間來作最終得分驗證(等同於一個小範圍的minimax)。

把當前MCTS的搜尋結果及其往後的所有分支中的前4名,作絕對分差的比較,取最高分。

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

gemini 3.1 pro

代码: 全选

This is an excellent approach to upgrading your AI! Combining Monte Carlo Tree Search (MCTS) for mid-game macro-strategy with Minimax (Alpha-Beta pruning) for exact endgame calculation is a classic and highly effective technique in abstract board game programming.

Here are the precise modifications you need to apply to `trirhombus287.html` to fulfill all three of your requirements.

### 一、 修改設定檔、全域變數與多語言字典

**1. 找到 `aiConfigs`,在每個難度中加入 `pureMinimaxN`,並宣告全域變數:**

```javascript
      // 替換原有的 aiConfigs 宣告
      const aiConfigs = {
        simple: {
          time: 12000, c: 2.4, heuristicN: 4, heuristicChecks: 6, pureMinimaxN: 2,
          icon: ' <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" /> '
        },
        hard: {
          time: 24000, c: 3.6, heuristicN: 8, heuristicChecks: 12, pureMinimaxN: 3,
          icon: ' <rect x="2" y="9" width="2" height="6" rx="1" fill="currentColor" /> <rect x="20" y="9" width="2" height="6" rx="1" fill="currentColor" /> <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" fill="currentColor" /> <circle cx="15" cy="11" r="1.2" fill="currentColor" /> <rect x="9" y="14" width="6" height="2" fill="currentColor" /> '
        },
        expert: {
          time: 36000, c: 4.8, heuristicN: 12, heuristicChecks: 24, pureMinimaxN: 4,
          icon: '<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" fill="currentColor"/>'
        },
        custom: {
          time: 48000, c: 6.0, heuristicN: 16, heuristicChecks: 48, pureMinimaxN: 5,
          icon: '<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" fill="currentColor"/>'
        },
        human: {
          icon: '<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>'
        }
      };
      
      // 在 mctsHeuristicChecks 下方新增全域變數
      let mctsExplorationConstant = 3.6;
      let mctsHeuristicN = 8;
      let mctsHeuristicChecks = 12;
      let mctsPureMinimaxN = 3; // 新增這行

```

**2. 找到 `I18N` 字典,修改與新增文字:**

將原本的 `heuristicChecks: '預判檢查數 (局部洗牌)'` 替換,並加入 `pureMinimaxN` 的翻譯。

```javascript
          // 在 zh 區塊中:
          heuristicChecks: '保底補充數',
          pureMinimaxN: '絕對預判步數 (Minimax)',
          minimaxTimeout: 'Minimax 運算超時,降級為簡單 MCTS...',

          // 在 en 區塊中:
          heuristicChecks: 'Guaranteed Fill Count',
          pureMinimaxN: 'Pure Minimax Steps',
          minimaxTimeout: 'Minimax timeout, falling back to Simple MCTS...',

```

### 二、 更新 AI 設定的 HTML 與 UI 綁定邏輯

**1. 在 HTML 中的 `#ai-modal` 內,找到 `<label id="label-heuristic-checks">` 的區塊,修改它並在下方加上 `pureMinimaxN` 的控制項:**

```html
        <div class="setting-row">
          <label id="label-heuristic-checks">保底補充數</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('heuristic-checks', -1, 1, 100, 0)">-</button>
            <input type="number" id="heuristic-checks" class="num-input" value="12" step="1" onchange="updateGlobalParams()" />
            <button class="num-btn" onclick="adjustGlobalParam('heuristic-checks', 1, 1, 100, 0)">+</button>
          </div>
        </div>

        <div class="setting-row">
          <label id="label-pure-minimax-n">絕對預判步數 (Minimax)</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('pure-minimax-n', -1, 1, 10, 0)">-</button>
            <input type="number" id="pure-minimax-n" class="num-input" value="3" step="1" onchange="updateGlobalParams()" />
            <button class="num-btn" onclick="adjustGlobalParam('pure-minimax-n', 1, 1, 10, 0)">+</button>
          </div>
        </div>

```

**2. 更新 UI 綁定的三個函數 (`selectAiTab`, `markCustom`, `updateGlobalParams`):**

```javascript
      function selectAiTab(preset) {
        document.querySelectorAll('.ai-tab').forEach(tab => {
          if (tab.dataset.preset === preset) tab.classList.add('active');
          else tab.classList.remove('active');
        });
        if (aiConfigs[preset]) {
          document.getElementById('ai-time').value = aiConfigs[preset].time / 1000;
          document.getElementById('mcts-c').value = aiConfigs[preset].c.toFixed(1);
          document.getElementById('heuristic-n').value = aiConfigs[preset].heuristicN;
          document.getElementById('heuristic-checks').value = aiConfigs[preset].heuristicChecks;
          document.getElementById('pure-minimax-n').value = aiConfigs[preset].pureMinimaxN; // 新增
          updateGlobalParams();
        }
      }

      function markCustom() {
        let t = parseFloat(document.getElementById('ai-time').value) * 1000;
        let c = parseFloat(document.getElementById('mcts-c').value);
        let n = parseInt(document.getElementById('heuristic-n').value, 10);
        let checks = parseInt(document.getElementById('heuristic-checks').value, 10);
        let pmn = parseInt(document.getElementById('pure-minimax-n').value, 10); // 新增

        let matchedPreset = 'custom';
        for (let k of ['simple', 'hard', 'expert']) {
          if (
            aiConfigs[k].time === t && Math.abs(aiConfigs[k].c - c) < 0.01 &&
            aiConfigs[k].heuristicN === n && aiConfigs[k].heuristicChecks === checks &&
            aiConfigs[k].pureMinimaxN === pmn // 新增判斷
          ) {
            matchedPreset = k;
            break;
          }
        }
        document.querySelectorAll('.ai-tab').forEach(tab => {
          if (tab.dataset.preset === matchedPreset) tab.classList.add('active');
          else tab.classList.remove('active');
        });
        aiConfigs.custom.time = t;
        aiConfigs.custom.c = c;
        aiConfigs.custom.heuristicN = n;
        aiConfigs.custom.heuristicChecks = checks;
        aiConfigs.custom.pureMinimaxN = pmn; // 新增
      }

      function updateGlobalParams() {
        mctsExplorationConstant = parseFloat(document.getElementById('mcts-c').value);
        mctsHeuristicN = parseInt(document.getElementById('heuristic-n').value, 10);
        mctsHeuristicChecks = parseInt(document.getElementById('heuristic-checks').value, 10);
        mctsPureMinimaxN = parseInt(document.getElementById('pure-minimax-n').value, 10); // 新增
        markCustom();
      }

```

*(記得在 `toggleLanguage` 中加入 `document.getElementById('label-pure-minimax-n').innerText = getText('pureMinimaxN');` 以支援雙語切換)*

### 三、 修改 MCTS 模擬階段:預判保底補充機制

找到 `runMCTSBatch` 中 `// 啟發式探索:在局末 n 手內介入` 區塊,**完全替換**其內的邏輯:

```javascript
              // 啟發式探索:在局末 n 手內介入
              if (36 - movesPlayed <= mctsHeuristicN) {
                let baseScores = simState.calculateScores();
                let myBase = simPlayer === 1 ? baseScores.p1Score : baseScores.p2Score;
                let oppPlayer = simPlayer === 1 ? 2 : 1;
                let oppBase = oppPlayer === 1 ? baseScores.p1Score : baseScores.p2Score;

                let myScoringMoves = [];
                let blockingMoves = [];
                
                // 掃描全盤合法步
                for (let j = 0; j < moves.length; j++) {
                  let move = moves[j];
                  let simPid = (move >> 24) & 0xff;
                  let p1Idx = (move >> 12) & 0xfff;
                  let p2Idx = move & 0xfff;

                  // 測試自己落子
                  let res = simState.tryPlacePiece(simPid + 1, simState._idxToCoord(p1Idx), simState._idxToCoord(p2Idx), false);
                  if (res.success) {
                    let newScore = simPlayer === 1 ? res.scores.p1Score : res.scores.p2Score;
                    simState.undo();
                    if (newScore > myBase) myScoringMoves.push(move);
                  }
                  
                  // 測試對手落子 (阻擋)
                  let oppRes = simState.tryPlacePiece(simPid + 1, simState._idxToCoord(p1Idx), simState._idxToCoord(p2Idx), false);
                  if (oppRes.success) {
                    let oppNewScore = oppPlayer === 1 ? oppRes.scores.p1Score : oppRes.scores.p2Score;
                    simState.undo();
                    if (oppNewScore > oppBase) blockingMoves.push(move);
                  }
                }

                // 決定優先使用的池子
                let pool = myScoringMoves.length > 0 ? myScoringMoves : blockingMoves;

                // 保底補充機制 (隨機補滿至 mctsHeuristicChecks)
                if (pool.length < mctsHeuristicChecks && moves.length > pool.length) {
                  let needed = mctsHeuristicChecks - pool.length;
                  // 過濾掉已經在 pool 中的步
                  let remainingMoves = moves.filter(x => !pool.includes(x));
                  
                  // 洗牌剩餘的步來隨機抽取
                  for (let i = remainingMoves.length - 1; i > 0; i--) {
                    let r = (Math.random() * (i + 1)) | 0;
                    [remainingMoves[i], remainingMoves[r]] = [remainingMoves[r], remainingMoves[i]];
                  }
                  
                  let fillCount = Math.min(needed, remainingMoves.length);
                  for(let i=0; i<fillCount; i++) {
                    pool.push(remainingMoves[i]);
                  }
                }

                if (pool.length > 0) {
                  m = pool[(Math.random() * pool.length) | 0];
                } else {
                  m = moves[Math.floor(Math.random() * moves.length)];
                }
              } else {
                // 一般隨機落子
                m = moves[Math.floor(Math.random() * moves.length)];
              }

```

### 四、 實作 Minimax 引擎與核心決策流程

**1. 在程式碼底部(`class HexGridGame` 外)新增 Minimax 演算法核心:**

```javascript
      function runMinimax(state, player, alpha, beta, workPieces, endTime) {
        if (Date.now() > endTime) return { timeout: true };
        
        let validMoves = state.getValidMoves(player, workPieces);
        if (validMoves.length === 0) {
          let scores = state.calculateScores();
          return { score: scores.p1Score - scores.p2Score, move: null };
        }

        let bestMove = null;
        let isMaximizing = (player === 1);
        let bestScore = isMaximizing ? -Infinity : Infinity;
        let nextPlayer = player === 1 ? 2 : 1;

        for (let i = 0; i < validMoves.length; i++) {
          let move = validMoves[i];
          let pid = (move >> 24) & 0xff;
          state.tryPlacePiece(pid + 1, state._idxToCoord((move >> 12) & 0xfff), state._idxToCoord(move & 0xfff), false);
          workPieces[player][pid]--;

          let result = runMinimax(state, nextPlayer, alpha, beta, workPieces, endTime);
          
          workPieces[player][pid]++;
          state.undo();

          if (result.timeout) return { timeout: true };

          if (isMaximizing) {
            if (result.score > bestScore) { bestScore = result.score; bestMove = move; }
            alpha = Math.max(alpha, bestScore);
          } else {
            if (result.score < bestScore) { bestScore = result.score; bestMove = move; }
            beta = Math.min(beta, bestScore);
          }
          if (beta <= alpha) break; // Alpha-Beta Pruning
        }
        return { score: bestScore, move: bestMove };
      }

```

**2. 大幅改寫 `triggerAITurn()` 以支援三階段混合決策:**

將原來的 `triggerAITurn()` 內部的核心迴圈(`let rootState = ...` 以下)替換為以下邏輯:

```javascript
        let rootState = gameLogic.clone();
        let startTime = Date.now();
        let endTime = startTime + timeLimit;
        
        // 計算剩餘步數
        let p1Rem = piecesLeft[1][0] + piecesLeft[1][1] + piecesLeft[1][2];
        let p2Rem = piecesLeft[2][0] + piecesLeft[2][1] + piecesLeft[2][2];
        let remainingPieces = p1Rem + p2Rem;
        let workPieces = { 1: new Int8Array([piecesLeft[1][0], piecesLeft[1][1], piecesLeft[1][2]]), 2: new Int8Array([piecesLeft[2][0], piecesLeft[2][1], piecesLeft[2][2]]) };
        
        let bestMoveToPlay = null;

        // 階段 1:純 Minimax (剩餘棋子 <= pureMinimaxN)
        if (remainingPieces <= mctsPureMinimaxN) {
          box.innerText = `執行絕對預判 (Minimax)...`;
          let mmResult = runMinimax(rootState, currentPlayer, -Infinity, Infinity, workPieces, endTime);
          
          if (!mmResult.timeout && mmResult.move !== null) {
            bestMoveToPlay = mmResult.move;
          } else {
            // Minimax 超時,降級為 Simple MCTS
            box.innerText = getText('minimaxTimeout') || 'Minimax 運算超時,降級為簡單 MCTS...';
            await new Promise(r => setTimeout(r, 800));
            
            endTime = Date.now() + aiConfigs.simple.time; // 重置時間為簡單難度
            let root = new MCTSNode(null, null, rootState, currentPlayer, piecesLeft);
            while (Date.now() < endTime && !abortAiSearch) {
              if (runMCTSBatch(root, rootState, workPieces, 20) === 0 || root.isFullyExplored) break;
            }
            let bestChild = root.children.reduce((best, c) => (c.visits > (best ? best.visits : -1) ? c : best), null);
            if (bestChild) bestMoveToPlay = bestChild.move;
          }
        } 
        else {
          let root = new MCTSNode(null, null, rootState, currentPlayer, piecesLeft);
          
          // 階段 2:混合驗證 (剩餘棋子 <= heuristicN)
          if (remainingPieces <= mctsHeuristicN) {
            let mctsTimeLimit = timeLimit * 0.64; // 64% 給 MCTS
            let mctsEndTime = startTime + mctsTimeLimit;
            let lastUiTime = startTime;
            let iterations = 0;

            // 執行 MCTS
            while (Date.now() < mctsEndTime && !abortAiSearch) {
              let ran = runMCTSBatch(root, rootState, workPieces, 20);
              iterations += ran;
              if (ran === 0 || root.isFullyExplored) break;
              
              let now = Date.now();
              if (now - lastUiTime > 100) {
                box.innerText = `混合分析中...\nMCTS: ${iterations} 次\n時間: ${((now - startTime) / 1000).toFixed(1)}s`;
                await new Promise(r => setTimeout(r, 0));
                lastUiTime = now;
              }
            }

            // 取前 4 名分支進行 Minimax 驗證
            box.innerText = `最終得分驗證中 (Minimax)...`;
            await new Promise(r => setTimeout(r, 0)); // 強制渲染 UI
            
            let top4 = [...root.children].sort((a, b) => b.visits - a.visits).slice(0, 4);
            let bestVerifiedScore = currentPlayer === 1 ? -Infinity : Infinity;
            
            for (let child of top4) {
              if (Date.now() >= endTime || abortAiSearch) break;

              let pid = (child.move >> 24) & 0xff;
              rootState.tryPlacePiece(pid + 1, rootState._idxToCoord((child.move >> 12) & 0xfff), rootState._idxToCoord(child.move & 0xfff), false);
              workPieces[currentPlayer][pid]--;
              
              let mmResult = runMinimax(rootState, currentPlayer === 1 ? 2 : 1, -Infinity, Infinity, workPieces, endTime);
              
              workPieces[currentPlayer][pid]++;
              rootState.undo();

              if (!mmResult.timeout) {
                if ((currentPlayer === 1 && mmResult.score > bestVerifiedScore) || 
                    (currentPlayer === 2 && mmResult.score < bestVerifiedScore)) {
                  bestVerifiedScore = mmResult.score;
                  bestMoveToPlay = child.move;
                }
              }
            }
            
            // 如果驗證全部超時,則預設回傳 MCTS 最高拜訪數的點
            if (bestMoveToPlay === null && top4.length > 0) bestMoveToPlay = top4[0].move;

          } else {
            // 階段 3:一般 MCTS
            let lastUiTime = startTime;
            let iterations = 0;
            while (Date.now() < endTime && !abortAiSearch) {
              let ran = runMCTSBatch(root, rootState, workPieces, 20);
              iterations += ran;
              if (ran === 0 || root.isFullyExplored) break;

              let now = Date.now();
              if (now - lastUiTime > 100) {
                box.innerText = `AI 思考中...\n時間: ${((now - startTime) / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s\n次數: ${iterations}`;
                await new Promise(r => setTimeout(r, 0));
                lastUiTime = now;
              }
            }
            let bestChild = root.children.reduce((best, c) => (c.visits > (best ? best.visits : -1) ? c : best), null);
            if (bestChild) bestMoveToPlay = bestChild.move;
          }
        }

        let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
        if (bestMoveToPlay !== null) {
          box.innerText = `思考完成: ${finalTime}s`;
          let pid = (bestMoveToPlay >> 24) & 0xff;
          let p1Coord = gameLogic._idxToCoord((bestMoveToPlay >> 12) & 0xfff);
          currentSelectedPiece = pid;
          isFirstMove = false;

          let halfWidth = 31.1769;
          let isRight = gameLogic.isRight(p1Coord.x, p1Coord.y);
          let cx = isRight ? (p1Coord.x - 1) * halfWidth + 10.392 : (p1Coord.x - 1) * halfWidth + 20.784;
          let tri = { idx: p1Coord.x, N: p1Coord.y, isRight: isRight, cx: cx, cy: p1Coord.y * 18 };
          attemptDrop(tri);
        } else {
          box.innerText = `無合法著法`;
          setTimeout(() => (box.style.opacity = '0'), 2000);
        }
        isAiThinking = false;

```

這些變更完美整合了 MCTS 的廣度與 Minimax 的深度,能顯著提升 AI 在收尾階段(Endgame)的準確度。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6314
注册时间: 2022年 11月 18日 17:36
联系:

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

加入minimx算法:
當剩餘棋子數量等於或少於pureMinimaxN時,將不再使用MCTS搜尋,而是改用minimax,並用絕對分差來排序搜尋結果。

pureMinimaxN在不同等級為:簡單2,困難3,專家4,自訂5,將pureMinimaxN加入AI設置窗口中。

要用「廣度優先算法」,當時間結束時,如果minimax還沒有算完則停止運算,並挑選當前分差最大的下法。如果是AI設置窗口中的搜尋,則按分差高低排序。

在搜尋時,調用消息框的方式跟MCTS搜尋時一樣,也要實時顯示時間和搜尋量。當搜尋結束開始落子時,要顯示所用時間、總搜尋量、最佳勝負結果。

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

代码: 全选

加入minimx算法:
當剩餘棋子數量等於或少於pureMinimaxN時,將不再使用MCTS搜尋,而是改用minimax,並用絕對分差來排序搜尋結果。

pureMinimaxN在不同等級為:簡單2,困難3,專家4,自訂5,將pureMinimaxN加入AI設置窗口中。

要用「廣度優先算法」,當時間結束時,如果minimax還沒有算完則停止運算,並挑選當前分差最大的下法。如果是AI設置窗口中的搜尋,則按分差高低排序。

在搜尋時,調用消息框的方式跟MCTS搜尋時一樣,也要實時顯示時間和搜尋量。當搜尋結束開始落子時,要顯示所用時間、總搜尋量、最佳勝負結果。

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

代码: 全选

這是一個非常棒的優化方向。當殘局棋子數量非常少時,遊戲的狀態樹會急劇縮小,此時 MCTS 的隨機 rollout 反而可能會帶來不精確的結果。改用 Minimax 搭配 Alpha-Beta 剪枝與**迭代加深 (Iterative Deepening)** (一種結合 DFS 效能與 BFS 廣度優先特性的算法,能在時間結束時隨時中斷並返回當前最優解),能保證在殘局給出「絕對精確」的必勝/必和/必敗著法。

以下是為您加入 Minimax 算法的具體修改步驟與替換代碼:

### 修改處 1:更新 `aiConfigs` 與全域變數

在 `const aiConfigs = {` 的定義中,為每個難度級別加入 `pureMinimaxN` 屬性,並在下方宣告對應的全域變數。

找到這段代碼 (約在 `<script>` 開頭不遠處):

```javascript
      const aiConfigs = {
        simple: {
          time: 12000,
          c: 2.4,
          heuristicN: 4,
          heuristicChecks: 6,

```

**將整個 `aiConfigs` 定義與其下方的全域變數替換為:**

```javascript
      const aiConfigs = {
        simple: {
          time: 12000,
          c: 2.4,
          heuristicN: 4,
          heuristicChecks: 6,
          pureMinimaxN: 2,
          icon: ' <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" /> '
        },
        hard: {
          time: 24000,
          c: 3.6,
          heuristicN: 8,
          heuristicChecks: 12,
          pureMinimaxN: 3,
          icon: ' <rect x="2" y="9" width="2" height="6" rx="1" fill="currentColor" /> <rect x="20" y="9" width="2" height="6" rx="1" fill="currentColor" /> <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" fill="currentColor" /> <circle cx="15" cy="11" r="1.2" fill="currentColor" /> <rect x="9" y="14" width="6" height="2" fill="currentColor" /> '
        },
        expert: {
          time: 36000,
          c: 4.8,
          heuristicN: 12,
          heuristicChecks: 24,
          pureMinimaxN: 4,
          icon: '<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" fill="currentColor"/>'
        },
        custom: {
          time: 48000,
          c: 6.0,
          heuristicN: 16,
          heuristicChecks: 48,
          pureMinimaxN: 5,
          icon: '<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" fill="currentColor"/>'
        },
        human: {
          icon: '<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>'
        }
      };
      let isAiThinking = false;
      let abortAiSearch = false;
      let mctsExplorationConstant = 3.6;
      let mctsHeuristicN = 8;
      let mctsHeuristicChecks = 12;
      let pureMinimaxN = 3; // 新增:全局 Minimax 觸發閾值

```

### 修改處 2:在 HTML 介面加入 Minimax 閾值控制與多語言

在 HTML 代碼中找到 `<label id="label-heuristic-checks">` 的設定區塊,並在其下方插入新的 Minimax 控制行:

```html
        <div class="setting-row">
          <label id="label-heuristic-checks">預判檢查數 (局部洗牌)</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('heuristic-checks', -1, 1, 100, 0)">-</button>
            <input type="number" id="heuristic-checks" class="num-input" value="12" step="1" onchange="updateGlobalParams()" />
            <button class="num-btn" onclick="adjustGlobalParam('heuristic-checks', 1, 1, 100, 0)">+</button>
          </div>
        </div>

        <div class="setting-row">
          <label id="label-pure-minimax">殘局絕對預判 (Minimax) 觸發步數</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('pure-minimax-n', -1, 0, 10, 0)">-</button>
            <input type="number" id="pure-minimax-n" class="num-input" value="3" step="1" onchange="updateGlobalParams()" />
            <button class="num-btn" onclick="adjustGlobalParam('pure-minimax-n', 1, 0, 10, 0)">+</button>
          </div>
        </div>

```

接著找到 `const I18N = {`,在對應的中英文翻譯字典中加入:
**中文字典 (`zh: { ...`) 加入:**

```javascript
          pureMinimaxN: '殘局絕對預判 (Minimax) 觸發步數',

```

**英文字典 (`en: { ...`) 加入:**

```javascript
          pureMinimaxN: 'Endgame Minimax Trigger (Remaining Pieces)',

```

並在 `toggleLanguage()` 函數中補上這行使其支持語言切換:

```javascript
        if (document.getElementById('label-pure-minimax')) document.getElementById('label-pure-minimax').innerText = getText('pureMinimaxN');

```

### 修改處 3:更新 AI 設定套用邏輯 (`selectAiTab`, `markCustom`, `updateGlobalParams`)

找到 `selectAiTab` 函數,加入對 `pure-minimax-n` 的更新:

```javascript
      function selectAiTab(preset) {
        document.querySelectorAll('.ai-tab').forEach(tab => {
          if (tab.dataset.preset === preset) tab.classList.add('active');
          else tab.classList.remove('active');
        });
        if (aiConfigs[preset]) {
          document.getElementById('ai-time').value = aiConfigs[preset].time / 1000;
          document.getElementById('mcts-c').value = aiConfigs[preset].c.toFixed(1);
          document.getElementById('heuristic-n').value = aiConfigs[preset].heuristicN;
          document.getElementById('heuristic-checks').value = aiConfigs[preset].heuristicChecks;
          document.getElementById('pure-minimax-n').value = aiConfigs[preset].pureMinimaxN; // 新增這行
          updateGlobalParams();
        }
      }

```

找到 `markCustom` 函數並修改:

```javascript
      function markCustom() {
        let t = parseFloat(document.getElementById('ai-time').value) * 1000;
        let c = parseFloat(document.getElementById('mcts-c').value);
        let n = parseInt(document.getElementById('heuristic-n').value, 10);
        let checks = parseInt(document.getElementById('heuristic-checks').value, 10);
        let pmn = parseInt(document.getElementById('pure-minimax-n').value, 10); // 新增這行

        let matchedPreset = 'custom';
        for (let k of ['simple', 'hard', 'expert']) {
          if (
            aiConfigs[k].time === t &&
            Math.abs(aiConfigs[k].c - c) < 0.01 &&
            aiConfigs[k].heuristicN === n &&
            aiConfigs[k].heuristicChecks === checks &&
            aiConfigs[k].pureMinimaxN === pmn // 新增判斷
          ) {
            matchedPreset = k;
            break;
          }
        }
        document.querySelectorAll('.ai-tab').forEach(tab => {
          if (tab.dataset.preset === matchedPreset) tab.classList.add('active');
          else tab.classList.remove('active');
        });
        aiConfigs.custom.time = t;
        aiConfigs.custom.c = c;
        aiConfigs.custom.heuristicN = n;
        aiConfigs.custom.heuristicChecks = checks;
        aiConfigs.custom.pureMinimaxN = pmn; // 新增這行
      }

```

找到 `updateGlobalParams` 函數,加入同步:

```javascript
      function updateGlobalParams() {
        mctsExplorationConstant = parseFloat(document.getElementById('mcts-c').value);
        mctsHeuristicN = parseInt(document.getElementById('heuristic-n').value, 10);
        mctsHeuristicChecks = parseInt(document.getElementById('heuristic-checks').value, 10);
        pureMinimaxN = parseInt(document.getElementById('pure-minimax-n').value, 10); // 新增這行
        markCustom();
      }

```

### 修改處 4:插入 Minimax 與迭代加深引擎核心代碼

請在 `class MCTSNode { ... }` 類別定義的正上方,插入以下**全新的 Minimax 相關函數** (這使用迭代加深來達成隨時中斷且廣度優先的效果):

```javascript
      // === 殘局絕對計算:Iterative Deepening Minimax 引擎 ===
      let mmNodesSearched = 0;
      let mmAbort = false;

      function evaluateBoardDiff(state) {
        let scores = state.calculateScores();
        return scores.p1Score - scores.p2Score; // 永遠以 P1 視角為正
      }

      function minimax(state, depth, alpha, beta, isMaximizingPlayer, player, workPieces, timeLimit, startTime) {
        mmNodesSearched++;
        // 每 500 個節點檢查一次時間,避免鎖死主線程並允許超時中斷
        if (mmNodesSearched % 500 === 0) {
          if (Date.now() - startTime > timeLimit || abortAiSearch) mmAbort = true;
        }
        if (mmAbort) return 0;

        let moves = state.getValidMoves(player, workPieces);
        if (depth === 0 || moves.length === 0) {
          return evaluateBoardDiff(state);
        }

        let nextPlayer = player === 1 ? 2 : 1;

        if (isMaximizingPlayer) {
          let maxEval = -Infinity;
          for (let i = 0; i < moves.length; i++) {
            let move = moves[i];
            let pid = (move >> 24) & 0xff;
            state.tryPlacePiece(pid + 1, state._idxToCoord((move >> 12) & 0xfff), state._idxToCoord(move & 0xfff), false);
            workPieces[player][pid]--;

            let evalScore = minimax(state, depth - 1, alpha, beta, false, nextPlayer, workPieces, timeLimit, startTime);

            workPieces[player][pid]++;
            state.undo();
            if (mmAbort) break;

            maxEval = Math.max(maxEval, evalScore);
            alpha = Math.max(alpha, evalScore);
            if (beta <= alpha) break;
          }
          return maxEval;
        } else {
          let minEval = Infinity;
          for (let i = 0; i < moves.length; i++) {
            let move = moves[i];
            let pid = (move >> 24) & 0xff;
            state.tryPlacePiece(pid + 1, state._idxToCoord((move >> 12) & 0xfff), state._idxToCoord(move & 0xfff), false);
            workPieces[player][pid]--;

            let evalScore = minimax(state, depth - 1, alpha, beta, true, nextPlayer, workPieces, timeLimit, startTime);

            workPieces[player][pid]++;
            state.undo();
            if (mmAbort) break;

            minEval = Math.min(minEval, evalScore);
            beta = Math.min(beta, evalScore);
            if (beta <= alpha) break;
          }
          return minEval;
        }
      }

      // 迭代加深包裝器 (確保具備廣度優先中斷能力)
      async function runIterativeDeepening(rootState, rootPlayer, timeLimit, statusCallback) {
        let startTime = Date.now();
        let workPieces = {
          1: new Int8Array([piecesLeft[1][0], piecesLeft[1][1], piecesLeft[1][2]]),
          2: new Int8Array([piecesLeft[2][0], piecesLeft[2][1], piecesLeft[2][2]])
        };
        let moves = rootState.getValidMoves(rootPlayer, workPieces);
        if (moves.length === 0) return { bestMove: null, score: 0, nodes: 0, results: [] };

        let moveResults = moves.map(m => ({ move: m, score: rootPlayer === 1 ? -Infinity : Infinity }));
        mmNodesSearched = 0;
        mmAbort = false;

        let bestMove = moves[0];
        let bestScoreOverall = rootPlayer === 1 ? -Infinity : Infinity;
        let maxDepth = workPieces[1][0] + workPieces[1][1] + workPieces[1][2] +
                       workPieces[2][0] + workPieces[2][1] + workPieces[2][2];

        let lastUiTime = startTime;

        // 依深度遞增展開 (廣度優先特性)
        for (let depth = 1; depth <= maxDepth; depth++) {
          // 利用上一深度的結果進行排序,大幅提升 Alpha-Beta 剪枝效率
          moveResults.sort((a, b) => rootPlayer === 1 ? b.score - a.score : a.score - b.score);

          let alpha = -Infinity;
          let beta = Infinity;
          let currentBestMove = null;
          let currentBestScore = rootPlayer === 1 ? -Infinity : Infinity;
          let completedCurrentDepth = true;

          for (let mr of moveResults) {
            let move = mr.move;
            let pid = (move >> 24) & 0xff;
            rootState.tryPlacePiece(pid + 1, rootState._idxToCoord((move >> 12) & 0xfff), rootState._idxToCoord(move & 0xfff), false);
            workPieces[rootPlayer][pid]--;

            let nextPlayer = rootPlayer === 1 ? 2 : 1;
            let evalScore = minimax(rootState, depth - 1, alpha, beta, rootPlayer !== 1, nextPlayer, workPieces, timeLimit, startTime);

            workPieces[rootPlayer][pid]++;
            rootState.undo();

            // 實時非阻塞 UI 更新
            let now = Date.now();
            if (now - lastUiTime > 150) {
              await statusCallback(now - startTime, mmNodesSearched, depth);
              lastUiTime = now;
            }

            if (mmAbort) {
              completedCurrentDepth = false;
              break;
            }

            mr.score = evalScore; // 記錄本層分數

            if (rootPlayer === 1) {
              if (evalScore > currentBestScore) {
                currentBestScore = evalScore;
                currentBestMove = move;
              }
              alpha = Math.max(alpha, evalScore);
            } else {
              if (evalScore < currentBestScore) {
                currentBestScore = evalScore;
                currentBestMove = move;
              }
              beta = Math.min(beta, evalScore);
            }
          }

          if (completedCurrentDepth && currentBestMove !== null) {
            bestMove = currentBestMove;
            bestScoreOverall = currentBestScore;
          }

          if (mmAbort || (completedCurrentDepth && depth === maxDepth)) break;
        }

        // 最終按照最佳結果排序 (供分析面板使用)
        moveResults.sort((a, b) => rootPlayer === 1 ? b.score - a.score : a.score - b.score);

        return {
          bestMove: bestMove,
          score: bestScoreOverall,
          nodes: mmNodesSearched,
          results: moveResults
        };
      }

```

### 修改處 5:修改 `triggerAITurn()` 以觸發 Minimax

找到 `async function triggerAITurn() {` 函數。我們需要在其中加入剩餘棋子數的判斷分支。

**將整段 `triggerAITurn()` 函數替換為:**

```javascript
      async function triggerAITurn() {
        if (gameState !== 'playing' || isAiThinking) return;
        isAiThinking = true;
        abortAiSearch = false; 

        let roundStart = currentRound === 2 ? 36 : 0;
        let movesInRound = historyIndex - roundStart + 1;
        if (movesInRound === 0) {
          autoPlayFirstBlue(currentPlayer);
          isAiThinking = false;
          return;
        }

        let pType = playerTypes[currentPlayer];
        let config = aiConfigs[pType] || aiConfigs['custom'];
        let timeLimit = config.time;

        const box = document.getElementById('test-box');
        box.style.opacity = '1';

        let rootState = gameLogic.clone();
        let startTime = Date.now();

        // 計算目前剩餘總子數
        let totalPiecesLeft = piecesLeft[1].reduce((a, b) => a + b) + piecesLeft[2].reduce((a, b) => a + b);

        if (totalPiecesLeft <= pureMinimaxN) {
          // --- 使用 Minimax 進行殘局絕對預判 ---
          let res = await runIterativeDeepening(rootState, currentPlayer, timeLimit, async (timeSpent, nodes, depth) => {
            box.innerText = `Minimax 預判中...\n時間: ${(timeSpent / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s\n深度: ${depth} | 節點: ${nodes}`;
            await new Promise(r => setTimeout(r, 0));
          });

          let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
          if (res.bestMove !== null) {
            let scoreText = res.score > 0 ? `P1領先 ${res.score}` : (res.score < 0 ? `P2領先 ${-res.score}` : `平局 0`);
            // 要求:顯示所用時間、總搜尋量、最佳勝負結果
            box.innerText = `搜尋完成: ${finalTime}s\n總搜尋量: ${res.nodes}\n最佳結果: ${scoreText}`;

            let pid = (res.bestMove >> 24) & 0xff;
            let p1Idx = (res.bestMove >> 12) & 0xfff;
            let p1Coord = gameLogic._idxToCoord(p1Idx);

            currentSelectedPiece = pid;
            isFirstMove = false;

            let halfWidth = 31.1769;
            let isRight = gameLogic.isRight(p1Coord.x, p1Coord.y);
            let cx = isRight ? (p1Coord.x - 1) * halfWidth + 10.392 : (p1Coord.x - 1) * halfWidth + 20.784;

            let tri = { idx: p1Coord.x, N: p1Coord.y, isRight: isRight, cx: cx, cy: p1Coord.y * 18 };
            attemptDrop(tri);
          } else {
            box.innerText = `無合法著法`;
            setTimeout(() => (box.style.opacity = '0'), 2000);
          }
          isAiThinking = false;
          return;
        }

        // --- 正常情況:使用 MCTS ---
        let root = new MCTSNode(null, null, rootState, currentPlayer, piecesLeft);
        let lastUiTime = startTime;
        let iterations = 0;
        let workPieces = { 1: new Int8Array(3), 2: new Int8Array(3) };

        while (Date.now() - startTime < timeLimit) {
          if (abortAiSearch) {
            isAiThinking = false;
            box.style.opacity = '0';
            return;
          }

          let ran = runMCTSBatch(root, rootState, workPieces, 20);
          iterations += ran;
          if (ran === 0 || root.isFullyExplored) break;

          let now = Date.now();
          if (now - lastUiTime > 100) {
            box.innerText = `AI 思考中...\n時間: ${((now - startTime) / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s\n次數: ${iterations}`;
            await new Promise(r => setTimeout(r, 0));
            lastUiTime = now;
          }
        }

        let bestChild = null, mostVisits = -1;
        for (let c of root.children) {
          if (c.visits > mostVisits) {
            mostVisits = c.visits;
            bestChild = c;
          }
        }

        let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
        if (bestChild) {
          let winRate = (((bestChild.wins / bestChild.visits + 1) / 2) * 100).toFixed(1);
          box.innerText = `思考完成: ${finalTime}s\n當前勝率: ${winRate}%`;

          let pid = (bestChild.move >> 24) & 0xff;
          let p1Idx = (bestChild.move >> 12) & 0xfff;
          let p1Coord = gameLogic._idxToCoord(p1Idx);

          currentSelectedPiece = pid;
          isFirstMove = false;

          let halfWidth = 31.1769;
          let isRight = gameLogic.isRight(p1Coord.x, p1Coord.y);
          let cx = isRight ? (p1Coord.x - 1) * halfWidth + 10.392 : (p1Coord.x - 1) * halfWidth + 20.784;

          let tri = { idx: p1Coord.x, N: p1Coord.y, isRight: isRight, cx: cx, cy: p1Coord.y * 18 };
          attemptDrop(tri);
        } else {
          box.innerText = `無合法著法`;
          setTimeout(() => (box.style.opacity = '0'), 2000);
        }
        isAiThinking = false;
      }

```

### 修改處 6:修改 `runAISearch()` 與專用的 DOM 繪製函數

找到 `async function runAISearch() {` 函數並替換,並在它上方加入一個解析 Minimax 節點專用的 `buildMinimaxNodeDOM` 函數。

**將整段 `runAISearch()` 與相關邏輯替換為:**

```javascript
      function buildMinimaxNodeDOM(resultObj) {
        let div = document.createElement('div');
        div.className = 'ai-tree-node';
        div.style.marginLeft = '0';

        let pid = (resultObj.move >> 24) & 0xff;
        let p1Idx = (resultObj.move >> 12) & 0xfff;
        let p2Idx = resultObj.move & 0xfff;
        let c1 = gameLogic._idxToCoord(p1Idx);
        let c2 = gameLogic._idxToCoord(p2Idx);

        let pieceName = pid === 0 ? '🔵藍' : pid === 1 ? '🔴紅' : '🟠橙';
        let notation = `[(${c1.x},${c1.y}),(${c2.x},${c2.y})]`;

        let header = document.createElement('div');
        header.className = 'ai-tree-header';

        let scoreDiff = resultObj.score;
        let scoreStr = scoreDiff > 0 ? `P1領先 ${scoreDiff}` : (scoreDiff < 0 ? `P2領先 ${-scoreDiff}` : `平局 0`);

        header.innerHTML = `
          <span style="width: 150px; display: inline-block;">${pieceName} ${notation}</span>
          <span style="color: #333; font-weight: bold; width: 140px; display: inline-block;">絕對分差: ${scoreStr}</span>
        `;
        div.appendChild(header);
        return div;
      }

      async function runAISearch() {
        if (gameState !== 'playing') {
          alert(getText('gameNotStarted'));
          return;
        }

        abortAiSearch = false;
        const resDiv = document.getElementById('ai-results');
        const statusDiv = document.getElementById('ai-search-status');
        const btn = document.getElementById('ai-search-btn');

        resDiv.innerHTML = '';
        btn.disabled = true;
        btn.style.opacity = '0.5';

        let timeLimit = parseFloat(document.getElementById('ai-time').value) * 1000;
        let rootState = gameLogic.clone();
        let startTime = Date.now();
        
        let totalPiecesLeft = piecesLeft[1].reduce((a, b) => a + b) + piecesLeft[2].reduce((a, b) => a + b);

        // 如果符合殘局條件,使用 Minimax 分析
        if (totalPiecesLeft <= pureMinimaxN) {
          statusDiv.innerHTML = `Minimax 搜尋中... 經過時間: 0.0s / ${(timeLimit / 1000).toFixed(0)}s`;
          
          let res = await runIterativeDeepening(rootState, currentPlayer, timeLimit, async (timeSpent, nodes, depth) => {
            statusDiv.innerHTML = `Minimax 搜尋中... 經過時間: ${(timeSpent / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s | 深度: ${depth} | 節點: ${nodes}`;
            await new Promise(r => setTimeout(r, 0));
          });

          statusDiv.innerHTML = `✅ Minimax 搜尋完成!總耗時: ${((Date.now() - startTime) / 1000).toFixed(1)}s / 總節點: ${res.nodes}`;
          btn.disabled = false;
          btn.style.opacity = '1';

          if (res.results.length === 0) {
            resDiv.innerHTML = '<div style="text-align:center; padding: 20px; color: #888;">無合法著法</div>';
          } else {
            for (let resultObj of res.results) {
              resDiv.appendChild(buildMinimaxNodeDOM(resultObj));
            }
          }
          return;
        }

        // --- MCTS 分析邏輯 ---
        let root = new MCTSNode(null, null, rootState, currentPlayer, piecesLeft);
        let lastUiTime = startTime;
        let iterations = 0;
        statusDiv.innerHTML = `搜尋中... 經過時間: 0.0s / ${(timeLimit / 1000).toFixed(0)}s`;
        let workPieces = { 1: new Int8Array(3), 2: new Int8Array(3) };

        while (Date.now() - startTime < timeLimit) {
          if (abortAiSearch) break;

          let ran = runMCTSBatch(root, rootState, workPieces, 30);
          iterations += ran;
          if (ran === 0 || root.isFullyExplored) break;

          let now = Date.now();
          if (now - lastUiTime > 100) {
            statusDiv.innerHTML = `搜尋中... 經過時間: ${((now - startTime) / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s | 模擬次數: ${iterations}`;
            await new Promise(r => setTimeout(r, 0));
            lastUiTime = now;
          }
        }

        statusDiv.innerHTML = `✅ 搜尋完成!總耗時: ${((Date.now() - startTime) / 1000).toFixed(1)}s / 總次數: ${iterations}`;
        btn.disabled = false;
        btn.style.opacity = '1';

        root.children.sort((a, b) => b.visits - a.visits);

        if (root.children.length === 0) {
          resDiv.innerHTML = '<div style="text-align:center; padding: 20px; color: #888;">無合法著法</div>';
        } else {
          for (let child of root.children) {
            resDiv.appendChild(buildTreeNodeDOM(child, true, currentPlayer));
          }
        }
      }

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

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

當加了minimax使AI棋力提升之後,兩個「專家」的對弈:

代码: 全选

[(0,0),(1,0)] [(0,-2),(0,-1)] [(-1,-3),(0,-3)] [(1,-2),(1,-1)] [(-1,-2),(-1,-1)] [(1,1),(2,1)] [(2,2),(2,3)] [(1,3),(1,4)] [(0,3),(0,4)] [(-2,0),(-1,0)] [(0,2),(1,2)] [(-1,3),(-1,4)] [(0,5),(0,6)] [(2,-1),(2,0)] [(2,4),(2,5)] [(2,6),(3,6)] [(0,7),(0,8)] [(3,2),(3,3)] [(-2,3),(-2,4)] [(-1,7),(-1,8)] [(0,9),(0,10)] [(-1,11),(0,11)] [(-1,1),(0,1)] [(3,0),(3,1)] [(1,9),(1,10)] [(1,11),(2,11)] [(-2,2),(-1,2)] [(3,4),(3,5)] [(1,5),(1,6)] [(-2,-2),(-2,-1)] [(2,10),(3,10)] [(-1,9),(-1,10)] [(1,7),(1,8)] [(4,5),(4,6)] [(-1,-5),(-1,-4)] [(3,7),(4,7)]
Screenshot from 2026-07-20 14-05-33.png
Screenshot from 2026-07-20 14-05-33.png (27.2 KiB) 查看 375 次
附件
trirhombus2876.html.7z
(28.35 KiB) 已下载 5 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6314
注册时间: 2022年 11月 18日 17:36
联系:

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

改進:
一,將P2默認的等級改為「簡單」。

P2的默認等級是「困難」,現在要改為「簡單」。

二,當切換AI時,一些變量未更改

當切換AI等級時,pureMinimaxN等變量似乎未更改。請修復。

三,當前語言應高亮

語言切換按鈕中的「中文」和「English」,當前語言應高亮。

四,給出最後一步落子提示

當有棋子落子後,在.etdrop的最後應加上一個svg-circle元素,其x,y的值等於最後一個棋子的兩個三角形中心的連線中點,半徑為3,fill為白色。

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

代码: 全选

改進:
一,將P2默認的等級改為「簡單」。

P2的默認等級是「困難」,現在要改為「簡單」。

二,當切換AI時,一些變量未更改

當切換AI等級時,pureMinimaxN等變量似乎未更改。請修復。

三,當前語言應高亮

語言切換按鈕中的「中文」和「English」,當前語言應高亮。

四,給出最後一步落子提示

當有棋子落子後,在.etdrop的最後應加上一個svg-circle元素,其x,y的值等於最後一個棋子的兩個三角形中心的連線中點,半徑為3,fill為白色。

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

代码: 全选

沒有問題!這就為你提供這四個部分的修改代碼。請在原始檔案中尋找對應的段落並進行替換:

### 修改一:將 P2 預設的等級改為「簡單」

**1. 尋找全域變數宣告(約在第 452 行):**

```javascript
      let playerTypes = { 1: 'human', 2: 'hard' }; // human, simple, hard, expert, custom

```

**替換為:**

```javascript
      let playerTypes = { 1: 'human', 2: 'simple' }; // human, simple, hard, expert, custom

```

**2. 尋找 `DOMContentLoaded` 內的選單初始化邏輯(約在第 649 行):**

```javascript
          for (let key in tabNames) {
            let activeClass = key === 'hard' ? ' active' : ''; // 預設選中「困難」
            aiTabsContainer.innerHTML += `
              <div class="ai-tab${activeClass}" data-preset="${key}" onclick="selectAiTab('${key}')">
                <svg viewBox="0 0 24 24">${aiConfigs[key].icon}</svg>
                ${tabNames[key]}
              </div>
            `;
          }
          // 同步初始化輸入框數值為「困難」的預設值
          document.getElementById('ai-time').value = aiConfigs['hard'].time / 1000;

```

**替換為:**

```javascript
          for (let key in tabNames) {
            let activeClass = key === 'simple' ? ' active' : ''; // 預設選中「簡單」
            aiTabsContainer.innerHTML += `
              <div class="ai-tab${activeClass}" data-preset="${key}" onclick="selectAiTab('${key}')">
                <svg viewBox="0 0 24 24">${aiConfigs[key].icon}</svg>
                ${tabNames[key]}
              </div>
            `;
          }
          // 同步初始化輸入框數值為「簡單」的預設值
          document.getElementById('ai-time').value = aiConfigs['simple'].time / 1000;

```

---

### 修改二:修復切換 AI 等級時變數未更改的問題

AI 在自動思考時,原本沒有讀取 `aiConfigs` 中的完整參數,我們需要在觸發 AI 時將對應的變數覆寫進去。

**尋找 `triggerAITurn()` 函數的前半段(約在第 1630 行):**

```javascript
        let pType = playerTypes[currentPlayer];
        let config = aiConfigs[pType] || aiConfigs['custom'];
        let timeLimit = config.time;

        const box = document.getElementById('test-box');

```

**替換為:**

```javascript
        let pType = playerTypes[currentPlayer];
        let config = aiConfigs[pType] || aiConfigs['custom'];
        let timeLimit = config.time;
        
        // 切換 AI 時同步更新全域 MCTS 與 Minimax 變數
        mctsExplorationConstant = config.c;
        mctsHeuristicN = config.heuristicN;
        mctsHeuristicChecks = config.heuristicChecks;
        pureMinimaxN = config.pureMinimaxN;

        const box = document.getElementById('test-box');

```

---

### 修改三:當前語言高亮

**1. 尋找 HTML 結構中的語言切換按鈕(約在第 380 行):**

```html
    <div id="start-screen" class="fullscreen-modal">
      <div class="lang-toggle" onclick="toggleLanguage()">中文︱English</div>

```

**替換為:**

```html
    <div id="start-screen" class="fullscreen-modal">
      <div class="lang-toggle" onclick="toggleLanguage()">
        <span id="lang-zh" style="color: #0056b3; font-weight: bold;">中文</span>︱<span id="lang-en" style="color: #666; font-weight: normal;">English</span>
      </div>

```

**2. 尋找 JavaScript 中的 `toggleLanguage()` 函數(約在第 1176 行):**

```javascript
      function toggleLanguage() {
        currentLang = currentLang === 'zh' ? 'en' : 'zh';
        document.getElementById('text-title').innerText = getText('title');

```

**替換為:**

```javascript
      function toggleLanguage() {
        currentLang = currentLang === 'zh' ? 'en' : 'zh';
        
        // 更新高亮狀態
        document.getElementById('lang-zh').style.color = currentLang === 'zh' ? '#0056b3' : '#666';
        document.getElementById('lang-zh').style.fontWeight = currentLang === 'zh' ? 'bold' : 'normal';
        document.getElementById('lang-en').style.color = currentLang === 'en' ? '#0056b3' : '#666';
        document.getElementById('lang-en').style.fontWeight = currentLang === 'en' ? 'bold' : 'normal';

        document.getElementById('text-title').innerText = getText('title');

```

---

### 修改四:給出最後一步落子提示 (白色圓點)

為了確保不管是用戶點擊、AI落子還是悔棋,都能準確標記最後一步,我們將這個標記連動寫在負責更新畫面的 `updateUI()` 中。

**尋找 `updateUI()` 函數(約在第 1030 行),在其末尾添加函數呼叫,並在下方宣告提示點計算邏輯:**

```javascript
        if (restoreBtn) {
          if (currentRound === 1) {
            restoreBtn.innerHTML = `
              <path d="M4 4h16v16H4V4zm2 4v10h12V8H6z" fill="#333" />
              <path d="M12 9.5 v5 M10.5 10.5 l1.5 -1" stroke="#333" stroke-width="1.5" fill="none" stroke-linecap="round" transform="translate(0.5,1)"/>
            `;
          } else {
            restoreBtn.innerHTML = `
              <path d="M4 4h16v16H4V4zm2 4v10h12V8H6z" fill="#333" />
              <path d="M9.5 10.5 c0 -1.5 4 -1.5 4 0 c0 1.5 -4 2 -4 4 h4" stroke="#333" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" transform="translate(0.5,1)"/>
            `;
          }
        }
      } // 這裡原本是 updateUI() 結束的地方

```

**將上述結尾替換為:**

```javascript
        if (restoreBtn) {
          if (currentRound === 1) {
            restoreBtn.innerHTML = `
              <path d="M4 4h16v16H4V4zm2 4v10h12V8H6z" fill="#333" />
              <path d="M12 9.5 v5 M10.5 10.5 l1.5 -1" stroke="#333" stroke-width="1.5" fill="none" stroke-linecap="round" transform="translate(0.5,1)"/>
            `;
          } else {
            restoreBtn.innerHTML = `
              <path d="M4 4h16v16H4V4zm2 4v10h12V8H6z" fill="#333" />
              <path d="M9.5 10.5 c0 -1.5 4 -1.5 4 0 c0 1.5 -4 2 -4 4 h4" stroke="#333" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" transform="translate(0.5,1)"/>
            `;
          }
        }

        updateLastMoveIndicator(); // 觸發更新最後一步提示
      }

      function updateLastMoveIndicator() {
        let dropG = document.getElementById('etanidrop');
        if (!dropG) return;
        let indicator = document.getElementById('last-move-indicator');
        
        if (!indicator) {
          indicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
          indicator.setAttribute('id', 'last-move-indicator');
          indicator.setAttribute('r', '3');
          indicator.setAttribute('fill', 'white');
          indicator.setAttribute('pointer-events', 'none');
        }
        
        let roundStart = currentRound === 2 ? 36 : 0;
        if (historyIndex >= roundStart && moveHistory[historyIndex]) {
          let m = moveHistory[historyIndex];
          let halfWidth = 31.1769;
          
          let getCenter = (t) => {
            let isRight = gameLogic.isRight(t.idx, t.N);
            let cx = isRight ? (t.idx - 1) * halfWidth + 10.392 : (t.idx - 1) * halfWidth + 20.784;
            let cy = t.N * 18;
            return {cx, cy};
          };
          
          let c1 = m.t1.cx !== undefined ? m.t1 : getCenter(m.t1);
          let c2 = m.t2.cx !== undefined ? m.t2 : getCenter(m.t2);
          let midX = (c1.cx + c2.cx) / 2;
          let midY = (c1.cy + c2.cy) / 2;
          
          indicator.setAttribute('cx', midX.toFixed(3));
          indicator.setAttribute('cy', midY.toFixed(3));
          indicator.style.display = 'block';
          dropG.appendChild(indicator); // 保證附加在最後節點,不被棋子蓋住
        } else {
          if (indicator.parentNode) indicator.parentNode.removeChild(indicator);
        }
      }

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

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

檢查代碼中的AI部分,給出建議,提升AI的實力。如何剪枝使其避免浪費算力?但是如果存在「先阻止對方得分,再使己方得分」的下法,MCTS能否走出來?

gemini 3.1 pro

代码: 全选

這份《三菱棋》的代碼寫得相當用心!你同時實作了 **MCTS(蒙地卡羅樹搜尋)** 作為主要決策引擎,並在殘局階段無縫切換到 **Iterative Deepening Minimax(迭代加深 Alpha-Beta 剪枝)**,這是一個非常經典且強大的架構,能有效兼顧中局的廣泛搜尋與殘局的絕對精準。

針對你的問題,我們分兩部分來探討:MCTS 能否走出「先防守後反擊」的策略,以及如何進一步剪枝與提升 AI 實力。

---

### 一、 MCTS 能否走出「先阻止對方得分,再使己方得分」的下法?

**簡短回答:理論上可以,但以目前的代碼邏輯來看,機率偏低且極度依賴算力與運氣。**

**詳細解析:**
MCTS 的決策依賴兩部分:**樹的展開(Tree Policy)** 與 **模擬/ rollout(Default Policy)**。

1. **在你的 Rollout(啟發式模擬)中:**
目前的邏輯是 **「貪婪(Greedy)」** 的深度 1 搜尋:
* 優先看「我這步能不能得分?」能就下。
* 如果不能,看「對手下步能不能得分?」能就擋。
* 這意味著模擬階段**完全沒有「佈局」的概念**。它不會為兩三步後的得分做打算。


2. **在你的 Tree 展開中:**
「先防守,再得分」這是一個至少需要 2 到 3 步(Ply)的連續邏輯。MCTS 要發現這條路徑,必須在隨機展開中「剛好」走過這個分支足夠多次,發現其勝率(Wins)或優勢(Advantage)很高,才會提高它的 UCB 值。
由於《三菱棋》的合法步數(Branching Factor)在中局非常龐大(因為要考慮不同顏色的棋子和大量的空位組合),如果完全依賴 UCB1 隨機展開,要剛好連續踩中這條「神之一手」路徑的機率很低。

---

### 二、 提升 AI 實力與剪枝建議

要讓 AI 變強且減少算力浪費,核心思路是:**縮小搜尋空間(剪枝)** 與 **給予 AI 更好的「直覺」(啟發)**。

#### 1. 導入 Zobrist Hashing(置換表 / 狀態快取)

* **現狀:** MCTS 展開樹時,不同的下棋順序(例如先下 A 再下 B,與先下 B 再下 A)在你的代碼中會產生完全不同的節點,導致大量重複計算。
* **建議:** 實作 Zobrist Hashing,為每個盤面產生一個唯一的 64-bit Hash。在 MCTS 中共用相同 Hash 的節點數據(Visits 和 Wins)。這樣可以大幅收斂樹的寬度,節省巨大的記憶體與運算力。

#### 2. RAVE (Rapid Action Value Estimation) 演算法

* **建議:** 這是提升棋盤類 MCTS 最有效的技術之一。在 Rollout 結束更新勝率時,不僅更新走過的這條路徑,還將這次模擬中所有出現過的著法(不論是誰下的),都給予一個 RAVE 更新。
* **效果:** RAVE 認為「在模擬中表現好的棋,提早在真實決策中下出來通常也不錯」。這能讓 AI 在展開初期極快地找到重點著法,避免盲目展開。

#### 3. 改善 Expansion(節點展開)的品質

* **現狀:** `let moveIdx = Math.floor(Math.random() * node.unexpanded.length);` 目前是完全隨機挑選未展開的步。
* **建議:** 加入 **Progressive Bias(漸進式偏差)**。在生成合法步時,利用一個非常輕量的評估函數(例如:是否靠近現有棋子群?是否形成特定形狀?)給每個合法步一個初始分數。展開時,優先展開初始分數高的步,甚至直接捨棄(剪枝)分數極低的無意義步(例如下在遙遠邊界且不相連的步)。

#### 4. Minimax 階段的進階剪枝

雖然你實作了 Iterative Deepening 並利用上一層的最佳步來優化排序(這很棒),但你還可以加入:

* **歷史啟發(History Heuristic):** 記錄在 Alpha-Beta 剪枝中引發截斷(Cut-off)次數最多的著法,在同深度的其他分支中優先嘗試這些著法。
* **殺手啟發(Killer Heuristic):** 在同一深度中,優先嘗試上一分支成功產生剪枝的步。這能讓 Alpha-Beta 剪枝的效率逼近理論極限。

#### 5. 增量式分數計算 (Incremental Evaluation)

* **現狀:** 每次 `calculateScores()` 都會遍歷所有的藍色棋子並進行遞迴計算。在每秒數千次的 MCTS 模擬中,這是極大的效能瓶頸。
* **建議:** 改為「增量更新」。因為每次落子只會影響局部的分數,你只需要計算剛落下的棋子對周圍連線造成的變化量(Delta),然後加減到當前總分上即可。省下的運算力能讓你的 MCTS 模擬次數翻倍。

---

這幾個方向中,**增量式分數計算**能最直接提升每秒模擬次數,而 **RAVE** 與 **Zobrist Hashing** 則能大幅提升 AI 的「棋力」與「智商」。

你目前最想先解決的是搜尋速度的效能瓶頸,還是 AI 在戰略佈局上的短視問題呢?
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6314
注册时间: 2022年 11月 18日 17:36
联系:

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

改進:
一,在game-over-screen要顯示「第幾輪」

由於遊戲可能是處於第一輪或第二輪,因此在一局遊戲沒有結束時,打開game-over-screen,要顯示「當前處於第幾輪」。

二,思考完畢要顯示總搜尋次數

當AI思考完畢後,在消息框顯示的是用時和勝率,現在還要加上「搜尋次數」。

三,更改點選棋子時的css

當點選棋子時,其會變大。現在去掉變大的效果,其它效果(如加黑邊)保持不變。

四,給出最後一步落子提示

當有棋子落子後,在.etdrop的最後應加上一個svg-circle元素,其x,y的值等於最後一個棋子的兩個三角形中心的連線中點,半徑為3,fill為白色,stroke為none。

五,調整工具欄的功能和位置

把debug-btn的id改為stop-ai-btn,title="中止AI思考"。當點擊時的功能改為,AI將停止思考,並把所有AI都切換為「人類」(功能在undo和redo中已寫有,可以直接用)。

把工具欄的button順序更改為:自動旋轉,AI設置,中止AI思考,後退,前進,導出/導入,下載棋盤,遊戲狀態。

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

代码: 全选

改進:
一,在game-over-screen要顯示「第幾輪」

由於遊戲可能是處於第一輪或第二輪,因此在一局遊戲沒有結束時,打開game-over-screen,要顯示「當前處於第幾輪」。

二,思考完畢要顯示總搜尋次數

當AI思考完畢後,在消息框顯示的是用時和勝率,現在還要加上「搜尋次數」。

三,更改點選棋子時的css

當點選棋子時,其會變大。現在去掉變大的效果,其它效果(如加黑邊)保持不變。

四,給出最後一步落子提示

當有棋子落子後,在.etdrop的最後應加上一個svg-circle元素,其x,y的值等於最後一個棋子的兩個三角形中心的連線中點,半徑為3,fill為白色,stroke為none。

五,調整工具欄的功能和位置

把debug-btn的id改為stop-ai-btn,title="中止AI思考"。當點擊時的功能改為,AI將停止思考,並把所有AI都切換為「人類」(功能在undo和redo中已寫有,可以直接用)。

把工具欄的button順序更改為:自動旋轉,AI設置,中止AI思考,後退,前進,導出/導入,下載棋盤,遊戲狀態。

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

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這就為你提供這五項功能改進的修改指南。請依照下列區塊,在你的程式碼中找到對應的部分並進行替換或新增。

### 一、在 game-over-screen 顯示「第幾輪」

找到 `function showGameStatus()` 函數,並修改標題的賦值:

**原代碼:**

```javascript
title.innerText = '遊戲狀態';

```

**替換為:**

```javascript
title.innerText = '遊戲狀態 (Round ' + currentRound + ')';

```

---

### 二、思考完畢顯示總搜尋次數

找到 `async function triggerAITurn()` 函數中,處理 MCTS 思考完成的這兩行代碼(約在檔案最後面):

**原代碼:**

```javascript
let winRate = (((bestChild.wins / bestChild.visits + 1) / 2) * 100).toFixed(1);
box.innerText = `思考完成: ${finalTime}s\n當前勝率: ${winRate}%`;

```

**替換為:**

```javascript
let winRate = (((bestChild.wins / bestChild.visits + 1) / 2) * 100).toFixed(1);
box.innerText = `思考完成: ${finalTime}s\n搜尋次數: ${iterations}\n當前勝率: ${winRate}%`;

```

---

### 三、移除點選棋子時的放大效果

在 `<style>` 區塊中找到 `.piece-box.selected` 的 CSS 設定,並刪除 `transform: scale(1.1);`。

**原代碼:**

```css
.piece-box.selected {
  border-color: #333;
  border-width: 3px;
  box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
  transform: scale(1.1);
}

```

**替換為:**

```css
.piece-box.selected {
  border-color: #333;
  border-width: 3px;
  box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
}

```

---

### 四、給出最後一步落子提示

為了確保在重繪、悔棋、前進等操作中,標記都能正確顯示在最後一步,我們建立一個獨立函式,並在每次更新介面時自動呼叫。

**1. 新增繪製函數:**
請將這段代碼放在 `function updateUI()` 的上方(或 `script` 區塊的任意全域位置)。

```javascript
function updateLastMoveIndicator() {
  let existing = document.getElementById('last-move-indicator');
  if (existing) existing.remove();

  let roundStart = currentRound === 2 ? 36 : 0;
  if (historyIndex >= roundStart && moveHistory[historyIndex]) {
    let lastMove = moveHistory[historyIndex];
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    circle.setAttribute('id', 'last-move-indicator');
    circle.setAttribute('cx', lastMove.px.toFixed(3));
    circle.setAttribute('cy', lastMove.py.toFixed(3));
    circle.setAttribute('r', '3');
    circle.setAttribute('fill', 'white');
    circle.setAttribute('stroke', 'none');
    circle.setAttribute('style', 'pointer-events: none;'); // 防止干擾點擊事件
    document.getElementById('etanidrop').appendChild(circle);
  }
}

```

**2. 綁定到 UI 更新中:**
找到 `function updateUI()` 函數的**最末端**,在結尾加上對該函數的呼叫:

```javascript
function updateUI() {
  // ... (保留原本的代碼) ...

  // 在函數的最底部加上這行
  updateLastMoveIndicator();
}

```

---

### 五、調整工具欄的功能和順序

找到 `<div id="floating-tools">` 開頭到 `</div>` 結尾的完整 HTML 區塊,並將其替換為下列代碼。這會同時完成按鈕順序的調換,以及把原先的 Debug 按鈕改造成「中止AI思考」。

**原代碼區塊替換為:**

```html
<div id="floating-tools">
  <svg id="auto-rotate-btn" class="tool-btn" onclick="toggleAutoRotate()" viewBox="0 0 24 24" title="自動旋轉">
    <path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" fill="#333" />
  </svg>
  <svg class="tool-btn" onclick="openAISettings()" viewBox="0 0 24 24" title="AI 設置">
    <path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z" fill="#333" />
  </svg>
  <svg class="tool-btn" id="stop-ai-btn" onclick="forceHumanPlayer()" viewBox="0 0 24 24" title="中止AI思考">
    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" fill="#333" />
  </svg>
  <svg class="tool-btn tool-btn-undo" onclick="undoMove()" viewBox="0 0 24 24" title="後退">
    <path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" fill="#333" />
  </svg>
  <svg class="tool-btn tool-btn-redo" onclick="redoMove()" viewBox="0 0 24 24" title="前進">
    <path d="M11.5 8C16.15 8 20.08 11.03 21.43 15.22l-2.37.78c-1.05-3.19-4.06-5.5-7.6-5.5-1.95 0-3.73.72-5.12 1.88L10 16H1V7l3.6 3.6C6.45 8.99 8.85 8 11.5 8z" fill="#333" />
  </svg>
  <svg class="tool-btn" onclick="openExportModal()" viewBox="0 0 24 24" title="導出/導入">
    <path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" fill="#333" />
  </svg>
  <svg class="tool-btn" onclick="downloadBoardSVG()" viewBox="0 0 24 24" title="下載棋盤">
    <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="#333" />
  </svg>
  <svg class="tool-btn" id="restore-go-btn" onclick="showGameStatus()" viewBox="0 0 24 24" title="遊戲狀態">
    <path d="M4 4h16v16H4V4zm2 4v10h12V8H6z" fill="#333" />
  </svg>
</div>

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

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

改進:
一,完善英文翻譯

在顯示「遊戲狀態(Round 1 or 2)」時,未正確顯示當前語言。

AI思考時的消息框,也未正確顯示當前語言。

二,更改AI思考時的UI事件問題

當AI思考時,player-type-icon應不可點擊,「自動旋轉」按鈕也不會生效。

當處於旋轉狀態時,雙方都將切換為人類,並且player-type-icon將不可點擊。

當停止思考並切換為人類時,棋子應變為可以點擊,請修復。

三,點擊下載功能的改進

當點擊「下載」時,程式會直接下載當前的svg棋盤。現在改為彈出一個窗口,其具體要求描述如下:

1,窗口右上角有叉形關閉按鈕,其內部有svg顯示區,下方選項區,以及下載按鈕。所有的元素都要顯示在當前視圖中,不能有縱向滾動條,可縮放svg顯示區以使其佔滿屏幕。

2,svg顯示區將等比例顯示所要下載的svg圖像文件,其內容為svg棋盤的clone。

3,選項區有「是否顯示網格,是否顯示數字,是否下載動畫」,做成現代美觀的切換按鈕,其左方文字為「網格、數字、動畫」。把三者排在一行,平分間隔。

4,網格默認是勾選的,當取消時,將把clone中的網格去掉。

5,數字默認不勾選,當勾選時,將在ejdrop內的最後加上text標籤:

<text x="棋子横坐標" y="棋子縱坐標" stroke="none" fill="black" font-size="24" font-family="ZT Nature">棋子次序</text>

按clone中每個use棋子的次序添加對應的數字,在代碼中寫明數字的横坐標和縱坐標相對於棋子中心坐標的偏移量,並分為一位數和兩位數,使我在測試中可以修改。

之後在clone的.etboard中添加:

```
<style>
@font-face {
font-family: 'ZT Nature';
src: url(...) format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
</style>
```

我會自行補充url的內容。

6,當動畫勾選時,數字將不能勾選,二者相斥。

動畫也會在clone的.etboard中添加同樣的style。

7,動畫的步驟為:

7.1,為.etdrop增加屬性opacity=0,同時在其內部最前面添加動畫「<animate id="tr0" begin="0;trend.end" attributeName="opacity" values="0;0;1" fill="freeze" dur="2s"/>」。

7.2,每個use都有transform="translate(x,y)",取到它們的屬性坐標(x,y),在所有的<use>內部添加動畫「<animateTransform attributeName="transform" attributeType="XML" type="translate" from="屬性坐標" to="視圖外不遠的坐標,我方在下面,對方在上面" dur="1s" fill="freeze" begin="tr0.begin"/>」。

視圖外不遠的坐標,這個坐標的計算在落子時已有,直接用即可。我方的所有棋子在視圖外的坐標的y軸是相同的,對方的所有棋子也是相同的。

7.3,按次序為每一個use內部添加動畫「<animateTransform id="tr(n)" attributeName="transform" attributeType="XML" type="translate" from="視圖外坐標" to="屬性坐標" dur="1s" fill="freeze" begin="tr(n-1).end"/>」。例如第一個use內加的是「<animateTransform id="tr1" attributeName="transform" attributeType="XML" type="translate" from="視圖外坐標" to="屬性坐標" dur="1s" fill="freeze" begin="tr0.end"/>」。

7.4,當最後一個use棋子添加動畫(假設id="tr36")完畢後,挑出對於我方得分無用的棋子(算法是去掉一個棋子,如果我方分數不變則無用),這些棋子將以朝向當前視圖中心的反方向,添加往視圖外移動再移回來的動畫「<animateTransform id="trme" attributeName="transform" attributeType="XML" type="translate" values="屬性坐標;視圖外坐標;視圖外坐標;視圖外坐標;屬性坐標" dur="4s" fill="freeze" begin="tr36.end+1s"/>」。

7.5,同時在.etboard內部的最後添加一個text標籤「<text id="trmetxt" x="視圖外距右邊界12" y="視圖內距下邊界12+自身高" stroke="none" fill="black" font-size="60" font-family="ZT Nature">我方得分</text>」,在其下方添加動畫將其移到視圖內,使之x="視圖內距右邊界12+自身寬":「<animateTransform href="trmetxt" attributeName="transform" attributeType="XML" type="translate" values="屬性坐標;視圖內坐標;視圖內坐標;視圖內坐標;屬性坐標" dur="4s" fill="freeze" begin="trme.begin"/>」。

注意「我方得分」是指一個整數,不要含有數字之外的任何字符。下方的「對方得分」也一樣。這個<text>標籤在出現時,它應當在視圖外看不見的地方。

7.6,之後挑出對於對方得分無用的棋子(算法是去掉一個棋子,如果對方分數不變則無用),這些棋子將添加添加往視圖外移動再移回來的動畫,朝向為當前視圖中心的反方向:「<animateTransform id="trhe" attributeName="transform" attributeType="XML" type="translate" values="屬性坐標;視圖外坐標;視圖外坐標;視圖外坐標;屬性坐標" dur="4s" fill="freeze" begin="trme.end+1s"/>」。

7.7,同時在.etboard內部的最後添加一個text標籤「<text id="trhetxt" x="視圖外距左邊界12" y="視圖內距上邊界12-自身高" stroke="none" fill="black" font-size="60" font-family="ZT Nature" transform="旋轉180度">對方得分</text>」,在其下方添加動畫將其移到視圖內,使之x="視圖內距左邊界12+自身寬":「<animateTransform href="trhetxt" attributeName="transform" attributeType="XML" type="translate" values="屬性坐標;視圖內坐標;視圖內坐標;視圖內坐標;屬性坐標" dur="4s" fill="freeze" begin="trhe.begin" additive="sum"/>」。

在移動時注意其已經有旋轉180度,要有從左上方,由左到右移進,再移出的效果。

7.8,最後在.etdrop內部最前面添加動畫「<animate id="trend" begin="trhe.end+1s" attributeName="opacity" values="1;1;0" fill="freeze" dur="2s"/>」。

如果取消勾選動畫,則恢復成之前沒有添加動畫標籤時的狀態。

8,最下方中間是下載按鈕,在按鈕下方標注文檔大小(byte)。

下載檔案名為「trirhombus_動畫或數字的英文_yyyymmdd_hhiiss.svg」,如果是動畫加上「動畫」的英文,如果是數字則加上「數字」的英文。

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

代码: 全选

改進:
一,完善英文翻譯

在顯示「遊戲狀態(Round 1 or 2)」時,未正確顯示當前語言。

AI思考時的消息框,也未正確顯示當前語言。

二,更改AI思考時的UI事件問題

當AI思考時,player-type-icon應不可點擊,「自動旋轉」按鈕也不會生效。

當處於旋轉狀態時,雙方都將切換為人類,並且player-type-icon將不可點擊。

當停止思考並切換為人類時,棋子應變為可以點擊,請修復。

三,點擊下載功能的改進

當點擊「下載」時,程式會直接下載當前的svg棋盤。現在改為彈出一個窗口,其具體要求描述如下:

1,窗口右上角有叉形關閉按鈕,其內部有svg顯示區,下方選項區,以及下載按鈕。所有的元素都要顯示在當前視圖中,不能有縱向滾動條,可縮放svg顯示區以使其佔滿屏幕。

2,svg顯示區將等比例顯示所要下載的svg圖像文件,其內容為svg棋盤的clone。

3,選項區有「是否顯示網格,是否顯示數字,是否下載動畫」,做成現代美觀的切換按鈕,其左方文字為「網格、數字、動畫」。把三者排在一行,平分間隔。

4,網格默認是勾選的,當取消時,將把clone中的網格去掉。

5,數字默認不勾選,當勾選時,將在ejdrop內的最後加上text標籤:

<text x="棋子横坐標" y="棋子縱坐標" stroke="none" fill="black" font-size="24" font-family="ZT Nature">棋子次序</text>

按clone中每個use棋子的次序添加對應的數字,在代碼中寫明數字的横坐標和縱坐標相對於棋子中心坐標的偏移量,並分為一位數和兩位數,使我在測試中可以修改。

之後在clone的.etboard中添加:

```
<style>
@font-face {
    font-family: 'ZT Nature';
    src: url(...) format('woff2');
    font-weight: normal;
    font-style: normal;
    font-display: swap;
}
</style>
```

我會自行補充url的內容。

6,當動畫勾選時,數字將不能勾選,二者相斥。

動畫也會在clone的.etboard中添加同樣的style。

7,動畫的步驟為:

7.1,為.etdrop增加屬性opacity=0,同時在其內部最前面添加動畫「<animate id="tr0" begin="0;trend.end" attributeName="opacity" values="0;0;1" fill="freeze" dur="2s"/>」。

7.2,每個use都有transform="translate(x,y)",取到它們的屬性坐標(x,y),在所有的<use>內部添加動畫「<animateTransform attributeName="transform" attributeType="XML" type="translate" from="屬性坐標" to="視圖外不遠的坐標,我方在下面,對方在上面" dur="1s" fill="freeze" begin="tr0.begin"/>」。

視圖外不遠的坐標,這個坐標的計算在落子時已有,直接用即可。我方的所有棋子在視圖外的坐標的y軸是相同的,對方的所有棋子也是相同的。

7.3,按次序為每一個use內部添加動畫「<animateTransform id="tr(n)" attributeName="transform" attributeType="XML" type="translate" from="視圖外坐標" to="屬性坐標" dur="1s" fill="freeze" begin="tr(n-1).end"/>」。例如第一個use內加的是「<animateTransform id="tr1" attributeName="transform" attributeType="XML" type="translate" from="視圖外坐標" to="屬性坐標" dur="1s" fill="freeze" begin="tr0.end"/>」。

7.4,當最後一個use棋子添加動畫(假設id="tr36")完畢後,挑出對於我方得分無用的棋子(算法是去掉一個棋子,如果我方分數不變則無用),這些棋子將以朝向當前視圖中心的反方向,添加往視圖外移動再移回來的動畫「<animateTransform id="trme" attributeName="transform" attributeType="XML" type="translate" values="屬性坐標;視圖外坐標;視圖外坐標;視圖外坐標;屬性坐標" dur="4s" fill="freeze" begin="tr36.end+1s"/>」。

7.5,同時在.etboard內部的最後添加一個text標籤「<text id="trmetxt" x="視圖外距右邊界12" y="視圖內距下邊界12+自身高" stroke="none" fill="black" font-size="60" font-family="ZT Nature">我方得分</text>」,在其下方添加動畫將其移到視圖內,使之x="視圖內距右邊界12+自身寬":「<animateTransform href="trmetxt" attributeName="transform" attributeType="XML" type="translate" values="屬性坐標;視圖內坐標;視圖內坐標;視圖內坐標;屬性坐標" dur="4s" fill="freeze" begin="trme.begin"/>」。

注意「我方得分」是指一個整數,不要含有數字之外的任何字符。下方的「對方得分」也一樣。這個<text>標籤在出現時,它應當在視圖外看不見的地方。

7.6,之後挑出對於對方得分無用的棋子(算法是去掉一個棋子,如果對方分數不變則無用),這些棋子將添加添加往視圖外移動再移回來的動畫,朝向為當前視圖中心的反方向:「<animateTransform id="trhe" attributeName="transform" attributeType="XML" type="translate" values="屬性坐標;視圖外坐標;視圖外坐標;視圖外坐標;屬性坐標" dur="4s" fill="freeze" begin="trme.end+1s"/>」。

7.7,同時在.etboard內部的最後添加一個text標籤「<text id="trhetxt" x="視圖外距左邊界12" y="視圖內距上邊界12-自身高" stroke="none" fill="black" font-size="60" font-family="ZT Nature" transform="旋轉180度">對方得分</text>」,在其下方添加動畫將其移到視圖內,使之x="視圖內距左邊界12+自身寬":「<animateTransform href="trhetxt" attributeName="transform" attributeType="XML" type="translate" values="屬性坐標;視圖內坐標;視圖內坐標;視圖內坐標;屬性坐標" dur="4s" fill="freeze" begin="trhe.begin" additive="sum"/>」。

在移動時注意其已經有旋轉180度,要有從左上方,由左到右移進,再移出的效果。

7.8,最後在.etdrop內部最前面添加動畫「<animate id="trend" begin="trhe.end+1s" attributeName="opacity" values="1;1;0" fill="freeze" dur="2s"/>」。

如果取消勾選動畫,則恢復成之前沒有添加動畫標籤時的狀態。

8,最下方中間是下載按鈕,在按鈕下方標注文檔大小(byte)。

下載檔案名為「trirhombus_動畫或數字的英文_yyyymmdd_hhiiss.svg」,如果是動畫加上「動畫」的英文,如果是數字則加上「數字」的英文。

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

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

gemini 3.1 pro 需要休息到16:52

代码: 全选

這是一個非常有創意的更新,尤其是加入 SVG SMIL 動畫來進行覆盤與計分演示,這讓下載出的棋盤圖具備了極高的觀賞性與實用價值。

以下是針對你提出的各項需求所做的代碼修復與替換。請按照指示找到對應的區塊並進行替換:

### 一、 多語言(I18N)擴充與修正

我們需要在 `I18N` 字典中補齊你提到的那些中英文文本。
**尋找** `const I18N = {` 區塊,並將其中的 `zh` 與 `en` 內容替換/擴充為以下內容:

```javascript
      // --- 多語言字典 ---
      const I18N = {
        zh: {
          title: '三菱棋',
          sub: '一個抽象策略遊戲,在二維與三維之間',
          ruleTitle: '規則簡介',
          rule: '雙方輪流落子,先手方第一手必須下藍色棋子,後手方第一手必須下紅色棋子,此後雙方輪流行動,分數高者獲勝。',
          startBtn: '開始遊戲',
          firstBluePrompt: '先手方第一手必須下藍色棋子',
          secondRedPrompt: '後手方第一手必須下紅色棋子',
          yourTurn: '輪到我方落子',
          oppTurn: '輪到對方落子',
          autoTurn: '輪到我方落子',
          noHole: '禁止產生空洞!',
          mustConnect: '必須與現有棋子相連',
          invalidDrop: '此處無法落子',
          gameOver: '遊戲結束',
          endGameBtn: '結束遊戲',
          swapBtn: '雙方交換先手下第二局',
          restartBtn: '重新開始',
          round1Score: '第一局比分:',
          round2Score: '第二局比分:',
          totalScore: '總分:',
          winnerMe: '我方獲勝!',
          winnerOpp: '對方獲勝!',
          draw: '平局!',
          autoRotateON: '自動旋轉已開啟',
          autoRotateOFF: '自動旋轉已關閉',
          aiThinking: 'AI 思考中...',
          aiAnalysisTitle: 'MCTS AI 分析與設定',
          tabSimple: '簡單',
          tabHard: '困難',
          tabExpert: '專家',
          tabCustom: '自訂',
          thinkTime: '思考時間 (秒)',
          mctsC: '探索常數C(越大隨機性越強)',
          heuristicN: '智能預判步數 (局末介入)',
          heuristicChecks: '預判檢查數 (局部洗牌)',
          pureMinimaxN: '必殺算法 局末觸發步數',
          runSearch: '執行盤面搜尋分析',
          ready: '準備就緒',
          exportTitle: '棋譜 導出 / 導入',
          exportLabel: '導出當前棋譜:',
          copyBtn: '複製',
          importLabel: '導入棋譜:',
          importBtn: '導入',
          ptypeTitle: '選擇玩家類型',
          typeHuman: '人類 (Human)',
          typeSimple: '簡單 AI (Simple)',
          typeHard: '困難 AI (Hard)',
          typeExpert: '專家 AI (Expert)',
          typeCustom: '自訂 AI (Custom)',
          copySuccess: '已複製到剪貼簿!',
          importSuccess: '導入成功!',
          importFail: '導入失敗:格式錯誤或資料損毀',
          debugOn: 'Debug 模式 ON',
          debugOff: 'Debug 模式 OFF',
          playerSwitched: '玩家 {0} 切換為: {1}',
          gameNotStarted: '遊戲尚未開始或已結束!',
          noValidMoves: '無合法著法',
          gameStateTitle: '遊戲狀態',
          gameStateTurn: '第 {0} 手棋,輪到{1}落子',
          currentScore: '當前比分',
          p1You: 'P1(我方)',
          p2Opp: 'P2(對方)',
          continueGame: '繼續遊戲',
          me: '我方',
          opp: '對方',
          // AI狀態文字
          minimaxSearch: 'Minimax 搜尋中...',
          time: '時間',
          depth: '深度',
          nodes: '節點',
          searchDone: '搜尋完成',
          totalNodes: '總搜尋量',
          bestResult: '最佳結果',
          winRate: '當前勝率',
          simCount: '次數',
          p1Lead: 'P1領先',
          p2Lead: 'P2領先',
          tie: '平局',
          // 下載選單文字
          dlTitle: '下載棋盤 SVG',
          dlGrid: '網格',
          dlNumbers: '數字',
          dlAnim: '動畫',
          dlBtn: '下載'
        },
        en: {
          title: 'Trirhombus',
          sub: 'An abstract strategy game between 2D and 3D',
          ruleTitle: 'Rules Overview',
          rule: 'Players take turns. The first player must place a blue piece first. The second player must place a red piece first. Play alternates thereafter. Highest score wins.',
          startBtn: 'Start Game',
          firstBluePrompt: 'The first player must place a BLUE piece.',
          secondRedPrompt: 'The second player must place a RED piece.',
          yourTurn: 'Your Turn',
          oppTurn: "Opponent's Turn",
          autoTurn: 'Your Turn',
          noHole: 'Holes are not allowed!',
          mustConnect: 'Must connect to existing pieces.',
          invalidDrop: 'Cannot drop here.',
          gameOver: 'Game Over',
          endGameBtn: 'End Game',
          swapBtn: 'Swap roles for Round 2',
          restartBtn: 'Restart Game',
          round1Score: 'Round 1 Score: ',
          round2Score: 'Round 2 Score: ',
          totalScore: 'Total Score: ',
          winnerMe: 'You Win!',
          winnerOpp: 'Opponent Wins!',
          draw: 'Draw!',
          autoRotateON: 'Auto-Rotate ON',
          autoRotateOFF: 'Auto-Rotate OFF',
          aiThinking: 'AI is thinking...',
          aiAnalysisTitle: 'MCTS AI Analysis & Settings',
          tabSimple: 'Simple',
          tabHard: 'Hard',
          tabExpert: 'Expert',
          tabCustom: 'Custom',
          thinkTime: 'Thinking Time (s)',
          mctsC: 'Exploration Constant C',
          heuristicN: 'Heuristic Steps (Endgame)',
          heuristicChecks: 'Heuristic Checks (Shuffle)',
          pureMinimaxN: 'Endgame Minimax Trigger',
          runSearch: 'Run Board Search Analysis',
          ready: 'Ready',
          exportTitle: 'Export / Import Match',
          exportLabel: 'Export current match:',
          copyBtn: 'Copy',
          importLabel: 'Import match:',
          importBtn: 'Import',
          ptypeTitle: 'Select Player Type',
          typeHuman: 'Human',
          typeSimple: 'Simple AI',
          typeHard: 'Hard AI',
          typeExpert: 'Expert AI',
          typeCustom: 'Custom AI',
          copySuccess: 'Copied to clipboard!',
          importSuccess: 'Import successful!',
          importFail: 'Import failed',
          debugOn: 'Debug Mode ON',
          debugOff: 'Debug Mode OFF',
          playerSwitched: 'Player {0} switched to: {1}',
          gameNotStarted: 'Game has not started or has ended!',
          noValidMoves: 'No valid moves',
          gameStateTitle: 'Game Status',
          gameStateTurn: "Move {0}, {1}'s turn",
          currentScore: 'Current Score',
          p1You: 'P1 (You)',
          p2Opp: 'P2 (Opp)',
          continueGame: 'Continue Game',
          me: 'Your',
          opp: 'Opponent',
          // AI狀態文字
          minimaxSearch: 'Minimax Searching...',
          time: 'Time',
          depth: 'Depth',
          nodes: 'Nodes',
          searchDone: 'Search Done',
          totalNodes: 'Total Nodes',
          bestResult: 'Best Result',
          winRate: 'Win Rate',
          simCount: 'Sims',
          p1Lead: 'P1 Leads',
          p2Lead: 'P2 Leads',
          tie: 'Tie',
          // 下載選單文字
          dlTitle: 'Download Board SVG',
          dlGrid: 'Grid',
          dlNumbers: 'Numbers',
          dlAnim: 'Animation',
          dlBtn: 'Download'
        }
      };

```

---

### 二、 修復狀態文字與 UI 邏輯

請找到 `showGameStatus()`,並修改其內部的標題指派,讓它支援多語言。
**替換為:**

```javascript
      function showGameStatus() {
        const screen = document.getElementById('game-over-screen');
        const title = document.getElementById('go-title');
        const scoresDiv = document.getElementById('go-scores');
        const actionsDiv = document.getElementById('go-actions');

        if (gameState === 'playing') {
          let roundStart = currentRound === 2 ? 36 : 0;
          let currentMove = historyIndex - roundStart + 2; 
          let turnText = currentPlayer === 1 ? getText('me') : getText('opp');

          // 【修復】使用多語言翻譯並拼接
          title.innerText = getText('gameStateTitle') + ' (Round ' + currentRound + ')';
          scoresDiv.innerHTML = `${getText('gameStateTurn').replace('{0}', currentMove).replace('{1}', turnText)}<br><br>${getText('currentScore')}<br>${getText('p1You')}: ${p1CurrentScore} - ${getText('p2Opp')}: ${p2CurrentScore}`;
          actionsDiv.innerHTML = `<button class="btn-primary" onclick="minimizeGameOver()">${getText('continueGame')}</button>`;
        } else {
          showRoundEnd();
          return;
        }
        screen.style.display = 'flex';
      }

```

接著修正 AI 思考時,按鈕不可點擊的 Bug。
**找到並替換以下三個函數(`forceHumanPlayer`, `openPlayerTypeMenu`, `toggleAutoRotate`):**

```javascript
      function forceHumanPlayer() {
        abortAiSearch = true;
        isAiThinking = false; // 【修復】重置思考狀態,使棋子能再次被點擊
        playerTypes[1] = 'human';
        playerTypes[2] = 'human';
        document.getElementById('ptype-1').innerHTML = aiConfigs['human'].icon;
        document.getElementById('ptype-2').innerHTML = aiConfigs['human'].icon;
        document.getElementById('ptype-1').style.color = '#0056b3';
        document.getElementById('ptype-2').style.color = '#a70000';
        setAiThinkingState(false);
        updateUI(); // 【修復】觸發 UI 更新,使棋子恢復實體與點擊效果
      }

      function openPlayerTypeMenu(player) {
        if (isAiThinking || isAutoRotate) return; // 【修復】AI思考時或自動旋轉狀態下不可點擊
        settingPlayerTypeFor = player;
        document.getElementById('ptype-modal').style.display = 'flex';

        const typeKeys = ['human', 'simple', 'hard', 'expert', 'custom'];
        const btnContainer = document.getElementById('ptype-btn-container');
        btnContainer.innerHTML = '';
        for (let type of typeKeys) {
          let typeName = getText('type' + type.charAt(0).toUpperCase() + type.slice(1));
          btnContainer.innerHTML += `
      <button class="btn-secondary" onclick="setPlayerType('${type}')" style="margin: 0; padding: 10px; display: flex; align-items: center; justify-content: center; gap: 8px;">
        <svg viewBox="0 0 24 24" style="width: 24px; height: 24px; fill: #333;">${aiConfigs[type].icon || ''}</svg>
        ${typeName}
      </button>`;
        }
      }

      function toggleAutoRotate() {
        if (isAiThinking) return; // 【修復】AI思考時自動旋轉按鈕無效
        isAutoRotate = !isAutoRotate;
        if (isAutoRotate) {
          forceHumanPlayer(); // 【修復】開啟旋轉狀態時,強制切換為人類
        }
        updateLayoutForTurn();
        showNotification(isAutoRotate ? getText('autoRotateON') : getText('autoRotateOFF'));

        const btns = document.querySelectorAll('#auto-rotate-btn');
        btns.forEach(b => (isAutoRotate ? b.classList.add('active-tool') : b.classList.remove('active-tool')));
      }

```

接著找到 `triggerAITurn`,並替換其內部被硬編碼的中文字串:

```javascript
        if (totalPiecesLeft <= pureMinimaxN) {
          let res = await runIterativeDeepening(
            rootState,
            currentPlayer,
            timeLimit,
            async (timeSpent, nodes, depth) => {
              box.innerText = `${getText('minimaxSearch')}\n${getText('time')}: ${(timeSpent / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s\n${getText('depth')}: ${depth} | ${getText('nodes')}: ${nodes}`;
              await new Promise(r => setTimeout(r, 0));
            }
          );

          let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
          if (res.bestMove !== null) {
            let scoreText = res.score > 0 ? `${getText('p1Lead')} ${res.score}` : res.score < 0 ? `${getText('p2Lead')} ${-res.score}` : `${getText('tie')} 0`;
            box.innerText = `${getText('searchDone')}: ${finalTime}s\n${getText('totalNodes')}: ${res.nodes}\n${getText('bestResult')}: ${scoreText}`;
// ...下方程式碼不變...

```

(同樣在 `triggerAITurn` 的 MCTS 迴圈處修改:)

```javascript
          let now = Date.now();
          if (now - lastUiTime > 100) {
            box.innerText = `${getText('aiThinking')}\n${getText('time')}: ${((now - startTime) / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s\n${getText('simCount')}: ${iterations}`;
            await new Promise(r => setTimeout(r, 0));
            lastUiTime = now;
          }
        }
        
        let bestChild = null, mostVisits = -1;
        for (let c of root.children) {
          if (c.visits > mostVisits) { mostVisits = c.visits; bestChild = c; }
        }

        let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
        if (bestChild) {
          let winRate = (((bestChild.wins / bestChild.visits + 1) / 2) * 100).toFixed(1);
          box.innerText = `${getText('searchDone')}: ${finalTime}s\n${getText('simCount')}: ${iterations}\n${getText('winRate')}: ${winRate}%`;
// ...下方程式碼不變...

```

---

### 三、 添加下載專屬 UI Modal

**請在 `#game-container` 區塊外的尾部**(與 `#export-modal` 同層級,例如 `#export-modal` 後方)添加這個彈窗介面的 HTML:

```html
    <div id="download-modal" class="fullscreen-modal" style="display: none; z-index: 300;">
      <div class="modal-content" style="width: 90%; max-width: 600px; height: 90%; display: flex; flex-direction: column; align-items: center; padding: 20px; box-sizing: border-box;">
        <svg class="close-btn" onclick="closeDownloadModal()" viewBox="0 0 24 24">
          <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
        </svg>
        <h3 id="dl-title" style="margin-top: 0;">下載棋盤 SVG</h3>
        <div id="dl-preview-container" style="flex: 1; width: 100%; min-height: 0; display: flex; justify-content: center; align-items: center; border: 1px solid #ccc; border-radius: 8px; margin-bottom: 20px; overflow: hidden; background: #fff;">
        </div>
        <div style="display: flex; gap: 30px; margin-bottom: 20px; font-size: 1.1rem; font-weight: bold; flex-wrap: wrap; justify-content: center;">
          <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
            <input type="checkbox" id="dl-opt-grid" checked onchange="updateDlPreview()"> <span id="dl-lbl-grid">網格</span>
          </label>
          <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
            <input type="checkbox" id="dl-opt-num" onchange="onDlOptChange('num')"> <span id="dl-lbl-num">數字</span>
          </label>
          <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
            <input type="checkbox" id="dl-opt-anim" onchange="onDlOptChange('anim')"> <span id="dl-lbl-anim">動畫</span>
          </label>
        </div>
        <button class="btn-primary" id="dl-btn" onclick="confirmDownloadSVG()" style="margin:0;">下載</button>
        <div id="dl-size" style="margin-top: 10px; color: #666; font-size: 0.9rem;"></div>
      </div>
    </div>

```

---

### 四、 SVG 動畫與下載邏輯

**找到原有的 `downloadBoardSVG()` 函數,並將它替換為以下完整的下載邏輯區塊:**

```javascript
      // === 下載與 SVG 處理邏輯 ===
      let currentDlSvgSource = "";

      function downloadBoardSVG() {
        document.getElementById('dl-title').innerText = getText('dlTitle');
        document.getElementById('dl-lbl-grid').innerText = getText('dlGrid');
        document.getElementById('dl-lbl-num').innerText = getText('dlNumbers');
        document.getElementById('dl-lbl-anim').innerText = getText('dlAnim');
        document.getElementById('dl-btn').innerText = getText('dlBtn');

        document.getElementById('download-modal').style.display = 'flex';
        updateDlPreview();
      }

      function closeDownloadModal() {
        document.getElementById('download-modal').style.display = 'none';
      }

      function onDlOptChange(changedType) {
        let numOpt = document.getElementById('dl-opt-num');
        let animOpt = document.getElementById('dl-opt-anim');
        if (changedType === 'num' && numOpt.checked) animOpt.checked = false;
        if (changedType === 'anim' && animOpt.checked) numOpt.checked = false;
        updateDlPreview();
      }

      function updateDlPreview() {
        const originalSvg = document.getElementById('etani');
        let svgClone = originalSvg.cloneNode(true);
        svgClone.setAttribute('id', 'etani-preview');
        svgClone.style.width = '100%';
        svgClone.style.height = '100%';
        
        let gridOpt = document.getElementById('dl-opt-grid').checked;
        let numOpt = document.getElementById('dl-opt-num').checked;
        let animOpt = document.getElementById('dl-opt-anim').checked;

        // 1. 處理網格
        if (!gridOpt) {
          let etboard = svgClone.querySelector('.etboard');
          let gridPath = etboard.querySelector('path');
          if (gridPath) gridPath.remove();
        }

        // 2. 獲取當前局的所有合法移動
        let roundStart = currentRound === 2 ? 36 : 0;
        let activeMoves = moveHistory.slice(roundStart, historyIndex + 1);

        // 3. 處理數字與動畫:重構 etdrop 內的棋子,確保順序與 ID 正確
        let etdrop = svgClone.querySelector('.etdrop');
        // 保留 board (含網格與紫點)
        let etboard = etdrop.querySelector('.etboard');
        etdrop.innerHTML = '';
        etdrop.appendChild(etboard);

        // 加入自定義字體
        if (numOpt || animOpt) {
          let styleEl = document.createElement('style');
          styleEl.innerHTML = `
            @font-face {
              font-family: 'ZT Nature';
              src: url('在此填入你的字體URL') format('woff2');
              font-weight: normal;
              font-style: normal;
              font-display: swap;
            }
          `;
          etboard.appendChild(styleEl);
        }

        // 準備全域動畫基底
        if (animOpt) {
          etdrop.setAttribute('opacity', '0');
          let baseAnim = document.createElementNS('http://www.w3.org/2000/svg', 'animate');
          baseAnim.setAttribute('id', 'tr0');
          baseAnim.setAttribute('begin', '0;trend.end');
          baseAnim.setAttribute('attributeName', 'opacity');
          baseAnim.setAttribute('values', '0;0;1');
          baseAnim.setAttribute('fill', 'freeze');
          baseAnim.setAttribute('dur', '2s');
          etdrop.appendChild(baseAnim);
        }

        // 重繪所有目前局的棋子
        activeMoves.forEach((m, i) => {
          let useNode = document.createElementNS('http://www.w3.org/2000/svg', 'use');
          useNode.setAttribute('href', `#tile${m.pid}`);
          useNode.setAttribute('class', 'tiledropped');
          useNode.setAttribute('fill', TILE_COLORS[m.pid]);
          useNode.setAttribute('transform', `translate(${m.px}, ${m.py})`);
          
          if (animOpt) {
            let fromY = m.player === 1 ? m.py + 400 : m.py - 400;
            let animNode = document.createElementNS('http://www.w3.org/2000/svg', 'animateTransform');
            animNode.setAttribute('id', `tr${i + 1}`);
            animNode.setAttribute('attributeName', 'transform');
            animNode.setAttribute('attributeType', 'XML');
            animNode.setAttribute('type', 'translate');
            animNode.setAttribute('from', `${m.px},${fromY}`);
            animNode.setAttribute('to', `${m.px},${m.py}`);
            animNode.setAttribute('dur', '1s');
            animNode.setAttribute('fill', 'freeze');
            animNode.setAttribute('begin', `tr${i}.end`);
            useNode.appendChild(animNode);
          }
          etdrop.appendChild(useNode);

          // 處理數字
          if (numOpt) {
            let halfWidth = 31.1769;
            let cx1 = m.t1.isRight ? (m.t1.idx - 1) * halfWidth + 10.392 : (m.t1.idx - 1) * halfWidth + 20.784;
            let cy1 = m.t1.N * 18;
            
            let isRight2 = gameLogic.isRight(m.t2.idx, m.t2.N);
            let cx2 = isRight2 ? (m.t2.idx - 1) * halfWidth + 10.392 : (m.t2.idx - 1) * halfWidth + 20.784;
            let cy2 = m.t2.N * 18;

            let pieceCx = (cx1 + cx2) / 2;
            let pieceCy = (cy1 + cy2) / 2;

            let numStr = String(i + 1);
            let offsetX1 = -7, offsetY1 = 8;
            let offsetX2 = -14, offsetY2 = 8;
            let dx = numStr.length === 1 ? offsetX1 : offsetX2;
            let dy = numStr.length === 1 ? offsetY1 : offsetY2;

            let textNode = document.createElementNS('http://www.w3.org/2000/svg', 'text');
            textNode.setAttribute('x', pieceCx + dx);
            textNode.setAttribute('y', pieceCy + dy);
            textNode.setAttribute('stroke', 'none');
            textNode.setAttribute('fill', 'black');
            textNode.setAttribute('font-size', '24');
            textNode.setAttribute('font-family', 'ZT Nature');
            textNode.textContent = numStr;
            etdrop.appendChild(textNode);
          }
        });

        // 4. 動畫結算邏輯
        if (animOpt && activeMoves.length > 0) {
          // 在 etdrop 建立兩個 Dummy animation 控制時間軸,避免陣列為空時時間軸斷裂
          let lastTrId = `tr${activeMoves.length}`;
          let trmeBase = document.createElementNS('http://www.w3.org/2000/svg', 'animate');
          trmeBase.setAttribute('id', 'trme');
          trmeBase.setAttribute('begin', `${lastTrId}.end+1s`);
          trmeBase.setAttribute('dur', '4s');
          etdrop.appendChild(trmeBase);

          let trheBase = document.createElementNS('http://www.w3.org/2000/svg', 'animate');
          trheBase.setAttribute('id', 'trhe');
          trheBase.setAttribute('begin', 'trme.end+1s');
          trheBase.setAttribute('dur', '4s');
          etdrop.appendChild(trheBase);

          let finalScores = gameLogic.calculateScores();
          let useElements = etdrop.querySelectorAll('.tiledropped');

          // 計算無用棋子 (拔掉後對應玩家分數不變)
          for (let i = 0; i < activeMoves.length; i++) {
            let testLogic = new HexGridGame();
            for (let j = 0; j < activeMoves.length; j++) {
              if (i === j) continue;
              let m = activeMoves[j];
              testLogic.tryPlacePiece(m.pid + 1, {x: m.t1.idx, y: m.t1.N}, {x: m.t2.idx, y: m.t2.N}, true);
            }
            let s = testLogic.calculateScores();
            
            let m = activeMoves[i];
            let len = Math.hypot(m.px, m.py) || 1;
            let outX = m.px + (m.px / len) * 400;
            let outY = m.py + (m.py / len) * 400;
            
            // 我方(P1)無用棋子
            if (s.p1Score === finalScores.p1Score) {
              let anim = document.createElementNS('http://www.w3.org/2000/svg', 'animateTransform');
              anim.setAttribute('attributeName', 'transform');
              anim.setAttribute('attributeType', 'XML');
              anim.setAttribute('type', 'translate');
              anim.setAttribute('values', `${m.px},${m.py}; ${outX},${outY}; ${outX},${outY}; ${outX},${outY}; ${m.px},${m.py}`);
              anim.setAttribute('dur', '4s');
              anim.setAttribute('fill', 'freeze');
              anim.setAttribute('begin', 'trme.begin');
              useElements[i].appendChild(anim);
            }
            // 對方(P2)無用棋子
            if (s.p2Score === finalScores.p2Score) {
              let anim = document.createElementNS('http://www.w3.org/2000/svg', 'animateTransform');
              anim.setAttribute('attributeName', 'transform');
              anim.setAttribute('attributeType', 'XML');
              anim.setAttribute('type', 'translate');
              anim.setAttribute('values', `${m.px},${m.py}; ${outX},${outY}; ${outX},${outY}; ${outX},${outY}; ${m.px},${m.py}`);
              anim.setAttribute('dur', '4s');
              anim.setAttribute('fill', 'freeze');
              anim.setAttribute('begin', 'trhe.begin');
              useElements[i].appendChild(anim);
            }
          }

          // 添加分數文字動畫
          let p1Text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
          p1Text.setAttribute('id', 'trmetxt');
          p1Text.setAttribute('x', '252'); // 視圖外距右邊界12 (240+12)
          p1Text.setAttribute('y', '228'); // 視圖內距下邊界12 (240-12)
          p1Text.setAttribute('stroke', 'none');
          p1Text.setAttribute('fill', 'black');
          p1Text.setAttribute('font-size', '60');
          p1Text.setAttribute('font-family', 'ZT Nature');
          p1Text.textContent = finalScores.p1Score.toString();
          
          let p1Anim = document.createElementNS('http://www.w3.org/2000/svg', 'animateTransform');
          p1Anim.setAttribute('attributeName', 'transform');
          p1Anim.setAttribute('attributeType', 'XML');
          p1Anim.setAttribute('type', 'translate');
          p1Anim.setAttribute('values', '0,0; -144,0; -144,0; -144,0; 0,0');
          p1Anim.setAttribute('dur', '4s');
          p1Anim.setAttribute('fill', 'freeze');
          p1Anim.setAttribute('begin', 'trme.begin');
          p1Text.appendChild(p1Anim);
          etboard.appendChild(p1Text);

          let p2Text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
          p2Text.setAttribute('id', 'trhetxt');
          p2Text.setAttribute('x', '252');
          p2Text.setAttribute('y', '228');
          p2Text.setAttribute('stroke', 'none');
          p2Text.setAttribute('fill', 'black');
          p2Text.setAttribute('font-size', '60');
          p2Text.setAttribute('font-family', 'ZT Nature');
          p2Text.setAttribute('transform', 'rotate(180)');
          p2Text.textContent = finalScores.p2Score.toString();

          let p2Anim = document.createElementNS('http://www.w3.org/2000/svg', 'animateTransform');
          p2Anim.setAttribute('attributeName', 'transform');
          p2Anim.setAttribute('attributeType', 'XML');
          p2Anim.setAttribute('type', 'translate');
          p2Anim.setAttribute('values', '0,0; -144,0; -144,0; -144,0; 0,0');
          p2Anim.setAttribute('dur', '4s');
          p2Anim.setAttribute('fill', 'freeze');
          p2Anim.setAttribute('additive', 'sum'); // 疊加在 rotate(180) 上
          p2Anim.setAttribute('begin', 'trhe.begin');
          p2Text.appendChild(p2Anim);
          etboard.appendChild(p2Text);

          // 結尾漸隱
          let endAnim = document.createElementNS('http://www.w3.org/2000/svg', 'animate');
          endAnim.setAttribute('id', 'trend');
          endAnim.setAttribute('begin', 'trhe.end+1s');
          endAnim.setAttribute('attributeName', 'opacity');
          endAnim.setAttribute('values', '1;1;0');
          endAnim.setAttribute('fill', 'freeze');
          endAnim.setAttribute('dur', '2s');
          etdrop.appendChild(endAnim);
        }

        // 5. 序列化與渲染預覽
        let serializer = new XMLSerializer();
        currentDlSvgSource = serializer.serializeToString(svgClone);
        if (!currentDlSvgSource.match(/^<svg[^>]+xmlns="http\:\/\/www\.w3\.org\/2000\/svg"/)) {
          currentDlSvgSource = currentDlSvgSource.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
        }

        document.getElementById('dl-preview-container').innerHTML = '';
        document.getElementById('dl-preview-container').appendChild(svgClone);
        
        let sizeKb = (new Blob([currentDlSvgSource]).size).toLocaleString();
        document.getElementById('dl-size').innerText = sizeKb + ' Bytes';
      }

      function confirmDownloadSVG() {
        let numOpt = document.getElementById('dl-opt-num').checked;
        let animOpt = document.getElementById('dl-opt-anim').checked;
        
        let d = new Date();
        let pad = (n) => n.toString().padStart(2, '0');
        let dateStr = `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
        
        let suffixStr = animOpt ? (currentLang === 'zh' ? '動畫' : 'animation') : (numOpt ? (currentLang === 'zh' ? '數字' : 'number') : '');
        let filename = `trirhombus${suffixStr ? '_' + suffixStr : ''}_${dateStr}.svg`;

        const a = document.createElement('a');
        a.href = URL.createObjectURL(new Blob([currentDlSvgSource], { type: 'image/svg+xml;charset=utf-8' }));
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
      }

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

Re: 研究提升三菱棋的AI棋力

帖子 ejsoon »

改進與修復:
一,前進與後退,棋子不再消失

在增加了「最後一個棋子添加小圓點」的功能之後,點擊前進與後退,棋子就不再增減。查找原因並修復。

二,在頁面打開時的「規則介紹」加入第二段

在原來第一段的下方加入第二段:「計分規則:雙方相對而坐,則我方的上面是對方的下面。從各方視角望去,藍色棋子視為一個立方體的頂面,紅色和橙色棋子視為側面,一個立方體記一分。」

記得配上英文翻譯。

三,英文翻譯仍有少量問題

當一局結束時,會顯示「P1(我方): … - P2(對方):…」,應更改為當前語言。

四,導出動畫的問題修復

首先是text的位置問題,它不應該是寫死的252和228,因為當前視圖.etdrop會縮放平移,如transform="translate(220.000, 303.000) scale(1.240)",則應根據其縮放平移的結果來計算邊界。

其次需要為每個use棋子添加「初始動畫」,「<animateTransform attributeName="transform" attributeType="XML" type="translate" from="屬性坐標" to="視圖外不遠的坐標" dur="1s" fill="freeze" begin="tr0.begin"/>」。

當前視圖.etdrop會縮放平移,需要計算「視圖外不遠的坐標」,計算方法參考「let startY = player === 1 ? (480 - panY) / currentScale + 100 : -panY / currentScale - 100;」。

最後,所有transform中的數值,都應最多保留3位小數,小數末尾的0應去掉。

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

代码: 全选

改進與修復:
一,前進與後退,棋子不再消失

在增加了「最後一個棋子添加小圓點」的功能之後,點擊前進與後退,棋子就不再增減。查找原因並修復。

二,在頁面打開時的「規則介紹」加入第二段

在原來第一段的下方加入第二段:「計分規則:雙方相對而坐,則我方的上面是對方的下面。從各方視角望去,藍色棋子視為一個立方體的頂面,紅色和橙色棋子視為側面,一個立方體記一分。」

記得配上英文翻譯。

三,英文翻譯仍有少量問題

當一局結束時,會顯示「P1(我方): … - P2(對方):…」,應更改為當前語言。

四,導出動畫的問題修復

首先是text的位置問題,它不應該是寫死的252和228,因為當前視圖.etdrop會縮放平移,如transform="translate(220.000, 303.000) scale(1.240)",則應根據其縮放平移的結果來計算邊界。

其次需要為每個use棋子添加「初始動畫」,「<animateTransform attributeName="transform" attributeType="XML" type="translate" from="屬性坐標" to="視圖外不遠的坐標" dur="1s" fill="freeze" begin="tr0.begin"/>」。

當前視圖.etdrop會縮放平移,需要計算「視圖外不遠的坐標」,計算方法參考「let startY = player === 1 ? (480 - panY) / currentScale + 100 : -panY / currentScale - 100;」。

最後,所有transform中的數值,都應最多保留3位小數,小數末尾的0應去掉。

請明確給出所要修改的地方和所要替換的代碼。
附件
trirhombus2879.html.7z
(35.13 KiB) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

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