分页: 3 / 3

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

发表于 : 2026年 6月 15日 10:04
ejsoon
改進:
在開局階段,「行走撤銷」和「行走重做」應無效。在行動階段,「行走撤銷」和「行走重做」應只能在當前行動方的回合中撤銷和重做。

當開局階段結束,行動階段開始時,turnUndo()仍可回到開局階段的最後一步。

ai-status窗口在AI思考結束後並不會關閉,而是顯示思考用時、勝率,直到行動結束才會關閉。

AI算法中,應加上「必殺」檢查,獲得六分即贏(初始為一分)。必殺計算深度為2,在代碼中作為可更改的變量。

「藍方AI」按鈕應改為一個「正六邊形內有一個AI機器人」的SVG圖標,「綠方AI」按鈕應改為一個「正三角形內有一個AI機器人」的SVG圖標,圖標的尺寸為24*24。

ai-strength應顯示三種強度AI機器人的SVG圖標,尺寸亦為24*24。當點擊展開菜單時,列出AI機器人圖標及其對應的秒數(精確到0.1秒)。

遊戲開始時,視角改為俯視。調整大小和中心位置使其初始時不會被下方的div#history-controls遮擋。

需要明確給出所要修改的地方和所要替換的代碼,不用給出全部代碼。

代码: 全选

改進:
在開局階段,「行走撤銷」和「行走重做」應無效。在行動階段,「行走撤銷」和「行走重做」應只能在當前行動方的回合中撤銷和重做。

當開局階段結束,行動階段開始時,turnUndo()仍可回到開局階段的最後一步。

ai-status窗口在AI思考結束後並不會關閉,而是顯示思考用時、勝率,直到行動結束才會關閉。

AI算法中,應加上「必殺」檢查,獲得六分即贏(初始為一分)。必殺計算深度為2,在代碼中作為可更改的變量。

「藍方AI」按鈕應改為一個「正六邊形內有一個AI機器人」的SVG圖標,「綠方AI」按鈕應改為一個「正三角形內有一個AI機器人」的SVG圖標,圖標的尺寸為24*24。

ai-strength應顯示三種強度AI機器人的SVG圖標,尺寸亦為24*24。當點擊展開菜單時,列出AI機器人圖標及其對應的秒數(精確到0.1秒)。

遊戲開始時,視角改為俯視。調整大小和中心位置使其初始時不會被下方的div#history-controls遮擋。

需要明確給出所要修改的地方和所要替換的代碼,不用給出全部代碼。

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

发表于 : 2026年 6月 15日 10:23
ejsoon
gemini 3.1 pro真的吃了:

代码: 全选

這裡為您整理了所需修改的區塊與對應的代碼替換,這將滿足您對 UI、撤銷邏輯、AI 狀態顯示以及增加「必殺計算」的需求。

### 1. 修改 CSS(增加自定義下拉選單樣式)

請在 `<style>` 區塊內,將下列代碼加入到 `#ai-status` 或 `.ai-toggle.active-green` 的下方:

```css
      /* 新增的自定義下拉選單樣式 */
      .custom-select-container {
        position: relative;
        display: flex;
        align-items: center;
      }
      .custom-select-trigger {
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .custom-select-options {
        position: absolute;
        bottom: 110%;
        left: 50%;
        transform: translateX(-50%);
        background-color: #34495e;
        border: 2px solid #bdc3c7;
        border-radius: 6px;
        display: none;
        flex-direction: column;
        min-width: 140px;
        z-index: 100;
        overflow: hidden;
      }
      .custom-select-options.open {
        display: flex;
      }
      .custom-option {
        padding: 10px;
        display: flex;
        align-items: center;
        gap: 10px;
        color: #fff;
        cursor: pointer;
        transition: background-color 0.2s;
        font-size: 14px;
      }
      .custom-option:hover {
        background-color: #4e6a85;
      }
      .custom-option svg {
        flex-shrink: 0;
      }
      .ai-toggle svg {
        pointer-events: none;
      }

```

### 2. 修改 HTML 控制區(替換 AI 按鈕及下拉選單)

請找到 `<div id="history-controls">` 區塊中,關於 AI 控制的按鈕和 `<select>`:

**原始代碼:**

```html
      <div style="width: 2px; background: #7f8c8d; margin: 0 5px"></div>
      <button id="btn-ai-blue" class="hist-btn ai-toggle" style="background-color: #34495e">藍方AI</button>
      <button id="btn-ai-green" class="hist-btn ai-toggle" style="background-color: #34495e">綠方AI</button>
      <select id="ai-strength" class="hist-btn" style="cursor: pointer; appearance: none; text-align: center">
        <option value="1200">簡單</option>
        <option value="3600" selected>困難</option>
        <option value="7200">專家</option>
      </select>

```

**替換為:**

```html
      <div style="width: 2px; background: #7f8c8d; margin: 0 5px"></div>
      <button id="btn-ai-blue" class="hist-btn ai-toggle" style="background-color: #34495e" title="藍方AI">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
          <polygon points="12,1 21.5,6.5 21.5,17.5 12,23 2.5,17.5 2.5,6.5" fill="none" stroke="currentColor" stroke-width="2"/>
          <path d="M15,10 h-6 v6 h6 z M10,8 h4 M11,6 h2 v2 h-2 z" fill="currentColor"/>
        </svg>
      </button>
      <button id="btn-ai-green" class="hist-btn ai-toggle" style="background-color: #34495e" title="綠方AI">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
          <polygon points="12,2 22,20 2,20" fill="none" stroke="currentColor" stroke-width="2"/>
          <path d="M15,12 h-6 v5 h6 z M10,10 h4 M11,8 h2 v2 h-2 z" fill="currentColor"/>
        </svg>
      </button>

      <div class="custom-select-container">
        <button id="ai-strength-trigger" class="hist-btn custom-select-trigger" title="AI 強度">
          <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12,2 A10,10 0 1,0 22,12 A10,10 0 0,0 12,2 Z M15,14 H9 V10 H15 Z M12,6 A2,2 0 1,1 10,8 A2,2 0 0,1 12,6 Z"/></svg>
        </button>
        <div id="ai-strength-options" class="custom-select-options">
          <div class="custom-option" data-value="1200">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12,2 A10,10 0 1,0 22,12 A10,10 0 0,0 12,2 Z M14,14 H10 V12 H14 Z M12,8 A1.5,1.5 0 1,1 10.5,9.5 A1.5,1.5 0 0,1 12,8 Z"/></svg>
            簡單 (1.2s)
          </div>
          <div class="custom-option" data-value="3600">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12,2 A10,10 0 1,0 22,12 A10,10 0 0,0 12,2 Z M15,14 H9 V10 H15 Z M12,6 A2,2 0 1,1 10,8 A2,2 0 0,1 12,6 Z"/></svg>
            困難 (3.6s)
          </div>
          <div class="custom-option" data-value="7200">
            <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12,2 A10,10 0 1,0 22,12 A10,10 0 0,0 12,2 Z M16,15 H8 V9 H16 Z M12,4 A2.5,2.5 0 1,1 9.5,6.5 A2.5,2.5 0 0,1 12,4 Z"/></svg>
            專家 (7.2s)
          </div>
        </div>
      </div>

```

### 3. 修改視角及 UI 事件榜定初始化

請找到 JS 開頭的相機與控制代碼,以及中間綁定 AI UI 事件的部分進行替換:

**原始代碼:**

```javascript
      const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 2000);
      camera.position.set(0, 500, 500);
      // ...
      const controls = new OrbitControls(camera, renderer.domElement);
      controls.enablePan = false;

```

