分享和讨论Javascript相关的话题
回复
头像
ejsoon
一枝独秀
帖子: 6455
注册时间: 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
一枝独秀
帖子: 6455
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

必殺搜尋也要計入思考時間。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6455
注册时间: 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) 已下载 42 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6455
注册时间: 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
一枝独秀
帖子: 6455
注册时间: 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
一枝独秀
帖子: 6455
注册时间: 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) 已下载 36 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6455
注册时间: 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/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6455
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

改進:
一,AI設置窗口的問題
AI設置窗口的四個切換按鈕中的文字「簡單、困難、專家、自定義」並沒有切換成當前語言,同時需要在文字前加上它們的svg圖標,用js直接從ai-strength-options複製。

當AI設置窗口打開,modal-ai-presets會使ai-strength-trigger當前所選的項目高亮。

當AI設置窗口關閉,不需要再更改ai-strength-trigger當前所選。

如果在modal-ai-presets的切換按鈕選中其中一個,則下方的ai-strength-trigger也會立即切換。

modal-ai-presets不能切換至「自定義」,現在定義它的切換規則:如果直接點擊這四個切換按鈕的其中一個,則下方的數字會立即變為其預置值,「自定義」的預置值是「思考時間10s、必殺深度10」。如果當前已經是「自定義」,且其值不是自定義的預置值,則再次點擊「自定義」無效。

當下方的兩個輸入框的值更改時,如果它們等於前面三個預置的數值,則modal-ai-presets會自動使其高亮,如果不等,則自動切換至「自定義」。

二,工具按鈕的問題
工具按鈕即btn-io-dropdown-trigger,它的圖標要替換成一個「眼睛」svg圖標。

當點擊時,會彈出一個菜單,這個菜單在手機端時,由於過於靠右,會顯示不全,請修復。

三,播放動畫的問題
當點擊「播放動畫」時,播放工具欄在手機端也是顯示不全,請修復。

當「正在播放」時,目前只能播完當前回合(如同只能行走重做),現在要改成,如果當前回合播放完畢,則需「回合前進」跳至下一個回合。

當播放時,除了當前移動的立方體,或者當前行動所放置的或被吃掉的話筒之外,其它棋子不應產生動畫。

如果從布局階段開始播放動畫,則圓柱體和遊戲板的放入,亦需要動畫,當在布局階段關閉動畫,則應產生「行動標示」。

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

代码: 全选

改進:
一,AI設置窗口的問題
AI設置窗口的四個切換按鈕中的文字「簡單、困難、專家、自定義」並沒有切換成當前語言,同時需要在文字前加上它們的svg圖標,用js直接從ai-strength-options複製。

當AI設置窗口打開,modal-ai-presets會使ai-strength-trigger當前所選的項目高亮。

當AI設置窗口關閉,不需要再更改ai-strength-trigger當前所選。

如果在modal-ai-presets的切換按鈕選中其中一個,則下方的ai-strength-trigger也會立即切換。

modal-ai-presets不能切換至「自定義」,現在定義它的切換規則:如果直接點擊這四個切換按鈕的其中一個,則下方的數字會立即變為其預置值,「自定義」的預置值是「思考時間10s、必殺深度10」。如果當前已經是「自定義」,且其值不是自定義的預置值,則再次點擊「自定義」無效。

當下方的兩個輸入框的值更改時,如果它們等於前面三個預置的數值,則modal-ai-presets會自動使其高亮,如果不等,則自動切換至「自定義」。

二,工具按鈕的問題
工具按鈕即btn-io-dropdown-trigger,它的圖標要替換成一個「眼睛」svg圖標。

當點擊時,會彈出一個菜單,這個菜單在手機端時,由於過於靠右,會顯示不全,請修復。

三,播放動畫的問題
當點擊「播放動畫」時,播放工具欄在手機端也是顯示不全,請修復。

當「正在播放」時,目前只能播完當前回合(如同只能行走重做),現在要改成,如果當前回合播放完畢,則需「回合前進」跳至下一個回合。

當播放時,除了當前移動的立方體,或者當前行動所放置的或被吃掉的話筒之外,其它棋子不應產生動畫。

如果從布局階段開始播放動畫,則圓柱體和遊戲板的放入,亦需要動畫,當在布局階段關閉動畫,則應產生「行動標示」。

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

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

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這裡為你提供詳盡的修改方案,針對你提出的三個部分(AI設置窗口、工具按鈕佈局、播放動畫邏輯)進行了優化與修復。

請在你的代碼中找到對應的部分進行 **替換** 或 **修改**。

### 一、 修改 CSS 樣式(修復手機端佈局問題)

找到 `<style>` 標籤內的 `@media (max-width: 768px)` 區塊,加入針對工具菜單和播放控制欄的樣式修正。

**替換為以下代碼:**

```css
      @media (max-width: 768px) {
        #history-controls {
          width: 100%;
          justify-content: center;
          gap: 4px;
          bottom: 10px;
          flex-wrap: nowrap;
        }
        .hist-btn {
          padding: 0;
          width: 36px;
          height: 36px;
        }
        .hist-btn svg {
          width: 20px;
          height: 20px;
        }
        .divider {
          display: none;
        }
        #message {
          font-size: 16px;
          top: 10px;
        }
        #btn-start {
          padding: 15px 30px;
          font-size: 20px;
        }
        /* 新增:修復手機端工具菜單超出螢幕 */
        #io-dropdown-options {
          left: auto !important;
          right: 0 !important;
          transform: none !important;
        }
        /* 新增:修復手機端播放動畫控制欄顯示不全 */
        #playback-controls {
          width: 90%;
          flex-wrap: wrap;
          justify-content: center;
          padding: 5px 10px;
        }
        #playback-slider {
          width: 100% !important;
          order: 3;
          margin-top: 5px;
        }
      }

```

---

### 二、 修改 HTML 結構(替換圖標與設置面板按鈕)

**1. 替換工具菜單按鈕(眼睛圖標)**
找到 `<button id="btn-io-dropdown-trigger"...>` 的區塊:

**替換為以下代碼:**

```html
        <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="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
          </svg>
        </button>

```

**2. 替換設置面板中的 AI 預設按鈕**
找到 `<div id="modal-ai-presets"...>` 的區塊,為每個按鈕補上 `id`、SVG圖標與預設結構。

**替換為以下代碼:**

```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 id="modal-preset-easy" class="hist-btn modal-preset-btn" data-val="1000" data-dep="4" title="簡單" style="flex: 1; margin: 0 5px; font-size: 13px; flex-direction: column; height: auto; padding: 5px;">
          <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><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"/><rect x="9" y="14" width="6" height="2"/></svg>
          <span>簡單</span>
        </button>
        <button id="modal-preset-hard" class="hist-btn modal-preset-btn" data-val="4000" data-dep="6" title="困難" style="flex: 1; margin: 0 5px; font-size: 13px; flex-direction: column; height: auto; padding: 5px;">
          <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><rect x="2" y="9" width="2" height="6" rx="1"/><rect x="20" y="9" width="2" height="6" rx="1"/><rect x="5" y="6" width="14" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="9" cy="11" r="1.2"/><circle cx="15" cy="11" r="1.2"/><rect x="9" y="14" width="6" height="2"/></svg>
          <span>困難</span>
        </button>
        <button id="modal-preset-expert" class="hist-btn modal-preset-btn" data-val="7000" data-dep="8" title="專家" style="flex: 1; margin: 0 5px; font-size: 13px; flex-direction: column; height: auto; padding: 5px;">
          <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><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"/></svg>
          <span>專家</span>
        </button>
        <button id="modal-preset-custom" class="hist-btn modal-preset-btn" data-val="10000" data-dep="10" title="自定義" style="flex: 1; margin: 0 5px; font-size: 13px; flex-direction: column; height: auto; padding: 5px;">
          <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
          <span>自定義</span>
        </button>
      </div>

```

