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

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

帖子 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遮擋。

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

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

帖子 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迴圈邏輯保持不變) ...

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

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

帖子 ejsoon »

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

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

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

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

代码: 全选

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

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

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

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

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

帖子 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);
      }

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

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

帖子 ejsoon »

Screenshot from 2026-06-15 14-21-22.png
Screenshot from 2026-06-15 14-21-22.png (69.32 KiB) 查看 168 次
【重大問題】藍方明明將要獲勝,卻在最後一步亂走(上左下)。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6066
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 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按鈕」,當點擊時,彈出選單,可選擇「導出棋譜、導出局面、導入棋局」。其中導入棋局既可以導入棋譜文本也可以導入局面文本,由程式自動分辨。

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

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

帖子 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;
          }
      }

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

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

帖子 ejsoon »

"io-modal"和"io-textarea"的樣式應寫到<style>中。

當進入行動階段,又回退到開局階段,又前進至行動階段時,最後兩個角遊戲板沒有自動補上。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6066
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

Screenshot from 2026-06-15 17-46-51.png
Screenshot from 2026-06-15 17-46-51.png (57.75 KiB) 查看 128 次
如果藍方往上走,則會在第三步停留在第一步停留的地方。

規則需要修改:當走完三步後,前兩步停留的地方如果有兩個空位,則選擇其一放置話筒,如果只有一個空位,則把話筒放在這個空位,如果沒有空位,則無需放置。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6066
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

必殺判定仍然存在問題:
Screenshot from 2026-06-16 06-37-19.png
Screenshot from 2026-06-16 06-37-19.png (50.34 KiB) 查看 111 次
在當前局面下,綠方已經必敗,因為藍方只要走「上左下」即可獲勝,如果綠方走「右上左」,則藍方可以走「上左上」。

而在此之前,綠方其實已經必殺,在這個局面下:
Screenshot from 2026-06-16 06-42-08.png
Screenshot from 2026-06-16 06-42-08.png (47.06 KiB) 查看 109 次
綠方只要走「右下左」即可完勝,即使藍方試圖用「右下左」來阻止,但這時藍方所放的新話筒也會被綠方用「上右下」吃掉。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6066
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

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

當兩局棋結束時,或者第一局結束時選擇「結束遊戲顯示總分」,則此時的「div全屏文字提示」也同樣包含這個「最小化按鈕」。

二,歷史回退和前進功能改進
btn-turn-undo和btn-turn-redo的功能仍然有誤,比如在開局階段redo的時候不能正確放回遊戲板。

當一局棋結束時,div#history-controls不必隱藏,仍然可以點擊btn-turn-undo回退。如果選擇「再戰一局」,仍可以選擇回退以重新選擇。即使棋局全部結束,仍然可以點擊btn-turn-undo回退到棋局結束前的最後一步。

三,增加導出棋譜和導出局面功能

設棋盤的横坐標為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;」還要從選擇角遊戲板開始。

局面格式為:從左上角開始,從左到右逐行描述,換行用「/」。如果是連續空位則為數字1―8,如果是圓柱體則為C,如果是六邊形話筒(藍方話筒)則為H,如果是三角形話筒(綠方話筒)則為T,如果是藍方立方體則為[n],綠方立方體則為(n),n為此時的分數,此時如果輪到藍方則為[*n],如果輪到綠方則為(*n)。局面將只表示行動階段的回合開始時的情形,不包括已走一步、兩步、三步時的情況。

例如「T1C5/7(1)/C7/4[*1]C2/7C/3C4/H7/4C3」,表示當前正輪到藍方,藍方和綠方都是1分,場上有一個藍方話筒和一個綠方話筒。

在「回合前進」的右邊增加一個「局面導入導出svg按鈕」,當點擊時,彈出選單,可選擇「導出棋譜、導出局面、導入棋局」。當點擊「導出棋譜」,則會下載一個棋譜文本,文件名為crazysingermoves_yyyymmdd_hhiiss.txt。當點擊「導出局面」則會下載局面文本crazysingerpos_yyyymmdd_hhiiss.txt

當點擊「導入棋局」,將選取文件導入。如果導入的是一個「棋譜文本」,則進入最後一步時的局面,並且可用「回合後退與前進」來察看整個棋局。如果導入的是一個「局面文本」,則默認視為是第一局。

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

代码: 全选

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

當兩局棋結束時,或者第一局結束時選擇「結束遊戲顯示總分」,則此時的「div全屏文字提示」也同樣包含這個「最小化按鈕」。

二,歷史回退和前進功能改進
btn-turn-undo和btn-turn-redo的功能仍然有誤,比如在開局階段redo的時候不能正確放回遊戲板。

當一局棋結束時,div#history-controls不必隱藏,仍然可以點擊btn-turn-undo回退。如果選擇「再戰一局」,仍可以選擇回退以重新選擇。即使棋局全部結束,仍然可以點擊btn-turn-undo回退到棋局結束前的最後一步。

三,增加導出棋譜和導出局面功能

設棋盤的横坐標為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;」還要從選擇角遊戲板開始。

局面格式為:從左上角開始,從左到右逐行描述,換行用「/」。如果是連續空位則為數字1―8,如果是圓柱體則為C,如果是六邊形話筒(藍方話筒)則為H,如果是三角形話筒(綠方話筒)則為T,如果是藍方立方體則為[n],綠方立方體則為(n),n為此時的分數,此時如果輪到藍方則為[*n],如果輪到綠方則為(*n)。局面將只表示行動階段的回合開始時的情形,不包括已走一步、兩步、三步時的情況。

