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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

重寫AI設置和搜尋窗口的界面和功能
當觸發openAISettings(),打開的AI設置搜尋窗口ai-modal,它的界面和功能要全部重寫。

一,四種AI強度改為並排切換標籤

四種AI強度改為並排切換標籤,每個標籤是「SVG圖標和文字」,文字只包括「簡單、困難、專家、自訂」。

思考時間和必殺深度應每行一個,左邊是標題,右邊是值。輸入框要加上現代美觀的css,去掉瀏覽器原有的樣式,同時輸入框的左和右都要加上「-」和「+」兩個按鈕。

二,重寫搜尋功能

「執行盤面搜尋分析」的button改小一些。

當點擊搜尋時,下方應有一個狀態欄,提示當前所用時間,搜尋次數。

搜尋所用的代碼邏輯,應和當前AI的邏輯是相同的。

當搜尋結束,下方應出現所有搜尋過的著法,它的棋子種類,notation,勝率(%),期望(歸一化),搜尋次數。

每一項著法,其右方還有展開按鈕,當點擊時,將展開其第二步搜尋到的所有著法以其屬性,點擊可再展開第三步著法,依此類推。

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

代码: 全选

重寫AI設置和搜尋窗口的界面和功能
當觸發openAISettings(),打開的AI設置搜尋窗口ai-modal,它的界面和功能要全部重寫。

一,四種AI強度改為並排切換標籤

四種AI強度改為並排切換標籤,每個標籤是「SVG圖標和文字」,文字只包括「簡單、困難、專家、自訂」。

思考時間和必殺深度應每行一個,左邊是標題,右邊是值。輸入框要加上現代美觀的css,去掉瀏覽器原有的樣式,同時輸入框的左和右都要加上「-」和「+」兩個按鈕。

二,重寫搜尋功能

「執行盤面搜尋分析」的button改小一些。

當點擊搜尋時,下方應有一個狀態欄,提示當前所用時間,搜尋次數。

搜尋所用的代碼邏輯,應和當前AI的邏輯是相同的。

當搜尋結束,下方應出現所有搜尋過的著法,它的棋子種類,notation,勝率(%),期望(歸一化),搜尋次數。

每一項著法,其右方還有展開按鈕,當點擊時,將展開其第二步搜尋到的所有著法以其屬性,點擊可再展開第三步著法,依此類推。

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這是一個非常棒的改進計畫!為了將您的 AI 設定視窗升級為具有並排標籤、現代化自訂數值控制器,以及完整 MCTS 搜尋樹檢視器的介面,我們需要修改 CSS、HTML 結構以及對應的 JavaScript 邏輯。

請依照以下步驟替換與新增程式碼:

### 第一步:新增並覆蓋 CSS 樣式

請在您的 `<style>` 區塊內(可以放在最下方)加入以下樣式,這些樣式定義了新的標籤頁、現代化的輸入框控制器以及樹狀結構的外觀:

```css
/* --- AI Modal 新 UI 樣式 --- */
.ai-tabs {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}
.ai-tab {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 10px 5px;
  background: #f8f9fa;
  border: 2px solid #eee;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
  color: #555;
  font-weight: bold;
  font-size: 0.9rem;
}
.ai-tab:hover {
  background: #e9ecef;
  border-color: #ccc;
}
.ai-tab.active {
  background: #e3f2fd;
  border-color: var(--p1-color);
  color: var(--p1-color);
}
.ai-tab svg {
  width: 24px;
  height: 24px;
  fill: currentColor;
  margin-bottom: 5px;
}

/* 現代化數字輸入控制器 */
.setting-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
  font-weight: bold;
  color: #555;
}
.num-ctrl {
  display: flex;
  align-items: center;
  border: 1px solid #ccc;
  border-radius: 6px;
  overflow: hidden;
  background: #fff;
}
.num-btn {
  width: 32px;
  height: 32px;
  background: #f1f1f1;
  border: none;
  font-size: 1.2rem;
  font-weight: bold;
  color: #333;
  cursor: pointer;
  display: flex;
  justify-content: center;
  align-items: center;
  transition: background 0.1s;
}
.num-btn:hover { background: #e0e0e0; }
.num-btn:active { background: #d0d0d0; }
.num-input {
  width: 50px;
  height: 32px;
  border: none;
  border-left: 1px solid #ccc;
  border-right: 1px solid #ccc;
  text-align: center;
  font-size: 1rem;
  font-family: monospace;
  font-weight: bold;
  outline: none;
  -moz-appearance: textfield; /* 隱藏 Firefox 預設箭頭 */
}
.num-input::-webkit-outer-spin-button,
.num-input::-webkit-inner-spin-button {
  -webkit-appearance: none; /* 隱藏 Chrome/Safari 預設箭頭 */
  margin: 0;
}

#ai-search-status {
  font-size: 0.9rem;
  color: #666;
  text-align: center;
  margin-top: 10px;
  font-family: monospace;
}

/* 搜尋樹樣式 */
.ai-tree-node {
  margin-left: 15px;
  border-left: 1px dashed #ccc;
  padding-left: 10px;
  margin-top: 5px;
}
.ai-tree-header {
  cursor: pointer;
  color: #333;
  padding: 4px;
  border-radius: 4px;
  font-family: monospace;
  font-size: 13px;
  display: flex;
  align-items: center;
  transition: background 0.2s;
}
.ai-tree-header:hover {
  background: #f0f0f0;
}
.tree-expand {
  display: inline-block;
  width: 16px;
  color: var(--p1-color);
  font-weight: bold;
}
.tree-content {
  margin-top: 2px;
}

```

### 第二步:替換 HTML 結構

請找到 `<div id="ai-modal"...>` 到 `</div>`(對應 AI 分析與設定的視窗),將整個區塊替換為以下 HTML:

```html
<div id="ai-modal" style="display: none" class="fullscreen-modal">
  <div class="modal-content" style="width: 500px; max-height: 90vh; display: flex; flex-direction: column;">
    <svg class="close-btn" onclick="document.getElementById('ai-modal').style.display = 'none'" 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>MCTS AI 分析與設定</h3>
    
    <div class="ai-tabs" id="ai-tabs-container">
      <div class="ai-tab" data-preset="simple" onclick="selectAiTab('simple')">
        <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
        簡單
      </div>
      <div class="ai-tab active" data-preset="hard" onclick="selectAiTab('hard')">
        <svg viewBox="0 0 24 24"><path d="M12 2L1 21h22L12 2zm0 3.99L19.53 19H4.47L12 5.99zM11 16h2v2h-2zm0-6h2v4h-2z"/></svg>
        困難
      </div>
      <div class="ai-tab" data-preset="expert" onclick="selectAiTab('expert')">
        <svg viewBox="0 0 24 24"><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"/></svg>
        專家
      </div>
      <div class="ai-tab" data-preset="custom" onclick="selectAiTab('custom')">
        <svg viewBox="0 0 24 24"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.73,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.43-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.49-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
        自訂
      </div>
    </div>

    <div class="setting-row">
      <label>思考時間 (秒)</label>
      <div class="num-ctrl">
        <button class="num-btn" onclick="adjustAiParam('ai-time', -1, 1, 60)">-</button>
        <input type="number" id="ai-time" class="num-input" value="4" onchange="markCustom()" />
        <button class="num-btn" onclick="adjustAiParam('ai-time', 1, 1, 60)">+</button>
      </div>
    </div>
    <div class="setting-row">
      <label>必殺搜尋深度</label>
      <div class="num-ctrl">
        <button class="num-btn" onclick="adjustAiParam('ai-depth', -1, 2, 20)">-</button>
        <input type="number" id="ai-depth" class="num-input" value="6" onchange="markCustom()" />
        <button class="num-btn" onclick="adjustAiParam('ai-depth', 1, 2, 20)">+</button>
      </div>
    </div>

    <button id="ai-search-btn" class="btn-primary" onclick="runAISearch()" style="width: 100%; margin: 10px 0; padding: 10px; font-size: 1rem;">
      🔍 執行盤面搜尋分析
    </button>
    <div id="ai-search-status">準備就緒</div>

    <div id="ai-results" style="flex: 1; overflow-y: auto; text-align: left; border-top: 1px solid #ccc; padding-top: 10px; margin-top: 10px;">
    </div>
  </div>
</div>

```

### 第三步:更新與新增 JavaScript 邏輯

請將原本原始碼中的 `applyAIPreset()`, `checkCustomAI()`, 與 `runAISearch()` 函數 **移除**,並替換為以下功能強大的 UI 綁定與完整實裝的 MCTS 搜尋樹建構邏輯:

```javascript
// === UI 控制:選擇 AI 強度標籤 ===
function selectAiTab(preset) {
  // 更新樣式
  document.querySelectorAll('.ai-tab').forEach(tab => {
    if(tab.dataset.preset === preset) tab.classList.add('active');
    else tab.classList.remove('active');
  });

  // 更新數值 (如果是預設選項)
  if (preset !== 'custom' && aiConfigs[preset]) {
    document.getElementById('ai-time').value = aiConfigs[preset].time / 1000;
    document.getElementById('ai-depth').value = aiConfigs[preset].depth;
  }
}

// === UI 控制:按鈕微調數值 ===
function adjustAiParam(inputId, delta, min, max) {
  let input = document.getElementById(inputId);
  let val = parseFloat(input.value) || min;
  val += delta;
  if (val < min) val = min;
  if (val > max) val = max;
  input.value = val;
  markCustom();
}

// === UI 控制:數值變更時自動切換至「自訂」標籤 ===
function markCustom() {
  let t = parseFloat(document.getElementById('ai-time').value) * 1000;
  let d = parseInt(document.getElementById('ai-depth').value);
  
  // 檢查是否剛好吻合某個預設值
  let matchedPreset = 'custom';
  for (let k of ['simple', 'hard', 'expert']) {
    if (aiConfigs[k].time === t && aiConfigs[k].depth === d) {
      matchedPreset = k;
      break;
    }
  }
  
  document.querySelectorAll('.ai-tab').forEach(tab => {
    if(tab.dataset.preset === matchedPreset) tab.classList.add('active');
    else tab.classList.remove('active');
  });

  // 同步更新自訂 config 設定
  aiConfigs.custom = { time: t, depth: d, icon: aiConfigs.custom.icon };
}

// === AI 分析搜尋引擎與繪製樹狀圖 ===
async function runAISearch() {
  if (gameState !== 'playing') {
    alert("遊戲尚未開始或已結束!");
    return;
  }

  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 depthLimit = parseInt(document.getElementById('ai-depth').value);

  // 複製深層狀態避免破壞主遊戲 UI
  let rootState = gameLogic.clone();
  let simPieces = { 1: [...piecesLeft[1]], 2: [...piecesLeft[2]] };
  let root = new MCTSNode(null, null, rootState, currentPlayer, simPieces);

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

  statusDiv.innerHTML = `搜尋中... 經過時間: 0.0s / 模擬次數: 0`;

  // MCTS 演算法 (與 triggerAITurn 共用邏輯)
  while (Date.now() - startTime < timeLimit) {
    for (let i = 0; i < 30; i++) {
      if (root.unexpanded.length === 0 && root.children.length === 0) break;

      let node = root;
      let simState = rootState.clone();
      // 在迴圈每次迭代中也必須複製一份棋子餘數
      let currentSimPieces = { 1: [...simPieces[1]], 2: [...simPieces[2]] };
      let simPlayer = currentPlayer;
      let currDepth = 0;

      // 1. Selection
      while (node.unexpanded.length === 0 && node.children.length > 0) {
        let bestUCT = -Infinity, bestChild = null;
        for (let c of node.children) {
          let C = 1.414;
          let uct = (c.wins / c.visits) + C * Math.sqrt(Math.log(node.visits) / c.visits);
          if (uct > bestUCT) { bestUCT = uct; bestChild = c; }
        }
        node = bestChild;
        
        let simPid = (node.move >> 24) & 0xff;
        let simP1Idx = (node.move >> 12) & 0xfff;
        let simP2Idx = node.move & 0xfff;
        simState.tryPlacePiece(simPid + 1, simState._idxToCoord(simP1Idx), simState._idxToCoord(simP2Idx), false);
        currentSimPieces[simPlayer][simPid]--;
        simPlayer = simPlayer === 1 ? 2 : 1;
        currDepth++;
      }

      // 2. Expansion
      if (node.unexpanded.length > 0) {
        let moveIdx = Math.floor(Math.random() * node.unexpanded.length);
        let move = node.unexpanded.splice(moveIdx, 1)[0];

        let simPid = (move >> 24) & 0xff;
        let simP1Idx = (move >> 12) & 0xfff;
        let simP2Idx = move & 0xfff;
        simState.tryPlacePiece(simPid + 1, simState._idxToCoord(simP1Idx), simState._idxToCoord(simP2Idx), false);
        currentSimPieces[simPlayer][simPid]--;
        simPlayer = simPlayer === 1 ? 2 : 1;

        let child = new MCTSNode(move, node, simState, simPlayer, currentSimPieces);
        node.children.push(child);
        node = child;
        currDepth++;
      }

      // 3. Simulation
      let tempDepth = currDepth;
      while (tempDepth < depthLimit) {
        let moves = simState.getValidMoves(simPlayer, currentSimPieces);
        if (moves.length === 0) break;

        let m = moves[Math.floor(Math.random() * moves.length)];
        let simPid = (m >> 24) & 0xff;
        let simP1Idx = (m >> 12) & 0xfff;
        let simP2Idx = m & 0xfff;
        simState.tryPlacePiece(simPid + 1, simState._idxToCoord(simP1Idx), simState._idxToCoord(simP2Idx), false);
        currentSimPieces[simPlayer][simPid]--;

        tempDepth++;
        simPlayer = simPlayer === 1 ? 2 : 1;
      }

      // 4. Backpropagation
      let scores = simState.calculateScores();
      let p1Diff = scores.p1Score - scores.p2Score;
      let advantage = Math.tanh(p1Diff / 10.0);

      let curr = node;
      while (curr !== null) {
        curr.visits++;
        if (curr.player === 1) curr.wins += -advantage;
        else curr.wins += advantage;
        curr = curr.parent;
      }
      iterations++;
    }

    let now = Date.now();
    // 每 100 毫秒更新一次 UI
    if (now - lastUiTime > 100) {
      statusDiv.innerHTML = `搜尋中... 經過時間: ${((now - startTime) / 1000).toFixed(1)}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));
    }
  }
}