---

### 三、 修改 JavaScript 邏輯

**1. 在語言切換中加入設置面板按鈕的翻譯**
找到 `updateUIAfterLangChange()` 函數,在其中加入以下代碼(放在函數末尾即可):

```javascript
        const pEasy = document.getElementById('modal-preset-easy');
        if (pEasy) pEasy.querySelector('span').innerText = window.currentLang === 'zh' ? '簡單' : 'Easy';
        const pHard = document.getElementById('modal-preset-hard');
        if (pHard) pHard.querySelector('span').innerText = window.currentLang === 'zh' ? '困難' : 'Hard';
        const pExpert = document.getElementById('modal-preset-expert');
        if (pExpert) pExpert.querySelector('span').innerText = window.currentLang === 'zh' ? '專家' : 'Expert';
        const pCustom = document.getElementById('modal-preset-custom');
        if (pCustom) pCustom.querySelector('span').innerText = window.currentLang === 'zh' ? '自訂' : 'Custom';

```

**2. 修改 RestoreSnapshot(讓播放時的動畫只對增量物件生效)**
找到 `function restoreSnapshot(snap)`,在清除物件之前先獲取舊狀態:

**將原本清空物件的代碼及重建物件的部分替換為以下邏輯:**

```javascript
      function restoreSnapshot(snap) {
        // 紀錄先前的所有物件狀態,供播放動畫過濾使用
        let prevCubesPos = {};
        let prevMics = [];
        let prevBoards = [];
        let prevCyls = [];

        if (window.IS_PLAYBACK_ANIM) {
          cubes.forEach(c => (prevCubesPos[c.userData.color] = { x: c.position.x, y: c.position.y, z: c.position.z, score: c.userData.score }));
          microphones.forEach(m => prevMics.push({c: m.userData.col, r: m.userData.row, color: m.userData.color}));
          boardsData.forEach(b => prevBoards.push({c: b.c, r: b.r}));
          cylindersData.forEach(c => prevCyls.push({c: c.c, r: c.r}));
        }

        // 第一步:徹底清空場上所有 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 = [];

        let prevFastForward = window.IS_FAST_FORWARD;

        // 恢復 Board (只針對新放的播動畫)
        snap.boardsData.forEach(b => {
          let isOld = prevBoards.some(pb => pb.c === b.c && pb.r === b.r);
          window.IS_FAST_FORWARD = window.IS_PLAYBACK_ANIM ? isOld : true;
          createGameBoard(b.c, b.r, b.dirs, b.startOffset);
        });

        // 恢復 Cylinder (只針對新放的播動畫)
        snap.cylindersData.forEach(cyl => {
          let isOld = prevCyls.some(pc => pc.c === cyl.c && pc.r === cyl.r);
          window.IS_FAST_FORWARD = window.IS_PLAYBACK_ANIM ? isOld : true;
          placeCylinder(cyl.c, cyl.r);
        });

        // 恢復 Mics (只針對新放的播動畫)
        snap.micsData.forEach(m => {
          let isOld = prevMics.some(pm => pm.c === m.c && pm.r === m.r && pm.color === m.color);
          window.IS_FAST_FORWARD = window.IS_PLAYBACK_ANIM ? isOld : true;
          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; });

            let posChanged = Math.abs(oldP.x - worldPos.x) > 1 || Math.abs(oldP.z - worldPos.z) > 1;
            let scoreChanged = oldP.score !== c.score;

            if (posChanged || scoreChanged) {
               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 = posChanged;
               newCube.userData.moveStartTime = performance.now();
               newCube.userData.moveDuration = 800;
               window.IS_FAST_FORWARD = false;
               if (scoreChanged) animateCubeToScore(newCube, c.score);
               else {
                   newCube.quaternion.copy(newCube.userData.targetQuat); // 只移動不跳躍
               }
               window.IS_FAST_FORWARD = true;
            } else {
               newCube.position.set(worldPos.x, targetY, worldPos.z);
               animateCubeToScore(newCube, c.score);
            }
          } else {
            newCube.position.y = targetY;
            animateCubeToScore(newCube, c.score);
          }
        });

        window.IS_FAST_FORWARD = prevFastForward;

        if (!gameplayActive) {
          let hasWinner = snap.cubesData.some(c => c.score >= 6);
          if (hasWinner) {
            if (!window.IS_FAST_FORWARD) triggerRoundEnd();
            return;
          }
          if (currentPhaseFn && !window.IS_PLAYBACK_ANIM) currentPhaseFn(); // 播放動畫時不叫醒標記
        } else {
          if (!window.IS_PLAYBACK_ANIM) { // 播放時隱藏行動標記
              if (snap.subPhase === 'walk') showWalkOptions();
              else showMicrophonePlacementOptions();
          }
        }
      }

```

**3. 播放動畫與停止邏輯(修改 `startPlaybackMode`, `stopPlaybackMode`, `togglePlayback`)**
找到對應的函數並替換:

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

      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) {
          if (markers.length === 0) {
              let snap = historyUndoStack[historyUndoStack.length - 1] || saveSnapshot();
              if (snap.subPhase === 'walk') showWalkOptions();
              else showMicrophonePlacementOptions();
          }
        } else if (currentPhaseFn && !cubes.some(c => c.userData.score >= 6)) {
          currentPhaseFn();
        }
      }

      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,強行前進一格(跨回合連播)
              historyUndoStack.push(saveSnapshot());
              restoreSnapshot(historyRedoStack.pop());
              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;
        }
      }

```

**4. 設置面板相關邏輯 (替換 `updateAIPresetsHighlight` 及按鈕事件)**
找到 `updateAIPresetsHighlight` 及其周邊事件綁定:

**替換為以下代碼:**

```javascript
      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;
        let selectedBtn = null;

        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.id !== 'modal-preset-custom') {
            btn.style.backgroundColor = '#2ecc71';
            matched = true;
            selectedBtn = btn;
          }
        });

        if (!matched) {
          const customBtn = document.getElementById('modal-preset-custom');
          customBtn.style.backgroundColor = '#2ecc71';
          selectedBtn = customBtn;
        }

        // 同步主按鈕 UI (從對應選項抓取 SVG)
        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;
           trigger.innerHTML = selectedBtn.querySelector('svg').outerHTML;
        }
      }

      presetBtns.forEach(btn => {
        btn.addEventListener('click', () => {
          const val = parseInt(btn.getAttribute('data-val'));
          const dep = parseInt(btn.getAttribute('data-dep'));
          const isCustom = btn.id === 'modal-preset-custom';

          if (isCustom) {
             const currentVal = parseInt(document.getElementById('inp-think-time').value) * 1000;
             const currentDep = parseInt(document.getElementById('inp-sure-kill').value);
             // 如果已經是自定義,且值不是預設的10/10,點擊無效
             if (btn.style.backgroundColor === 'rgb(46, 204, 113)' && (currentVal !== 10000 || currentDep !== 10)) {
                return;
             }
             document.getElementById('inp-think-time').value = 10;
             document.getElementById('inp-sure-kill').value = 10;
          } else {
             document.getElementById('inp-think-time').value = val / 1000;
             document.getElementById('inp-sure-kill').value = dep;
          }

          updateAIPresetsHighlight();
          syncCustomOption();
        });
      });

      document.getElementById('btn-settings').addEventListener('click', () => {
        modal.style.display = 'flex';
        // 進入時,依據系統當前 aiStrength 和 SURE_KILL_DEPTH 設定框,然後自動高亮
        document.getElementById('inp-think-time').value = aiStrength / 1000;
        document.getElementById('inp-sure-kill').value = SURE_KILL_DEPTH;
        updateAIPresetsHighlight();
        updateUIAfterLangChange();

        savedBlueAI = isBlueAI;
        savedGreenAI = isGreenAI;
        btnAiBlue.classList.remove('active-blue');
        btnAiGreen.classList.remove('active-green');
      });

      document.getElementById('btn-modal-close').addEventListener('click', () => {
        // 移除自動點擊 Custom,設定早已由 updateAIPresetsHighlight 實時同步完畢
        modal.style.display = 'none';
        restoreAIHighlights();
      });

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

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