**替換為:**

```javascript
      const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 2000);
      camera.position.set(0, 1100, 150); // 改為俯視角並稍微向Z軸偏移避開下方按鈕
      // ...
      const controls = new OrbitControls(camera, renderer.domElement);
      controls.target.set(0, 0, 150);
      controls.enablePan = false;

```

**原始代碼:**

```javascript
      const btnAiBlue = document.getElementById('btn-ai-blue');
      const btnAiGreen = document.getElementById('btn-ai-green');
      const selectAiStrength = document.getElementById('ai-strength');

```

**替換為:**

```javascript
      const btnAiBlue = document.getElementById('btn-ai-blue');
      const btnAiGreen = document.getElementById('btn-ai-green');
      
      const aiStrengthTrigger = document.getElementById('ai-strength-trigger');
      const aiStrengthOptions = document.getElementById('ai-strength-options');

```

並且在腳本最下方 `animate();` 呼叫之前,加入新的下拉選單事件,**替換掉原本的 `selectAiStrength.addEventListener('change', ...)`:**

```javascript
      aiStrengthTrigger.addEventListener('click', (e) => {
        e.stopPropagation();
        aiStrengthOptions.classList.toggle('open');
      });

      document.addEventListener('click', () => {
        aiStrengthOptions.classList.remove('open');
      });

      document.querySelectorAll('.custom-option').forEach(opt => {
        opt.addEventListener('click', () => {
          aiStrength = parseInt(opt.getAttribute('data-value'));
          aiStrengthTrigger.innerHTML = opt.querySelector('svg').outerHTML;
          aiStrengthOptions.classList.remove('open');
        });
      });

```

### 4. 撤銷與重做邏輯改進

找到 `updateUndoRedoButtons`, `walkUndo`, `walkRedo`, `turnUndo` 這四個函式,將它們**替換為**以下代碼,確保撤銷與回合後退行為嚴格符合您的新規則:

```javascript
      function updateUndoRedoButtons() {
        const wUndo = document.getElementById('btn-walk-undo');
        const wRedo = document.getElementById('btn-walk-redo');
        const tUndo = document.getElementById('btn-turn-undo');
        const tRedo = document.getElementById('btn-turn-redo');
        if (!wUndo) return;

        if (!gameplayActive) {
          // 【開局階段】行走撤銷與重做完全無效
          wUndo.disabled = true;
          wRedo.disabled = true;
          tUndo.disabled = historyUndoStack.length === 0;
          tRedo.disabled = historyRedoStack.length === 0;
        } else {
          // 【對戰階段】只能在當前行動方的回合內進行行走撤銷重做
          let canWalkUndo = false;
          if (historyUndoStack.length > 0) {
            let lastSnap = historyUndoStack[historyUndoStack.length - 1];
            if (lastSnap.currentPlayer === currentPlayer && lastSnap.gameplayActive) canWalkUndo = true;
          }
          wUndo.disabled = !canWalkUndo;

          let canWalkRedo = false;
          if (historyRedoStack.length > 0) {
            let nextSnap = historyRedoStack[historyRedoStack.length - 1];
            if (nextSnap.currentPlayer === currentPlayer && nextSnap.gameplayActive) canWalkRedo = true;
          }
          wRedo.disabled = !canWalkRedo;

          // 回合後退:找到上一個回合的起始狀態,或者能退回到開局階段
          let hasTurnUndo = false;
          for (let i = historyUndoStack.length - 1; i >= 0; i--) {
            let snap = historyUndoStack[i];
            if (!snap.gameplayActive || (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk')) {
              hasTurnUndo = true;
              break;
            }
          }
          tUndo.disabled = !hasTurnUndo;

          let hasTurnRedo = false;
          for (let i = historyRedoStack.length - 1; i >= 0; i--) {
            let snap = historyRedoStack[i];
            if (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk') {
              hasTurnRedo = true;
              break;
            }
          }
          tRedo.disabled = !hasTurnRedo;
        }
      }

      function walkUndo() {
        if (historyUndoStack.length === 0) return;
        if (gameplayActive) {
          let lastSnap = historyUndoStack[historyUndoStack.length - 1];
          if (lastSnap.currentPlayer !== currentPlayer || !lastSnap.gameplayActive) return;
        }
        historyRedoStack.push(saveSnapshot());
        restoreSnapshot(historyUndoStack.pop());
        updateUndoRedoButtons();
      }

      function walkRedo() {
        if (historyRedoStack.length === 0) return;
        if (gameplayActive) {
          let nextSnap = historyRedoStack[historyRedoStack.length - 1];
          if (nextSnap.currentPlayer !== currentPlayer || !nextSnap.gameplayActive) return;
        }
        historyUndoStack.push(saveSnapshot());
        restoreSnapshot(historyRedoStack.pop());
        updateUndoRedoButtons();
      }

      function turnUndo() {
        let targetIdx = -1;
        for (let i = historyUndoStack.length - 1; i >= 0; i--) {
          let snap = historyUndoStack[i];
          if (!snap.gameplayActive || (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk')) {
            targetIdx = i;
            break;
          }
        }
        if (targetIdx === -1) return;

        historyRedoStack.push(saveSnapshot());
        while (historyUndoStack.length > targetIdx + 1) {
          historyRedoStack.push(historyUndoStack.pop());
        }
        restoreSnapshot(historyUndoStack.pop());
        updateUndoRedoButtons();
      }

```

### 5. 確保 AI 狀態面板直到行動結束後才關閉

找到 `finishAIAction` 以及 `triggerAIIfReady`:

**原始代碼:**

```javascript
      function triggerAIIfReady() {
        if (markers.length > 0 && !isAIThinking) {
          const shouldRun = (currentTurnColor === 'blue' && isBlueAI) || (currentTurnColor === 'green' && isGreenAI);
          if (shouldRun) {
            checkAndRunAI();
          }
        }
      }

      function finishAIAction(selectedMarker, timeTaken, winRateText) {
        const statusDiv = document.getElementById('ai-status');
        statusDiv.innerHTML = `思考結束<br>用時: ${timeTaken}s<br>勝率: ${winRateText}`;

        setTimeout(() => {
          statusDiv.style.display = 'none';
          document.body.style.pointerEvents = 'auto';
          // ...省略...

```

**替換為:**

```javascript
      function triggerAIIfReady() {
        const statusDiv = document.getElementById('ai-status');
        if (markers.length > 0 && !isAIThinking) {
          const shouldRun = (currentTurnColor === 'blue' && isBlueAI) || (currentTurnColor === 'green' && isGreenAI);
          if (shouldRun) {
            checkAndRunAI();
          } else {
            statusDiv.style.display = 'none'; // 行動結束,輪到人類時才隱藏思考數據
          }
        }
      }

      function finishAIAction(selectedMarker, timeTaken, winRateText) {
        const statusDiv = document.getElementById('ai-status');
        statusDiv.innerHTML = `思考結束<br>用時: ${timeTaken}s<br>勝率: ${winRateText}`;

        setTimeout(() => {
          // 不在此處隱藏 statusDiv
          document.body.style.pointerEvents = 'auto';
          isAIThinking = false;
          if (selectedMarker) {
            let activeMarker = markers.find(
              m =>
                Math.abs(m.position.x - selectedMarker.position.x) < 1 &&
                Math.abs(m.position.z - selectedMarker.position.z) < 1
            );

            if (activeMarker) {
              activeMarker.userData.onClick();
            }
          }
        }, 800);
      }

```