// === 遞迴構建並延遲渲染 DOM 樹 ===
function buildTreeNodeDOM(node, isFirstLevel, evalPlayer) {
  let div = document.createElement('div');
  div.className = 'ai-tree-node';
  if (isFirstLevel) div.style.marginLeft = '0';

  // 解析 Move 位元坐標
  let pid = (node.move >> 24) & 0xff;
  let p1Idx = (node.move >> 12) & 0xfff;
  let p2Idx = node.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 expected = node.visits > 0 ? (node.wins / node.visits) : 0;
  
  // 校正顯示的視角:若評估此步的玩家與根節點發起玩家不同,則反轉期望 (Backprop 的邏輯)
  if (node.parent && node.parent.player !== evalPlayer) {
     expected = -expected; 
  }

  // 將壓縮後的值 [-1, 1] 轉換為易讀的勝率百分比 [0%, 100%]
  let winRate = (((expected + 1) / 2) * 100).toFixed(1);

  let header = document.createElement('div');
  header.className = 'ai-tree-header';
  
  let hasChildren = node.children && node.children.length > 0;
  let expandIcon = hasChildren ? '▶' : ' ';

  header.innerHTML = `
    <span class="tree-expand" style="width: 20px; display: inline-block; text-align: center;">${expandIcon}</span>
    <span style="width: 130px; display: inline-block;">${pieceName} ${notation}</span>
    <span style="color: ${winRate > 50 ? '#007b00' : '#d00000'}; width: 100px; display: inline-block;">勝率: ${winRate}%</span>
    <span style="color: #666;">期望: ${expected.toFixed(3)} | 次數: ${node.visits}</span>
  `;

  let childContainer = document.createElement('div');
  childContainer.className = 'tree-content';
  childContainer.style.display = 'none';

  if (hasChildren) {
    header.onclick = () => {
      if (childContainer.style.display === 'none') {
        childContainer.style.display = 'block';
        header.querySelector('.tree-expand').innerText = '▼';
        
        // 延遲渲染 (Lazy load):避免龐大的樹直接卡死瀏覽器
        if (childContainer.children.length === 0) {
          let sorted = [...node.children].sort((a, b) => b.visits - a.visits);
          // 為了效能,每層最多只展示前 15 種最好的應對
          let displayCount = Math.min(sorted.length, 15);
          for (let i = 0; i < displayCount; i++) {
            childContainer.appendChild(buildTreeNodeDOM(sorted[i], false, evalPlayer));
          }
          if (sorted.length > displayCount) {
            let more = document.createElement('div');
            more.style.marginLeft = '30px';
            more.style.color = '#999';
            more.style.fontSize = '12px';
            more.style.fontStyle = 'italic';
            more.innerText = `...還有 ${sorted.length - displayCount} 個被剪枝的次要著法`;
            childContainer.appendChild(more);
          }
        }
      } else {
        childContainer.style.display = 'none';
        header.querySelector('.tree-expand').innerText = '▶';
      }
    };
  }

  div.appendChild(header);
  div.appendChild(childContainer);
  return div;
}

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復與改進:
一,更改AI思考時的消息彈框

當AI思考時,要定制一個消息框,不能用之前的,因為之前的消息框會在1秒後消失。定制的AI思考消息框要等落子動畫結束後才消失。

二,重寫必殺邏輯

本棋的特點是要放完36手棋,最後一手棋放完之前是無法預測結果的。因此必殺判定改為從倒數第n手棋開始判斷,並且用在每次MCTS搜尋中。

必殺判定的結果分為3種:必贏、必和、必輸。

當一個局面有必殺判定結果後,把它的局面和判定結果以希哈值為索引保存在一個變量池中。當必殺判定時先將局面希哈並比對,如果當前的希哈索引有值,再進行局面比對,如果局面也相同,則直接使用這個局面的判定結果,如果不同,則index+1繼續往下比對,直至找到相同局面,如果直到空位都沒有相同局面則保存本局的結果到這個空位。如果當前的希哈索引無值,則直接保存局面及其判定結果到希哈對應的地方。

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

代码: 全选

修復與改進:
一,更改AI思考時的消息彈框

當AI思考時,要定制一個消息框,不能用之前的,因為之前的消息框會在1秒後消失。定制的AI思考消息框要等落子動畫結束後才消失。

二,重寫必殺邏輯

本棋的特點是要放完36手棋,最後一手棋放完之前是無法預測結果的。因此必殺判定改為從倒數第n手棋開始判斷,並且用在每次MCTS搜尋中。

必殺判定的結果分為3種:必贏、必和、必輸。

當一個局面有必殺判定結果後,把它的局面和判定結果以希哈值為索引保存在一個變量池中。當必殺判定時先將局面希哈並比對,如果當前的希哈索引有值,再進行局面比對,如果局面也相同,則直接使用這個局面的判定結果,如果不同,則index+1繼續往下比對,直至找到相同局面,如果直到空位都沒有相同局面則保存本局的結果到這個空位。如果當前的希哈索引無值,則直接保存局面及其判定結果到希哈對應的地方。

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro沒有糾正我的術語,難道我的描述已經相當專業?

代码: 全选

以下是針對您的兩個需求(定制 AI 思考消息框、增加 MCTS 必殺搜尋與哈希變量池)的具體修改方案。請依照指示替換與新增對應的程式碼。

### 修改一:定制 AI 思考消息框

**1. 新增 CSS 樣式**
在 `<style>` 標籤中,找到 `#toast-overlay` 的樣式,在它下方新增 `#ai-thinking-toast` 的樣式:

```css
      #toast-overlay .toast-msg {
        background: rgba(0, 0, 0, 0.82);
        color: #fff;
        padding: 18px 36px;
        border-radius: 12px;
        font-size: 1.2rem;
        font-family: inherit;
        text-align: center;
        box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
      }

      /* --- 在這裡新增 AI 思考專屬的彈窗樣式 --- */
      #ai-thinking-toast {
        position: fixed;
        top: 20px;
        left: 50%;
        transform: translateX(-50%);
        background: rgba(0, 86, 179, 0.9);
        color: white;
        padding: 12px 24px;
        border-radius: 8px;
        font-size: 1.1rem;
        font-weight: bold;
        z-index: 1000;
        display: none;
        box-shadow: 0 4px 10px rgba(0,0,0,0.3);
      }

```

**2. 新增 HTML 結構**
在 `<body>` 內,找到 `<div id="toast-overlay">...</div>`,在下方插入對應的 HTML:

```html
    <div id="toast-overlay"><div class="toast-msg" id="toast-msg"></div></div>
    <div id="ai-thinking-toast">AI 思考中...</div>

```

**3. 修改 `cleanUpSelection` 函數**
在 `<script>` 中找到 `cleanUpSelection()`,並將最後的判斷邏輯替換為以下程式碼。這能確保 AI 思考時顯示客製化提示框,並在輪回人類(即動畫結束後)將其隱藏。

```javascript
      function cleanUpSelection() {
        if (selectedBoxElement) selectedBoxElement.classList.remove('selected');
        currentSelectedPiece = null;
        selectedBoxElement = null;
        currentPlayer = currentPlayer === 1 ? 2 : 1;
        updateUI();

        let turnText = currentPlayer === 1 ? getText('yourTurn') : getText('oppTurn');
        if (isAutoRotate) turnText = getText('autoTurn');

        if (playerTypes[currentPlayer] !== 'human') {
          // 顯示專屬 AI 思考彈窗
          document.getElementById('ai-thinking-toast').style.display = 'block';
          setTimeout(triggerAITurn, 100); // 讓 UI 有時間更新
        } else {
          // 動畫結束換回人類時,隱藏 AI 思考彈窗
          document.getElementById('ai-thinking-toast').style.display = 'none';
          showNotification(turnText);
        }
      }

```

---

### 修改二:重寫 MCTS 必殺判定邏輯與 Hash 表

**1. 插入全局變量池(Transposition Table)與必殺核心算法**
在 `<script>` 中,找到 `class MCTSNode { ... }` 的定義位置。在它**上方**,插入以下哈希表與 Minimax 必殺搜尋的代碼:

```javascript
      // === 必殺搜尋:哈希變量池 (Transposition Table) ===
      const TT_SIZE = 1048583; // 質數大小,減少碰撞
      let ttKeys = new Array(TT_SIZE).fill(null);
      let ttValues = new Int8Array(TT_SIZE); // 1 (必贏), 0 (必和), -1 (必輸)

      function hashState(state) {
        let h = 5381;
        for (let i = 0; i < 4096; i++) {
          if (state.grid[i] !== 0) {
            h = ((h << 5) + h) ^ state.grid[i] ^ i;
          }
        }
        return h >>> 0;
      }

      function getTT(hash, grid) {
        let idx = hash % TT_SIZE;
        while (ttKeys[idx] !== null) {
          if (ttKeys[idx].hash === hash) {
            let same = true;
            for(let i = 0; i < 4096; i++) {
              if (ttKeys[idx].grid[i] !== grid[i]) {
                same = false;
                break;
              }
            }
            if (same) return ttValues[idx];
          }
          idx = (idx + 1) % TT_SIZE; // 衝突則 index + 1 繼續比對
        }
        return null;
      }

      function putTT(hash, grid, value) {
        let idx = hash % TT_SIZE;
        while (ttKeys[idx] !== null) {
          if (ttKeys[idx].hash === hash) {
            let same = true;
            for(let i = 0; i < 4096; i++) {
              if (ttKeys[idx].grid[i] !== grid[i]) {
                same = false;
                break;
              }
            }
            if (same) {
              ttValues[idx] = value;
              return;
            }
          }
          idx = (idx + 1) % TT_SIZE;
        }
        // 找到空位,寫入哈希值、局面與結果
        let gridCopy = new Int32Array(grid);
        ttKeys[idx] = { hash: hash, grid: gridCopy };
        ttValues[idx] = value;
      }

      function solveEndgame(state, player, pPiecesLeft, alpha, beta) {
        let p1Remaining = pPiecesLeft[1][0] + pPiecesLeft[1][1] + pPiecesLeft[1][2];
        let p2Remaining = pPiecesLeft[2][0] + pPiecesLeft[2][1] + pPiecesLeft[2][2];
        let movesLeft = p1Remaining + p2Remaining;

        if (movesLeft === 0) {
          let scores = state.calculateScores();
          if (scores.p1Score > scores.p2Score) return player === 1 ? 1 : -1;
          if (scores.p1Score < scores.p2Score) return player === 2 ? 1 : -1;
          return 0;
        }

        let hash = hashState(state);
        let cached = getTT(hash, state.grid);
        if (cached !== null) return cached;

        let validMoves = state.getValidMoves(player, pPiecesLeft);
        if (validMoves.length === 0) {
          let scores = state.calculateScores();
          let res = 0;
          if (scores.p1Score > scores.p2Score) res = (player === 1 ? 1 : -1);
          else if (scores.p1Score < scores.p2Score) res = (player === 2 ? 1 : -1);
          putTT(hash, state.grid, res);
          return res;
        }

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

        for (let move of validMoves) {
          let simPid = (move >> 24) & 0xFF;
          let simP1Idx = (move >> 12) & 0xFFF;
          let simP2Idx = move & 0xFFF;

          let p1 = state._idxToCoord(simP1Idx);
          let p2 = state._idxToCoord(simP2Idx);

          state.tryPlacePiece(simPid + 1, p1, p2, false);
          pPiecesLeft[player][simPid]--;

          let res = -solveEndgame(state, nextPlayer, pPiecesLeft, -beta, -Math.max(alpha, bestRes));

          state.undo();
          pPiecesLeft[player][simPid]++;

          if (res > bestRes) bestRes = res;
          if (bestRes >= beta) break; // Alpha-beta 剪枝
        }

        putTT(hash, state.grid, bestRes);
        return bestRes;
      }

```

**2. 在 `triggerAITurn()` 中接入必殺邏輯**
找到 `triggerAITurn()` 函數中標有 `// Simulation (快速模擬到深度限制)` 的部分,並替換為以下程式碼(包含反向傳播的修改):

