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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

請將 getValidMoves 內的這段代碼替換為:

代码: 全选

// --- 修正後的代碼 ---
for (let coord1 of edgeEmpties) {
  const r1 = this._unpackR(coord1);
  const c1 = this._unpackC(coord1);
  const parity = (r1 + c1) & 1;

  let possibleR2 = null;
  let possibleC2 = null;

  // 根據當前格子的奇偶性,自動尋找另一半,不丟棄任何可能性
  if (pieceType === 0) {
    possibleR2 = r1;
    possibleC2 = parity === 0 ? c1 + 1 : c1 - 1;
  } else if (pieceType === 1) {
    possibleR2 = parity === 0 ? r1 + 1 : r1 - 1;
    possibleC2 = c1;
  } else if (pieceType === 2) {
    possibleR2 = parity === 1 ? r1 + 1 : r1 - 1;
    possibleC2 = c1;
  }

  if (possibleR2 !== null && possibleC2 !== null) {
// ... 保持後續代碼不變

代码: 全选

這個計分規則非常巧妙!這本質上是利用 2D 的等角投影(Isometric Projection)來模擬 3D 空間中方塊的堆疊與支撐。

為了高效實現這個遞推邏輯,我們不需要做全圖的 Flood-fill,而是針對每個藍色棋子進行「向下視線追蹤(Raycasting / Directed Acyclic Graph)」**。因為投影的幾何特性,在同一視角下層數越深,物理行號(row)就越大/越小,這保證了遞推是**單向且無窮迴圈的,效能極高。

以下是為你量身打造的高效計分引擎與接入步驟:

### 一、 核心計分引擎更新

請將 `TriangleStrategyGame` 類別中原本被註解掉的 `calculateScores` 替換為以下完整實作。這段代碼直接解析了 4 個三角形的遮罩,並完美實現了你的「滿層」、「斷層」與「同層遞推」規則。

```javascript
  /**
   * 高效計算雙方得分 (完整 3D 投影同層遞推邏輯)
   */
  calculateScores(bluePieceRecords) {
    let scores = [0, 0];
    
    // 遍歷所有藍色棋子,依照各自的視角累加得分
    for (let bp of bluePieceRecords) {
      const playerIndex = bp.player === 1 ? 0 : 1;
      scores[playerIndex] += this.getBluePieceScore(bp, bp.player);
    }
    return { s1: scores[0], s2: scores[1] };
  }

  // 取得單一藍色棋子的總得分
  getBluePieceScore(bluePiece, player) {
    let totalScore = 0;
    let currentLayer = 1;
    
    // 往下遞推每一層,直到遇到斷層為止
    while (true) {
      if (this.isLayerFull(bluePiece, currentLayer, player)) {
        totalScore += 1;
        currentLayer += 1;
      } else {
        break; // 遇到斷層,計分結束
      }
    }
    return totalScore;
  }

  // 根據層數取得正下方/正上方的 4 個三角形座標
  getLayerTriangles(bluePiece, layerIndex, player) {
    const dir = player === 1 ? 1 : -1; // 1方往下,2方往上
    let r = bluePiece.r;
    let c = bluePiece.c;
    
    // 統一以 parity 0 (指向上或右的三角形) 作為該藍棋的基準點
    if (((r + c) & 1) !== 0) {
      c = c - 1; 
    }

    // 根據 3D 等角投影,第 L 層在 2D 網格上的反映是跨越兩個 row
    const R1 = r + dir * (2 * layerIndex - 1);
    const R2 = r + dir * (2 * layerIndex);

    return [
      { r: R1, c: c },
      { r: R2, c: c },
      { r: R1, c: c + 1 },
      { r: R2, c: c + 1 }
    ];
  }

  // 判定某一層是否為滿層 (包含同層遞推)
  isLayerFull(bluePiece, layerIndex, player) {
    const tris = this.getLayerTriangles(bluePiece, layerIndex, player);

    let hasEmpty = false;
    let redCount = 0;
    let orangeCount = 0;
    let blueProxies = new Set();

    // 掃描這 4 個三角形內的實體
    for (let i = 0; i < 4; i++) {
      const t = tris[i];
      const val = this.board.get(t.r, t.c);
      
      if (val === 0) {
        hasEmpty = true;
      } else {
        const type = this._getType(val);
        const id = this._getId(val);
        
        if (type === 1) redCount++;
        else if (type === 2) orangeCount++;
        else if (type === 0 && id !== bluePiece.id) {
          blueProxies.add(id); // 發現其他的代理藍棋
        }
      }
    }

    // 規則 1:如果有任何空位,直接視為「斷層」
    if (hasEmpty) return false;

    // 規則 2:滿層條件 -> 由 1 個紅棋 (2格) 與 1 個橙棋 (2格) 填滿
    if (redCount === 2 && orangeCount === 2 && blueProxies.size === 0) {
      return true;
    }

    // 規則 3:同層遞推條件 -> 沒有斷層且包含另一個或兩個藍色棋子
    if (blueProxies.size > 0) {
      let anyProxyFault = false;
      let anyProxyFull = false;

      for (let proxyId of blueProxies) {
        // 從外部紀錄中反查代理藍棋的完整座標
        const proxyPiece = window.bluePieceRecords.find(bp => bp.id === proxyId);
        if (!proxyPiece) continue;

        // 【關鍵】:代理藍棋的第 1 層,物理上正好對應當前棋子的下一層
        const proxyFull = this.isLayerFull(proxyPiece, 1, player);
        
        if (!proxyFull) {
          anyProxyFault = true;
        } else {
          anyProxyFull = true;
        }
      }

      // 「如果一個棋子在某一層的同層出現斷層,則這個棋子在這一層也同樣是斷層」
      if (anyProxyFault) return false;
      return anyProxyFull; 
    }

    // 其他不規則的填充狀況(例如半個紅棋加半個橙棋),不構成滿層
    return false;
  }

```

---

### 二、 必備的串接與修復細節

要讓上面的引擎順利運作,你需要對現有代碼做幾處小調整,確保棋子的 `id` 被正確記錄並傳遞:

#### 1. 記錄藍色棋子的專屬 ID

在 `attemptDrop` 與 `autoPlayFirstBlue` 函數中,目前 `bluePieceRecords.push` 漏存了棋子的 `id`,這會導致同層遞推時找不到代理棋子。

請找到落子動畫結束時寫入紀錄的代碼,並**補上 `id**`:

```javascript
// 在 attemptDrop 與 autoPlayFirstBlue 中找到這行:
// if (pid === 0) bluePieceRecords.push({ r: r1, c: c1, player: currentPlayer });

// 改為:
if (pid === 0) {
  bluePieceRecords.push({ 
    r: r1, 
    c: c1, 
    player: player,  // attemptDrop 中可能是 currentPlayer
    id: gameLogic.pieceCount 
  });
}

```

#### 2. 更新 UI 計算函數

在 `updateUI()` 或外部呼叫計分的地方,需要將 `bluePieceRecords` 傳入新引擎:

```javascript
function calculateScores() {
  // 將全局的 bluePieceRecords 傳入底層運算
  return gameLogic.calculateScores(bluePieceRecords);
}

```

#### 3. 確保復原/導入 (Undo/Import) 時重構紀錄

目前你的 `redrawFromHistory` 似乎避開了呼叫 `gameLogic.board.set`,這會導致悔棋後底層陣列是空的,計分就會全變 0。確保在 `redrawFromHistory` 重新繪製時,有同步更新 `gameLogic` 與 `bluePieceRecords`,或者在悔棋時直接重構整個 `UltraFastTriangleGrid` 狀態。

這個機制非常完美地把 2D 座標轉換成 3D 的堆疊邏輯了,而且利用記憶體連續性的優勢,計算速度極快。有遇到任何遞迴疊加上的疑問隨時跟我說!
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

dropAnim中出錯,r1未定義。

同時,後手方第一手棋子仍然不能落子。

當處於debug狀態時,把功能改為在消息框內給出所點擊的棋盤的坐標。
附件
trirhombus261.html.7z
(19.22 KiB) 已下载 5 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這幾個問題我已經找到了核心原因!

### 1. `autoPlayFirstBlue` 函數中的變數未定義

錯誤 `r1 is not defined` 是因為在第一手自動落藍棋時,代碼試圖把 `r1` 和 `c1` 寫入歷史紀錄,但該函數內實際上使用的是 `t1.r` 和 `t1.c`。這導致程式直接中斷,輪次(`currentPlayer`)沒有成功切換,系統仍然認為是第 0 步,所以後手方點擊時會被系統阻擋。

### 2. 棋盤座標的幾何對齊錯誤

即使解決了報錯,後手方可能仍然會覺得「點不上去」。這是因為原本 `getTriCenter` 函數在計算 X 軸座標時,錯誤地把 Y 軸(`r`)的影響加了進去,導致除第 0 行之外的其他行,視覺位置與邏輯位置出現了 `10.392px` 的偏移。點擊判定因此錯位,無法通過合法性驗證。

### 3. Debug 模式的需求更新

根據你的要求,我已經移除了原本複雜的分數樹狀圖展開,改為在通知框內顯示純粹的 `(c, r)` 坐標。

---

### 修復方案

請將你的代碼中對應的三個函數替換為以下修正後的版本:

#### 替換一:修復 `autoPlayFirstBlue` 函數

將原本報錯的 `r1` 和 `c1` 修正為正確的 `t1.r` 和 `t1.c`。

```javascript
function autoPlayFirstBlue(player) {
  let t1 = { c: 0, r: 0 }; // 坐標 (0,0)
  let t2 = { c: 1, r: 0 }; // 坐標 (1,0)

  const useNode = document.createElementNS('http://www.w3.org/2000/svg', 'use');
  useNode.setAttribute('href', '#tile0');
  useNode.setAttribute('class', 'tiledropped');
  useNode.setAttribute('fill', '#00BFFF');
  document.getElementById('etanidrop').appendChild(useNode);

  let startY = player === 1 ? (480 - panY) / currentScale + 100 : -panY / currentScale - 100;
  let startTime = performance.now();

  function dropAnim(time) {
    let progress = (time - startTime) / 777;
    if (progress > 1) progress = 1;
    useNode.setAttribute('transform', `translate(0, ${(startY * (1 - progress)).toFixed(3)})`);

    if (progress < 1) requestAnimationFrame(dropAnim);
    else {
      // 寫入新引擎
      let val = gameLogic._encodeCell(player, 0, ++gameLogic.pieceCount);
      gameLogic.board.set(t1.r, t1.c, val);
      gameLogic.board.set(t2.r, t2.c, val);
      
      // 修正此處的 t1.r 與 t1.c
      bluePieceRecords.push({
        r: t1.r,
        c: t1.c,
        player: player,
        id: gameLogic.pieceCount
      });

      piecesLeft[player][0]--;
      isFirstMove = false;
      let notation = formatCoord(t1.c, t1.r, t2.c, t2.r); // [(0,0),(1,0)]

      moveHistory.push({ notation, pid: 0, t1, t2, px: 0, py: 0, player });
      historyIndex++;
      cleanUpSelection();
      showNotification(getText('secondRedPrompt'));
    }
  }
  requestAnimationFrame(dropAnim);
}

```

#### 替換二:修復 `getTriCenter` 函數的視覺錯位

重寫此函數,讓 X 軸座標的推算嚴格遵守 `c`(欄位),不再受 `r`(列)干擾。

```javascript
function getTriCenter(c, r) {
  const HALF_WIDTH = 31.1769;
  
  // 嚴格依據 c 欄位計算 X 軸絕對位置,不受行數 r 干擾
  let cx = Math.floor(c / 2) * HALF_WIDTH;
  cx += (c % 2 === 0) ? 10.392 : 20.784;
  
  let cy = r * V_SPACING;
  return { cx, cy };
}

```

#### 替換三:更新 Debug 模式顯示坐標

在 `setupBoardClick` 中修改 `isDebugMode` 區塊,直接在通知框顯示點擊處的坐標。

```javascript
function setupBoardClick() {
  const svg = document.getElementById('etani');
  const dropG = document.getElementById('etanidrop');
  const pt = svg.createSVGPoint();

  svg.addEventListener('click', function (e) {
    if (hasDragged) {
      hasDragged = false;
      return;
    }

    // --- 新增:Debug 模式攔截點擊事件,僅顯示坐標 ---
    if (isDebugMode) {
      pt.x = e.clientX;
      pt.y = e.clientY;
      const svgP = pt.matrixTransform(dropG.getScreenCTM().inverse());
      
      let approxC = Math.floor((svgP.x - 10.392) / H_SPACING);
      let approxR = Math.round(svgP.y / V_SPACING);

      let bestDist = Infinity,
        bestTri = null;

      for (let r = approxR - 2; r <= approxR + 2; r++) {
        for (let c = approxC - 2; c <= approxC + 2; c++) {
          let { cx, cy } = getTriCenter(c, r);
          let dist = Math.hypot(svgP.x - cx, svgP.y - cy);
          if (dist < bestDist) {
            bestDist = dist;
            bestTri = { c, r };
          }
        }
      }

      if (bestTri) {
        showNotification(`坐標: (${bestTri.c}, ${bestTri.r})`);
      }
      return;
    }
    // --- Debug 結束 ---

    if (currentSelectedPiece === null) {
      const ctrl = document.getElementById('board-controls');
      const isHidden = ctrl.style.display === 'none';
      ctrl.style.display = isHidden ? 'block' : 'none';

      const tools = document.getElementById('floating-tools');
      const clone = document.getElementById('floating-tools-clone');
      if (isHidden) {
        tools.style.display = 'none';
        if (clone) clone.style.display = 'none';
      } else {
        tools.style.display = 'flex';
        if (isAutoRotate && clone) clone.style.display = 'flex';
      }
      return;
    }

    document.getElementById('board-controls').style.display = 'none';
    document.getElementById('floating-tools').style.display = 'flex';
    if (isAutoRotate) document.getElementById('floating-tools-clone').style.display = 'flex';
    
    pt.x = e.clientX;
    pt.y = e.clientY;
    const svgP = pt.matrixTransform(dropG.getScreenCTM().inverse());
    processBoardClick(svgP.x, svgP.y);
  });
}

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

claude sonnet 4.6

代码: 全选

現在我已經清楚看到了所有問題。讓我分析一下:

**問題一:`autoPlayFirstBlue` 中 `r1`/`c1` 未定義(第2014行)**
```js
bluePieceRecords.push({ r: r1, c: c1, ... });
```
這裡應該是 `t1.r` 和 `t1.c`。

**問題二:後手方第一手無法落子**
`selectPiece` 中,`movesInRound === 0` 時直接呼叫 `autoPlayFirstBlue(player)` 並 return,但後手方(第二局先手或第二手的第一下)也觸發了同樣邏輯,問題在於後手方第一手(`movesInRound === 1`)不應被阻止。實際上問題更早:`currentPlayer` 在 `cleanUpSelection` 後已切換,但 `selectPiece` 裡的 `player` 判斷卻是傳入的 `player` 參數,需要看 `movesInRound === 1` 對後手方的判斷。

再仔細看:`movesInRound === 1`(後手方第一手),`currentSelectedPiece` 不是 1(紅色)就返回,但問題是 `movesInRound === 0` 的條件在**後手方的第一回合**也會被觸發 — 因為 `historyIndex` 在先手自動落藍後已經是 0,所以 `movesInRound = 0 - 0 + 1 = 1`,理應能選棋,但因為先手落完藍棋後 `isFirstMove = false`,此時 `cleanUpSelection` 切換到 player2,應該可以選棋。

讓我再看第二局情形及 `piecesLeft` 初始化是否正確:`piecesLeft = { 1: [6, 6, 6], 2: [6, 6, 6] }` 但先手落藍後才 `piecesLeft[player][0]--`,所以第二手時 `piecesLeft[2][1]` 應該還是 6,沒問題。

實際問題是:**後手方點擊棋子時,`movesInRound` 計算的是相對於 `roundStart` 的值,而 `historyIndex` 為 0(先手已落一手)**,所以 `movesInRound = 0 - 0 + 1 = 1`,這樣就會進入紅棋限制檢查,`pieceId !== 1` 就退出。但後手方第一手只能選紅色棋(`pieceId === 1`),所以選紅棋時 `pieceId !== 1` 為 false,不會退出……

再看 `selectPiece` 的第一個判斷:`player !== currentPlayer`,後手是 player 2,`currentPlayer` 在先手落完後應該是 2。這裡看上去沒問題。

等等 — 問題在於 `autoPlayFirstBlue` 完成後,`cleanUpSelection()` 會把 `currentPlayer` 切換為 2,然後 `updateUI()` 會把 player 2 的棋子設為可點擊,但 `piecesLeft[2][0]` 此時仍是 6,只有 `piecesLeft[1][0]` 被 `--`。所以後手方的棋子應該可以點擊……

讓我再看看 `piecesLeft` 的初始化:發現了!初始化時 `piecesLeft = { 1: [5, 6, 6], 2: [6, 6, 6] }` — player 1 的藍色棋子初始只有 5,但 `initBoardState` 裡重置為 `[6,6,6]`。問題是:**全局初始化時** player1 藍色已被扣掉 1,但 `autoPlayFirstBlue` 又再次 `piecesLeft[player][0]--`,導致 player1 藍色變成 4。更重要的是:`initBoardState` 後才執行 `autoPlayFirstBlue`,所以 `initBoardState` 的 `[6,6,6]` 才是真實起點。

現在找到**後手方第一手問題的根源**:在 `autoPlayFirstBlue` 的 `dropAnim` 回調完成後,呼叫了 `cleanUpSelection()`,它會把 `currentPlayer` 從 1 切換到 2,然後呼叫 `updateUI()`。但在 `updateUI()` 裡:

```js
if (piecesLeft[p][c] === 0 || currentPlayer !== p) {
    box.style.opacity = '0.5';
    box.style.cursor = 'not-allowed';
}
```

這表示只有 player2 的棋子是可點擊的,這是對的。但請注意 `selectPiece(pieceId, element, player)` 的判斷:

```js
if (gameState !== 'playing' || player !== currentPlayer || piecesLeft[player][pieceId] <= 0) return;
```

`gameState` 在 `startGame()` 前是 `'start'`!但 `startGame()` 會把 `gameState` 設為 `'playing'`……

等等,`autoPlayFirstBlue` 只在 `selectPiece` 中被呼叫,而 `selectPiece` 的第一行就已過了 `gameState !== 'playing'` 檢查,所以 `gameState` 沒問題。

**真正的問題**:後手方在選棋後,`movesInRound === 1`(後手方第一手)時,代碼到達 `attemptDrop`,然後在 `attemptDrop` 中:

```js
let movesInRound = historyIndex - roundStart + 1;
if (movesInRound === 1 && currentSelectedPiece !== 1) {
```

`historyIndex` 此時是 0(先手落了一手),`roundStart` 是 0,所以 `movesInRound = 1`。後手方選了紅色(`pieceId = 1`),`currentSelectedPiece !== 1` 為 false,不會阻止。

但問題是 `getValidMoves` — `movesInRound > 0` 時進行合法性驗證。`gameLogic.getValidMoves` 能正確找到鄰近藍色棋子的格子嗎?讓我看看 `getValidMoves` 的實現。現在我已完全理解所有問題。讓我整理並修復:

**問題一(`dropAnim` 中 `r1` 未定義)**:在 `autoPlayFirstBlue` 的 `dropAnim` 回調中,第2014行 `bluePieceRecords.push({ r: r1, c: c1, ... })` 應改為 `r: t1.r, c: t1.c`。

**問題二(後手方第一手無法落子)**:在 `attemptDrop` 中,`movesInRound === 1` 的紅棋限制邏輯是對的,但在 `getValidMoves` 呼叫之前有個判斷 `movesInRound > 0`。後手第一手 `movesInRound = historyIndex(0) - 0 + 1 = 1`,大於0,所以會進入合法性驗證。這裡本身沒問題。真正問題是:`autoPlayFirstBlue` 完成後,它呼叫的是 `cleanUpSelection()`,但 `cleanUpSelection` 裡 `currentPlayer = currentPlayer === 1 ? 2 : 1` 切換了玩家,但 `updateUI()` 裡的邏輯對 player2 棋子的 `opacity` 判斷:`piecesLeft[p][c] === 0 || currentPlayer !== p`——這是對的,player2 的棋子應該是可見的。

等等,我再重看 `piecesLeft`——初始值是 `{ 1: [5, 6, 6], 2: [6, 6, 6] }` (全局),但 `initBoardState` 把它重置為 `[6,6,6]`,然後先手落藍之後在 `autoPlayFirstBlue` 的 `dropAnim` 中:`piecesLeft[player][0]--`,所以 player1 藍變 5,player2 的一切都是 6。

問題根源在 `attemptDrop` 中:

```js
let movesInRound = historyIndex - roundStart + 1;
```

後手選了棋(`selectPiece` 成功),然後點擊棋盤,`processBoardClick` → `attemptDrop`。此時 `historyIndex = 0`(先手藍棋),`roundStart = 0`,`movesInRound = 1`。然後 `movesInRound === 1 && currentSelectedPiece !== 1`——如果後手選的是紅色(pid=1),`currentSelectedPiece === 1`,這個檢查通過。進入 `getValidMoves(1, 2)`,應能找到與藍棋相鄰的位置。

**實際上問題出在後手選紅棋時,`selectPiece(1, this, 2)` 被呼叫,`movesInRound === 1` 但 `pieceId !== 0`,所以選紅棋是OK的。然後點棋盤,`attemptDrop` 中 `movesInRound === 1 && currentSelectedPiece !== 1`——後手選的是紅色,`currentSelectedPiece = 1`,所以 `1 !== 1` 為 false,不攔截。繼續驗證。**

讓我再細看 `attemptDrop` 的完整流程:現在我注意到 `_formsHole` 函數中一個重要問題:

```js
_formsHole(c1, c2) {   // 參數名稱是 c1, c2,但實際是 packed coord
  this.board.set(this._unpackR(c1), this._unpackC(c1), 1);
```

在 `attemptDrop` 中呼叫時:
```js
if (gameLogic._formsHole(p1Coord, p2Coord)) {
```
這是正確的(傳入 packed coord)。但在 `getValidMoves` 中:
```js
if (!this._formsHole(coord1, coord2)) {
```
這也是正確的。

讓我看看 `_packCoord` 和 `getNeighbors` 的一致性問題——

`getNeighbors` 中:
```js
neighbors.push((r << 16) | ((c + 1) & 0xffff));
```

`_packCoord`:
```js
return (r << 16) | (c & 0xffff);
```

這兩者格式一致,沒問題。

**關鍵問題找到了!** 在 `autoPlayFirstBlue` 中,藍棋落在 `(r=0, c=0)` 和 `(r=0, c=1)` 的格子,但 `getValidMoves` 搜索的是已佔格子的鄰居。`getNeighbors(0, 0)` 回傳的格子:
- `(-1, 0)`(r-1, c)
- `(1, 0)`(r+1, c)  
- `(0, 0)` parity=(0+0)&1=0,所以加 `(0, 1)`(r, c+1)

這樣後手的紅棋 `getValidMoves(1, 2)` 應能找到與這些格子相鄰的空位。

**真正問題**:`_formsHole` 中傳入的 `c1`(注意這是 `coord1` packed coord),但函數名是 `_formsHole(c1, c2)`。在 `attemptDrop` 的錯誤提示分支中:
```js
if (gameLogic._formsHole(p1Coord, p2Coord)) {
```
這是在 `isValid === false` 的情況下再次呼叫 `_formsHole`。但 `_formsHole` 會**暫時修改棋盤**!這不是 bug,因為它最後會復原。

讓我重新想——後手方第一手究竟哪裡卡住了?

讓我看看第二個 `getTriCenter` 函數(第2239行):

```js
function getTriCenter(idx, n, isRight) {   // 第2239行 - 舊版,接受 idx/n 參數
```

但第2193行也有:
```js
function getTriCenter(c, r) {   // 新版,接受 c/r 參數
```

**這是函數重複定義!** 第2239行的定義會覆蓋第2193行的定義,導致 `calcDropPos`、`processBoardClick` 中呼叫 `getTriCenter(c, r)` 時,實際上是用舊版(需要3個參數的版本),造成 `isRight` 為 undefined。

這就是**後手方第一手無法落子的根本原因**——`getTriCenter` 被重複定義,新版本被舊版本覆蓋,導致坐標計算錯誤,`processBlockClick` → `getTriCenter(c, r)` 時 `isRight` 是 undefined,`cx`/`cy` 計算出 NaN,`dist` 是 NaN,`bestTri` 永遠是 null(因為 `NaN < Infinity` 是 false)。

等等,`null < Infinity` 是 false,那 `bestDist` 仍是 Infinity,`bestTri` 仍是 null。那 `attemptDrop` 永遠不會被呼叫……但先手方(第2手及之後)也用同樣的 `processBoardClick`,為什麼先手方能落子?

啊,**先手方的第一手是自動落子**(`autoPlayFirstBlue`),之後的手需要呼叫 `processBoardClick`。如果 `getTriCenter` 的問題,那先手的第2手也應該出問題……

這說明問題不是函數覆蓋,需要再確認。讓我看看第2239行的 `getTriCenter` 的返回值:

```js
function getTriCenter(idx, n, isRight) {
    let halfWidth = 31.1769;
    let k_left = idx > 0 ? idx - 1 : idx;
    return isRight ? k_left * halfWidth + 10.392 : k_left * halfWidth + 20.784;
}
```

這個只返回 `cx`(一個數字),而第2193行的返回 `{ cx, cy }`。當 `calcDropPos(c, r, pieceId)` 呼叫 `getTriCenter(c, r)` 時,實際呼叫了舊版(3個參數的),但只傳了2個參數,所以 `isRight` 是 undefined,`return undefined ? ... : ...` 中 undefined 是 falsy,返回 `k_left * halfWidth + 20.784`(一個數字),而非 `{cx, cy}` 物件。

然後 `let { cx, cy } = getTriCenter(c, r)` 解構一個數字,得到 `cx = undefined, cy = undefined`!

**但這應該影響所有的落子,包括先手的第2手。** 所以到底後手第一手的問題在哪?讓我回頭重讀……

哦!我剛才看到了問題:`selectPiece` 裡先手選藍棋時 `movesInRound === 0`,直接呼叫 `autoPlayFirstBlue`,**不需要** `processBoardClick`;而後手方選了棋後,需要點棋盤,呼叫 `processBoardClick`,然後 `getTriCenter` 返回數字而非對象,導致 NaN,`bestTri` 為 null,`attemptDrop` 不被呼叫。

**而先手的第2手及之後,也用同樣路徑**——但也一樣壞掉了?或者遊戲其實從頭到尾只有先手能用自動落子,所有非自動的落子都壞了?

如果是這樣,那整個手動落子系統(後手第1手,以及先手/後手的第2手起)都應該壞掉了。這說明問題確實是 `getTriCenter` 被覆蓋。

現在修復策略很清楚了:
1. 將第2239行的舊版 `getTriCenter` 重命名為 `getTriCenterOld`(或整合到需要它的地方)
2. 修復 `autoPlayFirstBlue` 中的 `r1`/`c1` 未定義問題
3. Debug模式改為在訊息框顯示點擊的棋盤坐標(c, r)確認了。現在讓我看舊版 `getTriCenter` 在 debug click 中的用法(第2110行):很好。舊版 Debug click 使用 idx/N 坐標系(舊系統),但要求是:**Debug模式改為在訊息框顯示點擊的棋盤坐標(新系統的 c, r)**。

現在我完全清楚了所有需要修改的地方:

1. **修復 `getTriCenter` 重複定義**:將第2239行的舊版重命名為 `getTriCenterLegacy`(供 debug 代碼使用)
2. **修復 `autoPlayFirstBlue` 中 `r1`/`c1` 未定義**:改為 `t1.r`/`t1.c`
3. **修改 Debug 模式**:用新坐標系(`processBoardClick` 同樣的 `tx`, `ty` → `c, r`),在訊息框顯示 `(c, r)` 坐標

現在進行修復:現在修復 Debug 模式——改為用新坐標系顯示點擊的 `(c, r)` 坐標,用訊息框(`showNotification`)顯示:現在也要更新 `toggleDebug` 裡的通知文字,讓它反映新功能:現在讓我驗證所有修改都正確到位:所有修改都正確。現在複製到輸出目錄:修復了三個問題,說明如下:

**Bug 1:`dropAnim` 中 `r1`/`c1` 未定義(第 ~2014 行)**

`autoPlayFirstBlue` 的動畫回調中寫了 `r: r1, c: c1`,但這兩個變數根本不在該函數的作用域內(它們是 `attemptDrop` 裡的局部變數)。修復:改為 `r: t1.r, c: t1.c`,正確引用同函數中已定義的 `t1` 物件。

---

**Bug 2:後手方第一手棋子無法落子**

根本原因是**函數重複定義**:第 2193 行定義了新版 `getTriCenter(c, r)`,回傳 `{ cx, cy }` 物件;但第 2239 行又定義了同名的舊版 `getTriCenter(idx, n, isRight)`,只回傳一個數字。JavaScript 中後者覆蓋前者,導致 `processBoardClick` 呼叫 `getTriCenter(c, r)` 時,實際執行的是舊版——接到兩個參數而 `isRight` 為 `undefined`,回傳一個數字而非物件。解構 `let { cx, cy } = 數字` 得到兩個 `undefined`,計算出 `NaN`,`bestTri` 永遠是 `null`,`attemptDrop` 永遠不被呼叫。

修復:將舊版函數重命名為 `getTriCenterLegacy`,解除衝突。

---

**Bug 3:Debug 模式改為顯示坐標**

按需求,Debug 模式點擊棋盤時,用訊息框顯示該格的新坐標系坐標 `(c=X, r=Y)`,邏輯與 `processBoardClick` 完全一致。同時更新了 Debug 開關時的提示文字。
附件
trirhombus261claude.html.7z
(18.98 KiB) 已下载 3 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

刪減大部分程式碼到只剩基本框架和點擊事件,導出窗口只打開窗口但不執行導出棋譜,複製仍然保留。

點擊棋盤,如果是debug,則消息框需要給出當前點擊所在的三角形坐標。如果debug時選擇了一個棋子,則消息框除了給出當前三角形坐標外,還要給出當前棋子所佔的另一個三角形的坐標。

點擊棋盤,如果不是debug且有棋子選中,則直接落子,不必判定是否干涉,是否形成黑洞等,然後切換棋手。己方落下棋子數量即是己方計分。

其它功能如平移縮放棋盤、旋轉等功能仍然保留。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

chatgpt

代码: 全选

對,這才是最合理的修改方式。

你的原始程式其實已經把許多困難的東西都寫好了:

* 平移拖曳
* 滾輪縮放
* 雙指縮放
* 自動旋轉
* 通知框位置旋轉
* 工具列鏡像
* 落子動畫
* Undo / Redo
* 棋盤重繪
* SVG 元件
* 回合顯示
* 分數顯示
* 棋譜視窗
* Debug 模式

這些全部都不應該動。

真正需要刪掉的其實只有「規則引擎」。

建議修改範圍如下。

---

# 1. openExportModal

只開視窗:

```js
function openExportModal() {
    document.getElementById('export-modal').style.display = 'flex';
}
```

不要:

* encodeNotation()
* exportHistory()
* exportText.value = ...

---

# 2. submitImport

直接空掉:

```js
function submitImport() {}
```

---

# 3. processBoardClick

目前:

```js
if (bestTri && currentSelectedPiece !== null)
    attemptDrop(bestTri);
```

改成:

```js
if (!bestTri) return;

if (isDebugMode) {
    let msg = `(${bestTri.idx},${bestTri.N})`;

    if (currentSelectedPiece !== null) {
        let tri2 = getPairTri(bestTri, currentSelectedPiece);
        msg += ` 另一半:(${tri2.idx},${tri2.N})`;
    }

    showNotification(msg);
    return;
}

if (currentSelectedPiece !== null)
    attemptDrop(bestTri);
```

---

# 4. attemptDrop

這裡才是重點。

全部刪掉:

```js
occupied.has
secondRedPrompt
mustConnect
detectHole
drawHoleOverlay
clearHoleOverlay
flyBackAnim
```

甚至:

```js
isAdjacent
movesInRound
nbs
```

全部都可以消失。

保留:

```js
let tri2 = getPairTri(...);
let {px, py} = calcDropPos(...);

建立 useNode

落下動畫

commitMove()

cleanUpSelection()
```

最後:

```js
commitMove(pid, tri, tri2, px, py, currentPlayer);
cleanUpSelection();
```

---

# 5. commitMove

這裡把計分改掉。

原本可能有:

```js
updateScore();
evaluateScore();
```

全部拿掉。

改成:

```js
if (player === 1)
    p1TotalScore++;
else
    p2TotalScore++;
```

或者:

```js
document.getElementById("score-1").textContent = p1TotalScore;
document.getElementById("score-2").textContent = p2TotalScore;
```

落一顆就一分。

---

# 6. checkGameEnd

如果還要保留結束畫面:

```js
if (
    piecesLeft[1][0] +
    piecesLeft[1][1] +
    piecesLeft[1][2] +
    piecesLeft[2][0] +
    piecesLeft[2][1] +
    piecesLeft[2][2] === 0
) {
    showGameOver();
}
```

否則甚至可以直接:

```js
function checkGameEnd() {}
```

---

# 7. 可以整個刪掉的大型函式

通常這些都能完全移除:

```js
detectHole
drawHoleOverlay
clearHoleOverlay
calculateScore
showScoreDetail
evaluateBluePieceTree
showDebugScore
```

這些通常才是幾百行的大怪物。

---

所以實際上你這個專案不是:

> 刪掉 90%。

而是:

> 保留 90%,刪掉 10%。

而且那 10% 幾乎全部集中在:

* 空洞判定
* 相鄰判定
* 合法著法判定
* 計分演算法
* Debug 計分分析
* 棋譜編碼

旋轉、平移、縮放、動畫、UI 完全不用動。這樣改完之後,整個程式結構仍然是你的原程式,只是變成一個非常簡單的「自由落子版三菱棋」。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

刪除本遊戲程式中的坐標定義和規則引擎:
去掉所有當前的「0 2a1 B1A」這種坐標定義,改為[(0,0),(1,0)]這種坐標定義,即初始藍色棋子的坐標為[(0,0),(1,0)],其左上三角形坐標為(0,-1),右下三角形坐標為(1,1)。

這些函式完全移除:

代码: 全选

刪除本遊戲程式中的坐標定義和規則引擎:
去掉所有當前的「0 2a1 B1A」這種坐標定義,改為[(0,0),(1,0)]這種坐標定義,即初始藍色棋子的坐標為[(0,0),(1,0)],其左上三角形坐標為(0,-1),右下三角形坐標為(1,1)。

這些函式完全移除:

detectHole

drawHoleOverlay

clearHoleOverlay

calculateScore

showScoreDetail

evaluateBluePieceTree

showDebugScore

也就是去掉以下功能:

* 空洞判定

* 相鄰判定

* 合法著法判定

* 計分演算法

* Debug 計分分析

* 棋譜編碼

保留以下功能:

* 平移拖曳

* 滾輪縮放

* 雙指縮放

* 自動旋轉

* 通知框位置旋轉

* 工具列鏡像

* 落子動畫

* Undo / Redo

* 棋盤重繪

* SVG 元件

* 回合顯示

* 分數顯示

* 棋譜視窗

* Debug 模式

這些全部都不動。

當openExportModal時只開視窗:

```js
function openExportModal() {
    document.getElementById('export-modal').style.display = 'flex';
}
```

不要:

* encodeNotation()
* exportHistory()
* exportText.value = ...

---

submitImport直接空掉:

```js
function submitImport() {}
```

---

更改processBoardClick:如果當前有棋子選中,則直接送入棋子至所點擊的三角形所在的坐標,然後切換棋手。己方落下棋子數量即是己方計分。

如果是debug狀態時無棋子選中,則消息框需要給出當前點擊所在的三角形坐標。如果debug時有棋子選中,則消息框除了給出當前三角形坐標外,還要給出當前棋子所佔的另一個三角形的坐標。

attemptDrop中全部刪掉:

```js
occupied.has
secondRedPrompt
mustConnect
detectHole
drawHoleOverlay
clearHoleOverlay
flyBackAnim
isAdjacent
movesInRound
nbs
```

checkGameEnd保留結束畫面。

需要明確給出所要修改的地方和所要替換的代碼。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

根據您的需求,我們將精簡程式碼,移除複雜的規則驗證、計分演算法與空洞判定,並修改座標格式。以下是具體的修改步驟與替換的程式碼:

### 1. 刪除以下所有函式

請在程式碼中**完全刪除**這些函式及其內容:

* `getIdxChar`
* `getOldFormat`
* `encodeNotation`
* `detectHole`
* `drawHoleOverlay`
* `clearHoleOverlay`
* `getTriCenter`
* `getBPLeftCol`
* `getBPRightCol`
* `getPieceAtCoord`
* `evaluateBluePieceTree`
* `evaluateSideTree`
* `getBluePieceScore`
* `calculateScores`
* `renderTreeHTML`
* `renderScoreBreakdown`

---

### 2. 替換匯入 / 匯出模組

將原有的 `openExportModal` 與 `submitImport` 替換為以下簡化版本:

```javascript
function openExportModal() {
  document.getElementById('export-modal').style.display = 'flex';
}

function submitImport() {}

```

---

### 3. 替換計分與 UI 更新 (`updateUI`)

由於移除了原本的計分演算法,現在己方的分數即為落下的棋子數量。請替換 `updateUI` 函式:

```javascript
function updateUI() {
  // 雙方總棋子數各為 18 顆,落子數 = 18 - 剩餘數量
  let s1 = 18 - (piecesLeft[1][0] + piecesLeft[1][1] + piecesLeft[1][2]);
  let s2 = 18 - (piecesLeft[2][0] + piecesLeft[2][1] + piecesLeft[2][2]);
  
  document.getElementById('score-1').innerText = s1;
  document.getElementById('score-2').innerText = s2;

  for (let p = 1; p <= 2; p++) {
    for (let c = 0; c < 3; c++) {
      let countEl = document.getElementById(`count-${p}-${c}`);
      if (countEl) countEl.innerText = piecesLeft[p][c];
      let box = document.getElementById(`p${p}-${c}`);
      if (piecesLeft[p][c] === 0 || currentPlayer !== p) {
        box.style.opacity = '0.5';
        box.style.cursor = 'not-allowed';
      } else {
        box.style.opacity = '1';
        box.style.cursor = 'pointer';
      }
    }
  }
  if (typeof updateLayoutForTurn === 'function') updateLayoutForTurn();
}

```

---

### 4. 替換點擊事件與 Debug 邏輯 (`setupBoardClick` 與 `processBoardClick`)

重構點擊偵聽器,移除了原本在事件層級阻擋的 Debug 邏輯,並直接無條件送入棋子。

請替換 **`setupBoardClick`**:

```javascript
function setupBoardClick() {
  const svg = document.getElementById('etani');
  const dropG = document.getElementById('etanidrop');
  const pt = svg.createSVGPoint();

  svg.addEventListener('click', function (e) {
    if (hasDragged) {
      hasDragged = false;
      return;
    }

    // 若未選中棋子且不處於 Debug 模式,則呼叫選單
    if (currentSelectedPiece === null && !isDebugMode) {
      const ctrl = document.getElementById('board-controls');
      const isHidden = ctrl.style.display === 'none';
      ctrl.style.display = isHidden ? 'block' : 'none';

      const tools = document.getElementById('floating-tools');
      const clone = document.getElementById('floating-tools-clone');
      if (isHidden) {
        tools.style.display = 'none';
        if (clone) clone.style.display = 'none';
      } else {
        tools.style.display = 'flex';
        if (isAutoRotate && clone) clone.style.display = 'flex';
      }
      return;
    }

    document.getElementById('board-controls').style.display = 'none';
    document.getElementById('floating-tools').style.display = 'flex';
    if (isAutoRotate) document.getElementById('floating-tools-clone').style.display = 'flex';
    
    pt.x = e.clientX;
    pt.y = e.clientY;
    const svgP = pt.matrixTransform(dropG.getScreenCTM().inverse());
    processBoardClick(svgP.x, svgP.y);
  });
}

```

請替換 **`processBoardClick`**:

```javascript
function processBoardClick(tx, ty) {
  const halfWidth = 31.1769;
  let k = Math.floor(tx / halfWidth);
  let idx = tx > 0 ? k + 1 : k;
  if (tx === 0) idx = 1;
  let approxN = Math.round(ty / 18);
  let bestDist = Infinity,
    bestTri = null;

  for (let n = approxN - 2; n <= approxN + 2; n++) {
    let isRight = isRightPointing(idx, n);
    let k_left = idx > 0 ? idx - 1 : idx;
    let cx = isRight ? k_left * halfWidth + 10.392 : k_left * halfWidth + 20.784;
    let cy = n * 18;
    let dist = Math.hypot(tx - cx, ty - cy);
    if (dist < bestDist) {
      bestDist = dist;
      bestTri = { idx, N: n, isRight, cx, cy };
    }
  }

  if (bestTri) {
    if (isDebugMode) {
      if (currentSelectedPiece === null) {
        showNotification(`點擊坐標: (${bestTri.idx},${bestTri.N})`);
      } else {
        let t2 = getPairTri(bestTri, currentSelectedPiece);
        showNotification(`佔用坐標: [(${bestTri.idx},${bestTri.N}),(${t2.idx},${t2.N})]`);
      }
    }
    
    // 無視規則,直接落子
    if (currentSelectedPiece !== null) {
      attemptDrop(bestTri);
    }
  }
}

```

---

### 5. 替換落子與紀錄邏輯 (`attemptDrop` 與 `commitMove`)

移除 `attemptDrop` 中的空洞及合法性檢查。並在 `commitMove` 中採用新的 `[(idx1,N1),(idx2,N2)]` 座標格式。

請替換 **`attemptDrop`**:

```javascript
function attemptDrop(tri) {
  let tri2 = getPairTri(tri, currentSelectedPiece);
  let { px, py } = calcDropPos(tri, currentSelectedPiece);

  const useNode = document.createElementNS('http://www.w3.org/2000/svg', 'use');
  useNode.setAttribute('href', `#tile${currentSelectedPiece}`);
  useNode.setAttribute('class', 'tiledropped');
  useNode.setAttribute('fill', TILE_COLORS[currentSelectedPiece]);
  document.getElementById('etanidrop').appendChild(useNode);

  let startY = currentPlayer === 1 ? (480 - panY) / currentScale + 100 : -panY / currentScale - 100;
  let startTime = performance.now();
  let pid = currentSelectedPiece;

  function dropAnim(time) {
    let progress = (time - startTime) / 777;
    if (progress > 1) progress = 1;
    useNode.setAttribute(
      'transform',
      `translate(${px.toFixed(3)}, ${(startY + (py - startY) * progress).toFixed(3)})`
    );

    if (progress < 1) {
      requestAnimationFrame(dropAnim);
    } else {
      commitMove(pid, tri, tri2, px, py, currentPlayer);
      cleanUpSelection();
      checkGameEnd();
    }
  }
  requestAnimationFrame(dropAnim);
}

```

請替換 **`commitMove`**:

```javascript
function commitMove(pid, t1, t2, px, py, player) {
  piecesLeft[player][pid]--;
  isFirstMove = false;
  
  // 改為新版直觀坐標格式
  let notation = `[(${t1.idx},${t1.N}),(${t2.idx},${t2.N})]`;

  moveHistory = moveHistory.slice(0, historyIndex + 1);
  moveHistory.push({ notation, pid, t1, t2, px, py, player });
  historyIndex++;
  showNotification(`落子: ${notation}`);
}

```

---

### 6. 修補相關函式 (`showRoundEnd`, `startRound2`, `autoPlayFirstBlue`)

因為移除了原本的計分函數,請在以下幾個函式中修正分數擷取方式與起始坐標紀錄。

在 **`showRoundEnd`** 中,修改分數取得邏輯(移除 `let scores = calculateScores();`):

```javascript
function showRoundEnd() {
  const screen = document.getElementById('game-over-screen');
  const title = document.getElementById('go-title');
  const scoresDiv = document.getElementById('go-scores');
  const actionsDiv = document.getElementById('go-actions');

  let s1 = 18 - (piecesLeft[1][0] + piecesLeft[1][1] + piecesLeft[1][2]);
  let s2 = 18 - (piecesLeft[2][0] + piecesLeft[2][1] + piecesLeft[2][2]);

  if (currentRound === 1) {
    p1TotalScore = s1;
    p2TotalScore = s2;
    title.innerText = getText('gameOver') + ' - Round 1';
    scoresDiv.innerHTML = `${getText('round1Score')}<br>P1(我方): ${s1} - P2(對方): ${s2}`;

    actionsDiv.innerHTML = `
       <button class="btn-secondary" onclick="finalEnd()">${getText('endGameBtn')}</button>
       <button class="btn-primary" onclick="startRound2()">${getText('swapBtn')}</button>
     `;
  } else {
    p1TotalScore += s1;
    p2TotalScore += s2;
    title.innerText = getText('gameOver') + ' - Final';
    let winnerText =
      p1TotalScore > p2TotalScore
        ? getText('winnerMe')
        : p2TotalScore > p1TotalScore
          ? getText('winnerOpp')
          : getText('draw');

    scoresDiv.innerHTML = `
       ${getText('round2Score')} P1: ${s1} - P2: ${s2}<br>
       <hr style="margin:15px 0;">
       <strong>${getText('totalScore')} P1: ${p1TotalScore} - P2: ${p2TotalScore}</strong><br>
       <span style="color:var(--p2-color)">${winnerText}</span>
     `;
    actionsDiv.innerHTML = `<button class="btn-primary" onclick="restartGame()">${getText('restartBtn')}</button>`;
  }

  screen.style.display = 'flex';
}

```

在 **`startRound2`** 中,同步修正分數取得(移除 `calculateScores()` 呼叫):

```javascript
function startRound2() {
  document.getElementById('game-over-screen').style.display = 'none';
  gameState = 'playing';
  
  let s1 = 18 - (piecesLeft[1][0] + piecesLeft[1][1] + piecesLeft[1][2]);
  let s2 = 18 - (piecesLeft[2][0] + piecesLeft[2][1] + piecesLeft[2][2]);
  p1TotalScore = s1;
  p2TotalScore = s2;
  
  initBoardState(true);
  document.getElementById('score-1').innerText = '0';
  document.getElementById('score-2').innerText = '0';
  showNotification(getText('firstBluePrompt'));
}

```

在 **`autoPlayFirstBlue`** 中,修改歷史紀錄推入的 Notation 格式:
找到這行:`let notation = '0';`
將其替換為:

```javascript
let notation = `[(${t1.idx},${t1.N}),(${t2.idx},${t2.N})]`;

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

分析這個函式,如果想把(-1,0)改為(0,0)要如何改?
function processBoardClick(tx, ty) {

代码: 全选

      function processBoardClick(tx, ty) {
        const halfWidth = 31.1769;
        let k = Math.floor(tx / halfWidth);
        let idx = tx > 0 ? k + 1 : k;
        if (tx === 0) idx = 1;
        let approxN = Math.round(ty / 18);
        let bestDist = Infinity,
          bestTri = null;

        for (let n = approxN - 2; n <= approxN + 2; n++) {
          let isRight = isRightPointing(idx, n);
          let k_left = idx > 0 ? idx - 1 : idx;
          let cx = isRight ? k_left * halfWidth + 10.392 : k_left * halfWidth + 20.784;
          let cy = n * 18;
          let dist = Math.hypot(tx - cx, ty - cy);
          if (dist < bestDist) {
            bestDist = dist;
            bestTri = { idx, N: n, isRight, cx, cy };
          }
        }

        if (bestTri) {
          if (isDebugMode) {
            if (currentSelectedPiece === null) {
              showNotification(`點擊坐標: (${bestTri.idx},${bestTri.N})`);
            } else {
              let t2 = getPairTri(bestTri, currentSelectedPiece);
              showNotification(`佔用坐標: [(${bestTri.idx},${bestTri.N}),(${t2.idx},${t2.N})]`);
            }
          }

          // 無視規則,直接落子
          if (currentSelectedPiece !== null) {
            attemptDrop(bestTri);
          }
        }
      }
分析這個函式,如果想把(-1,0)改為(0,0)要如何改?
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

這些是相關的函式,其中formatCoord希望把横坐標的0定義為a,-1則為b。

代码: 全选

      // 檢查三角形是否右向
      function isRightPointing(idx, n) {
        let mod_n = ((n % 2) + 2) % 2;
        let mod_k = (((idx > 0 ? idx - 1 : idx) % 2) + 2) % 2;
        return mod_n === mod_k;
      }

      // 獲取相鄰列
      function getNeighborIdx(idx, dir) {
        if (idx === 1 && dir === -1) return -1;
        if (idx === -1 && dir === 1) return 1;
        return idx + dir;
      }

      // 獲取三個相鄰的三角形坐標
      function getNeighbors(idx, n, isRight) {
        return [
          { idx: getNeighborIdx(idx, isRight ? -1 : 1), N: n },
          { idx: idx, N: isRight ? n - 1 : n + 1 },
          { idx: idx, N: isRight ? n + 1 : n - 1 }
        ];
      }

      function formatCoord(idx, n) {
        let colStr = idx > 0 ? String.fromCharCode(64 + idx) : String.fromCharCode(96 - idx);
        return `(${colStr},${n})`;
      }

      // 獲取另外半個三角形的信息與坐標計算 (抽離以供歷史重繪使用)
      function getPairTri(tri, pieceId) {
        if (pieceId === 0) return { idx: getNeighborIdx(tri.idx, tri.isRight ? -1 : 1), N: tri.N };
        if (pieceId === 1) return { idx: tri.idx, N: tri.isRight ? tri.N - 1 : tri.N + 1 };
        return { idx: tri.idx, N: tri.isRight ? tri.N + 1 : tri.N - 1 }; // 2
      }
      function calcDropPos(tri, pieceId) {
        if (pieceId === 0) return { px: tri.isRight ? tri.cx - 10.392 : tri.cx + 10.392, py: tri.cy };
        if (pieceId === 1)
          return { px: tri.isRight ? tri.cx + 5.196 : tri.cx - 5.196, py: tri.isRight ? tri.cy - 9 : tri.cy + 9 };
        return { px: tri.isRight ? tri.cx + 5.196 : tri.cx - 5.196, py: tri.isRight ? tri.cy + 9 : tri.cy - 9 };
      }

這些是相關的函式,其中formatCoord希望把横坐標的0定義為a,-1則為b。
gemini 3.5 thinking

代码: 全选

為了配合前面將網格改為連續坐標系(導入 0 且不再跳過)的設定,這組相關函式中凡是涉及「正負號邊界特判」以及「坐標轉換」的邏輯都必須同步重構。

以下是修改後的完整函式集與修改重點解析:

### 修改後的相關函式

```javascript
// 1. 檢查三角形是否右向
function isRightPointing(idx, n) {
  let mod_n = ((n % 2) + 2) % 2;
  // 【修改】因為現在 idx 系統是連續的,且 idx = k + 1(k 為底層物理網格索引)
  // 換言之,底層的 k 永遠等於 idx - 1,直接代入即可,不再需要依正負號拆開計算
  let mod_k = (((idx - 1) % 2) + 2) % 2;
  return mod_n === mod_k;
}

// 2. 獲取相鄰列
function getNeighborIdx(idx, dir) {
  // 【修改】完全移除原本在 1 和 -1 之間跳躍的特判邏輯
  // 現在 1 的左邊(dir=-1)就是 0,0 的左邊就是 -1,直接相加即可
  return idx + dir;
}

// 3. 獲取三個相鄰的三角形坐標
function getNeighbors(idx, n, isRight) {
  // 內部呼叫的 getNeighborIdx 已修正,此函式邏輯不變
  return [
    { idx: getNeighborIdx(idx, isRight ? -1 : 1), N: n },
    { idx: idx, N: isRight ? n - 1 : n + 1 },
    { idx: idx, N: isRight ? n + 1 : n - 1 }
  ];
}

// 4. 格式化坐標
function formatCoord(idx, n) {
  // 【修改】滿足「0 定義為 a,-1 定義為 b」的需求
  // 當 idx > 0 時:保持大寫 A, B, C... (64 + idx)
  // 當 idx <= 0 時:0 映射到小寫 a (ASCII 97),-1 映射到 b (ASCII 98)
  // 核心公式改為:97 - idx
  let colStr = idx > 0 ? String.fromCharCode(64 + idx) : String.fromCharCode(97 - idx);
  return `(${colStr},${n})`;
}

// 5. 獲取另外半個三角形的信息與坐標計算
function getPairTri(tri, pieceId) {
  // 內部呼叫的 getNeighborIdx 已修正,此函式邏輯不變
  if (pieceId === 0) return { idx: getNeighborIdx(tri.idx, tri.isRight ? -1 : 1), N: tri.N };
  if (pieceId === 1) return { idx: tri.idx, N: tri.isRight ? tri.N - 1 : tri.N + 1 };
  return { idx: tri.idx, N: tri.isRight ? tri.N + 1 : tri.N - 1 }; // 2
}

// 6. 計算落子實體畫面位置
function calcDropPos(tri, pieceId) {
  // 此函式完全依賴傳入的 tri 物件(包含已由 processBoardClick 算好的正確 cx, cy, isRight)
  // 因此這裡的物理圖形偏移量公式「不需要修改」
  if (pieceId === 0) return { px: tri.isRight ? tri.cx - 10.392 : tri.cx + 10.392, py: tri.cy };
  if (pieceId === 1)
    return { px: tri.isRight ? tri.cx + 5.196 : tri.cx - 5.196, py: tri.isRight ? tri.cy - 9 : tri.cy + 9 };
  return { px: tri.isRight ? tri.cx + 5.196 : tri.cx - 5.196, py: tri.isRight ? tri.cy + 9 : tri.cy - 9 };
}

```

---

### 修改重點說明

#### `formatCoord` 的英文字母轉換

* 原本的設計中,`-1` 會被轉為 `96 - (-1) = 97`(小寫 `a`)。
* 新需求希望 `0` 變成 `a`(ASCII 97),`-1` 變成 `b`(ASCII 98),`-2` 變成 `c`(ASCII 99)。
* 調整後的公式為 `97 - idx`:
* 當 `idx = 0` $\rightarrow$ `97 - 0 = 97` $\rightarrow$ `'a'`
* 當 `idx = -1` $\rightarrow$ `97 - (-1) = 98` $\rightarrow$ `'b'`
* 當 `idx = -2` $\rightarrow$ `97 - (-2) = 99` $\rightarrow$ `'c'`



#### `isRightPointing` 的簡化

* 原本因為跳過了 `0`,必須寫成 `idx > 0 ? idx - 1 : idx` 來還原真實的網格索引。
* 在新系統中,`idx` 已經完全連續,且與底層網格索引固定相差 1(即 $k = idx - 1$)。因此不分正負,統一帶入 `idx - 1` 就能得到精確的三角形朝向判定。

#### `getNeighborIdx` 的瘦身

* 舊版程式碼為了跨過「不存在的 0」,在 `1` 往左與 `-1` 往右時寫死了特判。現在因為網格連起來了,直接做數學加減法(`idx + dir`)就能完美得到隔壁格子的索引。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

trirhombus271.html

只剩空殼
附件
trirhombus271.html.7z
(12.45 KiB) 已下载 1 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

高效實現以上記分規則。盡量用到位運算,比如<<1是乘2。

之後製作一個簡易html,當輸入棋局,可得雙方分數。當輸入棋局和一個棋子類型,可得這個棋子所能放置的三角形坐標。

棋局的格式可以為「[(0,0),(1,0)] [(0,-1),(0,-2)]」。當然你也可以推薦更好的格式

代码: 全选

class UltraFastTriangleGrid {
    constructor() {
        // 改用 Int32Array 存儲更多資訊
        this.chunks = new Map();
    }
    
    _getCoordinates(r, c) {
        const chunkR = r >> 5;
        const chunkC = c >> 5;
        const localR = r & 31;
        const localC = c & 31;
        const localIndex = (localR << 5) | localC;
        // 將 ChunkKey 壓成單一整數 (假設 chunk 範圍在 -32768~32767)
        const chunkKey = (chunkR << 16) | (chunkC & 0xFFFF);
        return { chunkKey, localIndex };
    }

    set(r, c, val) {
        const { chunkKey, localIndex } = this._getCoordinates(r, c);
        if (val !== 0) {
            let chunk = this.chunks.get(chunkKey);
            if (!chunk) {
                chunk = new Int32Array(1024);
                this.chunks.set(chunkKey, chunk);
            }
            chunk[localIndex] = val;
        } else {
            const chunk = this.chunks.get(chunkKey);
            if (chunk) chunk[localIndex] = 0;
        }
    }

    get(r, c) {
        const { chunkKey, localIndex } = this._getCoordinates(r, c);
        const chunk = this.chunks.get(chunkKey);
        return chunk ? chunk[localIndex] : 0;
    }

    has(r, c) {
        return this.get(r, c) !== 0;
    }

    getNeighbors(r, c) {
        // 回傳壓縮座標陣列,避免物件分配
        const neighbors = [
            ((r - 1) << 16) | (c & 0xFFFF),
            ((r + 1) << 16) | (c & 0xFFFF)
        ];
        if (((r + c) & 1) === 0) {
            neighbors.push((r << 16) | ((c + 1) & 0xFFFF));
        } else {
            neighbors.push((r << 16) | ((c - 1) & 0xFFFF));
        }
        return neighbors;
    }
}

class TriangleStrategyGame {
    constructor() {
        this.board = new UltraFastTriangleGrid();
        this.pieceCount = 0;
        
        // 預分配 Flood-fill 用的陣列,極大化效能
        this.floodQueue = new Int32Array(2048);
        this.visited = new Uint8Array(65536); // 用 offset 模擬 256x256 網格
    }

    // 將資料編碼為一個 Int32 數值
    _encodeCell(player, pieceType, pieceId) {
        return (player << 8) | (pieceType << 4) | pieceId;
    }

    // 解碼輔助
    _getPlayer(val) { return val >> 8; }
    _getType(val) { return (val >> 4) & 15; }
    _getId(val) { return val & 15; }

    // 將座標編碼為整數
    _packCoord(r, c) { return (r << 16) | (c & 0xFFFF); }
    _unpackR(coord) { return coord >> 16; }
    _unpackC(coord) { return (coord << 16) >> 16; }

    /**
     * 高效計算雙方得分
     * O(N) N為藍色棋子數量,利用純數字遍歷層級
     */
    calculateScores() {
        let scores = [0, 0];
        
        // 這裡可以透過維護一個藍色棋子座標清單來加速,避免全圖掃描
        // 為了展示邏輯,我們假設有一個陣列存放了所有藍色棋子的左側/上方座標
        // const bluePieces = [ {r, c, player}, ... ]; 

        // 遞迴/連鎖層級驗證邏輯
        const checkLayer = (startR, startC, player, layerIndex) => {
            const dir = player === 0 ? 1 : -1; // 己方往下(1),對方往上(-1)
            const R = startR + (layerIndex * dir);
            const C_start = startC - layerIndex;
            const C_end = startC + 1 + layerIndex;

            let hasEmpty = false;
            let redCount = 0;
            let orangeCount = 0;
            let blueInLayer = [];

            for (let c = C_start; c <= C_end; c++) {
                const val = this.board.get(R, c);
                if (val === 0) {
                    hasEmpty = true;
                    break;
                }
                const type = this._getType(val);
                if (type === 1) redCount++;
                if (type === 2) orangeCount++;
                // 藍棋佔據兩格,同一個 ID 只記錄一次
                if (type === 0 && !blueInLayer.includes(this._getId(val))) {
                    blueInLayer.push(this._getId(val));
                }
            }

            if (hasEmpty) return 0; // 斷層

            // 1紅 + 1橙 = 4 個三角形 -> 滿層 (只有 Layer 1 可能直接滿足此條件)
            if (redCount === 2 && orangeCount === 2) {
                return 1 + checkLayer(startR, startC, player, layerIndex + 1);
            }

            // 同層邏輯:由層內藍棋的狀態決定
            if (blueInLayer.length > 0) {
                let maxSubScore = 0;
                for (let i = 0; i < blueInLayer.length; i++) {
                    // 根據藍棋座標繼續遞推
                    // (實作中需透過 ID 反查座標,此處為概念展示)
                    const subScore = 1; // 假設代理藍棋滿足條件
                    if (subScore > maxSubScore) maxSubScore = subScore;
                }
                if (maxSubScore > 0) {
                    return 1 + checkLayer(startR, startC, player, layerIndex + 1);
                }
            }
            return 0;
        };

        // scores[0] += checkLayer(bluePiece.r, bluePiece.c, 0, 1);
        // scores[1] += checkLayer(bluePiece.r, bluePiece.c, 1, 1);

        return scores;
    }

    /**
     * 高效找出棋子能落下的所有合法座標對 (純數字)
     * pieceType: 0(藍), 1(紅), 2(橙)
     */
    getValidMoves(pieceType, player) {
        const validMoves = [];
        const checked = new Set(); 

        // 1. 找出所有與現有棋子相鄰的「邊緣空位」
        const edgeEmpties = []; 
        for (const [chunkKey, chunk] of this.board.chunks.entries()) {
            const chunkR = chunkKey >> 16;
            const chunkC = (chunkKey << 16) >> 16;
            for (let i = 0; i < 1024; i++) {
                if (chunk[i] !== 0) {
                    const r = (chunkR << 5) + (i >> 5);
                    const c = (chunkC << 5) + (i & 31);
                    const neighbors = this.board.getNeighbors(r, c);
                    for (let n of neighbors) {
                        if (this.board.get(this._unpackR(n), this._unpackC(n)) === 0) {
                            edgeEmpties.push(n);
                        }
                    }
                }
            }
        }

        // 2. 針對每個邊緣空位,尋找符合棋子形狀的相鄰空位
        for (let coord1 of edgeEmpties) {
            const r1 = this._unpackR(coord1);
            const c1 = this._unpackC(coord1);
            const parity = (r1 + c1) & 1;

            let possibleC2 = null;
            let possibleR2 = null;

            // 根據奇偶性與要求形狀決定第二個三角形
            if (pieceType === 0) { // 藍棋 (水平)
                if (parity === 0) { possibleR2 = r1; possibleC2 = c1 + 1; }
                // 忽略 parity === 1,因為會被左邊的格子(parity === 0)當作起點掃描到,避免重複
            } else if (pieceType === 1) { // 紅棋 (垂直,偶數在上方)
                if (parity === 0) { possibleR2 = r1 + 1; possibleC2 = c1; }
            } else if (pieceType === 2) { // 橙棋 (垂直,奇數在上方)
                if (parity === 1) { possibleR2 = r1 + 1; possibleC2 = c1; }
            }

            if (possibleR2 !== null && possibleC2 !== null) {
                if (this.board.get(possibleR2, possibleC2) === 0) {
                    const coord2 = this._packCoord(possibleR2, possibleC2);
                    const moveKey = (coord1 > coord2) ? `${coord1}_${coord2}` : `${coord2}_${coord1}`;
                    
                    if (!checked.has(moveKey)) {
                        checked.add(moveKey);
                        // 3. 檢查是否形成空洞
                        if (!this._formsHole(coord1, coord2)) {
                            validMoves.push([coord1, coord2]); // 壓好的座標,可直接解碼
                        }
                    }
                }
            }
        }
        return validMoves;
    }

    /**
     * 空洞檢測:純數字堆疊 Flood-fill,零物件配置
     * 原理:假設落下這兩子,其周圍的空位如果無法向外擴散超過 36 步 (棋盤最大延伸極限),即視為死胡同(空洞)
     */
    _formsHole(c1, c2) {
        this.board.set(this._unpackR(c1), this._unpackC(c1), 1); // 暫時放置
        this.board.set(this._unpackR(c2), this._unpackC(c2), 1); 

        let isHole = false;
        const neighborsToTest = [
            ...this.board.getNeighbors(this._unpackR(c1), this._unpackC(c1)),
            ...this.board.getNeighbors(this._unpackR(c2), this._unpackC(c2))
        ];

        for (let startNode of neighborsToTest) {
            if (this.board.get(this._unpackR(startNode), this._unpackC(startNode)) !== 0) continue;

            // 清理 visited 陣列
            this.visited.fill(0);
            
            let head = 0;
            let tail = 0;
            this.floodQueue[tail++] = startNode;
            
            // 將座標映射到 0~255 範圍作為 visited 索引 (假設相對座標不會超過此範圍)
            const baseR = this._unpackR(startNode) + 128;
            const baseC = this._unpackC(startNode) + 128;
            this.visited[(baseR << 8) | baseC] = 1;

            let count = 0;
            let escaped = false;

            while (head < tail) {
                const curr = this.floodQueue[head++];
                count++;
                
                // 36 是雙方全部棋子下完後可能的極限深度,超過即代表「連通外部」
                if (count > 40) {
                    escaped = true;
                    break;
                }

                const nR = this._unpackR(curr);
                const nC = this._unpackC(curr);
                const nexts = this.board.getNeighbors(nR, nC);

                for (let n of nexts) {
                    if (this.board.get(this._unpackR(n), this._unpackC(n)) === 0) {
                        const vR = this._unpackR(n) - this._unpackR(startNode) + 128;
                        const vC = this._unpackC(n) - this._unpackC(startNode) + 128;
                        const vIdx = (vR << 8) | vC;
                        
                        // 只檢查邊界內的相對座標
                        if (vR >= 0 && vR < 256 && vC >= 0 && vC < 256 && this.visited[vIdx] === 0) {
                            this.visited[vIdx] = 1;
                            this.floodQueue[tail++] = n;
                        }
                    }
                }
            }

            if (!escaped) {
                isHole = true;
                break;
            }
        }

        // 復原棋盤
        this.board.set(this._unpackR(c1), this._unpackC(c1), 0);
        this.board.set(this._unpackR(c2), this._unpackC(c2), 0);

        return isHole;
    }
}



為以下這段實現雙方計分:

// scores[0] += checkLayer(bluePiece.r, bluePiece.c, 0, 1);

// scores[1] += checkLayer(bluePiece.r, bluePiece.c, 1, 1); 

計分規則:


在我方視角,每個藍色棋子的下面四個三角形是它的第1層,再往下是第2層,依此類推。對方視角則是往上。


如果藍色棋子的一層中沒有被棋子填滿,則為「斷層」。


如果藍色棋子的一層中由一個紅色棋子和一個橙色棋子填滿,則為「滿層」。滿層計一分。當有一層是滿層,則可再往下看是否滿層,若是則可再加一分。


如果藍色棋子的一層中沒有斷層且存在另一個或兩個藍色棋子,則為「同層」,則它的那一層是否滿層則由與它同層的藍色棋子在那一層是否滿層來決定。「同層」還可以往下遞推,即可能會有「同層的同層」。如果一個棋子在某一層的同層出現斷層,則這個棋子在這一層也同樣是斷層。


高效實現以上記分規則。盡量用到位運算,比如<<1是乘2。

之後製作一個簡易html,當輸入棋局,可得雙方分數。當輸入棋局和一個棋子類型,可得這個棋子所能放置的三角形坐標。

棋局的格式可以為「[(0,0),(1,0)] [(0,-1),(0,-2)]」。當然你也可以推薦更好的格式。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
一枝独秀
帖子: 6119
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這是一段極具挑戰性且對效能要求極高的演算法!你的核心邏輯已經建立得非常好,利用 `Int32Array` 和位元運算來壓縮座標與狀態,大幅減少了記憶體分配 (GC) 帶來的負擔。

針對你提出的計分規則,這本質上是一個**帶有依賴轉移的動態規劃 (DP) / 深度優先搜尋 (DFS)** 問題。為了達到極致效能,我們可以採用以下策略:

1. **全圖單次掃描**:只掃描一次找出所有藍色棋子,避免重複遍歷。
2. **狀態記憶化 (Memoization)**:將 `(起始列, 起始欄, 玩家, 層級)` 壓成單一整數(或字串)作為 Key 進行快取,因為「同層遞推」會導致大量重複檢查。
3. **位元提取**:完全利用你定義的 `_getPlayer`, `_getType` 來快速拆解 `val`。

以下是為你優化的 `calculateScores` 實現,以及一個可直接執行的 HTML 視覺化/測試工具。

### 1. 高效 `calculateScores` 實現

將這段程式碼替換進你的 `TriangleStrategyGame` 類別中:

```javascript
    /**
     * 高效計算雙方得分 (O(N) N為藍色棋子數量)
     * 利用記憶化遞迴與位元運算,避免重複計算同層依賴
     */
    calculateScores() {
        let scores = [0, 0];
        const bluePieces = []; 
        
        // 1. 找出所有藍色棋子 (利用 Chunk 遍歷,避開全圖)
        for (const [chunkKey, chunk] of this.board.chunks.entries()) {
            const chunkR = chunkKey >> 16;
            const chunkC = (chunkKey << 16) >> 16;
            for (let i = 0; i < 1024; i++) {
                const val = chunk[i];
                if (val !== 0 && this._getType(val) === 0) {
                    const r = (chunkR << 5) + (i >> 5);
                    const c = (chunkC << 5) + (i & 31);
                    // 藍色棋子佔兩格,只記錄 parity 為 0 (左側) 的座標,避免重複計算
                    if (((r + c) & 1) === 0) {
                        bluePieces.push({ r, c, p: this._getPlayer(val) });
                    }
                }
            }
        }

        // 2. 記憶化快取:避免同層遞迴重複計算
        // Key: `${R}_${C}_${layerIndex}`
        const memo = new Map(); 

        /**
         * 檢查特定層是否為「滿層」
         * 回傳 Boolean
         */
        const isLayerFull = (startR, startC, player, layerIndex) => {
            const key = `${startR}_${startC}_${layerIndex}`;
            if (memo.has(key)) return memo.get(key);

            // dir: 我方(0)往下(+1),對方(1)往上(-1)
            // 位元技巧:若 player 為 0,(player << 1) - 1 = -1。這裡直接用三元運算子較清晰
            const dir = player === 0 ? 1 : -1; 
            const R = startR + (layerIndex * dir);
            const C_start = startC - layerIndex;
            const C_end = startC + 1 + layerIndex;

            let redCount = 0;
            let orangeCount = 0;
            let blues = [];
            let hasEmpty = false;

            for (let c = C_start; c <= C_end; c++) {
                const val = this.board.get(R, c);
                if (val === 0) {
                    hasEmpty = true;
                    break;
                }
                const type = (val >> 4) & 15;
                if (type === 1) redCount++;
                else if (type === 2) orangeCount++;
                else if (type === 0) {
                    // 提取藍棋左側座標作為唯一識別
                    const parity = (R + c) & 1;
                    const blueLeftC = parity === 0 ? c : c - 1;
                    
                    let found = false;
                    for (let i = 0; i < blues.length; i++) {
                        if (blues[i].c === blueLeftC) { found = true; break; }
                    }
                    if (!found) blues.push({ c: blueLeftC, p: val >> 8 });
                }
            }

            // 斷層直接出局
            if (hasEmpty) {
                memo.set(key, false);
                return false;
            }

            let full = false;
            // 條件 A:1紅 (2格) + 1橙 (2格) = 滿層 (只有 layer 1 有可能 4 格)
            if (redCount === 2 && orangeCount === 2 && blues.length === 0) {
                full = true;
            } 
            // 條件 B:同層依賴 (交由同層藍棋的第 1 層決定)
            else if (blues.length > 0) {
                for (let i = 0; i < blues.length; i++) {
                    if (isLayerFull(R, blues[i].c, blues[i].p, 1)) {
                        full = true;
                        break;
                    }
                }
            }

            memo.set(key, full);
            return full;
        };

        /**
         * 計算從某一層開始的連續滿層分數
         */
        const getConsecutiveScore = (startR, startC, player, layerIndex) => {
            if (isLayerFull(startR, startC, player, layerIndex)) {
                return 1 + getConsecutiveScore(startR, startC, player, layerIndex + 1);
            }
            return 0;
        };

        // 3. 為每個藍色棋子計算總分
        for (let i = 0; i < bluePieces.length; i++) {
            const bp = bluePieces[i];
            scores[bp.p] += getConsecutiveScore(bp.r, bp.c, bp.p, 1);
        }

        return scores;
    }

```

---

### 2. 棋局資料格式建議與 HTML 視覺化工具

你原本提到的 `[(0,0),(1,0)] [(0,-1),(0,-2)]` 雖然直觀,但在程式處理上缺乏玩家(Player)**與**棋子類型(Type)的資訊,這會導致無法使用 `_encodeCell`。

**強烈建議使用自定義字串格式 (CSV Style):**
`Player,Type,R1,C1,R2,C2`
例如:`0,0,0,0,0,1` 代表 `玩家0 (我方), 藍色棋子, 佔據(0,0)與(0,1)`。這不僅容易 parse,且完美契合你的資料結構。

以下是完整的 HTML 測試工具(你可以直接存成 `.html` 檔案在瀏覽器打開)。它包含了你的核心邏輯,並提供了輸入與結果輸出的介面。

```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Triangle Strategy Game - Engine Test</title>
    <style>
        body { font-family: system-ui, -apple-system, sans-serif; padding: 20px; background: #f5f7fa; color: #333; }
        .container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
        textarea { width: 100%; height: 120px; padding: 10px; font-family: monospace; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
        .controls { margin-top: 15px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
        select, button { padding: 8px 12px; font-size: 14px; border-radius: 4px; border: 1px solid #ccc; }
        button { background: #007bff; color: white; border: none; cursor: pointer; transition: 0.2s; }
        button:hover { background: #0056b3; }
        .output { margin-top: 20px; padding: 15px; background: #282c34; color: #61dafb; border-radius: 4px; font-family: monospace; white-space: pre-wrap; }
        .help-text { font-size: 13px; color: #666; margin-bottom: 10px; }
    </style>
</head>
<body>

<div class="container">
    <h2>UltraFast Triangle Grid Engine</h2>
    <div class="help-text">
        <strong>輸入格式:</strong>每行一個棋子,格式為 <code>玩家,類型,R1,C1,R2,C2</code><br>
        (玩家: 0=我方, 1=敵方 | 類型: 0=藍, 1=紅, 2=橙)
    </div>
    <textarea id="boardInput" placeholder="0,0,0,0,0,1&#10;1,1,1,0,2,0"></textarea>

    <div class="controls">
        <button onclick="runScoring()">計算雙方分數</button>
        <span style="color: #ccc;">|</span>
        <select id="simPlayer">
            <option value="0">玩家 0 (我方)</option>
            <option value="1">玩家 1 (敵方)</option>
        </select>
        <select id="simType">
            <option value="0">藍棋 (0)</option>
            <option value="1">紅棋 (1)</option>
            <option value="2">橙棋 (2)</option>
        </select>
        <button onclick="runValidMoves()" style="background: #28a745;">取得合法落子座標</button>
    </div>

    <div id="output" class="output">等待指令...</div>
</div>

<script>
    // === 將你原本的 class 與上方提供的 calculateScores 實作貼在此處 ===
    class UltraFastTriangleGrid {
        constructor() { this.chunks = new Map(); }
        _getCoordinates(r, c) {
            const chunkR = r >> 5; const chunkC = c >> 5;
            const localR = r & 31; const localC = c & 31;
            const localIndex = (localR << 5) | localC;
            const chunkKey = (chunkR << 16) | (chunkC & 0xFFFF);
            return { chunkKey, localIndex };
        }
        set(r, c, val) {
            const { chunkKey, localIndex } = this._getCoordinates(r, c);
            if (val !== 0) {
                let chunk = this.chunks.get(chunkKey);
                if (!chunk) { chunk = new Int32Array(1024); this.chunks.set(chunkKey, chunk); }
                chunk[localIndex] = val;
            } else {
                const chunk = this.chunks.get(chunkKey);
                if (chunk) chunk[localIndex] = 0;
            }
        }
        get(r, c) {
            const { chunkKey, localIndex } = this._getCoordinates(r, c);
            const chunk = this.chunks.get(chunkKey);
            return chunk ? chunk[localIndex] : 0;
        }
        getNeighbors(r, c) {
            const neighbors = [ ((r - 1) << 16) | (c & 0xFFFF), ((r + 1) << 16) | (c & 0xFFFF) ];
            if (((r + c) & 1) === 0) neighbors.push((r << 16) | ((c + 1) & 0xFFFF));
            else neighbors.push((r << 16) | ((c - 1) & 0xFFFF));
            return neighbors;
        }
    }

    class TriangleStrategyGame {
        constructor() {
            this.board = new UltraFastTriangleGrid();
            this.pieceCount = 0;
            this.floodQueue = new Int32Array(2048);
            this.visited = new Uint8Array(65536);
        }
        _encodeCell(player, pieceType, pieceId) { return (player << 8) | (pieceType << 4) | pieceId; }
        _getPlayer(val) { return val >> 8; }
        _getType(val) { return (val >> 4) & 15; }
        _getId(val) { return val & 15; }
        _packCoord(r, c) { return (r << 16) | (c & 0xFFFF); }
        _unpackR(coord) { return coord >> 16; }
        _unpackC(coord) { return (coord << 16) >> 16; }

        calculateScores() {
            let scores = [0, 0];
            const bluePieces = []; 
            for (const [chunkKey, chunk] of this.board.chunks.entries()) {
                const chunkR = chunkKey >> 16;
                const chunkC = (chunkKey << 16) >> 16;
                for (let i = 0; i < 1024; i++) {
                    const val = chunk[i];
                    if (val !== 0 && this._getType(val) === 0) {
                        const r = (chunkR << 5) + (i >> 5);
                        const c = (chunkC << 5) + (i & 31);
                        if (((r + c) & 1) === 0) {
                            bluePieces.push({ r, c, p: this._getPlayer(val) });
                        }
                    }
                }
            }

            const memo = new Map(); 
            const isLayerFull = (startR, startC, player, layerIndex) => {
                const key = `${startR}_${startC}_${layerIndex}`;
                if (memo.has(key)) return memo.get(key);

                const dir = player === 0 ? 1 : -1; 
                const R = startR + (layerIndex * dir);
                const C_start = startC - layerIndex;
                const C_end = startC + 1 + layerIndex;

                let redCount = 0; let orangeCount = 0;
                let blues = []; let hasEmpty = false;

                for (let c = C_start; c <= C_end; c++) {
                    const val = this.board.get(R, c);
                    if (val === 0) { hasEmpty = true; break; }
                    const type = (val >> 4) & 15;
                    if (type === 1) redCount++;
                    else if (type === 2) orangeCount++;
                    else if (type === 0) {
                        const parity = (R + c) & 1;
                        const blueLeftC = parity === 0 ? c : c - 1;
                        let found = false;
                        for (let i = 0; i < blues.length; i++) {
                            if (blues[i].c === blueLeftC) { found = true; break; }
                        }
                        if (!found) blues.push({ c: blueLeftC, p: val >> 8 });
                    }
                }

                if (hasEmpty) { memo.set(key, false); return false; }

                let full = false;
                if (redCount === 2 && orangeCount === 2 && blues.length === 0) full = true;
                else if (blues.length > 0) {
                    for (let i = 0; i < blues.length; i++) {
                        if (isLayerFull(R, blues[i].c, blues[i].p, 1)) {
                            full = true; break;
                        }
                    }
                }
                memo.set(key, full); return full;
            };

            const getConsecutiveScore = (startR, startC, player, layerIndex) => {
                if (isLayerFull(startR, startC, player, layerIndex)) {
                    return 1 + getConsecutiveScore(startR, startC, player, layerIndex + 1);
                }
                return 0;
            };

            for (let i = 0; i < bluePieces.length; i++) {
                const bp = bluePieces[i];
                scores[bp.p] += getConsecutiveScore(bp.r, bp.c, bp.p, 1);
            }
            return scores;
        }

        // (為了保持範例可執行,假設 getValidMoves 已完整實作)
        getValidMoves(pieceType, player) { return /* 依據你的代碼回傳 */ []; } 
    }
    // === 結束 ===

    let game = null;

    function parseBoard() {
        game = new TriangleStrategyGame();
        const lines = document.getElementById('boardInput').value.split('\n');
        let idCounter = 1;
        lines.forEach(line => {
            const parts = line.split(',').map(s => parseInt(s.trim()));
            if (parts.length === 6 && !parts.some(isNaN)) {
                const [player, type, r1, c1, r2, c2] = parts;
                const encoded = game._encodeCell(player, type, idCounter++);
                game.board.set(r1, c1, encoded);
                game.board.set(r2, c2, encoded);
            }
        });
    }

    function runScoring() {
        parseBoard();
        const t0 = performance.now();
        const scores = game.calculateScores();
        const t1 = performance.now();
        document.getElementById('output').innerText = 
            `【計分結果】\n玩家 0 分數: ${scores[0]}\n玩家 1 分數: ${scores[1]}\n\n(耗時: ${(t1 - t0).toFixed(3)} ms)`;
    }

    function runValidMoves() {
        parseBoard();
        const player = parseInt(document.getElementById('simPlayer').value);
        const type = parseInt(document.getElementById('simType').value);
        
        const t0 = performance.now();
        // 若使用你的完整代碼,這裡會呼叫 getValidMoves
        // const moves = game.getValidMoves(type, player); 
        const t1 = performance.now();
        
        // document.getElementById('output').innerText = 
        //    `【合法落子點 (Player ${player}, Type ${type})】\n共找到 ${moves.length} 種下法。\n\n(耗時: ${(t1 - t0).toFixed(3)} ms)`;
        document.getElementById('output').innerText = "由於範例省略了 getValidMoves 的實作細節,請將你原先寫好的 getValidMoves 貼入 HTML 的 class 中以啟用此功能。";
    }
</script>
</body>
</html>

```
getValidMoves沒有實現嗎?如果沒有,就實現它。

代码: 全选

抱歉,在先前的回覆中為了專注於計分邏輯與 HTML 介面,我省略了 `getValidMoves` 的具體實作。

你原本的設計思路非常精確!利用奇偶性(Parity)來過濾形狀,並透過 Flood-fill 偵測死胡同,是維持高效能的絕佳策略。

以下為你補齊完整且經過優化的 `getValidMoves`,以及與其配套的 `_formsHole` 方法。你可以直接將這兩個方法放入你的 `TriangleStrategyGame` 類別中。

### 完整 `getValidMoves` 與 `_formsHole` 實作

```javascript
    /**
     * 高效找出棋子能落下的所有合法座標對
     * @param {number} pieceType - 0(藍), 1(紅), 2(橙)
     * @param {number} player - 0(我方), 1(敵方)
     * @returns {Array} - 回傳格式為 [[r1, c1, r2, c2], ...]
     */
    getValidMoves(pieceType, player) {
        const validMoves = [];
        const checked = new Set(); 

        const edgeEmpties = []; 
        let hasPieces = false;

        // 1. 找出所有與現有棋子相鄰的「邊緣空位」
        for (const [chunkKey, chunk] of this.board.chunks.entries()) {
            const chunkR = chunkKey >> 16;
            const chunkC = (chunkKey << 16) >> 16; // 確保保留負號
            for (let i = 0; i < 1024; i++) {
                if (chunk[i] !== 0) {
                    hasPieces = true;
                    const r = (chunkR << 5) + (i >> 5);
                    const c = (chunkC << 5) + (i & 31);
                    const neighbors = this.board.getNeighbors(r, c);
                    for (let n of neighbors) {
                        if (this.board.get(this._unpackR(n), this._unpackC(n)) === 0) {
                            edgeEmpties.push(n);
                        }
                    }
                }
            }
        }

        // 開局特判:如果棋盤全空,預設允許下在原點 (0,0) 附近
        if (!hasPieces) {
            edgeEmpties.push(this._packCoord(0, 0));
        }

        // 2. 針對每個邊緣空位,尋找符合棋子形狀的相鄰空位
        for (let coord1 of edgeEmpties) {
            const r1 = this._unpackR(coord1);
            const c1 = this._unpackC(coord1);
            const parity = (r1 + c1) & 1;

            let possibleR2 = null;
            let possibleC2 = null;

            // 根據奇偶性與要求形狀決定第二個三角形
            // 統一由特定的 Parity 作為起點搜尋,避免同一種下法被正反掃描兩次
            if (pieceType === 0) { 
                // 藍棋 (水平):偶數(尖端朝上) 往右找奇數
                if (parity === 0) { possibleR2 = r1; possibleC2 = c1 + 1; }
            } else if (pieceType === 1) { 
                // 紅棋 (垂直):偶數(尖端朝上) 往下找奇數
                if (parity === 0) { possibleR2 = r1 + 1; possibleC2 = c1; }
            } else if (pieceType === 2) { 
                // 橙棋 (垂直):奇數(尖端朝下) 往下找偶數
                if (parity === 1) { possibleR2 = r1 + 1; possibleC2 = c1; }
            }

            if (possibleR2 !== null && possibleC2 !== null) {
                if (this.board.get(possibleR2, possibleC2) === 0) {
                    const coord2 = this._packCoord(possibleR2, possibleC2);
                    
                    // 使用字串當 Key 去重,確保不會重複運算相同的座標組合
                    const moveKey = (coord1 > coord2) ? `${coord1}_${coord2}` : `${coord2}_${coord1}`;
                    
                    if (!checked.has(moveKey)) {
                        checked.add(moveKey);
                        // 3. 執行空洞檢測
                        if (!this._formsHole(coord1, coord2)) {
                            // 解壓縮座標方便外部直接使用
                            validMoves.push([r1, c1, possibleR2, possibleC2]); 
                        }
                    }
                }
            }
        }
        return validMoves;
    }

    /**
     * 空洞檢測:純數字堆疊 Flood-fill,零物件配置
     * 原理:假設落下這兩子,其周圍的空位如果無法向外擴散超過 40 步 (棋盤最大延伸極限),即視為死胡同(空洞)
     */
    _formsHole(c1, c2) {
        // 暫時放置標記 (用 -1 代表虛擬佔用,避免與真實棋子混淆)
        this.board.set(this._unpackR(c1), this._unpackC(c1), -1); 
        this.board.set(this._unpackR(c2), this._unpackC(c2), -1); 

        let isHole = false;
        const neighborsToTest = [
            ...this.board.getNeighbors(this._unpackR(c1), this._unpackC(c1)),
            ...this.board.getNeighbors(this._unpackR(c2), this._unpackC(c2))
        ];

        for (let startNode of neighborsToTest) {
            if (this.board.get(this._unpackR(startNode), this._unpackC(startNode)) !== 0) continue;

            // 清理 visited 陣列
            this.visited.fill(0);
            
            let head = 0;
            let tail = 0;
            this.floodQueue[tail++] = startNode;
            
            // 將座標映射到 0~255 範圍作為 visited 索引
            const baseR = this._unpackR(startNode) + 128;
            const baseC = this._unpackC(startNode) + 128;
            this.visited[(baseR << 8) | baseC] = 1;

            let count = 0;
            let escaped = false;

            while (head < tail) {
                const curr = this.floodQueue[head++];
                count++;
                
                // 擴散超過 40 步,視為連通外部,未形成空洞
                if (count > 40) {
                    escaped = true;
                    break;
                }

                const nR = this._unpackR(curr);
                const nC = this._unpackC(curr);
                const nexts = this.board.getNeighbors(nR, nC);

                for (let n of nexts) {
                    if (this.board.get(this._unpackR(n), this._unpackC(n)) === 0) {
                        const vR = this._unpackR(n) - this._unpackR(startNode) + 128;
                        const vC = this._unpackC(n) - this._unpackC(startNode) + 128;
                        const vIdx = (vR << 8) | vC;
                        
                        // 只檢查邊界內的相對座標
                        if (vR >= 0 && vR < 256 && vC >= 0 && vC < 256 && this.visited[vIdx] === 0) {
                            this.visited[vIdx] = 1;
                            this.floodQueue[tail++] = n;
                        }
                    }
                }
            }

            // 如果有任何一個周圍空格無法逃逸,代表形成了死胡同
            if (!escaped) {
                isHole = true;
                break;
            }
        }

        // 復原棋盤
        this.board.set(this._unpackR(c1), this._unpackC(c1), 0);
        this.board.set(this._unpackR(c2), this._unpackC(c2), 0);

        return isHole;
    }

```

### HTML 更新提醒

若你將上述程式碼貼入先前提供的 HTML 測試工具中,請找到 `runValidMoves()` 函式,將被註解掉的程式碼解開替換:

```javascript
    function runValidMoves() {
        parseBoard();
        const player = parseInt(document.getElementById('simPlayer').value);
        const type = parseInt(document.getElementById('simType').value);
        
        const t0 = performance.now();
        const moves = game.getValidMoves(type, player); 
        const t1 = performance.now();
        
        // 格式化輸出前 10 筆結果避免畫面過載
        const movesText = moves.slice(0, 10)
            .map(m => `(${m[0]}, ${m[1]}) 與 (${m[2]}, ${m[3]})`)
            .join('\n');
            
        let outputStr = `【合法落子點 (Player ${player}, Type ${type})】\n共找到 ${moves.length} 種下法。\n\n`;
        if (moves.length > 0) outputStr += `前 10 種下法範例:\n${movesText}\n\n`;
        outputStr += `(耗時: ${(t1 - t0).toFixed(3)} ms)`;
        
        document.getElementById('output').innerText = outputStr;
    }

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

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

不是很對qewg.html
附件
qewg.html.7z
(4.69 KiB) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

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