帖子 ejsoon »

改進:
一,AI設置窗口的問題
現在定義modal-ai-presets的切換規則:當下方的兩個輸入框的值更改時,如果它們的組合等於前面三個按鈕預置的數值,則modal-ai-presets會自動切換使其高亮,如果不等,則切換至「自定義」。

二,播放動畫的問題
當點擊「播放動畫」時,播放工具欄的每一個按鈕(包括播放停止鍵、加減鍵、關閉鍵)的大小都是36*36px,且都要保持在一行,playback-info中的「回合數、步數」將分為兩行,餘下的寬度再由playback-slider佔滿。手機端也是如此。

如果從布局階段開始播放動畫,則立方體的放入也需要動畫。

當播放停止時,btn-playback-prev、playback-slider、btn-playback-next應能像「行走撤銷和重做、回合後退和前進」一樣,在棋譜歷史中來回進退,以一次行走或放置話筒作為步進單位,同時可跳至上下回合。

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

代码: 全选

改進:
一,AI設置窗口的問題
現在定義modal-ai-presets的切換規則:當下方的兩個輸入框的值更改時,如果它們的組合等於前面三個按鈕預置的數值,則modal-ai-presets會自動切換使其高亮,如果不等,則切換至「自定義」。

二,播放動畫的問題
當點擊「播放動畫」時,播放工具欄的每一個按鈕(包括播放停止鍵、加減鍵、關閉鍵)的大小都是36*36px,且都要保持在一行,playback-info中的「回合數、步數」將分為兩行,餘下的寬度再由playback-slider佔滿。手機端也是如此。

如果從布局階段開始播放動畫,則立方體的放入也需要動畫。

當播放停止時,btn-playback-prev、playback-slider、btn-playback-next應能像「行走撤銷和重做、回合後退和前進」一樣,在棋譜歷史中來回進退,以一次行走或放置話筒作為步進單位,同時可跳至上下回合。

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

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

帖子 ejsoon »

gemini 3.1 prp要睡到08:52

代码: 全选

沒問題!針對你提出的這兩個優化需求(AI 設置視窗的自動高亮邏輯、播放動畫的 UI 佈局與步進邏輯修正),我們只需要針對特定的 CSS 與 JavaScript 區塊進行替換即可。

以下是具體的修改指引與替換代碼:

### 1. 修改 CSS 樣式:修復播放工具欄的佈局

為了確保播放工具欄在所有設備(包含手機端)上都能保持單行排列,且按鈕均為 `36x36px`,你需要**刪除**原本在 `@media (max-width: 768px)` 裡的 `#playback-controls` 和 `#playback-slider` 覆蓋樣式。

**刪除以下代碼(約在 `<style>` 標籤中的第 142 行附近):**

```css
/* 請將這段從 @media (max-width: 768px) 內部刪除 */
#playback-controls {
  width: 90%;
  flex-wrap: wrap;
  justify-content: center;
  padding: 5px 10px;
}
#playback-slider {
  width: 100% !important;
  order: 3;
  margin-top: 5px;
}

```

### 2. 替換 HTML 結構:重新定義 `#playback-controls`

將原本的 `#playback-controls` 容器(約在第 243 行附近)替換為以下代碼,這會強制按鈕大小、將「回合與步數」分行顯示,並讓滑桿填滿剩餘空間:

```html
    <div
      id="playback-controls"
      style="
        display: none;
        position: absolute;
        bottom: 20px;
        left: 50%;
        transform: translateX(-50%);
        width: 90%;
        max-width: 600px;
        gap: 10px;
        z-index: 20;
        pointer-events: auto;
        align-items: center;
        justify-content: space-between;
        background: rgba(44, 62, 80, 0.9);
        padding: 10px;
        border-radius: 10px;
        border: 2px solid #bdc3c7;
        flex-wrap: nowrap;
      "
    >
      <button id="btn-playback-toggle" class="hist-btn" title="播放/暫停" style="background-color: #2ecc71; width: 36px; height: 36px; padding: 0; flex-shrink: 0;">
        <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; font-size: 14px; line-height: 1.2; text-align: center; min-width: 60px; flex-shrink: 0;">
        回合: 0<br>步數: 0
      </div>
      <button id="btn-playback-prev" class="hist-btn" style="width: 36px; height: 36px; padding: 0; flex-shrink: 0; font-size: 20px;">-</button>
      <input type="range" id="playback-slider" min="0" max="0" value="0" style="flex-grow: 1; min-width: 50px; cursor: pointer;" />
      <button id="btn-playback-next" class="hist-btn" style="width: 36px; height: 36px; padding: 0; flex-shrink: 0; font-size: 20px;">+</button>
      <button id="btn-playback-close" class="hist-btn" title="關閉播放" style="background-color: #e74c3c; width: 36px; height: 36px; padding: 0; flex-shrink: 0;">
        <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>

```

### 3. 修改 JavaScript:開局立方體動畫與播放文本更新

**第一處:開局立方體播放動畫**
在 `restoreSnapshot(snap)` 函數中,找到恢復 Cube 的區塊,移除掉寫死的 `window.IS_FAST_FORWARD = true;`,並修改成以下邏輯(約在第 774 行附近):

```javascript
        // 刪除原本的 window.IS_FAST_FORWARD = true;
        snap.cubesData.forEach(c => {
          // 判斷此立方體是否為新放置 (例如在開局階段),如果是則允許播放入場動畫
          let isOldCube = window.IS_PLAYBACK_ANIM ? (prevCubesPos[c.color] !== undefined) : true;
          window.IS_FAST_FORWARD = isOldCube;
          
          placeCube(c.c, c.r, c.color);
          const newCube = cubes[cubes.length - 1];

```

**第二處:更新文本換行**
在 `updatePlaybackUI()` 函數中,將文字賦值改為支援 `<br>` 換行的 `innerHTML`(約在第 1980 行附近):

```javascript
      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').innerHTML =
          window.currentLang === 'zh'
            ? `回合: ${gameRound}<br>步數: ${currentStep}`
            : `Round: ${gameRound}<br>Step: ${currentStep}`;
      }

```

### 4. 修改 JavaScript:修正播放器的無阻礙步進邏輯

為了讓播放器在暫停時能無視「輪到誰」的遊戲規則,自由穿梭歷史進度,我們需要替換 `scrubToStep(targetStep)` 函數(約在第 2000 行附近),讓它直接對棧(Stack)進行操作,而不是呼叫帶有防呆保護的 `walkUndo()`:

```javascript
      function scrubToStep(targetStep) {
        let currentStep = historyUndoStack.length;
        if (currentStep === targetStep) return;

        window.IS_FAST_FORWARD = true; // 拖曳/點擊步進時不播過渡動畫
        window.IS_PLAYBACK_ANIM = false;

        while (currentStep < targetStep && historyRedoStack.length > 0) {
          historyUndoStack.push(saveSnapshot());
          restoreSnapshot(historyRedoStack.pop());
          currentStep++;
        }
        while (currentStep > targetStep && historyUndoStack.length > 0) {
          historyRedoStack.push(saveSnapshot());
          restoreSnapshot(historyUndoStack.pop());
          currentStep--;
        }

        window.IS_FAST_FORWARD = false;
        window.IS_PLAYBACK_ANIM = true;
        updatePlaybackUI();
        clearMarkers();
      }

```

### 5. 修改 JavaScript:完善 AI 設置聯動機制

最後,我們需要重寫 `updateAIPresetsHighlight()` 函數與預設按鈕的點擊事件,確保當輸入框的數值不是那三個預設組合時,「自定義」按鈕會穩穩亮起(約在第 2017 行附近)。