```javascript
            // Simulation (快速模擬到深度限制,並結合必殺判定)
            let tempDepth = currDepth;
            let movesLeft = simPieces[1][0] + simPieces[1][1] + simPieces[1][2] +
                            simPieces[2][0] + simPieces[2][1] + simPieces[2][2];
            let isEndgameSolved = false;
            let solvedAdvantage = 0;
            // 根據設定的深度作為倒數的 N 手開始必殺判斷
            let nForEndgame = parseInt(document.getElementById('ai-depth').value) || 6;

            while (tempDepth < depthLimit) {
              if (movesLeft <= nForEndgame) {
                let res = solveEndgame(simState, simPlayer, simPieces, -1, 1);
                let p1Win = (simPlayer === 1) ? res : -res;
                solvedAdvantage = p1Win; // 1: 必贏, 0: 必和, -1: 必輸
                isEndgameSolved = true;
                break;
              }

              let moves = simState.getValidMoves(simPlayer, simPieces);
              if (moves.length === 0) break;

              let m = moves[Math.floor(Math.random() * moves.length)];

              let simPid = (m >> 24) & 0xff;
              let simP1Idx = (m >> 12) & 0xfff;
              let simP2Idx = m & 0xfff;
              simState.tryPlacePiece(simPid + 1, simState._idxToCoord(simP1Idx), simState._idxToCoord(simP2Idx), false);
              simPieces[simPlayer][simPid]--;

              tempDepth++;
              movesLeft--;
              simPlayer = simPlayer === 1 ? 2 : 1;
            }

            // Backpropagation 【修復視角邏輯並整合必殺結果】
            let advantage;
            if (isEndgameSolved) {
              advantage = solvedAdvantage; // 若觸發必殺,直接給出絕對勝負值
            } else {
              let scores = simState.calculateScores();
              let p1Diff = scores.p1Score - scores.p2Score;
              advantage = Math.tanh(p1Diff / 10.0);
            }

            let curr = node;
            while (curr !== null) {
              curr.visits++;
              if (curr.player === 1) {
                curr.wins += -advantage; // 存入優勢值
              } else {
                curr.wins += advantage;
              }
              curr = curr.parent;
            }

```

**3. 在 `runAISearch()` 中同樣接入必殺邏輯**
找到 `runAISearch()` 函數內的 `// 3. Simulation` 與 `// 4. Backpropagation` 段落,並將其替換為如下內容以保持一致:

```javascript
            // 3. Simulation (整合必殺判定)
            let tempDepth = currDepth;
            let movesLeft = currentSimPieces[1][0] + currentSimPieces[1][1] + currentSimPieces[1][2] +
                            currentSimPieces[2][0] + currentSimPieces[2][1] + currentSimPieces[2][2];
            let isEndgameSolved = false;
            let solvedAdvantage = 0;
            let nForEndgame = parseInt(document.getElementById('ai-depth').value) || 6;

            while (tempDepth < depthLimit) {
              if (movesLeft <= nForEndgame) {
                let res = solveEndgame(simState, simPlayer, currentSimPieces, -1, 1);
                let p1Win = (simPlayer === 1) ? res : -res;
                solvedAdvantage = p1Win; 
                isEndgameSolved = true;
                break;
              }

              let moves = simState.getValidMoves(simPlayer, currentSimPieces);
              if (moves.length === 0) break;

              let m = moves[Math.floor(Math.random() * moves.length)];
              let simPid = (m >> 24) & 0xff;
              let simP1Idx = (m >> 12) & 0xfff;
              let simP2Idx = m & 0xfff;
              simState.tryPlacePiece(simPid + 1, simState._idxToCoord(simP1Idx), simState._idxToCoord(simP2Idx), false);
              currentSimPieces[simPlayer][simPid]--;

              tempDepth++;
              movesLeft--;
              simPlayer = simPlayer === 1 ? 2 : 1;
            }

            // 4. Backpropagation
            let advantage;
            if (isEndgameSolved) {
              advantage = solvedAdvantage;
            } else {
              let scores = simState.calculateScores();
              let p1Diff = scores.p1Score - scores.p2Score;
              advantage = Math.tanh(p1Diff / 10.0);
            }

            let curr = node;
            while (curr !== null) {
              curr.visits++;
              if (curr.player === 1) curr.wins += -advantage;
              else curr.wins += advantage;
              curr = curr.parent;
            }

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復與改進:
一,更改AI思考時的消息彈框

當AI思考時的消息框,應實時顯示思考時間和搜尋次數,當思考完畢要落子時,應顯示總思考時間和當前勝率。

這個「AI思考消息框」出現的位置,應和一般消息框(提示輪到哪一方)出現的位置相同。一般消息框則不必再出現。

二,調整AI必殺邏輯等算法,使其能防卡死

當遊戲進行到準備結束時,頁面被卡死了。檢查代碼中可能會把頁面卡死的代碼邏輯,加入防卡死機制。

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

代码: 全选

修復與改進:
一,更改AI思考時的消息彈框

當AI思考時的消息框,應實時顯示思考時間和搜尋次數,當思考完畢要落子時,應顯示總思考時間和當前勝率。

這個「AI思考消息框」出現的位置,應和一般消息框(提示輪到哪一方)出現的位置相同。一般消息框則不必再出現。

二,調整AI必殺邏輯等算法,使其能防卡死

當遊戲進行到準備結束時,頁面被卡死了。檢查代碼中可能會把頁面卡死的代碼邏輯,加入防卡死機制。

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro 還能跑!

代码: 全选

這兩個問題都非常切中要害!AI 思考狀態若能整合到原有的位置,能讓介面更加簡潔;而遊戲後期的「卡死」問題,是因為 MCTS 引擎在模擬時,過度頻繁且深入地調用精確的 `solveEndgame`(必殺搜尋 Alpha-Beta 剪枝),導致主執行緒阻塞。

我們將透過加入「持久化通知」參數,以及在 `solveEndgame` 中加入「運算次數防護栓(Bailout)」來徹底解決這兩個問題。以下是需要修改的代碼:

### 第一部分:優化一般消息框與 AI 思考狀態

**1. 修改 `showNotification` 函數(約 549 行附近)**
加入 `persist` 參數,讓消息可以根據需求決定是否自動消失。

**找到原代碼:**

```javascript
      function showNotification(msg) {
        const box = document.getElementById('test-box');
        box.innerText = msg;
        box.style.opacity = '1';
        // ... 中間略 ...
        box.style.transform = rotateStr;

        clearTimeout(notifyTimeout);
        notifyTimeout = setTimeout(() => {
          box.style.opacity = '0';
        }, 1200); // 1.2 秒後消失
      }

```

**替換為:**

```javascript
      function showNotification(msg, persist = false) {
        const box = document.getElementById('test-box');
        box.innerText = msg;
        box.style.opacity = '1';

        const isMobile = window.innerWidth <= 768;
        let isOpponentTurn = currentPlayer === 2;
        let rotateStr = isAutoRotate && isOpponentTurn ? 'rotate(180deg)' : 'none';

        // 重置屬性
        box.style.top = 'auto';
        box.style.bottom = 'auto';
        box.style.left = 'auto';
        box.style.right = 'auto';

        if (isMobile) {
          if (isAutoRotate && isOpponentTurn) {
            box.style.top = '15px';
            box.style.left = '15px';
          } else {
            box.style.bottom = '15px';
            box.style.left = '15px';
          }
        } else {
          if (isOpponentTurn) {
            if (isAutoRotate) {
              box.style.top = '15px';
              box.style.left = '15px';
            } else {
              box.style.bottom = '15px';
              box.style.left = '15px';
            }
          } else {
            box.style.bottom = '15px';
            box.style.right = '15px';
          }
        }
        box.style.transform = rotateStr;

        clearTimeout(notifyTimeout);
        if (!persist) {
          notifyTimeout = setTimeout(() => {
            box.style.opacity = '0';
          }, 1200); // 1.2 秒後消失
        }
      }