### 6. AI 演算法:計分與「深度必殺檢查」

首先,修改 `generateAllValidTurns` 和 `applyTurnToState` 來攜帶真實的加分扣分:

**在 `generateAllValidTurns` 中找到原本設定 `scoreDelta` 和 `moves.push(...)` 的區段替換:**

```javascript
              // 替換這段
              let earnedPoints = 0;
              let penaltyPoint = 0;
              micsAfter2.forEach(m => {
                if (m.color === enColor && traversed3.some(t => t.c === m.c && t.r === m.r)) earnedPoints += 1;
              });

              if (micsAfter2.some(m => m.color === myColor && m.c === stop3.c && m.r === stop3.r)) {
                penaltyPoint = 1;
              }

              let scoreDelta = earnedPoints * 100 - penaltyPoint * 150 + (10 - (Math.abs(stop3.c - 3.5) + Math.abs(stop3.r - 3.5))) * 2;

              let finalMics = micsAfter2.filter(m => {
                if (m.color === enColor && traversed3.some(t => t.c === m.c && t.r === m.r)) return false;
                if (m.color === myColor && m.c === stop3.c && m.r === stop3.r) return false;
                return true;
              });

              const seqBase = [
                { type: 'walk', c: stop1.c, r: stop1.r },
                { type: 'walk', c: stop2.c, r: stop2.r },
                { type: 'walk', c: stop3.c, r: stop3.r }
              ];

              const ownMicsCount = finalMics.filter(m => m.color === myColor).length;
              let addedMic = false;
              if (ownMicsCount < 4) {
                const spots = [stop1, stop2].filter(s => !finalMics.some(m => m.c === s.c && m.r === s.r));
                if (spots.length > 0) {
                  const micSpot = spots[0];
                  moves.push({
                    sequence: [...seqBase, { type: 'mic', c: micSpot.c, r: micSpot.r }],
                    scoreDelta: scoreDelta + (10 - (Math.abs(micSpot.c - 3.5) + Math.abs(micSpot.r - 3.5))),
                    finalPos: stop3,
                    finalMics: [...finalMics, { color: myColor, c: micSpot.c, r: micSpot.r }],
                    earnedPoints: earnedPoints,
                    penaltyPoint: penaltyPoint
                  });
                  addedMic = true;
                }
              }

              if (!addedMic) {
                moves.push({ sequence: seqBase, scoreDelta, finalPos: stop3, finalMics, earnedPoints, penaltyPoint });
              }

```

**在 `applyTurnToState` 中,加入比分追蹤:**

```javascript
      function applyTurnToState(state, move) {
        const nextPlayer = state.player === 'blue' ? 'green' : 'blue';
        const isAI = state.player === currentPlayer;

        let newAiScore = state.aiScore;
        let newOppScore = state.oppScore;

        if (isAI) {
          newAiScore += move.earnedPoints || 0;
          newOppScore += move.penaltyPoint || 0;
        } else {
          newOppScore += move.earnedPoints || 0;
          newAiScore += move.penaltyPoint || 0;
        }

        return {
          player: nextPlayer,
          aiPos: isAI ? move.finalPos : state.aiPos,
          oppPos: isAI ? state.oppPos : move.finalPos,
          mics: move.finalMics,
          scoreDiff: state.scoreDiff + (isAI ? move.scoreDelta : -move.scoreDelta),
          aiScore: newAiScore,
          oppScore: newOppScore
        };
      }

```

**新增必殺搜尋函數並修改 `planFullTurnActionsMCTS` :**
在 `async function planFullTurnActionsMCTS(timeLimit)` 這個函式的上方加入必殺邏輯:

```javascript
      let SURE_KILL_DEPTH = 2; // 必勝計算深度

      function checkSureKill(state, depth, isMaximizingPlayer) {
        if (state.aiScore >= 6) return true;
        if (state.oppScore >= 6) return false;
        if (depth <= 0) return false;

        const moves = generateAllValidTurns(state);
        if (moves.length === 0) return false;

        if (isMaximizingPlayer) {
          for (let move of moves) {
            const nextState = applyTurnToState(state, move);
            if (checkSureKill(nextState, depth - 1, false)) return true;
          }
          return false;
        } else {
          for (let move of moves) {
            const nextState = applyTurnToState(state, move);
            if (!checkSureKill(nextState, depth - 1, true)) return false;
          }
          return true;
        }
      }

      async function planFullTurnActionsMCTS(timeLimit) {
        const aiColor = currentPlayer;
        const oppColor = aiColor === 'blue' ? 'green' : 'blue';
        const activeCube = cubes.find(q => q.userData.color === aiColor);
        const oppCube = cubes.find(q => q.userData.color === oppColor);

        const rootState = {
          player: aiColor,
          aiPos: { c: activeCube.userData.col, r: activeCube.userData.row },
          oppPos: { c: oppCube ? oppCube.userData.col : -1, r: oppCube ? oppCube.userData.row : -1 },
          mics: microphones.map(m => ({ color: m.userData.color, c: m.userData.col, r: m.userData.row })),
          scoreDiff: 0,
          aiScore: activeCube.userData.score,
          oppScore: oppCube ? oppCube.userData.score : 1
        };

        // --- 優先進行必勝步深度檢查 ---
        const initMoves = generateAllValidTurns(rootState);
        for (let move of initMoves) {
          const nextState = applyTurnToState(rootState, move);
          if (checkSureKill(nextState, SURE_KILL_DEPTH - 1, false)) {
             return move.sequence; // 觸發必殺條件,直接執行
          }
        }
        // --------------------------------

        const rootNode = new MCTSNode(null, rootState, null);
        // ... (原來的MCTS while迴圈邏輯保持不變) ...

```

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

发表于 : 2026年 6月 15日 14:07
ejsoon
改進:
當開局階段結束,行動階段開始時,turnUndo()仍可退回到開局階段的最後一步,這時如果再turnRedo()則會再回到行動階段的首回合。

ai-status窗口在AI思考結束後要顯示思考用時和勝率。

檢查AI算法和必殺算法是否正確理解規則,說明目前AI算法所理解的規則。檢查AI算法中是否有需要修正和改進的地方。

需要明確給出所要修改的地方和所要替換的代碼,不用給出全部代碼。

代码: 全选

改進:
當開局階段結束,行動階段開始時,turnUndo()仍可退回到開局階段的最後一步,這時如果再turnRedo()則會再回到行動階段的首回合。

ai-status窗口在AI思考結束後要顯示思考用時和勝率。

檢查AI算法和必殺算法是否正確理解規則,說明目前AI算法所理解的規則。檢查AI算法中是否有需要修正和改進的地方。

需要明確給出所要修改的地方和所要替換的代碼,不用給出全部代碼。

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

发表于 : 2026年 6月 15日 14:16
ejsoon
gemini 3.1 pro

代码: 全选

這款 3D 桌遊的開發非常精彩!蒙地卡洛樹搜尋 (MCTS) 與歷史紀錄棧 (Undo/Redo) 在邏輯狀態管理上非常複雜,你目前建立的框架已經相當有水準。

針對你提出的需求與問題,我整理了目前 AI 的邏輯分析、需要修正的致命 Bug,以及你需要替換的具體程式碼片段。

### 一、 目前 AI 演算法所理解的規則與診斷

**1. AI 目前正確理解的規則:**

