代码: 全选
這是一個非常棒的改進計畫!為了將您的 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;
}
```