```

**2. 修改 `cleanUpSelection` 函數中的觸發邏輯(約 745 行附近)**
廢棄舊的 `#ai-thinking-toast`,改用整合後的消息框。

**找到原代碼:**

```javascript
        if (playerTypes[currentPlayer] !== 'human') {
          // 顯示專屬 AI 思考彈窗
          document.getElementById('ai-thinking-toast').style.display = 'block';
          setTimeout(triggerAITurn, 100); // 讓 UI 有時間更新
        } else {
          // 動畫結束換回人類時,隱藏 AI 思考彈窗
          document.getElementById('ai-thinking-toast').style.display = 'none';
          showNotification(turnText);
        }

```

**替換為:**

```javascript
        if (playerTypes[currentPlayer] !== 'human') {
          document.getElementById('ai-thinking-toast').style.display = 'none';
          showNotification("AI 思考中...", true); // true 代表消息框持續顯示
          setTimeout(triggerAITurn, 100);
        } else {
          document.getElementById('ai-thinking-toast').style.display = 'none';
          showNotification(turnText);
        }

```

**3. 修改 `triggerAITurn` 函數中的 UI 更新邏輯(約 1163 行及 1176 行附近)**
讓 AI 在思考時與思考完成後,都能在原位顯示正確的狀態。

**第一處 - 找到原代碼:**

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

```

**替換為:**

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

```

**第二處 - 找到原代碼:**

```javascript
        let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
        if (bestChild) {
          let winRate = (bestChild.wins / bestChild.visits).toFixed(2);
          box.innerText = `思考完成: ${finalTime}s\n評估分數: ${winRate}`;

```

**替換為:**

```javascript
        let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
        if (bestChild) {
          // 將壓抑在 -1 到 1 的數值轉換為直觀的 0% ~ 100% 勝率
          let winRate = (((bestChild.wins / bestChild.visits) + 1) / 2 * 100).toFixed(1);
          box.innerText = `思考完成: ${finalTime}s\n當前勝率: ${winRate}%`;

```

---

### 第二部分:修復遊戲後期的「卡死」問題(防卡死機制)

為了解決遞迴導致主線程卡住,我們在 `solveEndgame` 中加入操作次數上限 `opsObj`,當局面過於複雜時直接中斷回傳 `null`,讓 MCTS 優雅地退回到隨機模擬(Rollout),不再強行窮舉。

**1. 修改 `solveEndgame` 函數參數與中斷邏輯(約 1058 行附近)**

**找到原代碼:**

```javascript
      function solveEndgame(state, player, pPiecesLeft, alpha, beta) {
        let p1Remaining = pPiecesLeft[1][0] + pPiecesLeft[1][1] + pPiecesLeft[1][2];

```

**替換為:**

```javascript
      function solveEndgame(state, player, pPiecesLeft, alpha, beta, opsObj = { count: 0 }) {
        opsObj.count++;
        // 防卡死機制:如果此分支窮舉超過 1000 次仍未觸底,直接放棄精算,退回隨機模擬
        if (opsObj.count > 1000) return null; 

        let p1Remaining = pPiecesLeft[1][0] + pPiecesLeft[1][1] + pPiecesLeft[1][2];

```

**2. 處理 `solveEndgame` 中的遞迴呼叫(約 1083 行附近)**

**找到原代碼:**

```javascript
          state.tryPlacePiece(simPid + 1, p1, p2, false);
          pPiecesLeft[player][simPid]--;

          let res = -solveEndgame(state, nextPlayer, pPiecesLeft, -beta, -Math.max(alpha, bestRes));

          state.undo();
          pPiecesLeft[player][simPid]++;

          if (res > bestRes) bestRes = res;

```

**替換為:**

```javascript
          state.tryPlacePiece(simPid + 1, p1, p2, false);
          pPiecesLeft[player][simPid]--;

          // 傳遞 opsObj 並接收可能為 null 的結果
          let childRes = solveEndgame(state, nextPlayer, pPiecesLeft, -beta, -Math.max(alpha, bestRes), opsObj);

          state.undo();
          pPiecesLeft[player][simPid]++;

          if (childRes === null) return null; // 接收到超載訊號,立即向上一層中斷

          let res = -childRes;
          if (res > bestRes) bestRes = res;

```

**3. 修改 `triggerAITurn` 中的調用(約 1139 行附近)**

**找到原代碼:**

```javascript
            while (tempDepth < depthLimit) {
              if (movesLeft <= nForEndgame) {
                let res = solveEndgame(simState, simPlayer, simPieces, -1, 1);
                let p1Win = simPlayer === 1 ? res : -res;
                solvedAdvantage = p1Win; // 1: 必贏, 0: 必和, -1: 必輸
                isEndgameSolved = true;
                break;
              }

```

**替換為:**

```javascript
            while (tempDepth < depthLimit) {
              if (movesLeft <= nForEndgame) {
                let opsObj = { count: 0 };
                let res = solveEndgame(simState, simPlayer, simPieces, -1, 1, opsObj);
                
                // 只有在沒有超載 (res !== null) 時,才套用必殺結果;否則忽略必殺,繼續走 MCTS
                if (res !== null) {
                  let p1Win = simPlayer === 1 ? res : -res;
                  solvedAdvantage = p1Win; // 1: 必贏, 0: 必和, -1: 必輸
                  isEndgameSolved = true;
                  break;
                }
              }

```

**4. 修改 `runAISearch` 中的調用(約 1342 行附近)**

**找到原代碼:**

```javascript
            while (tempDepth < depthLimit) {
              if (movesLeft <= nForEndgame) {
                let res = solveEndgame(simState, simPlayer, currentSimPieces, -1, 1);
                let p1Win = simPlayer === 1 ? res : -res;
                solvedAdvantage = p1Win;
                isEndgameSolved = true;
                break;
              }

```

**替換為:**