```javascript
      function updateAIPresetsHighlight() {
        const t = parseInt(document.getElementById('inp-think-time').value) * 1000;
        const d = parseInt(document.getElementById('inp-sure-kill').value);

        let matched = false;
        let selectedBtn = null;

        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.id !== 'modal-preset-custom') {
            btn.style.backgroundColor = '#2ecc71';
            matched = true;
            selectedBtn = btn;
          }
        });

        const customBtn = document.getElementById('modal-preset-custom');
        if (!matched) {
          customBtn.style.backgroundColor = '#2ecc71';
          selectedBtn = customBtn;
        } else {
          customBtn.style.backgroundColor = '#34495e';
        }

        // 同步主按鈕 UI (從對應選項抓取 SVG)
        const trigger = document.getElementById('ai-strength-trigger');
        if (trigger && selectedBtn) {
          trigger.setAttribute('data-value', t);
          trigger.setAttribute('data-depth', d);
          aiStrength = t;
          SURE_KILL_DEPTH = d;
          trigger.innerHTML = selectedBtn.querySelector('svg').outerHTML;
        }
      }

      presetBtns.forEach(btn => {
        btn.addEventListener('click', () => {
          const val = parseInt(btn.getAttribute('data-val'));
          const dep = parseInt(btn.getAttribute('data-dep'));
          const isCustom = btn.id === 'modal-preset-custom';

          // 如果點擊非自定義按鈕,則修改輸入框;若點擊自定義按鈕則保持當前輸入,不強制重置
          if (!isCustom) {
            document.getElementById('inp-think-time').value = val / 1000;
            document.getElementById('inp-sure-kill').value = dep;
          }

          updateAIPresetsHighlight();
          syncCustomOption();
        });
      });

```

---

這些修改可以直接無縫對接你原本的代碼邏輯。修改完之後,你在實際測試 AI 設置跟播放步進的時候,還有遇到什麼效能卡頓或是不符預期的狀況嗎?
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6455
注册时间: 2022年 11月 18日 17:36
联系:

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

帖子 ejsoon »

改進:
一,AI設置窗口的問題
現在定義modal-ai-presets的切換規則:當下方的兩個輸入框的值更改時,如果它們的組合等於前面三個按鈕預置的數值,則modal-ai-presets會自動切換使其高亮,如果不等,則切換至「自定義」。

例如,當思考時間設為4,必殺深度為6時,modal-ai-presets自動切換至「困難」,當我把必殺深度改為7時,modal-ai-presets將自動切換為「自定義」,這時如果再點擊一次「自定義」,則不會有任何改變。當我把必殺深度改為8,思考時間改為7時,modal-ai-presets又自動切換為「專家」,這時我點擊「自定義」,modal-ai-presets則切換為自定義,下方的數值將改為「思考時間10s、必殺深度10」。

二,播放動畫的問題
如果從布局階段開始播放動畫,立方體的放入應是跟遊戲時一樣從上往下落,從透明到全顯,跟遊戲時一樣。

當在行動階段播放動畫時,立方體在移動時應保持當前的旋轉角度,只有在第三次行走結束時,如果得分,才跳起旋轉方向,跟遊戲時一樣。如果在行動時經過對方話筒,它也是在本次行走結束時放大並變透明並最終消失,跟遊戲時一樣。

回合數和步數的顯示是不對的。首先,布局階段的回合數和步數始終是0,當行動階段開始時,回合數將是1,步數將是0。在一回合中會有3步,所以步數只能是0到3,如果當前回合沒有放置話筒,則步數只能是0到2。

三,新增「顯示箭頭」功能
當點擊「顯示箭頭」時,將處於「箭頭顯示」狀態。btn-ai-blue、btn-ai-green、ai-strength-trigger、 btn-settings這四個按鈕將隱藏,取而代之的是一個「顯示箭頭」的提示文字,同時「工具菜單」按鈕將變成「關閉按鈕」。

當處於「箭頭顯示」狀態時,「行動標示」將不再出現。

如果redo_stack存在數據,則用箭頭來提示後續走法。箭頭的升起高度為24,厚度為2,表面顏色跟當前行動方的「行動標示」顏色一致。每一步所經過的地方(不包括停留點)是箭頭的矩形部分,其寬度為24,最後有一個三角形箭頭指向停留點的中心。每一回合有三步,則如果當前處於一回合的開始,則會出現三個箭頭,如果點擊「行走重做」,則箭頭會變為兩個、一個、零個。如果當前回合有放置新的話筒,則這個話筒會在它所放置的格子上方高度為48的地方,一邊横向旋轉(藍方順時針,綠方逆時針),一邊上下移動(幅度為4)。

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

代码: 全选

改進:
一,AI設置窗口的問題
現在定義modal-ai-presets的切換規則:當下方的兩個輸入框的值更改時,如果它們的組合等於前面三個按鈕預置的數值,則modal-ai-presets會自動切換使其高亮,如果不等,則切換至「自定義」。

例如,當思考時間設為4,必殺深度為6時,modal-ai-presets自動切換至「困難」,當我把必殺深度改為7時,modal-ai-presets將自動切換為「自定義」,這時如果再點擊一次「自定義」,則不會有任何改變。當我把必殺深度改為8,思考時間改為7時,modal-ai-presets又自動切換為「專家」,這時我點擊「自定義」,modal-ai-presets則切換為自定義,下方的數值將改為「思考時間10s、必殺深度10」。

二,播放動畫的問題
如果從布局階段開始播放動畫,立方體的放入應是跟遊戲時一樣從上往下落,從透明到全顯,跟遊戲時一樣。

當在行動階段播放動畫時,立方體在移動時應保持當前的旋轉角度,只有在第三次行走結束時,如果得分,才跳起旋轉方向,跟遊戲時一樣。如果在行動時經過對方話筒,它也是在本次行走結束時放大並變透明並最終消失,跟遊戲時一樣。

回合數和步數的顯示是不對的。首先,布局階段的回合數和步數始終是0,當行動階段開始時,回合數將是1,步數將是0。在一回合中會有3步,所以步數只能是0到3,如果當前回合沒有放置話筒,則步數只能是0到2。

三,新增「顯示箭頭」功能
當點擊「顯示箭頭」時,將處於「箭頭顯示」狀態。btn-ai-blue、btn-ai-green、ai-strength-trigger、 btn-settings這四個按鈕將隱藏,取而代之的是一個「顯示箭頭」的提示文字,同時「工具菜單」按鈕將變成「關閉按鈕」。

當處於「箭頭顯示」狀態時,「行動標示」將不再出現。

如果redo_stack存在數據,則用箭頭來提示後續走法。箭頭的升起高度為24,厚度為2,表面顏色跟當前行動方的「行動標示」顏色一致。每一步所經過的地方(不包括停留點)是箭頭的矩形部分,其寬度為24,最後有一個三角形箭頭指向停留點的中心。每一回合有三步,則如果當前處於一回合的開始,則會出現三個箭頭,如果點擊「行走重做」,則箭頭會變為兩個、一個、零個。如果當前回合有放置新的話筒,則這個話筒會在它所放置的格子上方高度為48的地方,一邊横向旋轉(藍方順時針,綠方逆時針),一邊上下移動(幅度為4)。

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

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

帖子 ejsoon »

感謝gemini 3.1 pro

代码: 全选

這裡為您提供所需的修改方案,將分三個部分具體說明並給出替換代碼。請在您的 `csinger2452.html` 文件中尋找對應的段落進行替換或新增。

### 一、修改 AI 設置窗口的預置按鈕邏輯

在代碼末尾附近,找到 `presetBtns.forEach(btn => { ... })` 的事件綁定部分,替換為以下代碼以實現「自定義」按鈕的強制切換邏輯:

```javascript
      presetBtns.forEach(btn => {
        btn.addEventListener('click', () => {
          const isCustom = btn.id === 'modal-preset-custom';
          const isActive = btn.style.backgroundColor === 'rgb(46, 204, 113)' || btn.style.backgroundColor === '#2ecc71';

          if (isCustom) {
            if (isActive) return; // 如果自定義已經處於高亮,點擊無效
            // 強制設定為 10s 和 深度 10
            document.getElementById('inp-think-time').value = 10;
            document.getElementById('inp-sure-kill').value = 10;
          } else {
            const val = parseInt(btn.getAttribute('data-val'));
            const dep = parseInt(btn.getAttribute('data-dep'));
            document.getElementById('inp-think-time').value = val / 1000;
            document.getElementById('inp-sure-kill').value = dep;
          }

          updateAIPresetsHighlight();
          syncCustomOption();
        });
      });

```

---

### 二、修復播放動畫時的立方體落下、旋轉與 UI 步數顯示

**1. 修改 UI 的回合數與步數計算邏輯**
搜尋 `function updatePlaybackUI()` 並將整個函數替換為以下代碼:

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

        let dispRound = 0;
        let dispStep = 0;

        let snap = historyUndoStack[historyUndoStack.length - 1];
        if (snap) {
          if (!snap.gameplayActive) {
            dispRound = 0;
            dispStep = 0;
          } else {
            dispRound = snap.gameRound;
            if (snap.subPhase === 'mic') {
              dispStep = 3;
            } else {
              dispStep = snap.walkCount - 1;
            }
          }
        }

        document.getElementById('playback-info').innerHTML =
          window.currentLang === 'zh'
            ? `回合: ${dispRound}<br>步數: ${dispStep}`
            : `Round: ${dispRound}<br>Step: ${dispStep}`;
      }

```

**2. 完善話筒移除與立方體降落、行走的動畫處理**
搜尋 `function restoreSnapshot(snap)`,在 `snap.cubesData.forEach` 的前方插入檢查「消失話筒」的動畫邏輯,並修改 `snap.cubesData.forEach` 內部的程式碼。請將對應部分替換為:

```javascript
        // 播放動畫:檢查並執行話筒被吃掉的退場動畫
        if (window.IS_PLAYBACK_ANIM && !prevFastForward) {
          prevMics.forEach(pm => {
            let stillExists = snap.micsData.some(sm => sm.c === pm.c && sm.r === pm.r && sm.color === pm.color);
            if (!stillExists) {
              let geo = pm.color === 'blue' ? new THREE.CylinderGeometry(20, 20, 4, 6) : new THREE.CylinderGeometry(20, 20, 4, 3);
              let mat = new THREE.MeshLambertMaterial({ color: pm.color === 'blue' ? 0xff4444 : 0x800080 });
              let dummyMic = new THREE.Mesh(geo, mat);
              let pos = getCellWorldPos(pm.c, pm.r);
              dummyMic.position.set(pos.x, 6, pos.z);
              scene.add(dummyMic);
              removeMicrophoneAnim(dummyMic);
            }
          });
        }

        snap.cubesData.forEach(c => {
          let isOldCube = window.IS_PLAYBACK_ANIM ? prevCubesPos[c.color] !== undefined : true;
          window.IS_FAST_FORWARD = isOldCube;

          placeCube(c.c, c.r, c.color);
          const newCube = cubes[cubes.length - 1];
          newCube.userData.score = c.score;

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

            let posChanged = Math.abs(oldP.x - worldPos.x) > 1 || Math.abs(oldP.z - worldPos.z) > 1;
            let scoreChanged = oldP.score !== c.score;

            if (posChanged || scoreChanged) {
              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 = posChanged;
              newCube.userData.moveStartTime = performance.now();
              newCube.userData.moveDuration = 800;
              window.IS_FAST_FORWARD = false;
              
              if (scoreChanged) animateCubeToScore(newCube, c.score);
              // 若未得分,則完全不干預 quaternion,保持當前行走時的旋轉角度
              window.IS_FAST_FORWARD = true;
            } else {
              newCube.position.set(worldPos.x, targetY, worldPos.z);
              if (scoreChanged) animateCubeToScore(newCube, c.score);
            }
          } else {
            if (!window.IS_PLAYBACK_ANIM || window.IS_FAST_FORWARD) {
              newCube.position.y = targetY;
              animateCubeToScore(newCube, c.score);
            } else {
              // 在播放動畫模式下且為新放入的立方體(布局階段),保留原有的降落動畫,不調用 animateCubeToScore 引發跳躍
              newCube.userData.currentNumber = c.score;
              let rx = 0, ry = 0, rz = 0;
              if (c.score === 2) rx = -Math.PI / 2;
              else if (c.score === 3) rz = Math.PI / 2;
              else if (c.score === 4) rz = -Math.PI / 2;
              else if (c.score === 5) rx = Math.PI / 2;
              else if (c.score === 6) rx = Math.PI;
              newCube.quaternion.setFromEuler(new THREE.Euler(rx, ry, rz));
            }
          }
        });

```

---

### 三、新增「顯示箭頭」功能

**1. 添加全局變數與箭頭群組**
在文件開頭的全局變數區(如 `let cubes = [];` 附近)加入:

```javascript
      window.isArrowMode = false;
      let arrowsGroup = new THREE.Group();
      let floatingMic = null;
      scene.add(arrowsGroup);

```

**2. 插入箭頭繪製與清除的輔助函數**
將以下核心函數插入到全局 `script` 範圍內(例如放置在 `function showWalkOptions()` 的上方):

```javascript
      function clearArrows() {
        while(arrowsGroup.children.length > 0) {
          let c = arrowsGroup.children[0];
          arrowsGroup.remove(c);
          if (c.geometry) c.geometry.dispose();
          if (c.material) c.material.dispose();
        }
        floatingMic = null;
      }

      function drawArrows() {
        clearArrows();
        if (!window.isArrowMode || !gameplayActive) return;
        clearMarkers();

        let snapBase = historyUndoStack[historyUndoStack.length - 1];
        if (!snapBase) return;
        let cp = snapBase.currentPlayer;
        let colorHex = cp === 'blue' ? 0x0000ff : 0x28a745;

        let curC = snapBase.cubesData.find(c => c.color === cp).c;
        let curR = snapBase.cubesData.find(c => c.color === cp).r;

        let stepSnaps = [];
        for (let i = historyRedoStack.length - 1; i >= 0; i--) {
          let rSnap = historyRedoStack[i];
          stepSnaps.push(rSnap);
          if (rSnap.currentPlayer !== cp) break;
        }

        let lastPos = { c: curC, r: curR };
        stepSnaps.forEach(rs => {
          let nextCube = rs.cubesData.find(c => c.color === cp);
          if (nextCube && (nextCube.c !== lastPos.c || nextCube.r !== lastPos.r)) {
            createArrow(lastPos, nextCube, colorHex);
            lastPos = { c: nextCube.c, r: nextCube.r };
          }

          let oldMics = snapBase.micsData;
          let newMics = rs.micsData;
          let addedMic = newMics.find(nm => nm.color === cp && !oldMics.some(om => om.c === nm.c && om.r === nm.r));
          if (addedMic) createFloatingMic(cp, addedMic.c, addedMic.r);
        });
      }

      function createArrow(p1, p2, colorHex) {
        let pos1 = getCellWorldPos(p1.c, p1.r);
        let pos2 = getCellWorldPos(p2.c, p2.r);

        let dx = pos2.x - pos1.x;
        let dz = pos2.z - pos1.z;
        let len = Math.sqrt(dx * dx + dz * dz);
        if (len < 1) return;

        let arrowLen = 24;
        let boxLen = len - arrowLen;
        if (boxLen < 0) { boxLen = 0; arrowLen = len; }

        let mat = new THREE.MeshBasicMaterial({ color: colorHex });

        if (boxLen > 0) {
          let boxGeo = new THREE.BoxGeometry(24, 2, boxLen);
          let box = new THREE.Mesh(boxGeo, mat);
          let dirAngle = Math.atan2(dx, dz);
          box.position.set(pos1.x + (dx / len) * (boxLen / 2), 24, pos1.z + (dz / len) * (boxLen / 2));
          box.rotation.y = dirAngle;
          arrowsGroup.add(box);
        }

        let coneGeo = new THREE.CylinderGeometry(0, 16, arrowLen, 3);
        let cone = new THREE.Mesh(coneGeo, mat);
        let coneCenter = boxLen + arrowLen / 2;
        cone.position.set(pos1.x + (dx / len) * coneCenter, 24, pos1.z + (dz / len) * coneCenter);
        cone.lookAt(new THREE.Vector3(pos2.x, 24, pos2.z));
        cone.rotateX(Math.PI / 2);
        arrowsGroup.add(cone);
      }

      function createFloatingMic(color, c, r) {
        if (floatingMic) return;
        let geo = color === 'blue' ? new THREE.CylinderGeometry(20, 20, 4, 6) : new THREE.CylinderGeometry(20, 20, 4, 3);
        let mat = new THREE.MeshLambertMaterial({ color: color === 'blue' ? 0xff4444 : 0x800080 });
        floatingMic = new THREE.Mesh(geo, mat);
        let pos = getCellWorldPos(c, r);
        floatingMic.position.set(pos.x, 48, pos.z);
        floatingMic.userData = { baseY: 48, rotDir: color === 'blue' ? -1 : 1 };
        arrowsGroup.add(floatingMic);
      }