例如「T1C5/7(1)/C7/4[*1]C2/7C/3C4/H7/4C3」,表示當前正輪到藍方,藍方和綠方都是1分,場上有一個藍方話筒和一個綠方話筒。

在「回合前進」的右邊增加一個「局面導入導出svg按鈕」,當點擊時,彈出選單,可選擇「導出棋譜、導出局面、導入棋局」。當點擊「導出棋譜」,則會下載一個棋譜文本,文件名為crazysingermoves_yyyymmdd_hhiiss.txt。當點擊「導出局面」則會下載局面文本crazysingerpos_yyyymmdd_hhiiss.txt

當點擊「導入棋局」,將選取文件導入。如果導入的是一個「棋譜文本」,則進入最後一步時的局面,並且可用「回合後退與前進」來察看整個棋局。如果導入的是一個「局面文本」,則默認視為是第一局。

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

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

帖子 ejsoon »

gemini 3.1 pro:

代码: 全选

這是一份針對您需求的具體代碼修改指南,請依照以下各段落,替換或加入對應的代碼。

### 1. 修改樣式 (Style)

在 `<style>` 中找到 `#message` 的樣式,並補充 flex 佈局與事件穿透設定,以支援右側的「最大化按鈕」點擊:

```css
      #message {
        position: absolute;
        top: 20px;
        width: 100%;
        text-align: center;
        color: #fff;
        font-size: 24px;
        font-weight: bold;
        pointer-events: none;
        text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
        display: none;
        /* 新增以下佈局與置中對齊 */
        justify-content: center;
        align-items: center;
        gap: 15px;
      }
      /* 新增:用於讓 message 中的按鈕可以被點擊 */
      #message button {
        pointer-events: auto;
      }

```

### 2. 修改 HTML 結構 (UI 按鈕)

在 `<div id="history-controls" ...>` 中,找到 `` 和 `<div class="custom-select-container">...</div>` 之間,加入以下代碼,新增「局面導入導出」的按鈕選單及檔案上傳元件:

```html
      <div style="width: 2px; background: #7f8c8d; margin: 0 5px"></div>
      
      <div class="custom-select-container">
        <button id="btn-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="opt-export-moves">導出棋譜</div>
          <div class="custom-option" id="opt-export-state">導出局面</div>
          <div class="custom-option" id="opt-import">導入棋局</div>
        </div>
      </div>
      <input type="file" id="file-import" accept=".txt" style="display:none;" />

```

### 3. 全局變量與輔助函數 (腳本開頭)

在 `<script type="module">` 進入後,找個合適的地方(例如 `let blueTotalScore = 0;` 附近)加入記錄變數與坐標轉換函數:

```javascript
      // ==========================================
      // [新增] 棋譜記錄全局變數與輔助函數
      // ==========================================
      let globalMoveHistory = []; 
      let currentTurnCoords = [];

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

      function strToCoord(str) {
        return { c: str.charCodeAt(0) - 65, r: str.charCodeAt(1) - 97 };
      }

      function recordMove(c, r) {
        if (!gameplayActive) {
          globalMoveHistory.push(coordToStr(c, r));
        } else {
          currentTurnCoords.push(coordToStr(c, r));
        }
      }

```

### 4. 修復快照紀錄與還原機制 (支持棋譜與材質修復)

找到 `saveSnapshot()` 函數,在回傳的物件中加上 `globalMoveHistory` 與 `currentTurnCoords`:

```javascript
      function saveSnapshot() {
        return {
          // ... 略過原本屬性
          cylindersData: JSON.parse(JSON.stringify(cylindersData)),

          // [新增] 加入棋譜記錄快照
          globalMoveHistory: [...globalMoveHistory],
          currentTurnCoords: [...currentTurnCoords],
          
          boardMeshesRefs: [...boardMeshes],
          // ... 略過後面

```

找到 `restoreSnapshot(snap)` 函數,替換開局階段及材質透明度的修復邏輯:

```javascript
      function restoreSnapshot(snap) {
        currentPlayer = snap.currentPlayer;
        walkCount = snap.walkCount;
        lastDirection = snap.lastDirection;
        turnStopPositions = snap.turnStopPositions;
        gameplayActive = snap.gameplayActive;

        // [新增] 恢復棋譜狀態
        globalMoveHistory = [...snap.globalMoveHistory];
        currentTurnCoords = [...snap.currentTurnCoords];

        clearMarkers();

        // 【修改】開局階段與對戰階段的網格透明度防呆修復
        if (!gameplayActive) {
          currentPhaseFn = snap.currentPhaseFn;
          // ... 略過變數還原 (blueCornerType 等) ...
          cylindersData = JSON.parse(JSON.stringify(snap.cylindersData));

          // 修復透明度與同步
          const fixOpacity = (m) => {
            if (Array.isArray(m.material)) m.material.forEach(mat => { mat.opacity = 1; mat.transparent = false; });
            else { m.material.opacity = 1; m.material.transparent = false; }
          };

          boardMeshes.forEach(mesh => { if (!snap.boardMeshesRefs.includes(mesh)) scene.remove(mesh); });
          snap.boardMeshesRefs.forEach(mesh => { if (!boardMeshes.includes(mesh)) scene.add(mesh); fixOpacity(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); fixOpacity(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); fixOpacity(mesh); });
          cubes = [...snap.openingCubesRefs];

          if (currentPhaseFn) currentPhaseFn();
          return;
        }
        
        // ... (原對戰階段 restore 邏輯不變) ...

```

### 5. 綁定各階段的行動紀錄 (記錄棋譜)

找出所有調用 `createMarker` 成功回呼函數(`onClick`)的地方,並注入 `recordMove` 邏輯。

