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

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

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這三個問題分別涉及到回合歷史陣列的紀錄時機、跨對戰局數重做時的判定邏輯、以及介面和多國語系的微調。

以下為具體的修正步驟和需要替換的程式碼:

### 一、修復回合後退/前進、取消 AI 高亮與歷史紀錄漏寫問題

**1. 新增 `disableAI` 函數並套用到所有的進退按鈕中**
在代碼中找到 `function walkUndo()` 及其周圍的函數,替換為以下內容,確保任何人工撤銷或前進都會自動關閉 AI:

```javascript
      function disableAI() {
        isBlueAI = false;
        isGreenAI = false;
        document.getElementById('btn-ai-blue').classList.remove('active-blue');
        document.getElementById('btn-ai-green').classList.remove('active-green');
      }

      function walkUndo() {
        disableAI(); // 取消AI高亮
        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() {
        disableAI(); // 取消AI高亮
        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() {
        disableAI(); // 取消AI高亮
        // 在開局階段,回合後退就等同於單步撤銷
        if (!gameplayActive && !cubes.some(c => c.userData.score >= 6)) {
          if (historyUndoStack.length === 0) return;
          historyRedoStack.push(saveSnapshot());
          restoreSnapshot(historyUndoStack.pop());
          updateUndoRedoButtons();
          return;
        }

        let targetIdx = -1;
        for (let i = historyUndoStack.length - 1; i >= 0; i--) {
          let snap = historyUndoStack[i];

          // 如果往回找碰到了開局階段的末尾,這是個合法撤銷點
          if (!snap.gameplayActive && !snap.cubesData.some(c => c.score >= 6)) {
            targetIdx = i;
            break;
          }

          // 我們在尋找一個「回合起點」的快照
          if (snap.gameplayActive && snap.walkCount === 1 && snap.subPhase === 'walk') {
            if (!gameplayActive) {
              // 若當前在「遊戲結束」狀態,找到的第一個回合起點就是引發勝利的那一回合起點
              targetIdx = i;
              break;
            } else if (walkCount > 1 || markers.some(m => m.position.y === 6)) {
              // 處於回合中途,我們要退回到「當前回合」的起點
              if (snap.currentPlayer === currentPlayer) {
                targetIdx = i;
                break;
              }
            } else {
              // 處於回合起點,我們要退回到「上一回合」的起點
              if (snap.currentPlayer !== currentPlayer) {
                targetIdx = i;
                break;
              }
            }
          }
        }

        if (targetIdx === -1) return;

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

```

**2. 修改 `turnRedo` 邏輯解決跨局卡死的問題**
找到 `function turnRedo()` 並將其**完全替換**為以下邏輯(新增了 `isNextOpening` 判定):

```javascript
      function turnRedo() {
        disableAI(); // 取消AI高亮
        
        // 在開局階段,或者是重做堆疊的第一個是開局階段 (例如從結算畫面進入下一局)
        let isNextOpening = historyRedoStack.length > 0 && !historyRedoStack[historyRedoStack.length - 1].gameplayActive && !historyRedoStack[historyRedoStack.length - 1].cubesData.some(c => c.score >= 6);
        
        if ((!gameplayActive && !cubes.some(c => c.userData.score >= 6)) || isNextOpening) {
          if (historyRedoStack.length === 0) return;
          historyUndoStack.push(saveSnapshot());
          restoreSnapshot(historyRedoStack.pop());
          updateUndoRedoButtons();
          return;
        }

        let targetIdx = -1;
        for (let i = historyRedoStack.length - 1; i >= 0; i--) {
          let snap = historyRedoStack[i];

          // 若在重做堆疊中遇到遊戲結束狀態
          if (!snap.gameplayActive && snap.cubesData.some(c => c.score >= 6)) {
            targetIdx = i;
            break;
          }

          if (snap.gameplayActive && snap.walkCount === 1 && snap.subPhase === 'walk') {
            if (!gameplayActive) {
              break; // 遊戲若已結束無法再往前重做回合
            } else if (walkCount > 1 || markers.some(m => m.position.y === 6)) {
              // 處於回合中途,重做到「下一回合」的起點
              if (snap.currentPlayer !== currentPlayer) {
                targetIdx = i;
                break;
              }
            } else {
              // 處於回合起點,重做到「下一回合」的起點
              if (snap.currentPlayer !== currentPlayer) {
                targetIdx = i;
                break;
              }
            }
          }
        }

        if (targetIdx === -1) return;

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

```

---

### 二、修復勝利紀錄並改寫結算畫面 UI

找到 `function triggerRoundEnd()`,將裡面的這段範圍進行修改,確保勝利行動在快照前先一步合併進歷史紀錄,同時刪除「導出棋譜」按鈕並精簡第二局結算畫面:

```javascript
      function triggerRoundEnd() {
        gameplayActive = false;

        // 【修復】將最後獲勝那一回合的行動陣列合併並推入歷史紀錄
        if (currentTurnCoords.length > 0) {
          globalMoveHistory.push(currentTurnCoords.join(''));
          currentTurnCoords = [];
        }

        let lastSnap = historyUndoStack[historyUndoStack.length - 1];
        let isAlreadySaved = lastSnap && lastSnap.gameplayActive === false && lastSnap.cubes.some(c => c.score >= 6);
        if (!isAlreadySaved) {
          pushAction();
        }

        resetAIState();
        clearMarkers();

        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 ? '綠方' : '平手';
        if (window.currentLang === 'en') {
          roundWinner = roundWinner === '藍方' ? 'Blue' : roundWinner === '綠方' ? 'Green' : 'Draw';
        }

        const overlay = document.createElement('div');
        overlay.id = 'round-end-overlay';
        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 titleText =
          window.currentLang === 'zh'
            ? `第 ${gameRound} 局結束!本局勝者:${roundWinner}`
            : `Round ${gameRound} Ended! Winner: ${roundWinner}`;
        const title = document.createElement('h2');
        title.style.fontSize = '36px';
        title.innerText = titleText;

        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, titleText);
        overlay.appendChild(btnMin);

        const btnNo = createOverlayButton(tMsg('結束遊戲顯示總分'), () => {
          globalMoveHistory.push('1');
          document.body.removeChild(overlay);
          showFinalGameSummary();
        });
        const btnSame = createOverlayButton(tMsg('當前場地再戰一局'), () => {
          globalMoveHistory.push('2');
          document.body.removeChild(overlay);
          gameRound = 2;
          reinitNextRound(true);
        });
        const btnNew = createOverlayButton(tMsg('雙方換先再戰一局'), () => {
          globalMoveHistory.push('3');
          document.body.removeChild(overlay);
          gameRound = 2;
          reinitNextRound(false);
        });

        overlay.appendChild(title);
        const scoreInfo = document.createElement('p');
        scoreInfo.style.fontSize = '22px';
        scoreInfo.innerText =
          window.currentLang === 'zh'
            ? `藍方單局得分:${blueFinal} | 綠方單局得分:${orangeFinal}`
            : `Blue Score: ${blueFinal} | Green Score: ${orangeFinal}`;
        overlay.appendChild(scoreInfo);

        if (gameRound === 1) {
          overlay.appendChild(btnNo);
          overlay.appendChild(btnSame);
          overlay.appendChild(btnNew);
        } else {
          // 【修改】第二局結束直接顯示總分與重新開始按鈕
          let ultimateWinner = blueTotalScore > orangeTotalScore ? '藍方' : orangeTotalScore > blueTotalScore ? '綠方' : '平手';
          if (window.currentLang === 'en') {
            ultimateWinner = ultimateWinner === '藍方' ? 'Blue' : ultimateWinner === '綠方' ? 'Green' : 'Draw';
          }
          const totalScoreInfo = document.createElement('p');
          totalScoreInfo.style.fontSize = '26px';
          totalScoreInfo.style.margin = '20px 0';
          totalScoreInfo.innerText = window.currentLang === 'zh'
            ? `最終贏家:${ultimateWinner} | 總分 - 藍方:${blueTotalScore} 綠方:${orangeTotalScore}`
            : `Ultimate Winner: ${ultimateWinner} | Total - Blue: ${blueTotalScore} Green: ${orangeTotalScore}`;
          overlay.appendChild(totalScoreInfo);

          const btnRestartAll = createOverlayButton(tMsg('重新開始整個遊戲'), () => {
            globalMoveHistory.push('1'); // 推入終局標記
            document.body.removeChild(overlay);
            gameRound = 1;
            blueTotalScore = 0;
            orangeTotalScore = 0;
            reinitNextRound(false);
          });
          overlay.appendChild(btnRestartAll);
        }
        
        // 已移除 overlay.appendChild(btnDownload); 
        document.body.appendChild(overlay);
      }

```

接著,在 **`showFinalGameSummary()`** 裡面也要刪除這兩行「導出按鈕」的相關代碼:

```javascript
        // 刪除以下代碼:
        // const btnDownloadFinal = createOverlayButton(tMsg('導出當前棋譜'), () => {
        //   document.getElementById('btn-io-trigger').click();
        // });
        // overlay.appendChild(btnDownloadFinal);

```

---

### 三、修正語言切換問題

找到 `doMCTSAI()` 函數,將其中的 `winRateText` 賦值邏輯改為基於 `window.currentLang` 來顯示:

```javascript
          if (result.isSureKill) {
            winRateText = window.currentLang === 'zh' ? '100% (必勝局面)' : '100% (Sure Win)';
          } else if (result.isSureLoss) {
            winRateText = window.currentLang === 'zh' ? '0% (必敗局面)' : '0% (Sure Loss)';
          } else if (result.rootNode) {
            // ...

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

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

帖子 ejsoon »

必殺搜尋也要計入思考時間。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5975
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

改進:
一,回合後退與前進的問題
當從遊戲第二局回退到第一局之後,無法再前進至第二局。

原因可能是`if (!gameplayActive)`這個判斷使其無法前進。這個邏輯需要修改,即使遊戲結束仍然可以前進,只要往前還有數據。

檢查和修正turnRedo()和updateUndoRedoButtons()這兩個函式,以及其它可能有誤的函式。

二,AI思考計時的問題
AI在作必殺計算時,也要計入用時。檢查代碼是否已經計入。

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

代码: 全选

改進:
一,回合後退與前進的問題
當從遊戲第二局回退到第一局之後,無法再前進至第二局。

原因可能是`if (!gameplayActive)`這個判斷使其無法前進。這個邏輯需要修改,即使遊戲結束仍然可以前進,只要往前還有數據。

檢查和修正turnRedo()和updateUndoRedoButtons()這兩個函式,以及其它可能有誤的函式。

二,AI思考計時的問題
AI在作必殺計算時,也要計入用時。檢查代碼是否已經計入。

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

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

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

你的思路非常精準!這兩個問題確實分別出在「阻擋跨局回合前進的邏輯判斷」以及「`startTime` 計時宣告位置過晚」上。

以下是針對這兩個問題的代碼修正,請找到對應的函式並替換成更新後的代碼:

### 一、 修正「跨局回合無法前進 (Redo)」的問題

這個問題出在兩處:第一是 `updateUndoRedoButtons()` 沒有把「遊戲結束與開局狀態」視為有效的前進目標,導致按鈕被禁用;第二是 `turnRedo()` 中 `if (!gameplayActive) break;` 強行中斷了跨局尋找下一回合的行為。

**1. 替換 `updateUndoRedoButtons()` 裡關於 `hasTurnRedo` 的迴圈判斷:**

請找到 `let hasTurnRedo = false;` 所在的這段迴圈(約在第 774 行附近),替換為以下代碼:

```javascript
          let hasTurnRedo = false;
          for (let i = historyRedoStack.length - 1; i >= 0; i--) {
            let snap = historyRedoStack[i];
            // 修改:允許重做至非遊戲階段(如開局、結算)或是下一回合的起點
            if (!snap.gameplayActive || (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk')) {
              hasTurnRedo = true;
              break;
            }
          }
          tRedo.disabled = !hasTurnRedo;

```

**2. 替換 `turnRedo()` 裡的跨局判斷邏輯:**

請找到 `turnRedo()` 函式裡的 `if (snap.gameplayActive && snap.walkCount === 1 && snap.subPhase === 'walk')` 區塊(約在第 859 行附近),替換為以下代碼:

```javascript
            if (snap.gameplayActive && snap.walkCount === 1 && snap.subPhase === 'walk') {
              // 修改:移除原本阻擋前進的 !gameplayActive 判斷
              if (walkCount > 1 || markers.some(m => m.position.y === 6)) {
                // 處於回合中途,重做到「下一回合」的起點
                if (snap.currentPlayer !== currentPlayer) {
                  targetIdx = i;
                  break;
                }
              } else {
                // 處於回合起點(包含當前為遊戲結束狀態跨局的情境),重做到「下一回合」的起點
                if (snap.currentPlayer !== currentPlayer || !gameplayActive) {
                  targetIdx = i;
                  break;
                }
              }
            }

```

---

### 二、 修正「AI 必殺計算沒有計入總時間」的問題

目前的代碼是等 `minimaxEndgame()` 執行完畢後才呼叫 `performance.now()` 來倒數計時,這會導致總耗時「必殺計算時間 + 思考時間」,使得 AI 的回合時間會超出預期。

**替換 `planFullTurnActionsMCTS(timeLimit)` 頂部的代碼:**

請找到 `async function planFullTurnActionsMCTS(timeLimit)` 宣告處(約在第 1386 行),把 `const startTime = performance.now();` 移到最上面。替換至 `let iterations = 0;` 這區段:

```javascript
      async function planFullTurnActionsMCTS(timeLimit) {
        const startTime = performance.now(); // 修正:將計時器移至最頂部,確保包含必殺計算時間
        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 actualSureKillDepth = window.customSureKillDepth || SURE_KILL_DEPTH;
        let isSureLoss = false;

        // 強制執行必殺與必敗搜尋
        const endgameRes = minimaxEndgame(rootState, actualSureKillDepth, true);
        if (endgameRes.val > 5000 && endgameRes.move) {
          return { sequence: endgameRes.move.sequence, isSureKill: true, isSureLoss: false, val: endgameRes.val };
        } else if (endgameRes.val < -5000) {
          isSureLoss = true;
        }

        const rootNode = new MCTSNode(null, rootState, null);
        let iterations = 0; // 原本的 startTime 移除了

```

這樣修改之後,AI 就會嚴格將 `minimaxEndgame` 佔用的時間從 `timeLimit` 中扣除;如果必殺計算本身就耗盡了 `timeLimit` 額度,MCTS 就會只執行保底次數然後迅速結束,確保響應時間的穩定!
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5975
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

svg機器人圖標:

代码: 全选

<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
  <rect x="5" y="8" width="14" height="10" rx="2" fill="#333"/>
  <circle cx="9" cy="12" r="1.5" fill="#00FF00"/>
  <circle cx="15" cy="12" r="1.5" fill="#00FF00"/>
  <rect x="9" y="15" width="6" height="1" fill="#00FF00"/>
</svg>


<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
  <rect x="7" y="7" width="10" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
  <path d="M3 9h4M3 15h4M17 9h4M17 15h4M9 3v4M15 3v4M9 17v4M15 17v4"
        stroke="currentColor" stroke-width="2"/>
  <circle cx="10" cy="11" r="1" fill="currentColor"/>
  <circle cx="14" cy="11" r="1" fill="currentColor"/>
  <path d="M10 14h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
  <rect x="5" y="7" width="14" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="2"/>
  <line x1="12" y1="2" x2="12" y2="6" stroke="currentColor" stroke-width="2"/>
  <circle cx="9" cy="11" r="1.2" fill="currentColor"/>
  <circle cx="15" cy="11" r="1.2" fill="currentColor"/>
  <path d="M9 14 Q12 17 15 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <rect x="2" y="9" width="2" height="6" rx="1"/>
  <rect x="20" y="9" width="2" height="6" rx="1"/>
  <rect x="4" y="6" width="16" height="12" rx="3"/>
  <circle cx="9" cy="11" r="1.2"/>
  <circle cx="15" cy="11" r="1.2"/>
  <path d="M9 15h6"/>
</svg>
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 5975
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

改進:
一,導入導出按鈕的更改
這個元素`<button id="btn-io-trigger" style="display: none"></button>`應該去掉,並把它的綁定事件綁至「導入導出」按鈕上。

二,AI設置窗口功能的更改
在AI設置窗口的標題下面,加上一排四個切換按鈕,切換按鈕的圖案和文字跟「AI強度」按鈕內的一致,所高亮的也跟「AI強度」按鈕所選一致,下方的數值也跟其所選一致。

當「思考時間」和「必殺深度」的數值更改時,如果其數值跟預置的三個AI強度一致,則會自動使其對應的切換按鈕高亮,如果不一致,則是自定義高亮。下方的「AI強度」按鈕也將同步更改所選。

三,AI強度按鈕的更改
當前AI強度按鈕彈出的選單中,已經給出選項的「思考時間」,現在要加上「必殺深度」。

四,「播放動畫」功能的實現
當點擊「播放動畫」按鈕時,將處於「播放狀態」,下方一排的按鈕將隱藏,替換為:一個「播放、暫停」鍵(svg圖標),一個回合數顯示,一個較寬的拖動滑塊(左右有加減按鈕),一個關閉按鈕(svg圖標)。當處於播放狀態時,將不會再出現「行動標示」。點擊「關閉按鈕」將退出播放狀態。

當redo_stack不為空時,將自動開始播放,「播放、暫停鍵」顯示「暫停」,並將開始播放餘下行動,其效果如同按下「行走重做」和「回合前進」,不同的是,它有移動動畫,以及話筒落下的動畫,跟平時操作一樣。

當redo_stack為空時,將停止播放,「播放、暫停鍵」顯示「播放」。當因為點擊「回合後退」而使redo_stack不為空時,點擊播放,則將開始播放餘下行動。

拖動滑塊及其加減按鈕以「行動中的每一步」作為步進單位。當正在播放時,拖動滑塊及其加減按鈕都是無效的,只有在停止播放時,它們才可使用。

「回合數顯示」會顯示「回合數及當前步數(0,1,2,3)」,它的數值將會隨著播放而改變,同時也會隨著拖動滑塊的拖動而改變。

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

代码: 全选

改進:
一,導入導出按鈕的更改
這個元素`<button id="btn-io-trigger" style="display: none"></button>`應該去掉,並把它的綁定事件綁至「導入導出」按鈕上。

二,AI設置窗口功能的更改
在AI設置窗口的標題下面,加上一排四個切換按鈕,切換按鈕的圖案和文字跟「AI強度」按鈕內的一致,所高亮的也跟「AI強度」按鈕所選一致,下方的數值也跟其所選一致。

當「思考時間」和「必殺深度」的數值更改時,如果其數值跟預置的三個AI強度一致,則會自動使其對應的切換按鈕高亮,如果不一致,則是自定義高亮。下方的「AI強度」按鈕也將同步更改所選。

三,AI強度按鈕的更改
當前AI強度按鈕彈出的選單中,已經給出選項的「思考時間」,現在要加上「必殺深度」。

四,「播放動畫」功能的實現
當點擊「播放動畫」按鈕時,將處於「播放狀態」,下方一排的按鈕將隱藏,替換為:一個「播放、暫停」鍵(svg圖標),一個回合數顯示,一個較寬的拖動滑塊(左右有加減按鈕),一個關閉按鈕(svg圖標)。當處於播放狀態時,將不會再出現「行動標示」。點擊「關閉按鈕」將退出播放狀態。

當redo_stack不為空時,將自動開始播放,「播放、暫停鍵」顯示「暫停」,並將開始播放餘下行動,其效果如同按下「行走重做」和「回合前進」,不同的是,它有移動動畫,以及話筒落下的動畫,跟平時操作一樣。

當redo_stack為空時,將停止播放,「播放、暫停鍵」顯示「播放」。當因為點擊「回合後退」而使redo_stack不為空時,點擊播放,則將開始播放餘下行動。

拖動滑塊及其加減按鈕以「行動中的每一步」作為步進單位。當正在播放時,拖動滑塊及其加減按鈕都是無效的,只有在停止播放時,它們才可使用。

「回合數顯示」會顯示「回合數及當前步數(0,1,2,3)」,它的數值將會隨著播放而改變,同時也會隨著拖動滑塊的拖動而改變。

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

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

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這是一個非常具體且結構清晰的升級需求。我們將分成四個部分進行修改:**I. 導入導出按鈕合併**、**II. AI 設定窗口增強**、**III. AI 強度選單文字**、**IV. 播放動畫功能實作**。

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

### 1. 修改 HTML 結構 (刪除舊按鈕並加入新 UI)

**搜尋並替換 `<body>` 內的部分 UI 結構**:
找到這段代碼:

```html
      <div class="custom-select-container">
        <button id="btn-io-dropdown-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-dropdown-options" class="custom-select-options">
          <div class="csinger-option io-option" id="opt-io-menu" data-action="io">導入導出</div>
          <div class="csinger-option io-option" id="opt-play-anim" data-action="anim">播放動畫</div>
          <div class="csinger-option io-option" id="opt-show-arrow" data-action="arrow">顯示箭頭</div>
        </div>
      </div>
      <button id="btn-io-trigger" style="display: none"></button>
    </div>

```

**替換為:**

```html
      <div class="custom-select-container">
        <button id="btn-io-dropdown-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-dropdown-options" class="custom-select-options">
          <div class="csinger-option io-option" id="opt-io-menu" data-action="io">導入導出</div>
          <div class="csinger-option io-option" id="opt-play-anim" data-action="anim">播放動畫</div>
          <div class="csinger-option io-option" id="opt-show-arrow" data-action="arrow">顯示箭頭</div>
        </div>
      </div>
    </div>

    <div id="playback-controls" style="display: none; position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); gap: 12px; z-index: 20; pointer-events: auto; align-items: center; background: rgba(44, 62, 80, 0.9); padding: 10px 20px; border-radius: 10px; border: 2px solid #bdc3c7;">
      <button id="btn-playback-toggle" class="hist-btn" title="播放/暫停" style="background-color: #2ecc71;">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
      </button>
      <div id="playback-info" style="color: white; font-weight: bold; min-width: 100px; text-align: center;">回合: 0 步: 0</div>
      <button id="btn-playback-prev" class="hist-btn" style="padding: 5px;">-</button>
      <input type="range" id="playback-slider" min="0" max="0" value="0" style="width: 150px; cursor: pointer;">
      <button id="btn-playback-next" class="hist-btn" style="padding: 5px;">+</button>
      <button id="btn-playback-close" class="hist-btn" title="關閉播放" style="background-color: #e74c3c;">
        <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
      </button>
    </div>

```

---

### 2. 修改 AI 設置 UI 與強度選項文字

**找到以下代碼 (`#ai-strength-options` 區塊):**

```html
          <div class="csinger-option strength-option" id="opt-ai-easy" data-value="1000" data-depth="4">
...
            簡單 (1s)
          </div>
          <div class="csinger-option strength-option" id="opt-ai-hard" data-value="4000" data-depth="6">
...
            困難 (4s)
          </div>
          <div class="csinger-option strength-option" id="opt-ai-expert" data-value="7000" data-depth="8">
...
            專家 (7s)
          </div>

```

**將三處的文字替換為包含深度的文字:**

```html
            簡單 (1s, 深度 4)
...
            困難 (4s, 深度 6)
...
            專家 (7s, 深度 8)

```

**找到 `#settings-modal` 內的標題區塊:**

```html
      <h3 style="margin: 0; display: flex; justify-content: center; align-items: center; gap: 15px" id="modal-title-container">
        <span id="modal-title">設置</span>
        <span class="lang-toggle-text" id="modal-lang-toggle">
          <span id="lang-zh" class="lang-active">中文</span> | <span id="lang-en">En</span>
        </span>
      </h3>

```

**在其下方緊接著加入這排切換按鈕:**

```html
      <div id="modal-ai-presets" style="display: flex; justify-content: space-between; border-bottom: 1px solid #7f8c8d; padding-bottom: 15px; margin-top: 10px;">
        <button class="hist-btn modal-preset-btn" data-val="1000" data-dep="4" title="簡單" style="flex:1; margin: 0 5px;">簡單</button>
        <button class="hist-btn modal-preset-btn" data-val="4000" data-dep="6" title="困難" style="flex:1; margin: 0 5px;">困難</button>
        <button class="hist-btn modal-preset-btn" data-val="7000" data-dep="8" title="專家" style="flex:1; margin: 0 5px;">專家</button>
        <button class="hist-btn modal-preset-btn" data-val="0" data-dep="0" title="自定義" style="flex:1; margin: 0 5px;">自訂</button>
      </div>

```

---

### 3. 加入 JavaScript 播放邏輯與 AI 設定聯動

請在 `<script type="module">` 內合適的地方(例如事件綁定的區域)**新增以下代碼**:

```javascript
      // ==========================================
      // [新增] 播放動畫核心邏輯
      // ==========================================
      window.IS_PLAYBACK_ANIM = false;
      let playbackInterval = null;

      function startPlaybackMode() {
        document.getElementById('history-controls').style.display = 'none';
        document.getElementById('playback-controls').style.display = 'flex';
        clearMarkers();
        window.IS_PLAYBACK_ANIM = true;
        updatePlaybackUI();
        if (historyRedoStack.length > 0) {
          togglePlayback(true);
        } else {
          togglePlayback(false);
        }
      }

      function stopPlaybackMode() {
        togglePlayback(false);
        document.getElementById('playback-controls').style.display = 'none';
        document.getElementById('history-controls').style.display = 'flex';
        window.IS_PLAYBACK_ANIM = false;
        // 如果還在遊戲中且原本該顯示行走標記,重置回來
        if (gameplayActive && markers.length === 0) {
            showWalkOptions();
        }
      }

      function togglePlayback(forcePlay) {
        const isPlaying = forcePlay !== undefined ? forcePlay : !playbackInterval;
        const btn = document.getElementById('btn-playback-toggle');
        const slider = document.getElementById('playback-slider');
        const btnPrev = document.getElementById('btn-playback-prev');
        const btnNext = document.getElementById('btn-playback-next');

        if (isPlaying && historyRedoStack.length > 0) {
          btn.innerHTML = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>'; // 暫停圖標
          btn.style.backgroundColor = '#f39c12';
          slider.disabled = true; btnPrev.disabled = true; btnNext.disabled = true;

          playbackInterval = setInterval(() => {
            if (historyRedoStack.length === 0) {
              togglePlayback(false);
            } else {
              let prevFF = window.IS_FAST_FORWARD;
              window.IS_FAST_FORWARD = false;
              walkRedo();
              window.IS_FAST_FORWARD = prevFF;
              updatePlaybackUI();
              clearMarkers(); // 播放時隱藏標記
            }
          }, 1200);
        } else {
          btn.innerHTML = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>'; // 播放圖標
          btn.style.backgroundColor = '#2ecc71';
          if (playbackInterval) clearInterval(playbackInterval);
          playbackInterval = null;
          slider.disabled = false; btnPrev.disabled = false; btnNext.disabled = false;
        }
      }

      function updatePlaybackUI() {
        const slider = document.getElementById('playback-slider');
        const totalSteps = historyUndoStack.length + historyRedoStack.length;
        const currentStep = historyUndoStack.length;
        slider.max = totalSteps;
        slider.value = currentStep;

        document.getElementById('playback-info').innerText = window.currentLang === 'zh' 
          ? `回合: ${gameRound} 步數: ${currentStep}` 
          : `Round: ${gameRound} Step: ${currentStep}`;
      }

      document.getElementById('btn-playback-toggle').addEventListener('click', () => togglePlayback());
      document.getElementById('btn-playback-close').addEventListener('click', stopPlaybackMode);
      
      function scrubToStep(targetStep) {
          let currentStep = historyUndoStack.length;
          window.IS_FAST_FORWARD = true; // 拖曳時不播動畫
          window.IS_PLAYBACK_ANIM = false;
          while (currentStep < targetStep && historyRedoStack.length > 0) { walkRedo(); currentStep++; }
          while (currentStep > targetStep && historyUndoStack.length > 0) { walkUndo(); currentStep--; }
          window.IS_FAST_FORWARD = false;
          window.IS_PLAYBACK_ANIM = true;
          updatePlaybackUI();
          clearMarkers();
      }

      document.getElementById('playback-slider').addEventListener('input', (e) => scrubToStep(parseInt(e.target.value)));
      document.getElementById('btn-playback-prev').addEventListener('click', () => {
          let val = parseInt(document.getElementById('playback-slider').value);
          if (val > 0) scrubToStep(val - 1);
      });
      document.getElementById('btn-playback-next').addEventListener('click', () => {
          let max = parseInt(document.getElementById('playback-slider').max);
          let val = parseInt(document.getElementById('playback-slider').value);
          if (val < max) scrubToStep(val + 1);
      });

      // ==========================================
      // [新增] AI Preset 聯動邏輯
      // ==========================================
      const presetBtns = document.querySelectorAll('.modal-preset-btn');
      function updateAIPresetsHighlight() {
          const t = parseInt(document.getElementById('inp-think-time').value) * 1000;
          const d = parseInt(document.getElementById('inp-sure-kill').value);
          
          let matched = false;
          presetBtns.forEach(btn => {
              btn.style.backgroundColor = '#34495e'; // 重置顏色
              const val = parseInt(btn.getAttribute('data-val'));
              const dep = parseInt(btn.getAttribute('data-dep'));
              if (val === t && dep === d) {
                  btn.style.backgroundColor = '#2ecc71';
                  matched = true;
                  // 同步主按鈕
                  const trigger = document.getElementById('ai-strength-trigger');
                  if (trigger) {
                      trigger.setAttribute('data-value', t);
                      trigger.setAttribute('data-depth', d);
                  }
                  aiStrength = t;
                  SURE_KILL_DEPTH = d;
              }
          });
          if (!matched) {
              presetBtns[3].style.backgroundColor = '#2ecc71'; // 自定義亮起
              const trigger = document.getElementById('ai-strength-trigger');
              if (trigger) {
                  trigger.setAttribute('data-value', 10000); // Trigger 指向自定義
              }
          }
      }

      presetBtns.forEach(btn => {
          btn.addEventListener('click', () => {
              const val = parseInt(btn.getAttribute('data-val'));
              const dep = parseInt(btn.getAttribute('data-dep'));
              if (val > 0) {
                  document.getElementById('inp-think-time').value = val / 1000;
                  document.getElementById('inp-sure-kill').value = dep;
                  updateAIPresetsHighlight();
                  syncCustomOption();
              }
          });
      });

```

---

### 4. 替換匯入/匯出按鈕的綁定並啟用選單

**搜尋並刪除這段無用的程式碼:**

```javascript
      document.getElementById('btn-io-trigger').addEventListener('click', e => {
        e.stopPropagation();

        // 生成棋譜
        let saveHistory = [...globalMoveHistory];
...
        document.getElementById('ta-import').value = '';
        ioModal.style.display = 'flex';
      });

```

**找到這段代碼:**

```javascript
      document.querySelectorAll('.io-option').forEach(opt => {
        opt.addEventListener('click', e => {
          e.stopPropagation();
          ioDropdownOptions.classList.remove('open');
          const action = opt.getAttribute('data-action');
          if (action === 'io') {
            document.getElementById('btn-io-trigger').click();
          } else if (action === 'anim') {
            console.log('功能:播放動畫');
            // 未來在此實作播放動畫邏輯
          } else if (action === 'arrow') {
            console.log('功能:顯示箭頭');
            // 未來在此實作顯示箭頭邏輯
          }
        });
      });

```

**將其替換為:**

```javascript
      function openIoModal() {
        let saveHistory = [...globalMoveHistory];
        if (currentTurnCoords.length > 0) saveHistory.push(currentTurnCoords.join(''));
        document.getElementById('ta-export-moves').value = saveHistory.join(' ');

        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);
        }
        document.getElementById('ta-export-state').value = fen.join('/');
        document.getElementById('ta-import').value = '';
        ioModal.style.display = 'flex';
      }

      document.querySelectorAll('.io-option').forEach(opt => {
        opt.addEventListener('click', e => {
          e.stopPropagation();
          ioDropdownOptions.classList.remove('open');
          const action = opt.getAttribute('data-action');
          if (action === 'io') {
            openIoModal();
          } else if (action === 'anim') {
            startPlaybackMode();
          } else if (action === 'arrow') {
            console.log('功能:顯示箭頭');
          }
        });
      });

```

---

### 5. 實現播放時的「平滑移動」動畫 (修改 `restoreSnapshot`)

為了讓重做(`redo`)在播放時有滑行效果,我們需微調 `restoreSnapshot` 函數。
**搜尋 `function restoreSnapshot(snap) {**` 並**替換整個函數頭部到 `snap.cubesData.forEach**`:

```javascript
      function restoreSnapshot(snap) {
        // [新增] 紀錄先前的立方體位置,供播放動畫使用
        let prevCubesPos = {};
        if (window.IS_PLAYBACK_ANIM) {
            cubes.forEach(c => prevCubesPos[c.userData.color] = {x: c.position.x, y: c.position.y, z: c.position.z});
        }

        // 第一步:徹底清空場上所有 3D 物件
        boardMeshes.forEach(mesh => scene.remove(mesh));
        boardMeshes = [];
        cylinderMeshes.forEach(mesh => scene.remove(mesh));
        cylinderMeshes = [];
        cubes.forEach(mesh => scene.remove(mesh));
        cubes = [];
        microphones.forEach(mesh => scene.remove(mesh));
        microphones = [];
        clearMarkers();

        // 移除任何可能的結算遮罩
        let existingOverlay = document.getElementById('round-end-overlay');
        if (existingOverlay) existingOverlay.remove();
        let existingSummary = document.getElementById('final-summary-overlay');
        if (existingSummary) existingSummary.remove();

        // 恢復變數狀態
        gameRound = snap.gameRound;
        blueTotalScore = snap.blueTotalScore;
        orangeTotalScore = snap.orangeTotalScore;
        currentPlayer = snap.currentPlayer;
        walkCount = snap.walkCount;
        lastDirection = snap.lastDirection;
        turnStopPositions = snap.turnStopPositions;
        gameplayActive = snap.gameplayActive;

        globalMoveHistory = [...snap.globalMoveHistory];
        currentTurnCoords = [...snap.currentTurnCoords];

        currentPhaseFn = snap.currentPhaseFn;
        blueCornerType = snap.blueCornerType;
        orangeCornerType = snap.orangeCornerType;
        placedEdges = { ...snap.placedEdges };
        placedCorners = { ...snap.placedCorners };
        edgeTurn = snap.edgeTurn;
        cylindersData = []; 
        boardsData = []; 

        // 暫時開啟快進模式,讓重建 3D 物件時不會播放進場動畫
        let prevFastForward = window.IS_FAST_FORWARD;
        window.IS_FAST_FORWARD = true;

        snap.boardsData.forEach(b => createGameBoard(b.c, b.r, b.dirs, b.startOffset));
        snap.cylindersData.forEach(cyl => placeCylinder(cyl.c, cyl.r));
        
        // 播放模式下,讓話筒落下有動畫
        if (window.IS_PLAYBACK_ANIM) window.IS_FAST_FORWARD = false;
        snap.micsData.forEach(m => createMicrophoneMesh(m.color, m.c, m.r));
        window.IS_FAST_FORWARD = true;

        snap.cubesData.forEach(c => {
          placeCube(c.c, c.r, c.color);
          const newCube = cubes[cubes.length - 1];
          newCube.userData.score = c.score;
          newCube.userData.currentNumber = c.currentNumber;
          
          const onOwnMic = snap.micsData.some(m => m.color === c.color && m.c === c.c && m.r === c.r);
          const targetY = onOwnMic ? itemY + 5 : itemY;
          const worldPos = getCellWorldPos(c.c, c.r);

          if (window.IS_PLAYBACK_ANIM && prevCubesPos[c.color] && !prevFastForward) {
             // 播放模式滑行動畫
             let oldP = prevCubesPos[c.color];
             // 移除從天而降的進場動畫
             introAnimData = introAnimData.filter(a => a.mesh !== newCube);
             newCube.material.forEach(m => { m.transparent = false; m.opacity = 1; });
             
             newCube.position.set(oldP.x, oldP.y, oldP.z);
             newCube.userData.startX = oldP.x;
             newCube.userData.startZ = oldP.z;
             newCube.userData.startY = oldP.y;
             newCube.userData.targetX = worldPos.x;
             newCube.userData.targetZ = worldPos.z;
             newCube.userData.targetY = targetY;
             newCube.userData.moving = true;
             newCube.userData.moveStartTime = performance.now();
             newCube.userData.moveDuration = 800;
             window.IS_FAST_FORWARD = false; 
             animateCubeToScore(newCube, c.score);
             window.IS_FAST_FORWARD = true;
          } else {
             newCube.position.y = targetY;
             animateCubeToScore(newCube, c.score); 
          }
        });

        window.IS_FAST_FORWARD = prevFastForward;

```

最後,在 `setupNumberInput` 綁定的區塊下方 (**`inp-think-time` 更改時**),加入 `updateAIPresetsHighlight()` 以使調整輸入框時能即時連動按鈕:

```javascript
        inp.addEventListener('change', () => {
          let val = parseInt(inp.value);
          if (isNaN(val) || val < min) inp.value = min;
          if (val > max) inp.value = max;
          syncCustomOption();
          updateAIPresetsHighlight(); // 新增這行
        });

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

在线用户

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