* **物理空間與障礙物:** AI 知道棋盤邊界、圓柱體、以及對手立方體是不可逾越的障礙 (`simIsObstacleEnv`)。
* **行走限制:** AI 知道第一步可以走四個方向,第二、第三步必須左轉或右轉 90 度,且遇到死胡同才能 180 度後退 (`generateAllValidTurns`)。
* **話筒吃分機制:** AI 知道經過對手話筒會得分 (`earnedPoints`),停在自己話筒會被扣分/送分給對手 (`penaltyPoint`)。
* **佈局策略 (Heuristic):** AI 在評估分數時,內建了一個「向中心靠攏」的啟發式獎勵 `(10 - 距離) * 2`,這會讓 AI 在找不到明顯得分機會時,傾向於佔據棋盤中央。

**2. AI 算法中需要修正的致命問題 (Bugs):**

* **幽靈分數 (NaN) 導致必殺技失效:** 在 `planFullTurnActionsMCTS` 建立 `rootState` 時,**遺漏了初始化 `aiScore` 和 `oppScore**`。這導致後續 `applyTurnToState` 裡所有的加法都變成 `NaN`。因此,`checkSureKill` 裡的 `if (state.aiScore >= 6)` 永遠是 `false`,AI 實際上根本不知道自己快贏了或快輸了!
* **模擬不會提前結束:** 在 `simulateRandomPlayout` 隨機模擬時,就算某方分數已經大於等於 6 分(遊戲理論上已結束),它還會繼續傻傻地模擬到底,這會干擾最終的勝率與期望值判斷。
* **MCTS 勝率未能回傳 UI:** 目前 `doMCTSAI` 裡面的字串寫死了 `"MCTS 計算完成"`,沒有提取根節點子節點的統計數據。

---

### 二、 具體程式碼替換

以下是針對你的需求,精準替換的 4 個程式碼區塊。

#### 1. 修正歷史紀錄跨階段重做 (`turnRedo`)

為了讓開局階段退回後,能再次前進到正式對戰的首回合,請找到 `turnRedo()` 函數並整段替換:

```javascript
      function turnRedo() {
        let targetIdx = -1;
        // 尋找下一個回合的起點,或是從開局過渡到對戰的第一步
        for (let i = historyRedoStack.length - 1; i >= 0; i--) {
          let snap = historyRedoStack[i];
          if (snap.gameplayActive && (!gameplayActive || snap.currentPlayer !== currentPlayer) && snap.walkCount === 1 && snap.subPhase === 'walk') {
            targetIdx = i;
            break;
          }
        }

        if (targetIdx === -1) {
          // 若無回合可切換,且在開局階段,退化為 walkRedo
          if (!gameplayActive) {
            walkRedo();
          }
          return;
        }

        historyUndoStack.push(saveSnapshot());
        while (historyRedoStack.length > targetIdx + 1) {
          historyUndoStack.push(historyRedoStack.pop());
        }
        restoreSnapshot(historyRedoStack.pop());
        updateUndoRedoButtons();
      }

```

#### 2. 修正 MCTS 根狀態 (解決分數 NaN 的 Bug) 並回傳勝率結構

請找到 `planFullTurnActionsMCTS(timeLimit)`,修改 `rootState` 的定義,並在最後更改 `return` 的結構:

**替換 `rootState` 到 `checkSureKill` 的部分:**

```javascript
        const rootState = {
          player: aiColor,
          aiPos: { c: activeCube.userData.col, r: activeCube.userData.row },
          oppPos: { c: oppCube ? oppCube.userData.col : -1, r: oppCube ? oppCube.userData.row : -1 },
          mics: microphones.map(m => ({ color: m.userData.color, c: m.userData.col, r: m.userData.row })),
          scoreDiff: 0,
          // 【修正】初始化分數,解決 NaN 問題,激活必殺演算法
          aiScore: activeCube.userData.score, 
          oppScore: oppCube ? oppCube.userData.score : 0 
        };

        const initMoves = generateAllValidTurns(rootState);
        for (let move of initMoves) {
          const nextState = applyTurnToState(rootState, move);
          if (checkSureKill(nextState, SURE_KILL_DEPTH - 1, false)) {
             // 【修正】打上必殺標記,包裝成物件回傳
            return { sequence: move.sequence, isSureKill: true }; 
          }
        }

```

**替換該函數最底部的 `return`:**

```javascript
        console.log(`MCTS 執行了 ${iterations} 次迭代`);

        const bestChild = rootNode.getMostVisitedChild();
        // 【修正】回傳完整分析物件,供 UI 提取勝率
        return { 
          sequence: bestChild ? bestChild.actionSequence : [], 
          rootNode: rootNode, 
          isSureKill: false 
        };
      }

```

#### 3. 讓模擬遇到終局時提早結束 (優化效能與精準度)

請找到 `simulateRandomPlayout`,在 `for` 迴圈的一開始加入提前結束的判斷:

```javascript
      function simulateRandomPlayout(state, aiColor, depthLimit) {
        let currState = state;
        for (let i = 0; i < depthLimit; i++) {
          // 【新增】如果已經觸發勝負條件,給予極端權重並立即停止模擬
          if (currState.aiScore >= 6) return currState.scoreDiff + 10000;
          if (currState.oppScore >= 6) return currState.scoreDiff - 10000;

          const validMoves = generateAllValidTurns(currState);
          if (validMoves.length === 0) break;
          // 隨機挑選一條路徑
          const randomMove = validMoves[Math.floor(Math.random() * validMoves.length)];
          currState = applyTurnToState(currState, randomMove);
        }
        return currState.scoreDiff;
      }

```

#### 4. 在 UI 顯示思考用時與預期勝率

請找到 `doMCTSAI()` 函數,處理剛才修改的 MCTS 回傳結構,並計算 Sigmoid 勝率:

**整段替換 `doMCTSAI`:**

```javascript
      async function doMCTSAI() {
        const startTime = performance.now();
        let winRateText = "N/A"; // 【新增】勝率文字

        if (walkCount === 1) {
          const timeLimit = typeof aiStrength !== 'undefined' ? aiStrength : 7000;
          
          // 【修正】接收新的回傳結構
          const result = await planFullTurnActionsMCTS(timeLimit);
          aiPlannedActions = result.sequence;

          if (result.isSureKill) {
            winRateText = "100% (必勝局面)";
          } else if (result.rootNode) {
            const bestChild = result.rootNode.getMostVisitedChild();
            if (bestChild) {
              const expectedScore = bestChild.totalScore / bestChild.visits;
              // 【新增】將期望分數差轉換為百分比勝率 (使用 Sigmoid 函數平滑化)
              const winRate = (1 / (1 + Math.exp(-expectedScore / 200))) * 100;
              winRateText = `${winRate.toFixed(1)}%`;
            }
          }
        }

        if (!aiPlannedActions || aiPlannedActions.length === 0) {
          console.warn('MCTS 未能計算出動作,使用防呆預設');
          return;
        }

        const firstAction = aiPlannedActions.shift();
        const selectedMarker = markers.find(m => {
          const mc = Math.round(m.position.x / 60 + 3.5);
          const mr = Math.round(m.position.z / 60 + 3.5);
          return mc === firstAction.c && mr === firstAction.r;
        });

        const timeTaken = ((performance.now() - startTime) / 1000).toFixed(2);
        // 【修正】將算出的勝率傳入 UI
        finishAIAction(selectedMarker, timeTaken, winRateText);
      }

```

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

发表于 : 2026年 6月 15日 14:22
ejsoon
Screenshot from 2026-06-15 14-21-22.png
Screenshot from 2026-06-15 14-21-22.png (69.32 KiB) 查看 52 次
【重大問題】藍方明明將要獲勝,卻在最後一步亂走(上左下)。

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