* **開局階段 (Phase 2 ~ Phase 8)**: 在 `() => { pushAction(); ... }` 裡面加入 `recordMove(x, y);`
例如在 `startPhase2`:
```javascript
      createMarker(1, 6, 0x00008b, () => {
        pushAction(); recordMove(1, 6); clearMarkers(); // ... 

```


*(請依此類推將 `recordMove(c, r)` 補充到 startPhase3 ~ startPhase8 的 `createMarker` 內)*
* **行走階段 (`showWalkOptions`)**:
```javascript
      createMarker(mv.stop.c, mv.stop.r, colorHex, () => {
          pushAction(); recordMove(mv.stop.c, mv.stop.r); clearMarkers(); // ... 

```


* **放話筒階段 (`showMicrophonePlacementOptions`)**:
```javascript
      createMarker(spot.c, spot.r, micMarkerColor, () => {
          pushAction(); recordMove(spot.c, spot.r); clearMarkers(); // ... 

```


* **在 `switchTurn` 追加合併回合棋譜的邏輯**:
```javascript
  function switchTurn() {
    // [新增] 回合切換時合併紀錄
    if (currentTurnCoords.length > 0) {
      globalMoveHistory.push(currentTurnCoords.join(','));
      currentTurnCoords = [];
    }
    currentPlayer = currentPlayer === 'blue' ? 'green' : 'blue';
    startPlayerTurn();
  }

```



### 6. UI 覆蓋層與局末最小化邏輯、棋譜記錄選項

在腳本末尾加入以下用於處理最小化的函數:

```javascript
      function minimizeOverlay(overlayDiv, winnerText) {
        overlayDiv.style.display = 'none';
        uiMsg.style.display = 'flex';
        uiMsg.innerHTML = `<span>${winnerText}</span>
          <button id="btn-maximize" style="background: none; border: none; cursor: pointer; color: white;" title="最大化面板">
            <svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor">
              <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 9h-2V7H8v5H6v2h2v5h2v-5h2v-2z"/>
            </svg>
          </button>`;
        document.getElementById('btn-maximize').addEventListener('click', () => {
          overlayDiv.style.display = 'flex';
          uiMsg.style.display = 'none';
        });
      }

```

找到 `triggerRoundEnd()` 函數,進行以下兩項修改:

1. **刪除** `document.getElementById('history-controls').style.display = 'none';`,使按鈕列常駐。
2. 在 `overlay.appendChild(title);` 的上方,加入最小化按鈕及選單選項的棋譜記錄:

```javascript
        // 【新增最小化按鈕】
        const btnMin = document.createElement('div');
        btnMin.style.cssText = 'position:absolute; top:20px; right:20px; cursor:pointer; color:#fff;';
        btnMin.innerHTML = '<svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor"><path d="M6 19h12v2H6v-2z"/></svg>';
        btnMin.onclick = () => minimizeOverlay(overlay, `第 ${gameRound} 局結束!本局勝者:${roundWinner}`);
        overlay.appendChild(btnMin);

        // ... 在建立 btnNo, btnSame, btnNew 的回呼中,加入導出標記:
        const btnNo = createOverlayButton('結束遊戲顯示總分', () => {
          globalMoveHistory.push("1");
          document.body.removeChild(overlay); showFinalGameSummary();
        });
        const btnSame = createOverlayButton('當前場地再戰一局', () => {
          globalMoveHistory.push("2");
          document.body.removeChild(overlay); gameRound = 2; reinitNextRound(true);
        });
        const btnNew = createOverlayButton('雙方換先再戰一局', () => {
          globalMoveHistory.push("3");
          document.body.removeChild(overlay); gameRound = 2; reinitNextRound(false);
        });

```

在 `showFinalGameSummary()` 函數,同樣為它加入最小化按鈕:

```javascript
        const btnMin = document.createElement('div');
        btnMin.style.cssText = 'position:absolute; top:20px; right:20px; cursor:pointer; color:#fff;';
        btnMin.innerHTML = '<svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor"><path d="M6 19h12v2H6v-2z"/></svg>';
        btnMin.onclick = () => minimizeOverlay(overlay, `對抗賽終局!最終贏家:${ultimateWinner}`);
        overlay.appendChild(btnMin);

```

### 7. 匯入匯出核心邏輯 (掛載於尾段)

在檔案腳本最末端 (約 `animate()` 前後),加入導入匯出功能:

```javascript
      function getTimestamp() {
        const d = new Date();
        const pad = n => n.toString().padStart(2, '0');
        return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
      }

      function downloadFile(filename, content) {
        const a = document.createElement('a');
        const file = new Blob([content], { type: 'text/plain' });
        a.href = URL.createObjectURL(file);
        a.download = filename;
        a.click();
      }

      // 導出局面 FEN
      document.getElementById('opt-export-state').addEventListener('click', () => {
        let fen = [];
        for (let r = 0; r <= 7; r++) {
          let rowStr = '';
          let emptyCount = 0;
          for (let c = 0; c <= 7; c++) {
            let cellStr = '';
            let isBlueCube = cubes.find(q => q.userData.color === 'blue' && q.userData.col === c && q.userData.row === r);
            let isGreenCube = cubes.find(q => q.userData.color === 'green' && q.userData.col === c && q.userData.row === r);
            let isCyl = cylindersData.some(cyl => cyl.c === c && cyl.r === r);
            let isBlueMic = microphones.find(m => m.userData.color === 'blue' && m.userData.col === c && m.userData.row === r);
            let isGreenMic = microphones.find(m => m.userData.color === 'green' && m.userData.col === c && m.userData.row === r);

            if (isBlueCube) cellStr = currentPlayer === 'blue' ? `[*${isBlueCube.userData.score}]` : `[${isBlueCube.userData.score}]`;
            else if (isGreenCube) cellStr = currentPlayer === 'green' ? `(*${isGreenCube.userData.score})` : `(${isGreenCube.userData.score})`;
            else if (isCyl) cellStr = 'C';
            else if (isBlueMic) cellStr = 'H';
            else if (isGreenMic) cellStr = 'T';

            if (cellStr === '') emptyCount++;
            else {
              if (emptyCount > 0) { rowStr += emptyCount; emptyCount = 0; }
              rowStr += cellStr;
            }
          }
          if (emptyCount > 0) rowStr += emptyCount;
          fen.push(rowStr);
        }
        downloadFile(`crazysingerpos_${getTimestamp()}.txt`, fen.join('/'));
        document.getElementById('io-options').classList.remove('open');
      });

      // 導出棋譜
      document.getElementById('opt-export-moves').addEventListener('click', () => {
        let saveHistory = [...globalMoveHistory];
        if (currentTurnCoords.length > 0) saveHistory.push(currentTurnCoords.join(','));
        downloadFile(`crazysingermoves_${getTimestamp()}.txt`, saveHistory.join(';'));
        document.getElementById('io-options').classList.remove('open');
      });

      // 匯入按鈕觸發
      const ioTrigger = document.getElementById('btn-io-trigger');
      const ioOptions = document.getElementById('io-options');
      ioTrigger.addEventListener('click', (e) => {
        e.stopPropagation(); ioOptions.classList.toggle('open');
      });
      document.getElementById('opt-import').addEventListener('click', () => {
        document.getElementById('file-import').click();
        ioOptions.classList.remove('open');
      });

      // 處理匯入檔案邏輯
      document.getElementById('file-import').addEventListener('change', (e) => {
        const file = e.target.files[0];
        if (!file) return;
        const reader = new FileReader();
        reader.onload = async (evt) => {
          const content = evt.target.result.trim();
          if (content.includes('/')) {
            // 解析局面 FEN
            cleanupEntities();
            boardMeshes.forEach(b => scene.remove(b)); boardMeshes = [];
            cylinderMeshes.forEach(c => scene.remove(c)); cylinderMeshes = [];
            cylindersData = []; cubes = []; microphones = [];
            
            // 簡化補齊地板供遊玩
            for(let r=0; r<=7; r++) {
              for(let c=0; c<=7; c++) createGameBoard(c+0.5, r+0.5, ['top','bottom','left','right']);
            }

            let rows = content.split('/');
            currentPlayer = 'blue'; 
            rows.forEach((rowStr, r) => {
              let c = 0, i = 0;
              while (i < rowStr.length) {
                let char = rowStr[i];
                if (/[1-8]/.test(char)) { c += parseInt(char); i++; }
                else if (char === 'C') { placeCylinder(c, r); c++; i++; }
                else if (char === 'H') { createMicrophoneMesh('blue', c, r); c++; i++; }
                else if (char === 'T') { createMicrophoneMesh('green', c, r); c++; i++; }
                else if (char === '[' || char === '(') {
                  let end = rowStr.indexOf(char === '[' ? ']' : ')', i);
                  let inner = rowStr.substring(i + 1, end);
                  let isAiTurn = inner.startsWith('*');
                  let color = char === '[' ? 'blue' : 'green';
                  if (isAiTurn) { currentPlayer = color; inner = inner.substring(1); }
                  placeCube(c, r, color);
                  let q = cubes[cubes.length - 1];
                  q.userData.score = parseInt(inner);
                  animateCubeToScore(q, parseInt(inner));
                  c++; i = end + 1;
                } else { i++; }
              }
            });
            gameRound = 1; gameplayActive = true;
            globalMoveHistory = []; historyUndoStack = []; historyRedoStack = [];
            document.getElementById('ui-layer').style.display = 'none';
            document.getElementById('history-controls').style.display = 'flex';
            startPlayerTurn();

          } else {
            // 解析棋譜並自動重播 (為避免衝突暫停 AI)
            let prevBlue = isBlueAI; let prevGreen = isGreenAI;
            isBlueAI = false; isGreenAI = false;
            gameRound = 1; blueTotalScore = 0; orangeTotalScore = 0;
            globalMoveHistory = []; historyUndoStack = []; historyRedoStack = [];
            reinitNextRound(false);
            document.getElementById('ui-layer').style.display = 'none';

            await new Promise(res => setTimeout(res, 850)); // 等待場地初始動畫
            let turns = content.split(';');
            for (let turn of turns) {
              if (!turn) continue;
              if (turn === '1' || turn === '2' || turn === '3') {
                const overlay = document.querySelector('div[style*="rgba(0,0,0,0.85)"]');
                if (overlay) document.body.removeChild(overlay);
                if (turn === '1') { showFinalGameSummary(); break; }
                else if (turn === '2') { gameRound = 2; reinitNextRound(true); }
                else if (turn === '3') { gameRound = 2; reinitNextRound(false); await new Promise(res => setTimeout(res, 850)); }
                continue;
              }
              let actions = turn.split(',');
              for (let action of actions) {
                let {c, r} = strToCoord(action);
                let marker = markers.find(m => Math.abs(m.position.x/60 + 3.5 - c) < 0.1 && Math.abs(m.position.z/60 + 3.5 - r) < 0.1);
                if (marker) {
                  marker.userData.onClick();
                  await new Promise(res => setTimeout(res, 350)); // 等待狀態穩定
                }
              }
            }
            isBlueAI = prevBlue; isGreenAI = prevGreen;
            triggerAIIfReady();
          }
        };
        reader.readAsText(file);
        e.target.value = ""; // 重設以允許重複匯入
      });

```
附件
csinger2431.html.7z
(20.56 KiB) 已下载 9 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6066
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