```

**3. 在動畫循環中處理話筒旋轉浮動**
搜尋 `function animate()`,在其內部的結尾附近(例如在 `controls.update();` 前面)加入:

```javascript
        // 處理箭頭顯示模式下的動態話筒特效
        if (window.isArrowMode && floatingMic) {
          floatingMic.rotation.y += floatingMic.userData.rotDir * 0.05;
          floatingMic.position.y = floatingMic.userData.baseY + Math.sin(now * 0.005) * 4;
        }

```

**4. 控制行動標示在 Arrow Mode 下不顯示**
在 `function showWalkOptions()` 以及 `function showMicrophonePlacementOptions()` 的 **最開頭處**,皆加上這段邏輯阻斷標示生成:

```javascript
        if (window.isArrowMode) {
          clearMarkers();
          return;
        }

```

**5. 確保 Undo/Redo 連動繪製箭頭**
搜尋 `function updateUndoRedoButtons()`,在其函數體結尾處加上:

```javascript
        if (window.isArrowMode) drawArrows();

```

**6. 設定菜單按鈕與關閉切換邏輯**
搜尋 `document.querySelectorAll('.io-option').forEach(opt => {` 的事件監聽器,修改 `action === 'arrow'` 分支:

```javascript
          } else if (action === 'arrow') {
            window.isArrowMode = true;
            document.getElementById('btn-ai-blue').style.display = 'none';
            document.getElementById('btn-ai-green').style.display = 'none';
            document.getElementById('ai-strength-trigger').style.display = 'none';
            document.getElementById('btn-settings').style.display = 'none';

            let lbl = document.getElementById('arrow-mode-label');
            if (!lbl) {
              lbl = document.createElement('div');
              lbl.id = 'arrow-mode-label';
              lbl.style.cssText = 'color: white; font-weight: bold; margin: 0 10px; font-size: 16px; white-space: nowrap;';
              document.getElementById('history-controls').insertBefore(lbl, document.getElementById('btn-walk-undo'));
            }
            lbl.innerText = window.currentLang === 'zh' ? '箭頭顯示' : 'Arrow Mode';
            lbl.style.display = 'block';

            const trig = document.getElementById('btn-io-dropdown-trigger');
            trig.innerHTML = '<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>';

            drawArrows();
          }

```

最後,搜尋 `ioDropdownTrigger.addEventListener('click', ...)`,修改其關閉與點擊邏輯:

```javascript
      if (ioDropdownTrigger) {
        ioDropdownTrigger.addEventListener('click', e => {
          e.stopPropagation();
          if (window.isArrowMode) {
            window.isArrowMode = false;
            document.getElementById('btn-ai-blue').style.display = '';
            document.getElementById('btn-ai-green').style.display = '';
            document.getElementById('ai-strength-trigger').style.display = '';
            document.getElementById('btn-settings').style.display = '';
            
            let lbl = document.getElementById('arrow-mode-label');
            if (lbl) lbl.style.display = 'none';
            
            ioDropdownTrigger.innerHTML = '<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>';
            clearArrows();
            
            if (gameplayActive) {
              let snap = historyUndoStack[historyUndoStack.length - 1] || saveSnapshot();
              if (snap.subPhase === 'walk') showWalkOptions();
              else showMicrophonePlacementOptions();
            }
          } else {
            ioDropdownOptions.classList.toggle('open');
          }
        });
      }

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

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

帖子 ejsoon »

改進:
一,播放動畫的問題
當在行動階段播放動畫時,當立方體走到第三步拿到分數時,一邊移動一邊翻轉,這是不對的。應該要先移動完,再跳躍翻轉使當前的分數朝上。

在播放動畫時,當一個立方體行走完畢,無論其獲得幾分,都會變回1分(即點數1朝上),這是不對的。應該保持當前的點數朝上,即保持立方體當前的旋轉角度。

在播放動畫時,當一個立方體經過對方話筒,這個話筒會消失,但沒有消失動畫,這是不對的。應該要像遊戲時那樣,變大1.2倍,同時變透明,最終消失。

在播放動畫時的回合數和步數需要更正。首先,當進入行動階段時是第1回合,假設藍方先手,則藍方走完三步並放完話筒後,就輪到綠方,此時則會是第2回合。當一方回合開始時,步數是1,一方走完第一步時,步數變為2,一方走完第二步,步數變為3。當一方走完第三步,如果接下來有放話筒的步驟,則步數顯示「3+1」,當放完話筒後,切換行動方,回合數加1,步數變回1。當一方走完第三步,如果接下來沒有放話筒的步驟,則直接切換行動方,回合數加1,步數變回1。

當關閉動畫時,應作一次按鈕是否disable的判斷。

二,更改箭頭顯示邏輯
之前的箭頭顯示時允許「行走撤銷或重做」,現在改為不允許「行走撤銷或重做」,隱藏這兩個按鈕。

箭頭顯示應為當前方所行走的三步,即有三個箭頭,但三個箭頭的高度和顏色不同,從第一步到第三步的高度分別為12、24、36,顏色為淺藍、中藍、深藍或淺綠、中綠、深綠。

如本回合有放話筒,則這個話筒會在高度為48的地方上下旋轉浮動。

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

代码: 全选

改進:
一,播放動畫的問題
當在行動階段播放動畫時,當立方體走到第三步拿到分數時,一邊移動一邊翻轉,這是不對的。應該要先移動完,再跳躍翻轉使當前的分數朝上。

在播放動畫時,當一個立方體行走完畢,無論其獲得幾分,都會變回1分(即點數1朝上),這是不對的。應該保持當前的點數朝上,即保持立方體當前的旋轉角度。

在播放動畫時,當一個立方體經過對方話筒,這個話筒會消失,但沒有消失動畫,這是不對的。應該要像遊戲時那樣,變大1.2倍,同時變透明,最終消失。

在播放動畫時的回合數和步數需要更正。首先,當進入行動階段時是第1回合,假設藍方先手,則藍方走完三步並放完話筒後,就輪到綠方,此時則會是第2回合。當一方回合開始時,步數是1,一方走完第一步時,步數變為2,一方走完第二步,步數變為3。當一方走完第三步,如果接下來有放話筒的步驟,則步數顯示「3+1」,當放完話筒後,切換行動方,回合數加1,步數變回1。當一方走完第三步,如果接下來沒有放話筒的步驟,則直接切換行動方,回合數加1,步數變回1。

當關閉動畫時,應作一次按鈕是否disable的判斷。

二,更改箭頭顯示邏輯
之前的箭頭顯示時允許「行走撤銷或重做」,現在改為不允許「行走撤銷或重做」,隱藏這兩個按鈕。

箭頭顯示應為當前方所行走的三步,即有三個箭頭,但三個箭頭的高度和顏色不同,從第一步到第三步的高度分別為12、24、36,顏色為淺藍、中藍、深藍或淺綠、中綠、深綠。

如本回合有放話筒,則這個話筒會在高度為48的地方上下旋轉浮動。

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

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

帖子 ejsoon »

gemini 3.1 pro要睡到12:52

代码: 全选

以下是針對您指出的問題所進行的代碼修正。請在您的 `csinger2453.html` 檔案中找到對應的代碼區塊並進行替換。

### 1. 修正話筒退場動畫並加入延遲參數

為了讓播放動畫時話筒能在方塊抵達時才播放退場特效,我們需要為 `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);
      }

```

**替換為:**

```javascript
      function removeMicrophoneAnim(mic, delay = 0) {
        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() + delay,
          duration: 600,
          mats: mats
        };
        outroAnimData.push(mic);
      }

```

### 2. 儲存播放時的立方體朝向及修改 Dummy Mic 的觸發

在 `restoreSnapshot` 中,我們需要完整保存立方體先前的旋轉角度,並為消失的話筒動畫加上 800ms 的延遲。

**尋找以下代碼(約在 `restoreSnapshot` 開頭處):**

```javascript
        if (window.IS_PLAYBACK_ANIM) {
          cubes.forEach(
            c =>
              (prevCubesPos[c.userData.color] = {
                x: c.position.x,
                y: c.position.y,
                z: c.position.z,
                score: c.userData.score
              })
          );

```

**替換為:**

```javascript
        if (window.IS_PLAYBACK_ANIM) {
          cubes.forEach(
            c =>
              (prevCubesPos[c.userData.color] = {
                x: c.position.x,
                y: c.position.y,
                z: c.position.z,
                score: c.userData.score,
                quat: c.quaternion.clone(),
                currentNumber: c.userData.currentNumber
              })
          );

```

**繼續尋找(同一函數中處理 dummyMic 的部分):**

```javascript
              let dummyMic = new THREE.Mesh(geo, mat);
              let pos = getCellWorldPos(pm.c, pm.r);
              dummyMic.position.set(pos.x, 6, pos.z);
              scene.add(dummyMic);
              removeMicrophoneAnim(dummyMic);

```

**替換為:**

```javascript
              let dummyMic = new THREE.Mesh(geo, mat);
              dummyMic.material.transparent = true; // 確保材質可透明
              let pos = getCellWorldPos(pm.c, pm.r);
              dummyMic.position.set(pos.x, 6, pos.z);
              scene.add(dummyMic);
              removeMicrophoneAnim(dummyMic, 800); // 延遲 800 毫秒等方塊走到

```

### 3. 分離方塊的「移動」與「翻轉」,並保留不變的分數朝向

還是在 `restoreSnapshot` 函數內,處理 `snap.cubesData.forEach` 的區塊。

**尋找以下代碼:**

```javascript
            if (posChanged || scoreChanged) {
              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 = posChanged;
              newCube.userData.moveStartTime = performance.now();
              newCube.userData.moveDuration = 800;
              window.IS_FAST_FORWARD = false;

              if (scoreChanged) animateCubeToScore(newCube, c.score);
              // 若未得分,則完全不干預 quaternion,保持當前行走時的旋轉角度
              window.IS_FAST_FORWARD = true;
            } else {
              newCube.position.set(worldPos.x, targetY, worldPos.z);
              if (scoreChanged) animateCubeToScore(newCube, c.score);
            }

```

**替換為:**

```javascript
            // 恢復原本的旋轉角度與數字朝向
            newCube.quaternion.copy(oldP.quat);
            newCube.userData.currentNumber = oldP.currentNumber;

            if (posChanged || scoreChanged) {
              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 = posChanged;
              newCube.userData.moveStartTime = performance.now();
              newCube.userData.moveDuration = 800;
              window.IS_FAST_FORWARD = false;

              if (posChanged && scoreChanged) {
                // 如果有移動且有得分,先記錄要改變的分數,等移動完再跳躍翻轉
                newCube.userData.deferredScore = c.score;
              } else if (scoreChanged) {
                // 如果沒移動但有得分,直接翻轉
                animateCubeToScore(newCube, c.score);
              }
              window.IS_FAST_FORWARD = true;
            } else {
              newCube.position.set(worldPos.x, targetY, worldPos.z);
              // 同樣保留原本的旋轉角度
              if (scoreChanged) animateCubeToScore(newCube, c.score);
            }

```

### 4. 主迴圈中執行延遲動畫與翻轉

在 `animate()` 函數內,處理 `outroAnimData` 的部分。

**尋找以下代碼:**

```javascript
        // 處理話筒退場動畫 (放大與透明度消失)
        for (let i = outroAnimData.length - 1; i >= 0; i--) {
          const mic = outroAnimData[i];
          const anim = mic.userData.outroAnim;
          const elapsed = now - anim.startTime;
          let t = elapsed / anim.duration;

```

**替換為:**

```javascript
        // 處理話筒退場動畫 (放大與透明度消失)
        for (let i = outroAnimData.length - 1; i >= 0; i--) {
          const mic = outroAnimData[i];
          const anim = mic.userData.outroAnim;
          const elapsed = now - anim.startTime;
          if (elapsed < 0) continue; // 等待延遲結束
          let t = elapsed / anim.duration;

```

**繼續在 `animate()` 中尋找處理 `cubes.forEach` 的部分:**

```javascript
            if (t >= 1) {
              t = 1;
              cube.userData.moving = false;
              if (cube.userData.onMoveComplete) {
                const cb = cube.userData.onMoveComplete;
                cube.userData.onMoveComplete = null;
                cb();
              }
            }

```

**替換為:**

```javascript
            if (t >= 1) {
              t = 1;
              cube.userData.moving = false;
              // 移動結束後,如果有延遲的得分動畫則執行跳躍翻轉
              if (cube.userData.deferredScore !== undefined) {
                animateCubeToScore(cube, cube.userData.deferredScore);
                delete cube.userData.deferredScore;
              }
              if (cube.userData.onMoveComplete) {
                const cb = cube.userData.onMoveComplete;
                cube.userData.onMoveComplete = null;
                cb();
              }
            }

```

### 5. 修正播放動畫面板的回合與步數顯示

**尋找以下代碼:**

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

        let dispRound = 0;
        let dispStep = 0;

        let snap = historyUndoStack[historyUndoStack.length - 1];
        if (snap) {
          if (!snap.gameplayActive) {
            dispRound = 0;
            dispStep = 0;
          } else {
            dispRound = snap.gameRound;
            if (snap.subPhase === 'mic') {
              dispStep = 3;
            } else {
              dispStep = snap.walkCount - 1;
            }
          }
        }

        document.getElementById('playback-info').innerHTML =
          window.currentLang === 'zh'
            ? `回合: ${dispRound}<br>步數: ${dispStep}`
            : `Round: ${dispRound}<br>Step: ${dispStep}`;
      }