发表于 : 2026年 6月 15日 19:14
ejsoon
改進:
當開局階段結束,行動階段開始時,btn-turn-undo應可點擊(現在是會變為disable),當點擊它時將退回到開局階段的最後一步,這時如果再點擊btn-turn-redo則會再回到行動階段的首回合。

當一局棋結束時,會展示一個全屏的文字提示「第n局結束!本局勝者:……」和「選擇是否再戰一局按鈕」的div元素。現在要在這個div元素的右上角加上一個最小化svg按鈕,點擊它將隱藏這個div。同時div#message將顯示「第n局結束!本局勝者:……」,其右方將出現一個最大化svg按鈕,當點擊時,之前的div元素將再次顯示。

當兩局棋結束時,或者第一局結束時選擇「結束遊戲顯示總分」,則出現的全屏文字提示也是一樣可以最小化和最大化。

當棋局結束時,div#history-controls不必隱藏,它將可以點擊回合回退。如果選擇再戰一局,仍可以選擇回退以選擇如何再戰一局。即使棋局全部結束,仍然可以回退到棋局結束前的最後一步。

設棋盤的横坐標為ABCDEFGH,縱坐標為abcdefgh,則當要放「角遊戲板」時,行動標示將改為:右下角Gg,左下角Bg,左上角Bb,右上角Gb。

增加記譜功能。記譜格式為:把所點擊的「行動標示」的坐標記下,坐標用逗號「,」分隔,行動方切換時用分號「;」分隔。

例如:「Gg;Bb;Df;Fe;Eh;Ad;Hc;Ca;Hg;Aa;Ag,Ae,Ee,Ae;...」,其中「Gg;Bb」表示「角遊戲板」選擇了右下角和左上角,「Df;Fe;Eh;Ad;Hc;Ca」是六個圓柱的擺放,「Hg;Aa」是兩個立方體出場的位置,「Ag,Ae,Ee,Ae」是藍方第一回合的行動(Ag,Ae,Ee是行走三步所停留的地方,最後一個Ae是放話筒的地方)。

如果第一局結束時,選擇「結束遊戲顯示總分」,則記作「;1」,如果選擇「當前場地再戰一局」,則記作「;2;...」,如果選擇「雙方換先再戰一局」,則記作「;3;...」,「;2;」和「;3;」的區別是,「;2;」接下來是立體放置,然後開始行動,而「;3;」後面將從選擇角遊戲板開始。

局面格式為json,包含以下屬性:圓柱體坐標,雙方立方體坐標,雙方話筒坐標,當前輪到哪一方,當前行動方在本回合已完成的行動(剛開始、已走一步、兩步、三步)。

在「AI強度選擇」的右邊增加一個「局面導入導出svg按鈕」,當點擊時,彈出選單,可選擇「導出棋譜、導出局面、導入棋局」。其中導入棋局既可以導入棋譜文本也可以導入局面文本,由程式自動分辨。

需要明確給出所有要修改的地方和要替換的代碼,不用給出全部代碼。

代码: 全选

改進:
當開局階段結束,行動階段開始時,btn-turn-undo應可點擊(現在是會變為disable),當點擊它時將退回到開局階段的最後一步,這時如果再點擊btn-turn-redo則會再回到行動階段的首回合。

當一局棋結束時,會展示一個全屏的文字提示「第n局結束!本局勝者:……」和「選擇是否再戰一局按鈕」的div元素。現在要在這個div元素的右上角加上一個最小化svg按鈕,點擊它將隱藏這個div。同時div#message將顯示「第n局結束!本局勝者:……」,其右方將出現一個最大化svg按鈕,當點擊時,之前的div元素將再次顯示。

當兩局棋結束時,或者第一局結束時選擇「結束遊戲顯示總分」,則出現的全屏文字提示也是一樣可以最小化和最大化。

當棋局結束時,div#history-controls不必隱藏,它將可以點擊回合回退。如果選擇再戰一局,仍可以選擇回退以選擇如何再戰一局。即使棋局全部結束,仍然可以回退到棋局結束前的最後一步。

設棋盤的横坐標為ABCDEFGH,縱坐標為abcdefgh,則當要放「角遊戲板」時,行動標示將改為:右下角Gg,左下角Bg,左上角Bb,右上角Gb。

增加記譜功能。記譜格式為:把所點擊的「行動標示」的坐標記下,坐標用逗號「,」分隔,行動方切換時用分號「;」分隔。

例如:「Gg;Bb;Df;Fe;Eh;Ad;Hc;Ca;Hg;Aa;Ag,Ae,Ee,Ae;...」,其中「Gg;Bb」表示「角遊戲板」選擇了右下角和左上角,「Df;Fe;Eh;Ad;Hc;Ca」是六個圓柱的擺放,「Hg;Aa」是兩個立方體出場的位置,「Ag,Ae,Ee,Ae」是藍方第一回合的行動(Ag,Ae,Ee是行走三步所停留的地方,最後一個Ae是放話筒的地方)。

如果第一局結束時,選擇「結束遊戲顯示總分」,則記作「;1」,如果選擇「當前場地再戰一局」,則記作「;2;...」,如果選擇「雙方換先再戰一局」,則記作「;3;...」,「;2;」和「;3;」的區別是,「;2;」接下來是立體放置,然後開始行動,而「;3;」後面將從選擇角遊戲板開始。

局面格式為json,包含以下屬性:圓柱體坐標,雙方立方體坐標,雙方話筒坐標,當前輪到哪一方,當前行動方在本回合已完成的行動(剛開始、已走一步、兩步、三步)。

在「AI強度選擇」的右邊增加一個「局面導入導出svg按鈕」,當點擊時,彈出選單,可選擇「導出棋譜、導出局面、導入棋局」。其中導入棋局既可以導入棋譜文本也可以導入局面文本,由程式自動分辨。

需要明確給出所有要修改的地方和要替換的代碼,不用給出全部代碼。

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

发表于 : 2026年 6月 15日 19:25
ejsoon
gemini 3.1 pro 真是太好用啦!

代码: 全选

以下是實現您所有需求的代碼修改指引,請根據步驟替換或新增對應的代碼塊:

### 1. 修改 HTML 結構(加入導入導出按鈕及模態框,更新消息列結構)

將原本的 `<div id="message"></div>` 替換為支援最大化與文字獨立的結構:

```html
    <div id="message" style="display: none; justify-content: center; align-items: center; gap: 10px;"></div>

```

在 `<div id="history-controls">` 內部,找到 `<div class="custom-select-container">` (AI 強度選擇器) 的結束標籤 `</div>` 後,**插入以下導入導出選單按鈕**:

```html
      <div style="width: 2px; background: #7f8c8d; margin: 0 5px"></div>
      <div class="custom-select-container">
        <button id="io-trigger" class="hist-btn custom-select-trigger" title="導入/導出">
          <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
            <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
          </svg>
        </button>
        <div id="io-options" class="custom-select-options">
          <div class="custom-option" id="btn-export-notation">導出棋譜</div>
          <div class="custom-option" id="btn-export-state">導出局面</div>
          <div class="custom-option" id="btn-import-game">導入棋局</div>
        </div>
      </div>

```

在 `<body>` 內,`<div id="ui-layer">` 下方(或整個腳本前面),加入導入/導出的彈出視窗 UI:

```html
    <div id="io-modal" style="display:none; position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:300; justify-content:center; align-items:center;">
      <div style="background:#34495e; padding:20px; border-radius:10px; width:80%; max-width:500px; text-align:center;">
         <h3 id="io-title" style="color:white; margin-top:0;">操作</h3>
         <textarea id="io-textarea" style="width:100%; height:150px; margin-bottom:10px; font-family:monospace; background:#ecf0f1; border-radius:5px; padding:10px;"></textarea>
         <div>
            <button id="io-btn-confirm" style="padding:10px 20px; margin-right:10px; cursor:pointer; font-weight:bold; border-radius:5px;">確認</button>
            <button id="io-btn-close" style="padding:10px 20px; cursor:pointer; font-weight:bold; border-radius:5px;">關閉</button>
         </div>
      </div>
    </div>

```

---

### 2. 定義記譜與 Replay 核心變數

在 `<script type="module">` 內,找到全域變數定義區塊(例如 `let historyUndoStack = [];` 附近),新增以下變數:

```javascript
      // ==========================================
      // [新增] 記譜與全局 UI 管理變數
      // ==========================================
      let gameNotation = "";
      let currentTurnMoves = [];
      let replayMoves = [];
      let isReplaying = false;
      let currentEndOverlay = null;

      function getNotation(c, r) {
        return String.fromCharCode(65 + c) + String.fromCharCode(97 + r);
      }

      function addMoveToNotation(c, r) {
        currentTurnMoves.push(getNotation(c, r));
      }

      function commitTurnNotation(suffix = "") {
        if (currentTurnMoves.length > 0) {
          if (gameNotation !== "") gameNotation += ";";
          gameNotation += currentTurnMoves.join(",");
          currentTurnMoves = [];
        }
        if (suffix) {
          gameNotation += ";" + suffix;
        }
      }

```

---

### 3. 修改跨階段 Undo/Redo 邏輯與記譜快照

**替換 `saveSnapshot` 與 `restoreSnapshot`:**
將記譜資訊存入與提取,並在 `restoreSnapshot` 中保證隱藏結算面板:

```javascript
      function saveSnapshot() {
        return {
          currentPlayer: currentPlayer,
          walkCount: walkCount,
          lastDirection: lastDirection ? { ...lastDirection } : null,
          turnStopPositions: JSON.parse(JSON.stringify(turnStopPositions)),
          gameplayActive: gameplayActive,
          subPhase: markers.some(m => m.position.y === 6) ? 'mic' : 'walk',

          currentPhaseFn: currentPhaseFn,
          blueCornerType: blueCornerType,
          orangeCornerType: orangeCornerType,
          placedEdges: { ...placedEdges },
          placedCorners: { ...placedCorners },
          edgeTurn: edgeTurn,
          cylindersData: JSON.parse(JSON.stringify(cylindersData)),

          // 記譜紀錄
          gameNotation: gameNotation,
          currentTurnMoves: [...currentTurnMoves],

          boardMeshesRefs: [...boardMeshes],
          cylinderMeshesRefs: [...cylinderMeshes],
          openingCubesRefs: [...cubes],

          cubes: cubes.map(c => ({
            color: c.userData.color, col: c.userData.col, row: c.userData.row,
            score: c.userData.score, currentNumber: c.userData.currentNumber, quat: c.quaternion.clone()
          })),
          microphones: microphones.map(m => ({ color: m.userData.color, col: m.userData.col, row: m.userData.row }))
        };
      }

      function restoreSnapshot(snap) {
        // [新增] 還原時若有結算畫面則強制關閉
        if (currentEndOverlay && currentEndOverlay.parentNode) {
            currentEndOverlay.parentNode.removeChild(currentEndOverlay);
        }
        currentEndOverlay = null;
        document.getElementById('message').style.display = 'none';

        currentPlayer = snap.currentPlayer;
        walkCount = snap.walkCount;
        lastDirection = snap.lastDirection;
        turnStopPositions = snap.turnStopPositions;
        gameplayActive = snap.gameplayActive;

        // 還原記譜
        gameNotation = snap.gameNotation || "";
        currentTurnMoves = snap.currentTurnMoves ? [...snap.currentTurnMoves] : [];

        // ... 保留其餘原始 restoreSnapshot 代碼不變 ...
        clearMarkers();
        if (!gameplayActive) {
            currentPhaseFn = snap.currentPhaseFn;
            blueCornerType = snap.blueCornerType;
            orangeCornerType = snap.orangeCornerType;
            placedEdges = { ...snap.placedEdges };
            placedCorners = { ...snap.placedCorners };
            edgeTurn = snap.edgeTurn;
            cylindersData = JSON.parse(JSON.stringify(snap.cylindersData));
            boardMeshes.forEach(mesh => { if (!snap.boardMeshesRefs.includes(mesh)) scene.remove(mesh); });
            snap.boardMeshesRefs.forEach(mesh => { if (!boardMeshes.includes(mesh)) scene.add(mesh); });
            boardMeshes = [...snap.boardMeshesRefs];
            cylinderMeshes.forEach(mesh => { if (!snap.cylinderMeshesRefs.includes(mesh)) scene.remove(mesh); });
            snap.cylinderMeshesRefs.forEach(mesh => { if (!cylinderMeshes.includes(mesh)) scene.add(mesh); });
            cylinderMeshes = [...snap.cylinderMeshesRefs];
            cubes.forEach(mesh => { if (!snap.openingCubesRefs.includes(mesh)) scene.remove(mesh); });
            snap.openingCubesRefs.forEach(mesh => { if (!cubes.includes(mesh)) scene.add(mesh); });
            cubes = [...snap.openingCubesRefs];
            if (currentPhaseFn) currentPhaseFn();
            return;
        }

        cubes.forEach(c => scene.remove(c));
        cubes = [];
        microphones.forEach(m => scene.remove(m));
        microphones = [];

        snap.cubes.forEach(sCube => {
          placeCube(sCube.col, sCube.row, sCube.color);
          const newCube = cubes[cubes.length - 1];
          newCube.userData.score = sCube.score;
          newCube.userData.currentNumber = sCube.currentNumber;
          newCube.userData.moving = false;
          newCube.userData.animating = false;
          newCube.quaternion.copy(sCube.quat);
          const onOwnMic = snap.microphones.some(m => m.color === sCube.color && m.col === sCube.col && m.row === sCube.row);
          const targetY = onOwnMic ? itemY + 5 : itemY;
          newCube.position.y = targetY;
          newCube.userData.targetY = targetY;
        });
        snap.microphones.forEach(sMic => { createMicrophoneMesh(sMic.color, sMic.col, sMic.row); });
        if (snap.subPhase === 'walk') showWalkOptions();
        else showMicrophonePlacementOptions();
      }

```

**修改 `startGameplayPhase`:**
(移除清空 Undo 堆疊的代碼,使跨階段回退生效)

```javascript
      function startGameplayPhase() {
        gameplayActive = true;
        currentPlayer = gameRound === 1 ? 'blue' : 'green';

        // 移除初始化清空堆疊邏輯,讓回溯能夠退回開局最後一步
        document.getElementById('history-controls').style.display = 'flex';
        updateUndoRedoButtons();
        startPlayerTurn();
      }

```

---

### 4. 植入記譜紀錄及角遊戲板坐標變更

**修改 `showMessage` 函數:**

```javascript
      const uiMsg = document.getElementById('message');
      function showMessage(msg) {
        uiMsg.style.display = 'flex';
        uiMsg.innerHTML = `<span style="font-weight:bold; font-size:24px;">${msg}</span>`;

        if (msg.includes('藍方')) currentTurnColor = 'blue';
        else if (msg.includes('綠方')) currentTurnColor = 'green';
      }

```