改進:
一,移動端的CSS適配

移動端下方的按鈕應始終不超出屏幕,排成一行。

移動端的按鈕應該稍小一些。div#message的字體也應該小一點。

二,導入的問題

當導入「局面文本」時,一些棋子的擺放位置是錯誤的,且「遊戲板」分為「中遊戲板」、「角遊戲板」、「邊遊戲板」,它們的線型都是不一樣的。

在導入「棋譜文本」時,應由程式先演算它的最終局面,然後只展示最終局面。也就是說導入「棋譜文本」時的效果,跟導入「局面文本」的效果是一樣的,只不過導入「棋譜文本」之後可以「回合後退和前進」。

三,AI必殺算法有誤

當局面為「2TCH1H1/8/4C2H/7C/C4T2/4TC2/(*5)7/1TCH3[5]」時,綠方已經必敗無疑,但是AI仍算出綠方的勝率為100%。

當局面為「3CH(*5)H1/3[4]4/4C2H/T6C/C7/4TC2/8/1TCH4」時,綠方有必殺,即走「Ha,Hc,Fc」,但是AI卻走了其它路線。

說明必殺的算法是有誤的,請查明原因。

同時AI好像有一個「佈局策略 (Heuristic):AI 在評估分數時,內建了一個「向中心靠攏」的啟發式獎勵 `(10 - 距離) * 2`,這會讓 AI 在找不到明顯得分機會時,傾向於佔據棋盤中央。」現在要去掉這個佈局策略,AI在蒙特卡羅樹搜尋時只需要隨機走棋,之後根據勝率來選擇行動就可以了

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

代码: 全选

改進:
一,移動端的CSS適配

移動端下方的按鈕應始終不超出屏幕,排成一行。

移動端的按鈕應該稍小一些。div#message的字體也應該小一點。

二,導入的問題

當導入「局面文本」時,一些棋子的擺放位置是錯誤的,且「遊戲板」分為「中遊戲板」、「角遊戲板」、「邊遊戲板」,它們的線型都是不一樣的。

在導入「棋譜文本」時,應由程式先演算它的最終局面,然後只展示最終局面。也就是說導入「棋譜文本」時的效果,跟導入「局面文本」的效果是一樣的,只不過導入「棋譜文本」之後可以「回合後退和前進」。

三,AI必殺算法有誤

當局面為「2TCH1H1/8/4C2H/7C/C4T2/4TC2/(*5)7/1TCH3[5]」時,綠方已經必敗無疑,但是AI仍算出綠方的勝率為100%。

當局面為「3CH(*5)H1/3[4]4/4C2H/T6C/C7/4TC2/8/1TCH4」時,綠方有必殺,即走「Ha,Hc,Fc」,但是AI卻走了其它路線。

說明必殺的算法是有誤的,請查明原因。

同時AI好像有一個「佈局策略 (Heuristic):AI 在評估分數時,內建了一個「向中心靠攏」的啟發式獎勵 `(10 - 距離) * 2`,這會讓 AI 在找不到明顯得分機會時,傾向於佔據棋盤中央。」現在要去掉這個佈局策略,AI在蒙特卡羅樹搜尋時只需要隨機走棋,之後根據勝率來選擇行動就可以了

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

gemini 3.1 pro能不能行:

代码: 全选

這裡已經為你查明了所有問題的原因,並準備了完整的代碼替換方案。

### 問題分析:

1. **移動端適配**:透過增加 `@media (max-width: 768px)` 媒體查詢,可以針對手機螢幕強制縮小字體與按鈕,並使控制列排成一排。
2. **導入局面網格錯誤**:之前導入 FEN 局面時,使用了雙層迴圈強制鋪滿了 64 個網格,導致網格重疊和線型錯誤。已將其修改為標準開局中規定的 16 塊標準遊戲板拼裝。
3. **導入棋譜無延遲計算**:增設了一個 `window.IS_FAST_FORWARD` 的全域加速標記,當程式判斷正在導入棋譜時,會忽略所有動畫和延遲(`setTimeout`),在 1 個 Frame 內從頭到尾無縫計算完畢並直接渲染最終局面。
4. **AI 必殺演算法失效**:
* 你反饋的勝率異常為 `MCTS` 的一個副作用:在隨機 Playout 中,敵方如果擁有多個話筒,非常容易隨機踩雷自殺送分。已為你移除了 AI 盲目往中心靠攏的**佈局啟發式權重**,使 `MCTS` 評價回歸真實勝負導向。
* 必殺漏算的真正原因是原本的 `SURE_KILL_DEPTH` 深度僅設定為 2(等同只看了「自己的一步」與「對手的一步」)。由於深度為 0 時永遠返回 false,這代表 AI 無法運算出**需要兩回合才能完成**的必殺連招。將搜尋深度上調至 `3` 後,即修復此漏算漏洞。



以下是針對你原本代碼的修改位置與替換內容:

### 1. 修改 CSS(增加移動端適配)

在 `<style>` 標籤內部的最下方(即 `</style>` 上方),加入以下 `@media` 查詢:

```css
      @media (max-width: 768px) {
        #history-controls {
          width: 95%;
          justify-content: center;
          gap: 6px;
          bottom: 10px;
        }
        .hist-btn {
          padding: 6px;
        }
        .hist-btn svg {
          width: 20px;
          height: 20px;
        }
        #message {
          font-size: 16px;
          top: 10px;
        }
        #btn-start {
          padding: 15px 30px;
          font-size: 20px;
        }
      }

```