```

**替換為:**

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

        let dispRound = 0;
        let dispStep = '0';

        let snap = historyUndoStack[historyUndoStack.length - 1];
        if (snap) {
          if (!snap.gameplayActive) {
            dispRound = 0;
            dispStep = '0';
          } else {
            // 計算當前對抗的「回合」數:從行動階段開始算,切換行動方就+1
            dispRound = 1;
            let lastPlayer = null;
            for (let i = 0; i < historyUndoStack.length; i++) {
              let s = historyUndoStack[i];
              if (s.gameplayActive) {
                if (lastPlayer === null) lastPlayer = s.currentPlayer;
                else if (s.currentPlayer !== lastPlayer) {
                  dispRound++;
                  lastPlayer = s.currentPlayer;
                }
              }
            }

            if (snap.subPhase === 'mic') {
              dispStep = '3+1';
            } else {
              dispStep = snap.walkCount.toString();
            }
          }
        }

        document.getElementById('playback-info').innerHTML =
          window.currentLang === 'zh'
            ? `回合: ${dispRound}<br>步數: ${dispStep}`
            : `Round: ${dispRound}<br>Step: ${dispStep}`;
      }

```

### 6. 關閉播放動畫時重新防呆檢查按鈕狀態

**尋找以下代碼:**

```javascript
      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) {
          if (markers.length === 0) {
            let snap = historyUndoStack[historyUndoStack.length - 1] || saveSnapshot();
            if (snap.subPhase === 'walk') showWalkOptions();
            else showMicrophonePlacementOptions();
          }
        } else if (currentPhaseFn && !cubes.some(c => c.userData.score >= 6)) {
          currentPhaseFn();
        }
      }

```