```javascript
            while (tempDepth < depthLimit) {
              if (movesLeft <= nForEndgame) {
                let opsObj = { count: 0 };
                let res = solveEndgame(simState, simPlayer, currentSimPieces, -1, 1, opsObj);
                
                if (res !== null) {
                  let p1Win = simPlayer === 1 ? res : -res;
                  solvedAdvantage = p1Win;
                  isEndgameSolved = true;
                  break;
                }
              }

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

這個AI怎麼越改越蠢了呢?是從哪步開始改壞的呢……

代码: 全选

[(0,0),(1,0)] [(0,-2),(0,-1)] [(0,2),(0,1)] [(1,-2),(1,-1)] [(0,-3),(-1,-3)] [(2,1),(1,1)] [(-1,1),(-1,0)] [(2,-1),(2,0)] [(2,2),(2,3)] [(0,-5),(0,-4)] [(1,-6),(0,-6)] [(-1,-4),(-1,-5)] [(0,4),(0,3)] [(1,-4),(1,-3)] [(2,-5),(1,-5)] [(2,-2),(2,-3)] [(3,-1),(3,0)] [(4,0),(4,-1)] [(3,2),(3,1)] [(0,5),(-1,5)] [(3,-2),(3,-3)] [(-1,3),(-1,4)] [(2,5),(2,4)] [(2,7),(2,6)] [(1,2),(1,3)] [(5,-2),(4,-2)] [(2,-4),(3,-4)] [(3,4),(3,3)] [(5,-1),(5,0)] [(1,8),(1,7)] [(-1,-1),(-1,-2)] [(-1,2),(-2,2)] [(4,-5),(3,-5)] [(5,-3),(6,-3)] [(1,5),(1,4)] [(3,8),(2,8)]
Screenshot from 2026-07-02 20-43-04.png
Screenshot from 2026-07-02 20-43-04.png (202.05 KiB) 查看 45 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6169
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

trirhombus2751.html

第二盤專家,9比5,AI到底出了什麼問題!
Screenshot from 2026-07-02 20-54-13.png
Screenshot from 2026-07-02 20-54-13.png (202.73 KiB) 查看 31 次
附件
trirhombus2751.html.7z
(24.58 KiB) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6169
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

改進:
一,結束則停止思考

當一局或兩局結束時,如果輪到AI,它還會思考,結果頁面卡死了。應該判斷場上棋子數量,如果達到36則停止思考。

輪到一方時它的分數背景變色,若一局結束則不再變色。

二,MCTS搜尋:
把期望常數C提高,大概提高至2.7。應把變量提到最前面,不要寫到兩個地方。

UCT 目前對每個子節點無條件計算 c.wins / c.visits。加入一個「最低訪問次數」門檻,讓還沒被充分模擬過的節點優先被選中,而不是立刻信任其期望值。

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


gemini3.1pro

代码: 全选

這是一個非常具體且有助於提升遊戲體驗與 AI 強度的優化需求。我已經為你整理好需要修改的代碼片段,請在你的 HTML 檔案中找到對應的部分並進行替換。

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

為了將常數統一管理,請在 `<script>` 標籤的最上方(約第 480 行,`let isAiThinking = false;` 的下方)加入兩個全域變數:

```javascript
let isAiThinking = false;

// --- 新增:MCTS 搜尋全域常數 ---
const MCTS_EXPLORATION_CONSTANT = 2.7; // 控制開發與探索的比例 (UCT 的 C 值)
const MCTS_MIN_VISITS = 3; // 節點最低訪問次數門檻,未達此門檻前優先探索

```

---

### 2. 修復:遊戲結束時停止 AI 思考與回合文字提示

在 `cleanUpSelection()` 函數中,我們需要在一開始就檢查場上棋子總數。如果歸零(達到 36 步),就直接中斷,避免觸發 AI 的 setTimeout。

**尋找(約第 1060 行):**

```javascript
function cleanUpSelection() {
  if (selectedBoxElement) selectedBoxElement.classList.remove('selected');
  currentSelectedPiece = null;
  selectedBoxElement = null;
  currentPlayer = currentPlayer === 1 ? 2 : 1;
  updateUI();

  let turnText = currentPlayer === 1 ? getText('yourTurn') : getText('oppTurn');
  if (isAutoRotate) turnText = getText('autoTurn');

  if (playerTypes[currentPlayer] !== 'human') {
    document.getElementById('ai-thinking-toast').style.display = 'none';
    showNotification('AI 思考中...', true); // true 代表消息框持續顯示
    setTimeout(triggerAITurn, 100);
  } else {
    document.getElementById('ai-thinking-toast').style.display = 'none';
    showNotification(turnText);
  }
}

```

**替換為:**

```javascript
function cleanUpSelection() {
  if (selectedBoxElement) selectedBoxElement.classList.remove('selected');
  currentSelectedPiece = null;
  selectedBoxElement = null;
  currentPlayer = currentPlayer === 1 ? 2 : 1;
  updateUI();

  // 新增:檢查遊戲是否結束,若無剩餘棋子則直接終止,避免觸發 AI 思考或顯示回合
  let totalPieces = piecesLeft[1].reduce((a, b) => a + b) + piecesLeft[2].reduce((a, b) => a + b);
  if (totalPieces === 0) return;

  let turnText = currentPlayer === 1 ? getText('yourTurn') : getText('oppTurn');
  if (isAutoRotate) turnText = getText('autoTurn');

  if (playerTypes[currentPlayer] !== 'human') {
    document.getElementById('ai-thinking-toast').style.display = 'none';
    showNotification('AI 思考中...', true); // true 代表消息框持續顯示
    setTimeout(triggerAITurn, 100);
  } else {
    document.getElementById('ai-thinking-toast').style.display = 'none';
    showNotification(turnText);
  }
}

```

---

### 3. 修復:遊戲結束時取消雙方分數背景變色

在 `updateLayoutForTurn()` 中,加入判斷式,當局數結束時清除背景色。

**尋找(約第 1326 行):**

```javascript
  // auto-rotate-btn 樣式與綠色圓圈標示輪次
  const scorearea1 = document.getElementById('score-area-1');
  const scorearea2 = document.getElementById('score-area-2');

  let bgColor = 'rgb(213, 247, 207)';

  if (scorearea1 && scorearea2) {
    if (currentPlayer === 1) {
      // 輪到我方(下)
      scorearea1.style.background = bgColor;
      scorearea2.style.background = 'transparent';
    } else {
      scorearea1.style.background = 'transparent';
      scorearea2.style.background = bgColor;
    }
  }
}

```

**替換為:**

```javascript
  // auto-rotate-btn 樣式與綠色圓圈標示輪次
  const scorearea1 = document.getElementById('score-area-1');
  const scorearea2 = document.getElementById('score-area-2');

  let bgColor = 'rgb(213, 247, 207)';

  // 新增:判斷遊戲是否結束,若結束則清除雙方背景變色
  let totalPieces = piecesLeft[1].reduce((a, b) => a + b) + piecesLeft[2].reduce((a, b) => a + b);
  if (totalPieces === 0 || gameState === 'ended') {
    if (scorearea1) scorearea1.style.background = 'transparent';
    if (scorearea2) scorearea2.style.background = 'transparent';
    return;
  }

  if (scorearea1 && scorearea2) {
    if (currentPlayer === 1) {
      // 輪到我方(下)
      scorearea1.style.background = bgColor;
      scorearea2.style.background = 'transparent';
    } else {
      scorearea1.style.background = 'transparent';
      scorearea2.style.background = bgColor;
    }
  }
}