### 2. 加入全局導入加速標記

找到代碼中的這行全局變數宣告:`let currentPhaseFn = null;`(約第 374 行附近),在其下方加入:

```javascript
      window.IS_FAST_FORWARD = false; // 用於導入棋譜時跳過動畫直接演算

```

### 3. 將動畫及延遲加入無縫跳轉邏輯

你需要搜尋並替換以下幾個動畫相關函數的段落:

**替換 `addIntroAnimation`:**

```javascript
      function addIntroAnimation(mesh, startPos, targetPos, duration = 800) {
        const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
        if (window.IS_FAST_FORWARD) {
          mesh.position.copy(targetPos);
          mats.forEach(m => {
            m.transparent = false;
            m.opacity = 1;
            m.needsUpdate = true;
          });
        } else {
          mats.forEach(m => {
            m.transparent = true;
            m.opacity = 0;
            m.needsUpdate = true;
          });
          mesh.position.copy(startPos);
          introAnimData.push({
            mesh: mesh,
            startTime: performance.now(),
            duration: duration,
            startPos: startPos.clone(),
            targetPos: targetPos.clone(),
            mats: mats
          });
        }
      }

```

**替換 `removeMicrophoneAnim`:**

```javascript
      function removeMicrophoneAnim(mic) {
        if (window.IS_FAST_FORWARD) {
          scene.remove(mic);
          return;
        }
        const mats = Array.isArray(mic.material) ? mic.material : [mic.material];
        mats.forEach(m => {
          m.transparent = true;
        });
        mic.userData.outroAnim = {
          startTime: performance.now(),
          duration: 600,
          mats: mats
        };
        outroAnimData.push(mic);
      }

```

**修改 `startPhase1` 和 `startPhase8` 結尾的延遲:**

```javascript
      // 找到 startPhase1 中的:
      // setTimeout(() => startPhase2(), 800);
      // 替換為:
        if (window.IS_FAST_FORWARD) startPhase2();
        else setTimeout(() => startPhase2(), 800);

      // 找到 startPhase8 中的:
      // setTimeout(() => { uiMsg.style.display = 'none'; startGameplayPhase(); }, 2500);
      // 替換為:
            if (window.IS_FAST_FORWARD) {
              uiMsg.style.display = 'none';
              startGameplayPhase();
            } else {
              setTimeout(() => {
                uiMsg.style.display = 'none';
                startGameplayPhase();
              }, 2500);
            }

```

**替換 `executeCubeMovement` 中的運動配置:**

```javascript
        cube.userData.onMoveComplete = () => {
          cube.userData.col = stop.c;
          cube.userData.row = stop.r;
          handleWalkComplete(stop.c, stop.r, dir, traversed);
        };

        if (window.IS_FAST_FORWARD) {
          cube.position.set(cube.userData.targetX, cube.userData.targetY, cube.userData.targetZ);
          cube.userData.onMoveComplete();
        } else {
          cube.userData.moving = true;
          cube.userData.moveStartTime = performance.now();
        }

```

**替換 `handleWalkComplete` 與無路可走的延遲:**

```javascript
          // 找到 pCube.userData.score >= 6 結算的 setTimeout
          // setTimeout(() => { triggerRoundEnd(); }, 1000);
          // 替換為:
            if (window.IS_FAST_FORWARD) triggerRoundEnd();
            else setTimeout(() => { triggerRoundEnd(); }, 1000);

```

```javascript
        // 找到 showWalkOptions() 中如果 validMoves.length === 0 的部分替換為:
        if (validMoves.length === 0) {
          showMessage(`${currentPlayer === 'blue' ? '藍方' : '綠方'}第 ${walkCount} 次行走無路可走`);
          if (window.IS_FAST_FORWARD) handleWalkComplete(c, r, null, []);
          else setTimeout(() => { handleWalkComplete(c, r, null, []); }, 1200);
          return;
        }

```

**替換 `animateCubeToScore` 中的動畫標記:**

```javascript
      // 找到該函數中的 cube.userData.targetQuat.setFromEuler(...) 後,替換剩下的賦值邏輯:
        cube.userData.targetQuat.setFromEuler(new THREE.Euler(rx, ry, rz));
        if (window.IS_FAST_FORWARD) {
          cube.quaternion.copy(cube.userData.targetQuat);
          cube.userData.animating = false;
        } else {
          cube.userData.animating = true;
          cube.userData.startTime = performance.now();
        }

```

### 4. 修正 AI 必殺深度與移除佈局策略

尋找 `let SURE_KILL_DEPTH = 2;` 並修改成 `3` 以補全連招搜尋深度:

```javascript
      let SURE_KILL_DEPTH = 3; // 必勝計算深度提升至 3,使 AI 可推算連招

```

然後搜尋 `generateAllValidTurns` 函數中,計算 `scoreDelta` 的兩行程式碼,**移除權重計算**:

```javascript
              // 找到並將原來的這行替換為:
              let scoreDelta = earnedPoints * 100 - penaltyPoint * 150;

              // 向下找到這行並替換為:
                  if (spots.length > 0) {
                    const micSpot = spots[0];
                    moves.push({
                      sequence: [...seqBase, { type: 'mic', c: micSpot.c, r: micSpot.r }],
                      scoreDelta: scoreDelta, // 已移除 (10 - ...) 的距離加權
                      finalPos: stop3,

```

### 5. 重寫「導入局面」的網格建立以及「導入棋譜」快速演算機制

搜尋 `document.getElementById('file-import').addEventListener('change'` 中的 `reader.onload = async evt => {` 部分。修改導入的判定區塊:

```javascript
          if (content.includes('/')) {
            // 解析局面 FEN
            cleanupEntities();
            boardMeshes.forEach(b => scene.remove(b));
            boardMeshes = [];
            cylinderMeshes.forEach(c => scene.remove(c));
            cylinderMeshes = [];
            cylindersData = [];
            cubes = [];
            microphones = [];

            // 依據標準遊戲規則,完整還原 16 塊遊戲板 (解決舊版重疊錯誤)
            createGameBoard(2.5, 2.5, ['top', 'bottom', 'left', 'right']);
            createGameBoard(4.5, 2.5, ['top', 'bottom', 'left', 'right']);
            createGameBoard(2.5, 4.5, ['top', 'bottom', 'left', 'right']);
            createGameBoard(4.5, 4.5, ['top', 'bottom', 'left', 'right']);
            createGameBoard(0.5, 6.5, ['top', 'right']);
            createGameBoard(6.5, 6.5, ['top', 'left']);
            createGameBoard(0.5, 0.5, ['bottom', 'right']);
            createGameBoard(6.5, 0.5, ['bottom', 'left']);
            createGameBoard(2.5, 6.5, ['left', 'right', 'bottom']);
            createGameBoard(4.5, 6.5, ['left', 'right', 'bottom']);
            createGameBoard(2.5, 0.5, ['left', 'right', 'top']);
            createGameBoard(4.5, 0.5, ['left', 'right', 'top']);
            createGameBoard(0.5, 2.5, ['top', 'bottom', 'left']);
            createGameBoard(0.5, 4.5, ['top', 'bottom', 'left']);
            createGameBoard(6.5, 2.5, ['top', 'bottom', 'right']);
            createGameBoard(6.5, 4.5, ['top', 'bottom', 'right']);

            let rows = content.split('/');
            // ... (原本局面讀取的 FEN 迴圈維持不變) ...

```

在同一個監聽器下方的 `else {`(解析棋譜邏輯)內,將原本需要等待 350ms 的部分改寫為無縫計算:

```javascript
          } else {
            // 解析棋譜並無縫快速重播到最新狀態
            let prevBlue = isBlueAI;
            let prevGreen = isGreenAI;
            isBlueAI = false;
            isGreenAI = false;
            gameRound = 1;
            blueTotalScore = 0;
            orangeTotalScore = 0;
            globalMoveHistory = [];
            historyUndoStack = [];
            historyRedoStack = [];
            
            window.IS_FAST_FORWARD = true; // 開啟跳過動畫運算
            reinitNextRound(false);
            document.getElementById('ui-layer').style.display = 'none';

            let turns = content.split(';');
            for (let turn of turns) {
              if (!turn) continue;
              if (turn === '1' || turn === '2' || turn === '3') {
                const overlay = document.querySelector('div[style*="rgba(0,0,0,0.85)"]');
                if (overlay) document.body.removeChild(overlay);
                if (turn === '1') {
                  showFinalGameSummary();
                  break;
                } else if (turn === '2') {
                  gameRound = 2;
                  reinitNextRound(true);
                } else if (turn === '3') {
                  gameRound = 2;
                  reinitNextRound(false);
                }
                continue;
              }
              let actions = turn.split(',');
              for (let action of actions) {
                let { c, r } = strToCoord(action);
                let marker = markers.find(
                  m => Math.abs(m.position.x / 60 + 3.5 - c) < 0.1 && Math.abs(m.position.z / 60 + 3.5 - r) < 0.1
                );
                if (marker) {
                  marker.userData.onClick();
                }
              }
            }
            window.IS_FAST_FORWARD = false; // 還原
            isBlueAI = prevBlue;
            isGreenAI = prevGreen;
            updateUndoRedoButtons();
            triggerAIIfReady();
          }

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

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

帖子 ejsoon »

改進:
一,導入文本之後的問題
在導入「棋譜文本」之後,當前行動方如果原本是能往上下左右四個方向行走的,現在不能往左和往右了(行動標示出現之後又消失)。

在導入「棋譜文本」之後,如果點擊「回合後退」,無法再點擊「回合前進」。

二,AI計算或文字提示有誤

當處於這個局面「2TCH1H1/8/4C2H/7C/C4T2/4TC2/(*5)7/1TCH3[5]」時,綠方應為必敗,但是現在卻得出綠方勝率100%,查找原因,可能需要增加「必敗」的文字提示。

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

代码: 全选

改進:
一,導入文本之後的問題
在導入「棋譜文本」之後,當前行動方如果原本是能往上下左右四個方向行走的,現在不能往左和往右了(行動標示出現之後又消失)。

在導入「棋譜文本」之後,如果點擊「回合後退」,無法再點擊「回合前進」。

二,AI計算或文字提示有誤

當處於這個局面「2TCH1H1/8/4C2H/7C/C4T2/4TC2/(*5)7/1TCH3[5]」時,綠方應為必敗,但是現在卻得出綠方勝率100%,查找原因,可能需要增加「必敗」的文字提示。

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

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

帖子 ejsoon »

gemini 3.1 pro需要休息到4:52

代码: 全选

這三個問題其實有著連帶關係,以下為您分析原因並提供需要替換的代碼:

### 問題一與問題二的原因

**導入棋譜與 Undo/Redo 失效的原因是同一個**:在執行「快速演算(跳過動畫)」時,`executeCubeMovement` 觸發了瞬間移動,但卻沒有把 `cube.userData.moving` 狀態設為 `false`。這導致下一幀的 `animate()` 迴圈**再次誤判**該立方體正在移動,從而「重複觸發了兩次」行動完成的回呼函數。這不僅導致第一步與第二步的狀態被瞬間跳過(使你無法左右轉向),也讓歷史紀錄堆疊(Undo/Redo Stack)記錄到了錯亂的資料,進而導致「回合前進」等功能卡死。

### 問題三的原因

**必敗局面顯示 100% 的原因**:在 MCTS(蒙地卡洛樹搜尋)的隨機模擬(Playout)中,如果 AI 已經處於必敗狀態(無解),但對手在隨機模擬的過程中「盲目亂走」導致踩到自己的話筒而扣分,AI 的期望分數就會被錯誤拉高。此外,原本的演算法只有針對「必勝」進行 Minimax 的窮舉深度搜尋,缺少了對「必敗」的窮舉。我們需要補上 `checkSureLoss` 函數來明確標記必敗狀態。

---

### 需要替換的代碼

請找到以下對應的函數並將其整段替換:

#### 1. 修復導入棋譜與歷史紀錄錯亂 (替換 `executeCubeMovement` 函數)

找到 `executeCubeMovement`,並將其替換為以下代碼(約在原代碼的 660 行附近):

```javascript
      function executeCubeMovement(cube, dir, stop) {
        let traversed = [];
        let curC = cube.userData.col;
        let curR = cube.userData.row;

        // 紀錄直線平移中經過的所有路徑格子 (含終點)
        while (curC !== stop.c || curR !== stop.r) {
          curC += dir.dc;
          curR += dir.dr;
          traversed.push({ c: curC, r: curR });
        }

        const worldPos = getCellWorldPos(stop.c, stop.r);
        cube.userData.startX = cube.position.x;
        cube.userData.startZ = cube.position.z;
        cube.userData.startY = cube.position.y; // 新增起始高度紀錄

        cube.userData.targetX = worldPos.x;
        cube.userData.targetZ = worldPos.z;

        // 判斷終點是否有己方話筒,並設定目標高度
        const onOwnMic = microphones.some(
          m => m.userData.color === currentPlayer && m.userData.col === stop.c && m.userData.row === stop.r
        );
        cube.userData.targetY = onOwnMic ? itemY + 5 : itemY;

        cube.userData.moveStartTime = performance.now();

        cube.userData.onMoveComplete = () => {
          cube.userData.col = stop.c;
          cube.userData.row = stop.r;
          handleWalkComplete(stop.c, stop.r, dir, traversed);
        };

        if (window.IS_FAST_FORWARD) {
          cube.userData.moving = false; // 【修正】:強制設定為 false,防止被 animate() 重複觸發
          cube.position.set(cube.userData.targetX, cube.userData.targetY, cube.userData.targetZ);
          cube.userData.onMoveComplete();
        } else {
          cube.userData.moving = true;
          cube.userData.moveStartTime = performance.now();
        }
      }