**替換為:**

```javascript
      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) {
          if (markers.length === 0) {
            let snap = historyUndoStack[historyUndoStack.length - 1] || saveSnapshot();
            if (snap.subPhase === 'walk') showWalkOptions();
            else showMicrophonePlacementOptions();
          }
        } else if (currentPhaseFn && !cubes.some(c => c.userData.score >= 6)) {
          currentPhaseFn();
        }
        updateUndoRedoButtons(); // 關閉動畫時進行防呆判斷
      }

```

### 7. 重寫箭頭顯示邏輯 (`drawArrows` 與 `createArrow`)

**尋找以下代碼,將原本的 `drawArrows` 與 `createArrow` 整個區塊刪除:**

```javascript
      function drawArrows() {
        // ...(省略中間原始代碼)
          cone.rotateX(Math.PI / 2);
          arrowsGroup.add(cone);
        }
      }

```

**替換為:**

```javascript
      function drawArrows() {
        clearArrows();
        if (!window.isArrowMode || !gameplayActive) return;
        clearMarkers();

        // 蒐集目前這整個回合的所有歷史紀錄(Undo+Redo),精確提取本回合的三個步驟
        let allSnaps = historyUndoStack.concat(historyRedoStack.slice().reverse());
        let currentIndex = historyUndoStack.length - 1;
        if (currentIndex < 0) return;

        let snapBase = allSnaps[currentIndex];
        let cp = snapBase.currentPlayer;

        let startIdx = currentIndex;
        while (startIdx > 0 && allSnaps[startIdx - 1].gameplayActive && allSnaps[startIdx - 1].currentPlayer === cp) {
          startIdx--;
        }
        let endIdx = currentIndex;
        while (endIdx < allSnaps.length - 1 && allSnaps[endIdx + 1].gameplayActive && allSnaps[endIdx + 1].currentPlayer === cp) {
          endIdx++;
        }

        let turnSnaps = allSnaps.slice(startIdx, endIdx + 1);
        let steps = [];
        let lastPos = null;
        let micPos = null;

        turnSnaps.forEach(s => {
          let cube = s.cubesData.find(c => c.color === cp);
          if (cube) {
            if (!lastPos || cube.c !== lastPos.c || cube.r !== lastPos.r) {
              steps.push({ c: cube.c, r: cube.r });
              lastPos = { c: cube.c, r: cube.r };
            }
          }
          let initialMics = turnSnaps[0].micsData;
          let currentMics = s.micsData;
          let addedMic = currentMics.find(cm => cm.color === cp && !initialMics.some(im => im.c === cm.c && im.r === cm.r));
          if (addedMic) micPos = addedMic;
        });

        // 依據步驟對應不同高度與深淺顏色
        let blueColors = [0x5dade2, 0x2e86c1, 0x1b4f72];
        let greenColors = [0x82e0aa, 0x28b463, 0x186a3b];
        let colors = cp === 'blue' ? blueColors : greenColors;
        let heights = [12, 24, 36];

        for (let i = 0; i < steps.length - 1 && i < 3; i++) {
          createArrow(steps[i], steps[i + 1], colors[i], heights[i]);
        }

        if (micPos) {
          createFloatingMic(cp, micPos.c, micPos.r); // 高度由 createFloatingMic 固定在 48
        }
      }

      function createArrow(p1, p2, colorHex, height) {
        let pos1 = getCellWorldPos(p1.c, p1.r);
        let pos2 = getCellWorldPos(p2.c, p2.r);

        let dx = pos2.x - pos1.x;
        let dz = pos2.z - pos1.z;
        let len = Math.sqrt(dx * dx + dz * dz);
        if (len < 1) return;

        let arrowLen = 24;
        let boxLen = len - arrowLen;
        if (boxLen < 0) {
          boxLen = 0;
          arrowLen = len;
        }

        let mat = new THREE.MeshBasicMaterial({ color: colorHex });

        if (boxLen > 0) {
          let boxGeo = new THREE.BoxGeometry(24, 2, boxLen);
          let box = new THREE.Mesh(boxGeo, mat);
          let dirAngle = Math.atan2(dx, dz);
          box.position.set(pos1.x + (dx / len) * (boxLen / 2), height, pos1.z + (dz / len) * (boxLen / 2));
          box.rotation.y = dirAngle;
          arrowsGroup.add(box);
        }

        let coneGeo = new THREE.CylinderGeometry(0, 16, arrowLen, 3);
        let cone = new THREE.Mesh(coneGeo, mat);
        let coneCenter = boxLen + arrowLen / 2;
        cone.position.set(pos1.x + (dx / len) * coneCenter, height, pos1.z + (dz / len) * coneCenter);
        cone.lookAt(new THREE.Vector3(pos2.x, height, pos2.z));
        cone.rotateX(Math.PI / 2);
        arrowsGroup.add(cone);
      }

```

### 8. 在箭頭模式下隱藏「行走撤銷/重做」按鈕

**尋找以下代碼(約在檔案最後方 `ioDropdownTrigger.addEventListener` 中):**

```javascript
            window.isArrowMode = false;
            document.getElementById('btn-ai-blue').style.display = '';
            document.getElementById('btn-ai-green').style.display = '';
            document.getElementById('ai-strength-trigger').style.display = '';
            document.getElementById('btn-settings').style.display = '';

            let lbl = document.getElementById('arrow-mode-label');

```

**替換為:**

```javascript
            window.isArrowMode = false;
            document.getElementById('btn-ai-blue').style.display = '';
            document.getElementById('btn-ai-green').style.display = '';
            document.getElementById('ai-strength-trigger').style.display = '';
            document.getElementById('btn-settings').style.display = '';
            document.getElementById('btn-walk-undo').style.display = '';
            document.getElementById('btn-walk-redo').style.display = '';

            let lbl = document.getElementById('arrow-mode-label');

```

**再往下尋找開啟箭頭模式的部分:**

```javascript
          } else if (action === 'arrow') {
            window.isArrowMode = true;
            document.getElementById('btn-ai-blue').style.display = 'none';
            document.getElementById('btn-ai-green').style.display = 'none';
            document.getElementById('ai-strength-trigger').style.display = 'none';
            document.getElementById('btn-settings').style.display = 'none';

            let lbl = document.getElementById('arrow-mode-label');

```

**替換為:**

```javascript
          } else if (action === 'arrow') {
            window.isArrowMode = true;
            document.getElementById('btn-ai-blue').style.display = 'none';
            document.getElementById('btn-ai-green').style.display = 'none';
            document.getElementById('ai-strength-trigger').style.display = 'none';
            document.getElementById('btn-settings').style.display = 'none';
            document.getElementById('btn-walk-undo').style.display = 'none';
            document.getElementById('btn-walk-redo').style.display = 'none';

            let lbl = document.getElementById('arrow-mode-label');

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

在线用户

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