**修改 `startPhase2` 與 `startPhase3` (替換對應 Marker 坐標與增加記譜):**

```javascript
      function startPhase2() {
        currentPhaseFn = startPhase2;
        document.getElementById('history-controls').style.display = 'flex';
        updateUndoRedoButtons();

        if (gameRound === 1) {
          showMessage('藍方行動:選擇並放置角遊戲板');
          // 替換原本的 0.5/6.5 為精確坐標 Bg (1,6) 和 Gg (6,6)
          createMarker(1, 6, 0x00008b, () => {
            pushAction(); clearMarkers(); blueCornerType = 'LB'; placeCornerBoard('LB');
            addMoveToNotation(1, 6); commitTurnNotation(); startPhase3();
          });
          createMarker(6, 6, 0x00008b, () => {
            pushAction(); clearMarkers(); blueCornerType = 'RB'; placeCornerBoard('RB');
            addMoveToNotation(6, 6); commitTurnNotation(); startPhase3();
          });
        } else {
          showMessage('綠方行動:選擇並放置角遊戲板(左上角或右上角)');
          createMarker(1, 1, 0xa5ff00, () => {
            clearMarkers(); orangeCornerType = 'LT'; placeCornerBoard('LT');
            addMoveToNotation(1, 1); commitTurnNotation(); startPhase3();
          });
          createMarker(6, 1, 0xa5ff00, () => {
            clearMarkers(); orangeCornerType = 'RT'; placeCornerBoard('RT');
            addMoveToNotation(6, 1); commitTurnNotation(); startPhase3();
          });
        }
        triggerAIIfReady();
      }

      function startPhase3() {
        currentPhaseFn = startPhase3;
        if (gameRound === 1) {
          showMessage('綠方行動:放置對角遊戲板');
          orangeCornerType = blueCornerType === 'LB' ? 'RT' : 'LT';
          const centerPos = orangeCornerType === 'RT' ? { c: 6, r: 1 } : { c: 1, r: 1 };
          createMarker(centerPos.c, centerPos.r, 0xa5ff00, () => {
            clearMarkers(); placeCornerBoard(orangeCornerType);
            addMoveToNotation(centerPos.c, centerPos.r); commitTurnNotation(); startPhase4();
          });
        } else {
          showMessage('藍方行動:放置對角遊戲板');
          blueCornerType = orangeCornerType === 'LT' ? 'RB' : 'LB';
          const centerPos = blueCornerType === 'RB' ? { c: 6, r: 6 } : { c: 1, r: 6 };
          createMarker(centerPos.c, centerPos.r, 0x00008b, () => {
            clearMarkers(); placeCornerBoard(blueCornerType);
            addMoveToNotation(centerPos.c, centerPos.r); commitTurnNotation(); startPhase4();
          });
        }
        triggerAIIfReady();
      }

```

**全局搜尋所有 `createMarker(c, r, colorHex, () => {`:**
在每一個 Phase4、Phase5、Phase6、Phase7、Phase8 中,進入 `clearMarkers();` 後方補上:
`addMoveToNotation(c, r); commitTurnNotation();`
(邊界 `cell.c, cell.r` 的部份請照填 `addMoveToNotation(cell.c, cell.r); commitTurnNotation();`)

**修改 Gameplay 對戰行動紀錄:**
在 `showWalkOptions` 內的 `createMarker` 回調加入行動:

```javascript
          createMarker(mv.stop.c, mv.stop.r, colorHex, () => {
              pushAction();
              clearMarkers();
              addMoveToNotation(mv.stop.c, mv.stop.r); // 加入這行
              executeCubeMovement(activeCube, mv.dir, mv.stop);
          }, markerY);

```

在 `showMicrophonePlacementOptions` 內的 `createMarker` 回調加入行動:

```javascript
        availableSpots.forEach(spot => {
          createMarker(spot.c, spot.r, micMarkerColor, () => {
              pushAction();
              clearMarkers();
              addMoveToNotation(spot.c, spot.r); // 加入這行
              createMicrophoneMesh(currentPlayer, spot.c, spot.r);
              switchTurn();
          }, 6);
        });

```

在 `switchTurn` 的最頂部加入提交判斷:

```javascript
      function switchTurn() {
        commitTurnNotation(); // 加入這行提交整回合記譜
        currentPlayer = currentPlayer === 'blue' ? 'green' : 'blue';
        startPlayerTurn();
      }

```

---

### 5. 局末介面與最大化/最小化控制

**完全替換 `triggerRoundEnd` 與 `showFinalGameSummary` 函數:**

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

        const minBtn = document.createElement('div');
        minBtn.innerHTML = '<svg width="32" height="32" viewBox="0 0 24 24" fill="white"><path d="M6 19h12v2H6z"/></svg>';
        minBtn.style.cssText = 'position:absolute; top:20px; right:20px; cursor:pointer; padding: 10px; background: rgba(255,255,255,0.2); border-radius: 5px;';
        minBtn.title = "最小化";
        minBtn.onclick = () => {
            overlay.style.display = 'none';
            uiMsg.style.display = 'flex';
            uiMsg.innerHTML = `<span style="font-weight:bold; font-size:24px;">${titleText}</span>
            <svg id="btn-maximize" style="cursor:pointer; width:32px; height:32px; fill:white;" viewBox="0 0 24 24"><path d="M4 4h6v2H6v4H4V4zm10 0h6v6h-2V6h-4V4zm6 16h-6v-2h4v-4h2v6zM4 20v-6h2v4h4v2H4z"/></svg>`;
            document.getElementById('btn-maximize').onclick = () => {
                uiMsg.style.display = 'none';
                overlay.style.display = 'flex';
            };
        };
        overlay.appendChild(minBtn);

        const title = document.createElement('h2'); title.style.fontSize = '36px'; title.innerText = titleText; overlay.appendChild(title);
        const scoreInfo = document.createElement('p'); scoreInfo.style.fontSize = '22px'; scoreInfo.innerText = infoText; overlay.appendChild(scoreInfo);

        buttonsData.forEach(btnInfo => {
           const btn = createOverlayButton(btnInfo.text, () => {
               if(btnInfo.suffix) commitTurnNotation(btnInfo.suffix);
               if(overlay.parentNode) overlay.parentNode.removeChild(overlay);
               currentEndOverlay = null;
               btnInfo.action();
           });
           overlay.appendChild(btn);
        });

        currentEndOverlay = overlay;
        document.body.appendChild(overlay);
      }

      function triggerRoundEnd() {
        resetAIState();
        gameplayActive = false;
        clearMarkers();
        // 取消隱藏歷史按鈕,允許回退
        // document.getElementById('history-controls').style.display = 'none'; 

        const blueFinal = cubes.find(q => q.userData.color === 'blue').userData.score;
        const orangeFinal = cubes.find(q => q.userData.color === 'green').userData.score;
        blueTotalScore += blueFinal; orangeTotalScore += orangeFinal;
        let roundWinner = blueFinal > orangeFinal ? '藍方' : orangeFinal > blueFinal ? '綠方' : '平手';

        let btns = [];
        if (gameRound === 1) {
            btns = [
                {text: '結束遊戲顯示總分', suffix: '1', action: () => showFinalGameSummary()},
                {text: '當前場地再戰一局', suffix: '2', action: () => { gameRound = 2; reinitNextRound(true); }},
                {text: '雙方換先再戰一局', suffix: '3', action: () => { gameRound = 2; reinitNextRound(false); }}
            ];
        } else {
            btns = [ {text: '查看最終贏家及總分', suffix: '1', action: () => showFinalGameSummary()} ];
        }
        
        generateEndOverlay(`第 ${gameRound} 局結束!本局勝者:${roundWinner}`, `藍方單局得分:${blueFinal} | 綠方單局得分:${orangeFinal}`, btns);
      }

      function showFinalGameSummary() {
        resetAIState();
        let ultimateWinner = blueTotalScore > orangeTotalScore ? '藍方' : orangeTotalScore > blueTotalScore ? '綠方' : '平手';
        generateEndOverlay(`對抗賽終局!最終贏家:${ultimateWinner}`, `總比分 -> 藍方總得分:${blueTotalScore} 點 || 綠方總得分:${orangeTotalScore} 點`, [
            {text: '重新開始整個遊戲', action: () => {
                gameRound = 1; blueTotalScore = 0; orangeTotalScore = 0;
                gameNotation = ""; currentTurnMoves = [];
                reinitNextRound(false);
            }}
        ]);
      }