```

#### 2. 修復 AI 必敗狀態判定 (替換 `doMCTSAI` 函數)

找到 `doMCTSAI`,並將其替換為以下代碼(約在原代碼的 970 行附近):

```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.isSureLoss) {
            winRateText = '0% (必敗局面)'; // 【新增】:攔截並顯示必敗狀態
          } 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);
      }

```

#### 3. 新增 `checkSureLoss` 並替換 `planFullTurnActionsMCTS` 演算法核心

找到 `MCTS 核心演算法 (Async 入口)` 區塊,在 `checkSureKill` 的正下方加入 `checkSureLoss` 函數,並且替換掉整個 `planFullTurnActionsMCTS`(約在原代碼 1010 行至 1065 行):

```javascript
      // =========================================================
      // MCTS 核心演算法 (Async 入口)
      // =========================================================
      let SURE_KILL_DEPTH = 3; // 必勝計算深度

      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;
        }
      }

      // 【新增】檢查必敗局面
      function checkSureLoss(state, depth, isAI) {
        if (state.oppScore >= 6) return true;
        if (state.aiScore >= 6) return false;
        if (depth <= 0) return false;

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

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

      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 : 0
        };

        // 【新增】先檢查是否為必敗局面
        const isSureLoss = checkSureLoss(rootState, SURE_KILL_DEPTH, true);

        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, isSureLoss: false };
          }
        }

        const rootNode = new MCTSNode(null, rootState, null);
        const startTime = performance.now();
        let iterations = 0;

        const statusDiv = document.getElementById('ai-status');

        while (performance.now() - startTime < timeLimit) {
          let node = rootNode;
          while (node.isFullyExpanded() && !node.isTerminal()) {
            node = node.getBestUCTChild();
          }

          if (!node.isFullyExpanded() && !node.isTerminal()) {
            node = node.expand();
          }

          const simScore = simulateRandomPlayout(node.state, aiColor, 3);
          node.backpropagate(simScore);

          iterations++;

          if (iterations % 50 === 0) {
            const elapsedSeconds = ((performance.now() - startTime) / 1000).toFixed(1);

            if (statusDiv) {
              statusDiv.innerHTML = `AI 思考中...<br>
                                     <span style="font-size: 0.9em; color: #ccc;">
                                       耗時: ${elapsedSeconds}s | 搜尋次數: ${iterations}
                                     </span>`;
            }

            await new Promise(resolve => setTimeout(resolve, 0));
          }
        }

        const bestChild = rootNode.getMostVisitedChild();
        // 【修正】回傳加入 isSureLoss 標記,並防止在沒有合法行動時報錯
        return {
          sequence: bestChild ? bestChild.actionSequence : (initMoves.length > 0 ? initMoves[0].sequence : []),
          rootNode: rootNode,
          isSureKill: false,
          isSureLoss: isSureLoss
        };
      }

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

正浏览此版面之用户: Semrush [Bot] 和 1 访客