```

---

### 4. 改進:MCTS UCT 演算法(`triggerAITurn` 與 `runAISearch`)

這會將你原本寫死的 `C = 1.414` 替換為全域變數,並加入訪問次數(Visits)未達門檻時強制探索的邏輯。你需要改兩個地方:

#### 4-1. 在 `triggerAITurn()` 函數中

**尋找(約第 1555 行):**

```javascript
      // Selection
      while (node.unexpanded.length === 0 && node.children.length > 0) {
        let bestUCT = -Infinity,
          bestChild = null;
        for (let c of node.children) {
          // 標準化探索常數 C
          let C = 1.414; // 即 Math.sqrt(2),控制開發與探索的比例
          let uct = c.wins / c.visits + C * Math.sqrt(Math.log(node.visits) / c.visits);
          if (uct > bestUCT) {
            bestUCT = uct;
            bestChild = c;
          }
        }

```

**替換為:**

```javascript
      // Selection
      while (node.unexpanded.length === 0 && node.children.length > 0) {
        let bestUCT = -Infinity,
          bestChild = null;
        for (let c of node.children) {
          let uct;
          if (c.visits < MCTS_MIN_VISITS) {
            // 尚未達到最低訪問次數,給予極高權重強制探索
            uct = 10000 + Math.random();
          } else {
            // 使用全域常數計算 UCT
            uct = c.wins / c.visits + MCTS_EXPLORATION_CONSTANT * Math.sqrt(Math.log(node.visits) / c.visits);
          }
          if (uct > bestUCT) {
            bestUCT = uct;
            bestChild = c;
          }
        }

```

#### 4-2. 在 `runAISearch()` 函數中

**尋找(約第 1735 行):**

```javascript
      // 1. Selection
      while (node.unexpanded.length === 0 && node.children.length > 0) {
        let bestUCT = -Infinity,
          bestChild = null;
        for (let c of node.children) {
          let C = 1.414;
          let uct = c.wins / c.visits + C * Math.sqrt(Math.log(node.visits) / c.visits);
          if (uct > bestUCT) {
            bestUCT = uct;
            bestChild = c;
          }
        }

```

**替換為:**

```javascript
      // 1. Selection
      while (node.unexpanded.length === 0 && node.children.length > 0) {
        let bestUCT = -Infinity,
          bestChild = null;
        for (let c of node.children) {
          let uct;
          if (c.visits < MCTS_MIN_VISITS) {
            // 尚未達到最低訪問次數,給予極高權重強制探索
            uct = 10000 + Math.random();
          } else {
            // 使用全域常數計算 UCT
            uct = c.wins / c.visits + MCTS_EXPLORATION_CONSTANT * Math.sqrt(Math.log(node.visits) / c.visits);
          }
          if (uct > bestUCT) {
            bestUCT = uct;
            bestChild = c;
          }
        }

```

代码: 全选

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

claude sonnet5

代码: 全选

这个洞察非常关键——如果分数可以在最后几步才「兑现」,那当前局面的 `calculateScores()` 快照本质上是不可靠的、还会随最后阶段大幅翻转的暂时值。目前程式把这个「还没定案」的绝对分数直接塞进 `tanh(p1Diff/10)`,会造成两个问题:

1. **常数偏移淹没搜索差异**:由于 rootState 里已经落子的部分(那些分数还没兑现、但已经贡献进 `calculateScores()`)对这次搜索的所有 rollout 都是**相同的固定偏移量**,但 `tanh` 是非线性函数——一旦这个偏移把 `p1Diff` 推向较大值,`tanh` 就会提前饱和到接近 ±1,导致不同候选走法之间「真正因为这次搜索模拟出来的差异」被压缩到几乎看不出来(饱和区间导数趋近 0)。
2. **误信未兑现的分数**:既然分数会在残局才兑现,当前 `p1Diff` 的绝对值本身就不该被当作「优势」的可靠指标。

---

## 解法:改用「相对于 root 的分数变化量」而非绝对分数

核心想法:**先算出 root 局面本身的分数差,之后每次 rollout 只用「这次搜索新增的分数变化」来算 advantage**,把已经存在、还没兑现的固定偏移量扣除掉。

### 修改位置 1:`triggerAITurn()`(约第 2979 行起)

在建立 root 之后,先算出 baseline:

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

// 新增:記錄 root 當下的分數差作為基準,之後只看「相對變化量」
let rootScores = rootState.calculateScores();
let rootDiff = rootScores.p1Score - rootScores.p2Score;
```

### 修改位置 2:Backpropagation 段(约第 3082–3088 行)

```js
let advantage;
if (isEndgameSolved) {
  advantage = solvedAdvantage; // 必殺判定本身就是精確解,不受影響,維持原樣
} else {
  let scores = simState.calculateScores();
  let p1Diff = scores.p1Score - scores.p2Score;

  // 改動重點:扣除 root 基準值,只用「這次搜索造成的變化量」進 tanh
  let deltaDiff = p1Diff - rootDiff;
  advantage = Math.tanh(deltaDiff / 10.0);
}
```

同样的逻辑要同步套用到第二份重复的 MCTS 迴圈(约第 3248 行 `analyzeMoveDeep` 之类的函数,第 3355 行附近的 `p1Diff` 那段)。

---

## 为什么这样改能达到你要的效果

- **当前分数不再直接决定优劣**:无论 root 局面当下 p1 领先多少分,这个领先量会被当作 baseline 扣掉,UCT 比较的是「各分支从现在开始还能再抢多少分」,而不是「现在看起来谁比较厉害」。这正好呼应你说的「分数要到最后几步才拿得到」——搜索关注的应该是**未来还能变动的部分**,而不是**已经存在但尚未确定是否保得住的部分**。
- **避免 tanh 提前饱和**:因为 `deltaDiff` 通常远小于原始 `p1Diff`(已经扣掉了大部分固定偏移),数值会落在 tanh 更线性、更敏感的区间,让不同候选走法之间真正的差异能被辨识出来,而不是全部挤在 ±1 附近变得难以区分。

---

## 补充建议:搭配调整必杀判定深度

由于这个游戏的分数在残局才兑现,`solveEndgame` 精确求解的重要性比启发式评分更高。可以考虑把第 3048 行的:
```js
let nForEndgame = parseInt(document.getElementById('ai-depth').value) || 6;
```
适度调大(如果计算资源允许),让更多步数交给精确必杀判定处理,而不是依赖可能失真的 `tanh(deltaDiff/10)` 启发式评分——这样能进一步降低「当前分数」这种不可靠中间值对整体搜索质量的影响。

要不要我直接帮你把这两处改动写进档案里?
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

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