```

---

### 6. 寫入導入/導出功能與 Replay 解譯

**在 `triggerAIIfReady()` 中注入 Replay 截斷邏輯:**

```javascript
      function triggerAIIfReady() {
        // [新增] 如果正在回放,優先攔截執行
        if (isReplaying) {
            checkAndRunReplay();
            return;
        }

        const statusDiv = document.getElementById('ai-status');
        if (markers.length > 0 && !isAIThinking) {
        // ... (保留下方既有 AI 判斷)

```

**在腳本末尾事件綁定區(如 `animate();` 之前)新增 IO 邏輯與 Replay 解析器:**

```javascript
      // ==========================================
      // [新增] 導入導出選單按鈕事件與 Replay 核心
      // ==========================================
      const ioTrigger = document.getElementById('io-trigger');
      const ioOptions = document.getElementById('io-options');

      ioTrigger.addEventListener('click', e => {
        e.stopPropagation();
        ioOptions.classList.toggle('open');
        document.getElementById('ai-strength-options').classList.remove('open');
      });
      document.addEventListener('click', () => { ioOptions.classList.remove('open'); });

      document.getElementById('btn-export-notation').onclick = () => {
          document.getElementById('io-title').innerText = "導出棋譜";
          document.getElementById('io-textarea').value = gameNotation + (currentTurnMoves.length>0 ? ";" + currentTurnMoves.join(",") : "");
          document.getElementById('io-textarea').readOnly = true;
          document.getElementById('io-modal').style.display = 'flex';
          document.getElementById('io-btn-confirm').style.display = 'none';
      };

      document.getElementById('btn-export-state').onclick = () => {
          document.getElementById('io-title').innerText = "導出局面";
          const state = {
              cylinders: cylindersData,
              cubes: cubes.map(c => ({c: c.userData.col, r: c.userData.row, color: c.userData.color, score: c.userData.score})),
              mics: microphones.map(m => ({c: m.userData.col, r: m.userData.row, color: m.userData.color})),
              currentPlayer: currentPlayer,
              walkCount: walkCount,
              subPhase: markers.some(m=>m.position.y===6)?'mic':'walk'
          };
          document.getElementById('io-textarea').value = JSON.stringify(state);
          document.getElementById('io-textarea').readOnly = true;
          document.getElementById('io-modal').style.display = 'flex';
          document.getElementById('io-btn-confirm').style.display = 'none';
      };

      document.getElementById('btn-import-game').onclick = () => {
          document.getElementById('io-title').innerText = "導入棋局";
          document.getElementById('io-textarea').value = "";
          document.getElementById('io-textarea').readOnly = false;
          document.getElementById('io-modal').style.display = 'flex';
          document.getElementById('io-btn-confirm').style.display = 'inline-block';
      };

      document.getElementById('io-btn-close').onclick = () => { document.getElementById('io-modal').style.display = 'none'; };

      document.getElementById('io-btn-confirm').onclick = () => {
          const text = document.getElementById('io-textarea').value.trim();
          document.getElementById('io-modal').style.display = 'none';
          if (!text) return;

          if (text.startsWith("{")) {
              const parsed = JSON.parse(text);
              cleanupEntities();
              cylindersData = parsed.cylinders;
              parsed.cylinders.forEach(cyl => {
                  const cylMesh = new THREE.Mesh(new THREE.CylinderGeometry(24, 24, 48, 32), new THREE.MeshLambertMaterial({ color: 0xffa500 }));
                  const pos = getCellWorldPos(cyl.c, cyl.r);
                  cylMesh.position.set(pos.x, itemY, pos.z);
                  scene.add(cylMesh);
                  cylinderMeshes.push(cylMesh);
              });
              parsed.cubes.forEach(c => {
                  placeCube(c.c, c.r, c.color);
                  cubes[cubes.length-1].userData.score = c.score;
                  animateCubeToScore(cubes[cubes.length-1], c.score);
              });
              parsed.mics.forEach(m => createMicrophoneMesh(m.color, m.c, m.r));
              currentPlayer = parsed.currentPlayer;
              walkCount = parsed.walkCount;
              gameplayActive = true;
              if (parsed.subPhase === 'walk') showWalkOptions();
              else showMicrophonePlacementOptions();
          } else {
              replayMoves = text.split(';').map(t => t.split(',').filter(x=>x));
              isReplaying = true;
              gameRound = 1; blueTotalScore = 0; orangeTotalScore = 0;
              gameNotation = ""; currentTurnMoves = [];
              reinitNextRound(false);
              checkAndRunReplay();
          }
      };

      function checkAndRunReplay() {
          if (!isReplaying || replayMoves.length === 0) { isReplaying = false; return; }
          if (markers.length === 0 && !currentEndOverlay) { setTimeout(checkAndRunReplay, 200); return; }

          let currentTurn = replayMoves[0];
          if (currentTurn.length === 0) {
              replayMoves.shift();
              setTimeout(checkAndRunReplay, 50);
              return;
          }

          const nextActionStr = currentTurn.shift();
          if (["1", "2", "3"].includes(nextActionStr)) {
              if (nextActionStr === "1") showFinalGameSummary();
              else if (nextActionStr === "2") { gameRound = 2; reinitNextRound(true); }
              else if (nextActionStr === "3") { gameRound = 2; reinitNextRound(false); }
              setTimeout(checkAndRunReplay, 1000);
              return;
          }

          const targetC = nextActionStr.charCodeAt(0) - 65;
          const targetR = nextActionStr.charCodeAt(1) - 97;
          const targetMarker = markers.find(m => Math.round(m.position.x/60 + 3.5) === targetC && Math.round(m.position.z/60 + 3.5) === targetR);

          if (targetMarker) {
              targetMarker.userData.onClick();
              setTimeout(checkAndRunReplay, 850);
          } else {
              console.warn("Replay 找不到行動標示:", nextActionStr);
              isReplaying = false;
          }
      }

```

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

发表于 : 2026年 6月 15日 19:34
ejsoon
"io-modal"和"io-textarea"的樣式應寫到<style>中。

當進入行動階段,又回退到開局階段,又前進至行動階段時,最後兩個角遊戲板沒有自動補上。

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

发表于 : 2026年 6月 15日 20:18
ejsoon
Screenshot from 2026-06-15 17-46-51.png
Screenshot from 2026-06-15 17-46-51.png (57.75 KiB) 查看 12 次
如果藍方往上走,則會在第三步停留在第一步停留的地方。

規則需要修改:當走完三步後,前兩步停留的地方如果有兩個空位,則選擇其一放置話筒,如果只有一個空位,則把話筒放在這個空位,如果沒有空位,則無需放置。