分页: 6 / 7

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

发表于 : 2026年 6月 30日 07:44
ejsoon
gemini 3.1 pro

代码: 全选

這是一個非常精彩且高效的資料結構設計!你利用了 JavaScript 的 32 位元整數特性與全位元運算,將二維坐標降維映射並優化效能,完美避免了字串拼接帶來的效能瓶頸。

基於你的網格基礎,我將為你完整實作這套二人抽象策略遊戲的邏輯。為了貫徹你的「極致效能與無字串拼接」核心理念,我將所有 `Set` 與 `Map` 的鍵值(包括 BFS 走訪紀錄)都改用了 64 位元整數 (`BigInt`) 或 32 位元整數來封裝 $(x, y)$ 坐標,確保 O(1) 的極速存取。

以下是完整的類別實作,涵蓋了歷史回退、空洞判定、鄰近空位搜索、干涉判定與遞迴同層計分:

### 核心系統實作

```javascript
class HexGridGame {
    constructor() {
        // 使用 32 位元整數作為 chunkKey
        // chunkX 佔高 16 位,chunkY 佔低 16 位
        this.chunks = new Map();
        
        this.history = [];
        this.bluePieces = []; // 存儲藍色棋子資訊,用於快速檢索與同層遞推
        
        // 邊界框,用於快速空洞判定 (BFS 的限界)
        this.minX = Infinity; this.maxX = -Infinity;
        this.minY = Infinity; this.maxY = -Infinity;
        this.totalPieces = 0;
    }

    // --------------------------------------------------------
    // 核心坐標與位元運算
    // --------------------------------------------------------
    
    _getCoordinates(x, y) {
        const chunkX = x >> 5;
        const chunkY = y >> 5;
        const localX = x & 31;
        const localY = y & 31;
        
        const localIndex = (localY << 5) | localX;
        // 將 chunkX 與 chunkY 打包成 32 位元整數 (完全避免字串)
        const chunkKey = (chunkX << 16) | (chunkY & 0xFFFF);

        return { chunkKey, localIndex };
    }

    // 將 (x, y) 打包為 64 位元 BigInt,專供 Set/Map 高效記錄使用
    _packCoord(x, y) {
        return (BigInt(x) << 32n) | BigInt(y >>> 0);
    }

    // 將 BigInt 還原為 (x, y)
    _unpackCoord(packed) {
        const x = Number(packed >> 32n);
        const y = Number(packed & 0xFFFFFFFFn) | 0; // 強制轉為有號整數
        return { x, y };
    }

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

    set(x, y, value) {
        const { chunkKey, localIndex } = this._getCoordinates(x, y);

        if (value !== 0) {
            let chunk = this.chunks.get(chunkKey);
            if (!chunk) {
                chunk = new Uint8Array(1024);
                this.chunks.set(chunkKey, chunk);
            }
            chunk[localIndex] = value;
            
            // 動態更新邊界,加速空洞判定的限界
            if (x < this.minX) this.minX = x;
            if (x > this.maxX) this.maxX = x;
            if (y < this.minY) this.minY = y;
            if (y > this.maxY) this.maxY = y;
        } else {
            const chunk = this.chunks.get(chunkKey);
            if (chunk) chunk[localIndex] = 0;
        }
    }

    // 鄰居判定 (x為水平,y為垂直。y遞增為往下)
    getNeighbors(x, y) {
        const neighbors = [
            { x: x, y: y - 1 }, // 上
            { x: x, y: y + 1 }  // 下
        ];
        
        // (x + y) 偶數:三角形朝左,平整面在右
        // (x + y) 奇數:三角形朝右,平整面在左
        if (((x + y) & 1) === 0) {
            neighbors.push({ x: x + 1, y: y }); // 右
        } else {
            neighbors.push({ x: x - 1, y: y }); // 左
        }
        return neighbors;
    }

    // --------------------------------------------------------
    // 遊戲規則與操作
    // --------------------------------------------------------

    // 回退歷史
    undo() {
        if (this.history.length === 0) return false;
        const lastMove = this.history.pop();
        
        this.set(lastMove.p1.x, lastMove.p1.y, 0);
        this.set(lastMove.p2.x, lastMove.p2.y, 0);
        this.totalPieces--;

        if (lastMove.pieceType === 1) {
            this.bluePieces.pop(); // 移除最後一個藍色棋子
        }
        
        // 若要嚴謹應重算邊界,但實務上限界放寬不影響 BFS 正確性,故省略重算以保證 O(1)
        return true;
    }

    // 落子嘗試:判斷干涉、空洞,並返回預期得分
    // 如果合法則真正寫入棋盤並記錄歷史
    tryPlacePiece(pieceType, p1, p2) {
        // 1. 判斷干涉 (是否已經有棋子)
        if (this.get(p1.x, p1.y) !== 0 || this.get(p2.x, p2.y) !== 0) {
            return { success: false, reason: "干涉:該位置已有棋子" };
        }

        // 暫時寫入以判定空洞
        this.set(p1.x, p1.y, pieceType);
        this.set(p2.x, p2.y, pieceType);
        this.totalPieces++;

        // 2. 判斷是否形成空洞
        if (this.hasHoleAfterPlacement([p1, p2])) {
            // 還原狀態
            this.set(p1.x, p1.y, 0);
            this.set(p2.x, p2.y, 0);
            this.totalPieces--;
            return { success: false, reason: "違規:形成空洞" };
        }

        // 3. 落子成功,寫入歷史與藍色陣列
        this.history.push({ pieceType, p1, p2 });
        if (pieceType === 1) {
            // 將 x 較小者定為主坐標,確保一致性
            const mainP = p1.x < p2.x ? p1 : p2;
            this.bluePieces.push({ 
                x: mainP.x, 
                y: mainP.y, 
                sameLayerDown: null, 
                sameLayerUp: null 
            });
        }

        // 4. 計算落子後得分
        const scores = this.calculateScores();
        return { success: true, scores };
    }

    // --------------------------------------------------------
    // 空洞判定與空位搜索
    // --------------------------------------------------------

    // 快速判斷是否形成空洞 (利用極限邊界 BFS)
    hasHoleAfterPlacement(coords) {
        if (this.totalPieces <= 1) return false;

        let emptyNeighbors = [];
        for (let pt of coords) {
            for (let n of this.getNeighbors(pt.x, pt.y)) {
                if (this.get(n.x, n.y) === 0) {
                    emptyNeighbors.push(n);
                }
            }
        }

        // 設定檢測的外部邊界 (+1)
        const outMinX = this.minX - 1; const outMaxX = this.maxX + 1;
        const outMinY = this.minY - 1; const outMaxY = this.maxY + 1;

        for (let start of emptyNeighbors) {
            if (this.get(start.x, start.y) !== 0) continue;

            let q = [start];
            let visited = new Set();
            visited.add(this._packCoord(start.x, start.y));
            let reachedOutside = false;
            let head = 0;

            while (head < q.length) {
                let curr = q[head++];
                
                // 接觸到邊界框外側,表示這是一片連通的「無限」外部空間,不是空洞
                if (curr.x < outMinX || curr.x > outMaxX || curr.y < outMinY || curr.y > outMaxY) {
                    reachedOutside = true;
                    break;
                }

                for (let n of this.getNeighbors(curr.x, curr.y)) {
                    if (this.get(n.x, n.y) === 0) {
                        let key = this._packCoord(n.x, n.y);
                        if (!visited.has(key)) {
                            visited.add(key);
                            q.push(n);
                        }
                    }
                }
            }

            if (!reachedOutside) return true; // BFS被困住,代表這是一個空洞!
        }
        return false;
    }

    // 獲取與場上已有棋子相鄰的所有合法空位
    getAdjacentEmptyCells() {
        let emptyCells = new Set();
        
        for (let [chunkKey, chunk] of this.chunks.entries()) {
            let chunkX = chunkKey >> 16;
            let chunkY = (chunkKey << 16) >> 16; // 算術右移,保留正負號

            for (let i = 0; i < 1024; i++) {
                if (chunk[i] !== 0) {
                    let localY = i >> 5;
                    let localX = i & 31;
                    let x = (chunkX << 5) + localX;
                    let y = (chunkY << 5) + localY;

                    for (let n of this.getNeighbors(x, y)) {
                        if (this.get(n.x, n.y) === 0) {
                            emptyCells.add(this._packCoord(n.x, n.y));
                        }
                    }
                }
            }
        }
        
        return Array.from(emptyCells).map(packed => this._unpackCoord(packed));
    }

    // 給出特定棋子,判斷其能落在哪些空位上 (不干涉)
    getValidMoves(pieceType) {
        const emptyCells = this.getAdjacentEmptyCells();
        const validMoves = [];
        const checked = new Set();

        for (let cell of emptyCells) {
            let p1, p2;
            
            if (pieceType === 1) { // 藍棋 (左右相鄰)
                if (((cell.x + cell.y) & 1) === 0) {
                    p1 = { x: cell.x, y: cell.y };
                    p2 = { x: cell.x + 1, y: cell.y };
                } else {
                    p1 = { x: cell.x - 1, y: cell.y };
                    p2 = { x: cell.x, y: cell.y };
                }
            } else if (pieceType === 2) { // 紅棋 (上下,上方朝左=偶數)
                if (((cell.x + cell.y) & 1) === 0) {
                    p1 = { x: cell.x, y: cell.y };
                    p2 = { x: cell.x, y: cell.y + 1 };
                } else {
                    p1 = { x: cell.x, y: cell.y - 1 };
                    p2 = { x: cell.x, y: cell.y };
                }
            } else if (pieceType === 3) { // 橙棋 (上下,上方朝右=奇數)
                if (((cell.x + cell.y) & 1) !== 0) {
                    p1 = { x: cell.x, y: cell.y };
                    p2 = { x: cell.x, y: cell.y + 1 };
                } else {
                    p1 = { x: cell.x, y: cell.y - 1 };
                    p2 = { x: cell.x, y: cell.y };
                }
            }

            // 打包排序成唯一鍵值,避免重複計算
            let packed1 = this._packCoord(p1.x, p1.y);
            let packed2 = this._packCoord(p2.x, p2.y);
            let key = packed1 < packed2 ? `${packed1}|${packed2}` : `${packed2}|${packed1}`;
            
            if (checked.has(key)) continue;
            checked.add(key);

            if (this.get(p1.x, p1.y) === 0 && this.get(p2.x, p2.y) === 0) {
                validMoves.push([p1, p2]);
            }
        }
        return validMoves;
    }

    // --------------------------------------------------------
    // 計分與同層遞推邏輯
    // --------------------------------------------------------

    calculateScores() {
        // 重置所有同層指標
        for (let bp of this.bluePieces) {
            bp.sameLayerDown = null;
            bp.sameLayerUp = null;
        }

        let p1Score = 0; // 我方視角 (往下看,y 遞增)
        let p2Score = 0; // 對方視角 (往上看,y 遞減)

        for (let bp of this.bluePieces) {
            p1Score += this.evaluateBluePiece(bp, 1);
            p2Score += this.evaluateBluePiece(bp, -1);
        }
        
        return { p1Score, p2Score };
    }

    // 遞迴計算藍色棋子的得分
    evaluateBluePiece(bp, direction, visited = new Set()) {
        const key = `${bp.x},${bp.y},${direction}`;
        // 防呆,避免同層指標互相循環參照
        if (visited.has(key)) return 0;
        visited.add(key);

        let score = 0;
        let k = 1;
        let currentBP = bp;

        while (true) {
            // 計算第 k 層的 y 範圍 (一層佔據 2 個垂直單位)
            let y1 = direction === 1 ? currentBP.y + 2 * k - 1 : currentBP.y - 2 * k + 1;
            let y2 = direction === 1 ? currentBP.y + 2 * k : currentBP.y - 2 * k;
            let x = currentBP.x;

            let c1 = this.get(x, y1), c2 = this.get(x + 1, y1);
            let c3 = this.get(x, y2), c4 = this.get(x + 1, y2);

            // 若有任何一格是空缺 (0),則為「斷層」,計分終止
            if (c1 === 0 || c2 === 0 || c3 === 0 || c4 === 0) break;

            let countBlue = (c1 === 1) + (c2 === 1) + (c3 === 1) + (c4 === 1);
            
            if (countBlue === 0) {
                // 沒有藍色棋子,檢驗是否由 1個紅(2) 和 1個橙(3) 填滿
                let countRed = (c1 === 2) + (c2 === 2) + (c3 === 2) + (c4 === 2);
                let countOrange = (c1 === 3) + (c2 === 3) + (c3 === 3) + (c4 === 3);
                
                if (countRed === 2 && countOrange === 2) {
                    score++; // 滿層!
                    k++;     // 繼續往下看第 k+1 層
                } else {
                    break; // 未滿層
                }
            } else {
                // 「同層」邏輯:交接給層內的另一個藍色棋子
                let delegatedBP = null;
                for (let otherBP of this.bluePieces) {
                    if ((otherBP.x === x && otherBP.y === y1) || 
                        (otherBP.x === x && otherBP.y === y2)) {
                        delegatedBP = otherBP;
                        break;
                    }
                }
                
                if (delegatedBP) {
                    // 填入陣列需要的「同層歷史資訊」
                    if (direction === 1) currentBP.sameLayerDown = delegatedBP;
                    else currentBP.sameLayerUp = delegatedBP;

                    // 層次遞推:它的那一層由與它同層的藍色棋子在那一層的滿層情況來決定
                    score += this.evaluateBluePiece(delegatedBP, direction, visited);
                }
                break; // 同層遞交後,由另一個藍棋接手往下算,這顆藍棋本身就不用再看了
            }
        }
        return score;
    }
}

```

### 設計亮點與規則對應:

1. **陣列歷史與可回退 (`undo`)**:利用 `history` 陣列推入與彈出,並實作邊界檢測。
2. **同層遞推鏈**:透過 `evaluateBluePiece` 將第 $k$ 層發現的其他藍色棋子賦值給 `sameLayerDown` 與 `sameLayerUp`,並使用 `visited` Set 防止無窮迴圈(在極端互繞的情況下)。
3. **無字串坐標化 (`_packCoord`)**:利用 JavaScript 的 `BigInt` 可以將 32 位元的有號 `x` 與有號 `y` 安全地封裝在單一個 64 位的變數中,在走訪「禁止空洞」或「有效空位」時效能卓越。
4. **極限限界判定 (Bounding Box BFS)**:對於空洞判斷,一旦走訪的範圍超出了當前所有棋子的最遠外圍(`minX-1`、`maxX+1`等),就能光速確定此區域並未被包圍,中斷遍歷,達到了 $O(1)$ 常數級別的極限檢測。

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

发表于 : 2026年 6月 30日 07:54
ejsoon
將以上實現的規則,套用至所上傳的程式框架中,包括落子(與場上已有棋子相鄰但不發生干涉,不能形成空洞),切換行動方,棋子剩餘數量,雙方得分,歷史前進與回退,棋局信息導出與導入等功能的對接。

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

gemini 3.1 pro

代码: 全选

這是一個非常棒的實作挑戰!你已經有了很好的 UI 框架、動畫和多語系基礎。現在我們只需要將先前的 `HexGridGame` 核心邏輯(以位元運算為主)以「核心引擎」的角色注入到你的框架中。

為了完全符合你的需求,我將列出**需要修改或新增的程式碼片段**,你只需在 `trirhombus271.html` 的 `<script>` 區塊中進行對應的替換與插入即可。

---

### 第一步:注入核心邏輯類別與全域變數

在 `<script>` 區塊的最上方,加入 `HexGridGame` 類別,並宣告核心邏輯與分數變數:

```javascript
// === 在 <script> 最頂部新增 ===
let gameLogic = null;
let p1CurrentScore = 0;
let p2CurrentScore = 0;

class HexGridGame {
    constructor() {
        this.chunks = new Map();
        this.history = [];
        this.bluePieces = [];
        this.minX = Infinity; this.maxX = -Infinity;
        this.minY = Infinity; this.maxY = -Infinity;
        this.totalPieces = 0;
    }

    _getCoordinates(x, y) {
        const chunkX = x >> 5;
        const chunkY = y >> 5;
        const localX = x & 31;
        const localY = y & 31;
        const localIndex = (localY << 5) | localX;
        const chunkKey = (chunkX << 16) | (chunkY & 0xFFFF);
        return { chunkKey, localIndex };
    }

    _packCoord(x, y) { return (BigInt(x) << 32n) | BigInt(y >>> 0); }
    _unpackCoord(packed) { return { x: Number(packed >> 32n), y: Number(packed & 0xFFFFFFFFn) | 0 }; }

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

    set(x, y, value) {
        const { chunkKey, localIndex } = this._getCoordinates(x, y);
        if (value !== 0) {
            let chunk = this.chunks.get(chunkKey);
            if (!chunk) {
                chunk = new Uint8Array(1024);
                this.chunks.set(chunkKey, chunk);
            }
            chunk[localIndex] = value;
            if (x < this.minX) this.minX = x;
            if (x > this.maxX) this.maxX = x;
            if (y < this.minY) this.minY = y;
            if (y > this.maxY) this.maxY = y;
        } else {
            const chunk = this.chunks.get(chunkKey);
            if (chunk) chunk[localIndex] = 0;
        }
    }

    // 將鄰居判定與 UI 的 isRightPointing 邏輯完全同步
    getNeighbors(x, y) {
        const neighbors = [
            { x: x, y: y - 1 },
            { x: x, y: y + 1 }
        ];
        let mod_y = ((y % 2) + 2) % 2;
        let mod_x = (((x - 1) % 2) + 2) % 2;
        let isRight = (mod_y === mod_x);
        
        if (isRight) {
            neighbors.push({ x: x - 1, y: y }); // 右向三角形,平整面在左
        } else {
            neighbors.push({ x: x + 1, y: y }); // 左向三角形,平整面在右
        }
        return neighbors;
    }

    tryPlacePiece(pieceType, p1, p2, force = false) {
        if (this.get(p1.x, p1.y) !== 0 || this.get(p2.x, p2.y) !== 0) return { success: false, reason: "干涉" };
        
        this.set(p1.x, p1.y, pieceType);
        this.set(p2.x, p2.y, pieceType);
        this.totalPieces++;

        if (!force && this.hasHoleAfterPlacement([p1, p2])) {
            this.set(p1.x, p1.y, 0);
            this.set(p2.x, p2.y, 0);
            this.totalPieces--;
            return { success: false, reason: "空洞" };
        }

        this.history.push({ pieceType, p1, p2 });
        if (pieceType === 1) {
            const mainP = p1.x < p2.x ? p1 : p2;
            this.bluePieces.push({ x: mainP.x, y: mainP.y, sameLayerDown: null, sameLayerUp: null });
        }
        return { success: true, scores: this.calculateScores() };
    }

    undo() {
        if (this.history.length === 0) return { p1Score: 0, p2Score: 0 };
        const lastMove = this.history.pop();
        this.set(lastMove.p1.x, lastMove.p1.y, 0);
        this.set(lastMove.p2.x, lastMove.p2.y, 0);
        this.totalPieces--;
        if (lastMove.pieceType === 1) this.bluePieces.pop();
        return this.calculateScores();
    }

    hasHoleAfterPlacement(coords) {
        if (this.totalPieces <= 1) return false;
        let emptyNeighbors = [];
        for (let pt of coords) {
            for (let n of this.getNeighbors(pt.x, pt.y)) {
                if (this.get(n.x, n.y) === 0) emptyNeighbors.push(n);
            }
        }
        const outMinX = this.minX - 1; const outMaxX = this.maxX + 1;
        const outMinY = this.minY - 1; const outMaxY = this.maxY + 1;

        for (let start of emptyNeighbors) {
            if (this.get(start.x, start.y) !== 0) continue;
            let q = [start];
            let visited = new Set();
            visited.add(this._packCoord(start.x, start.y));
            let reachedOutside = false;
            let head = 0;

            while (head < q.length) {
                let curr = q[head++];
                if (curr.x < outMinX || curr.x > outMaxX || curr.y < outMinY || curr.y > outMaxY) {
                    reachedOutside = true;
                    break;
                }
                for (let n of this.getNeighbors(curr.x, curr.y)) {
                    if (this.get(n.x, n.y) === 0) {
                        let key = this._packCoord(n.x, n.y);
                        if (!visited.has(key)) {
                            visited.add(key);
                            q.push(n);
                        }
                    }
                }
            }
            if (!reachedOutside) return true;
        }
        return false;
    }

    calculateScores() {
        for (let bp of this.bluePieces) { bp.sameLayerDown = null; bp.sameLayerUp = null; }
        let p1Score = 0; let p2Score = 0;
        for (let bp of this.bluePieces) {
            p1Score += this.evaluateBluePiece(bp, 1);
            p2Score += this.evaluateBluePiece(bp, -1);
        }
        return { p1Score, p2Score };
    }

    evaluateBluePiece(bp, direction, visited = new Set()) {
        const key = `${bp.x},${bp.y},${direction}`;
        if (visited.has(key)) return 0;
        visited.add(key);

        let score = 0, k = 1, currentBP = bp;
        while (true) {
            let y1 = direction === 1 ? currentBP.y + 2 * k - 1 : currentBP.y - 2 * k + 1;
            let y2 = direction === 1 ? currentBP.y + 2 * k : currentBP.y - 2 * k;
            let x = currentBP.x;
            let c1 = this.get(x, y1), c2 = this.get(x + 1, y1);
            let c3 = this.get(x, y2), c4 = this.get(x + 1, y2);

            if (c1 === 0 || c2 === 0 || c3 === 0 || c4 === 0) break;
            let countBlue = (c1 === 1) + (c2 === 1) + (c3 === 1) + (c4 === 1);
            
            if (countBlue === 0) {
                let countRed = (c1 === 2) + (c2 === 2) + (c3 === 2) + (c4 === 2);
                let countOrange = (c1 === 3) + (c2 === 3) + (c3 === 3) + (c4 === 3);
                if (countRed === 2 && countOrange === 2) { score++; k++; } 
                else break;
            } else {
                let delegatedBP = null;
                for (let otherBP of this.bluePieces) {
                    if ((otherBP.x === x && otherBP.y === y1) || (otherBP.x === x && otherBP.y === y2)) {
                        delegatedBP = otherBP; break;
                    }
                }
                if (delegatedBP) {
                    if (direction === 1) currentBP.sameLayerDown = delegatedBP;
                    else currentBP.sameLayerUp = delegatedBP;
                    score += this.evaluateBluePiece(delegatedBP, direction, visited);
                }
                break;
            }
        }
        return score;
    }
}

```

---

### 第二步:對接初始化與分數更新

找到原本的 `initBoardState` 與 `updateUI`,將計分與核心引擎連動。

```javascript
// === 修改 initBoardState ===
function initBoardState(isRound2 = false) {
    document.getElementById('etanidrop').innerHTML =
        '<g class="etboard"><circle r="6" cx="0" cy="0" fill="purple" stroke="none" stroke-width="0" class="bcenter" /></g>';
    drawGrid();
    piecesLeft = { 1: [6, 6, 6], 2: [6, 6, 6] };
    
    // 初始化核心邏輯與當前分數
    gameLogic = new HexGridGame();
    p1CurrentScore = 0;
    p2CurrentScore = 0;

    if (!isRound2) {
        moveHistory = [];
        historyIndex = -1;
        currentRound = 1;
        currentFirstPlayer = 1; 
        p1TotalScore = 0;
        p2TotalScore = 0;
    } else {
        currentFirstPlayer = 2;
        currentRound = 2;
        if (historyIndex < 35) historyIndex = 35;
    }

    currentPlayer = currentFirstPlayer;
    isFirstMove = true;
    updateUI();
}

// === 修改 updateUI ===
function updateUI() {
    // 改為顯示核心邏輯算出的實際分數,而非剩餘棋子數
    document.getElementById('score-1').innerText = p1CurrentScore;
    document.getElementById('score-2').innerText = p2CurrentScore;

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

```

---

### 第三步:落子合法性與空洞判定對接

修改 `processBoardClick` 與 `commitMove`,並修正 `autoPlayFirstBlue` 讓它在核心陣列中註冊。

```javascript
// === 修改 processBoardClick ===
function processBoardClick(tx, ty) {
    const halfWidth = 31.1769;
    let k = Math.floor(tx / halfWidth);
    let idx = k + 1;
    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 = k;
        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 (currentSelectedPiece !== null) {
            let tri2 = getPairTri(bestTri, currentSelectedPiece);
            let p1 = { x: bestTri.idx, y: bestTri.N };
            let p2 = { x: tri2.idx, y: tri2.N };
            let pieceType = currentSelectedPiece + 1; // 1藍, 2紅, 3橙

            // 檢查相連 (如果不是第一手)
            if (!isFirstMove) {
                let neighbors1 = gameLogic.getNeighbors(p1.x, p1.y);
                let neighbors2 = gameLogic.getNeighbors(p2.x, p2.y);
                let hasNeighbor = false;
                for (let n of [...neighbors1, ...neighbors2]) {
                    if (gameLogic.get(n.x, n.y) !== 0) {
                        hasNeighbor = true; break;
                    }
                }
                if (!hasNeighbor) {
                    showNotification(getText('mustConnect'));
                    return;
                }
            }

            // 核心邏輯判定干涉與空洞
            let result = gameLogic.tryPlacePiece(pieceType, p1, p2);
            if (!result.success) {
                showNotification(result.reason === "空洞" ? getText('noHole') : getText('invalidDrop'));
                return;
            }

            // 驗證成功,為了配合原本的動畫流程,先復原核心狀態,等動畫播完再真正寫入
            gameLogic.undo();
            attemptDrop(bestTri); 
        }
    }
}

// === 修改 commitMove ===
function commitMove(pid, t1, t2, px, py, player) {
    piecesLeft[player][pid]--;
    isFirstMove = false;
    
    // 正式寫入核心邏輯並更新分數
    let p1 = { x: t1.idx, y: t1.N };
    let p2 = { x: t2.idx, y: t2.N };
    let result = gameLogic.tryPlacePiece(pid + 1, p1, p2, true); // force=true 略過空洞檢查以節省效能
    
    if (result.success) {
        p1CurrentScore = result.scores.p1Score;
        p2CurrentScore = result.scores.p2Score;
    }

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

// === 補充:在 autoPlayFirstBlue 動畫結束的 callback 內,也要註冊進 gameLogic ===
// 找到 autoPlayFirstBlue 的 else { ... } 區塊,替換為:
          else {
            piecesLeft[player][0]--;
            isFirstMove = false;
            // 寫入核心邏輯:坐標固定為 (-1, 0) 與 (1, 0)
            let result = gameLogic.tryPlacePiece(1, {x: -1, y: 0}, {x: 1, y: 0}, true);
            if (result.success) {
                p1CurrentScore = result.scores.p1Score;
                p2CurrentScore = result.scores.p2Score;
            }
            let notation = `[(${t1.idx},${t1.N}),(${t2.idx},${t2.N})]`;
            moveHistory.push({ notation, pid: 0, t1, t2, px: 0, py: 0, player });
            historyIndex++;
            cleanUpSelection();
            showNotification(getText('secondRedPrompt'));
          }

```

---

### 第四步:導入與導出棋譜功能

將現有空殼的 `openExportModal` 與 `submitImport` 加入 JSON 的序列化支持。

```javascript
// === 修改 openExportModal ===
function openExportModal() {
    // 將 moveHistory 轉換為 JSON 字串
    let exportData = JSON.stringify(moveHistory, null, 2);
    document.getElementById('export-text').value = exportData;
    document.getElementById('export-modal').style.display = 'flex';
}

// === 修改 submitImport ===
function submitImport() {
    let jsonStr = document.getElementById('import-text').value;
    try {
        let importedHistory = JSON.parse(jsonStr);
        if (!Array.isArray(importedHistory)) throw new Error("無效的棋譜格式");
        
        document.getElementById('export-modal').style.display = 'none';
        
        // 重置盤面
        initBoardState(false);
        
        // 瞬間依序執行棋譜中的每一手棋
        for (let i = 0; i < importedHistory.length; i++) {
            let move = importedHistory[i];
            
            // 由於動畫需要時間,導入時我們直接調用核心邏輯與 SVG 繪製,跳過動畫
            piecesLeft[move.player][move.pid]--;
            let result = gameLogic.tryPlacePiece(move.pid + 1, {x: move.t1.idx, y: move.t1.N}, {x: move.t2.idx, y: move.t2.N}, true);
            if (result.success) {
                p1CurrentScore = result.scores.p1Score;
                p2CurrentScore = result.scores.p2Score;
            }

            // 繪製 SVG
            const useNode = document.createElementNS('http://www.w3.org/2000/svg', 'use');
            useNode.setAttribute('href', `#tile${move.pid}`);
            useNode.setAttribute('class', 'tiledropped');
            useNode.setAttribute('fill', TILE_COLORS[move.pid]);
            useNode.setAttribute('transform', `translate(${move.px.toFixed(3)}, ${move.py.toFixed(3)})`);
            document.getElementById('etanidrop').appendChild(useNode);
        }
        
        moveHistory = importedHistory;
        historyIndex = moveHistory.length - 1;
        isFirstMove = (historyIndex < 0);
        currentPlayer = (moveHistory.length > 0) ? (moveHistory[moveHistory.length - 1].player === 1 ? 2 : 1) : 1;
        
        updateUI();
        showToast("導入成功!");
    } catch (e) {
        alert("導入失敗:格式錯誤或資料損毀");
    }
}

```

*(註:如果你原有的 `redrawFromHistory` 函式還沒有與 `gameLogic` 連動,請確保在該函式迴圈重繪時,也要同步呼叫 `gameLogic.undo()` 或 `gameLogic.tryPlacePiece()` 來維持核心狀態的一致性,或者最保險的做法是**清空 gameLogic 並從 0 到 historyIndex 重新 tryPlacePiece 一次**,這是實作歷史軸最無懈可擊的做法。)*

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

发表于 : 2026年 6月 30日 09:04
ejsoon
修復與改進:
一,導出導入格式

導出與導入應該只需要notation,用空白分隔每一手棋。

二,計分邏輯有誤

在以下局面:

[(0,0),(1,0)] [(0,-1),(0,-2)] [(1,-1),(1,-2)] [(0,-3),(-1,-3)] [(1,-3),(2,-3)] [(-1,-1),(-1,-2)] [(2,-1),(2,-2)] [(2,1),(2,0)] [(-1,0),(-1,1)] [(1,1),(1,2)] [(0,2),(0,1)] [(0,-5),(0,-4)] [(-1,-5),(-1,-4)] [(0,-7),(0,-6)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)]

我方應該是5分,對方應該是4分。但是現在結果是我方是3分,對方是3分。查找問題並修復。

三,對接history,實現歷史回退和前進

將moveHistory對接至HexGridGame.history,並重寫undoMove和redoMove以實現歷史回退和前進。

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

代码: 全选

修復與改進:
一,導出導入格式

導出與導入應該只需要notation,用空白分隔每一手棋。

二,計分邏輯有誤

在以下局面:

[(0,0),(1,0)] [(0,-1),(0,-2)] [(1,-1),(1,-2)] [(0,-3),(-1,-3)] [(1,-3),(2,-3)] [(-1,-1),(-1,-2)] [(2,-1),(2,-2)] [(2,1),(2,0)] [(-1,0),(-1,1)] [(1,1),(1,2)] [(0,2),(0,1)] [(0,-5),(0,-4)] [(-1,-5),(-1,-4)] [(0,-7),(0,-6)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)]

我方應該是5分,對方應該是4分。但是現在結果是我方是3分,對方是3分。查找問題並修復。

三,對接history,實現歷史回退和前進

將moveHistory對接至HexGridGame.history,並重寫undoMove和redoMove以實現歷史回退和前進。

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

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

发表于 : 2026年 6月 30日 09:16
ejsoon
修復與改進:
一,導出導入格式

導出與導入應該只需要notation,用空白分隔每一手棋。

二,計分邏輯有誤

在以下局面:

[(0,0),(1,0)] [(0,-1),(0,-2)] [(1,-1),(1,-2)] [(0,-3),(-1,-3)] [(1,-3),(2,-3)] [(-1,-1),(-1,-2)] [(2,-1),(2,-2)] [(2,1),(2,0)] [(-1,0),(-1,1)] [(1,1),(1,2)] [(0,2),(0,1)] [(0,-5),(0,-4)] [(-1,-5),(-1,-4)] [(0,-7),(0,-6)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)]

我方應該是5分,對方應該是4分。但是現在結果是我方是3分,對方是3分。

我方的[(1,-3),(2,-3)]和[(0,-3),(-1,-3)]的第一層都是滿層,第二層因為其分層是滿層所以也是滿層,[(0,0),(1,0)]是以上二者在第二層的分層,它的第一層是滿層,所以我方應該是5分。

對方的[(0,0),(1,0)]第一層是滿層,第二層斷層。[(0,-3),(-1,-3)]第一層滿層,第二層有分層,而它的分層是滿層,所以它也是滿層。[(-1,-6),(-2,-6)]是前者的分層,第一層是滿層,所以一共是4分。

查找問題並修復。

三,對接history,實現歷史回退和前進

將moveHistory對接至HexGridGame.history,並重寫undoMove和redoMove以實現歷史回退和前進。

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

代码: 全选

修復與改進:
一,導出導入格式

導出與導入應該只需要notation,用空白分隔每一手棋。

二,計分邏輯有誤

在以下局面:

[(0,0),(1,0)] [(0,-1),(0,-2)] [(1,-1),(1,-2)] [(0,-3),(-1,-3)] [(1,-3),(2,-3)] [(-1,-1),(-1,-2)] [(2,-1),(2,-2)] [(2,1),(2,0)] [(-1,0),(-1,1)] [(1,1),(1,2)] [(0,2),(0,1)] [(0,-5),(0,-4)] [(-1,-5),(-1,-4)] [(0,-7),(0,-6)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)]

我方應該是5分,對方應該是4分。但是現在結果是我方是3分,對方是3分。

我方的[(1,-3),(2,-3)]和[(0,-3),(-1,-3)]的第一層都是滿層,第二層因為其分層是滿層所以也是滿層,[(0,0),(1,0)]是以上二者在第二層的分層,它的第一層是滿層,所以我方應該是5分。

對方的[(0,0),(1,0)]第一層是滿層,第二層斷層。[(0,-3),(-1,-3)]第一層滿層,第二層有分層,而它的分層是滿層,所以它也是滿層。[(-1,-6),(-2,-6)]是前者的分層,第一層是滿層,所以一共是4分。

查找問題並修復。

三,對接history,實現歷史回退和前進

將moveHistory對接至HexGridGame.history,並重寫undoMove和redoMove以實現歷史回退和前進。

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

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

发表于 : 2026年 6月 30日 09:23
ejsoon
chatgpt thinking

代码: 全选

你這份檔案裡,三個問題的來源都很明確:

導出/導入目前還是 JSON:`openExportModal()` 用 `JSON.stringify(moveHistory)`,`submitImport()` 也在 `JSON.parse()` 後逐手重播。 

計分的漏算點在 `evaluateBluePiece()`:一旦找到藍子層,它只取第一個 `delegatedBP` 就 `break`,同層其他藍支線直接被忽略。

歷史目前也是分散處理:`commitMove()` 直接 `push` 到 `moveHistory`,`undoMove()` / `redoMove()` 只是改 `historyIndex`。

下面直接給你要替換的地方。

---

## 一、導出 / 導入:改成只輸出 notation,空白分隔

### 1) 把 `openExportModal()` 整段換掉

位置:`function openExportModal()`。

```js
function openExportModal() {
  const exportData = moveHistory.map(m => m.notation).join(' ');
  document.getElementById('export-text').value = exportData;
  document.getElementById('export-modal').style.display = 'flex';
}
```

### 2) 在導入區塊上方新增這個 helper

位置:放在 `openExportModal()` 前後都可以。

```js
function parseNotationMove(notation) {
  const m = notation.match(/^\[\((-?\d+),(-?\d+)\),\((-?\d+),(-?\d+)\)\]$/);
  if (!m) return null;

  const a = {
    idx: Number(m[1]),
    N: Number(m[2]),
    isRight: isRightPointing(Number(m[1]), Number(m[2]))
  };
  const b = {
    idx: Number(m[3]),
    N: Number(m[4]),
    isRight: isRightPointing(Number(m[3]), Number(m[4]))
  };

  for (const [t1, t2] of [
    [a, b],
    [b, a]
  ]) {
    for (let pid = 0; pid < 3; pid++) {
      const pair = getPairTri(t1, pid);
      if (pair.idx === t2.idx && pair.N === t2.N) {
        return { notation, pid, t1, t2 };
      }
    }
  }

  return null;
}
```

### 3) 把 `submitImport()` 整段換掉

位置:`function submitImport()`。

```js
function submitImport() {
  const text = document.getElementById('import-text').value.trim();

  try {
    const tokens = text ? text.split(/\s+/) : [];
    const importedMoves = tokens.map(parseNotationMove);

    if (importedMoves.some(m => !m)) {
      throw new Error('無效的棋譜格式');
    }

    document.getElementById('export-modal').style.display = 'none';

    // 重置盤面
    initBoardState(false);
    moveHistory = gameLogic.history; // 對接核心 history

    // 依 notation 重播
    for (let i = 0; i < importedMoves.length; i++) {
      const move = importedMoves[i];

      // 依既有規則推回 player:
      // 前 36 手為第一局,之後第二局先手換成 2
      const player = i < 36 ? (i % 2 === 0 ? 1 : 2) : (i % 2 === 0 ? 2 : 1);

      piecesLeft[player][move.pid]--;

      const result = gameLogic.tryPlacePiece(
        move.pid + 1,
        { x: move.t1.idx, y: move.t1.N },
        { x: move.t2.idx, y: move.t2.N },
        true
      );

      if (!result.success) {
        throw new Error('棋譜與盤面不一致');
      }

      p1CurrentScore = result.scores.p1Score;
      p2CurrentScore = result.scores.p2Score;

      const tri = {
        idx: move.t1.idx,
        N: move.t1.N,
        isRight: isRightPointing(move.t1.idx, move.t1.N)
      };
      tri.cx = tri.isRight
        ? (tri.idx - 1) * 31.1769 + 10.392
        : (tri.idx - 1) * 31.1769 + 20.784;
      tri.cy = tri.N * 18;

      const { px, py } = calcDropPos(tri, move.pid);

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

      const coreMove = gameLogic.history[gameLogic.history.length - 1];
      Object.assign(coreMove, {
        notation: move.notation,
        pid: move.pid,
        t1: move.t1,
        t2: move.t2,
        px,
        py,
        player
      });
    }

    historyIndex = moveHistory.length - 1;
    currentRound = historyIndex >= 36 ? 2 : 1;
    currentFirstPlayer = currentRound === 2 ? 2 : 1;
    currentPlayer = historyIndex < 0 ? 1 : (moveHistory[moveHistory.length - 1].player === 1 ? 2 : 1);
    isFirstMove = historyIndex < 0;

    updateUI();
    showToast('導入成功!');
  } catch (e) {
    alert('導入失敗:格式錯誤或資料損毀');
  }
}
```

### 4) `initBoardState()` 裡把 `moveHistory = [];` 改掉

位置:`gameLogic = new HexGridGame();` 後面那段。現在這裡是把 history 斷開的關鍵點。

把這幾行:

```js
gameLogic = new HexGridGame();
p1CurrentScore = 0;
p2CurrentScore = 0;

if (!isRound2) {
  moveHistory = [];
  historyIndex = -1;
  ...
}
```

改成:

```js
gameLogic = new HexGridGame();
moveHistory = gameLogic.history;
p1CurrentScore = 0;
p2CurrentScore = 0;

if (!isRound2) {
  historyIndex = -1;
  currentRound = 1;
  currentFirstPlayer = 1;
  p1TotalScore = 0;
  p2TotalScore = 0;
} else {
  currentFirstPlayer = 2;
  currentRound = 2;
  if (historyIndex < 35) historyIndex = 35;
}
```

---

## 二、計分:修正 `evaluateBluePiece()`

位置:`class HexGridGame` 裡的 `evaluateBluePiece()`。現在的問題就是這個分支只吃第一個藍子層。

把整個函式換成下面這版:

```js
evaluateBluePiece(bp, direction, visited = new Set()) {
  const key = `${bp.x},${bp.y},${direction}`;
  if (visited.has(key)) return 0;
  visited.add(key);

  let score = 0;
  let currentBP = bp;
  let k = 1;

  while (true) {
    const y1 = direction === 1 ? currentBP.y + 2 * k - 1 : currentBP.y - 2 * k + 1;
    const y2 = direction === 1 ? currentBP.y + 2 * k : currentBP.y - 2 * k;
    const x = currentBP.x;

    const cells = [
      { x, y: y1, v: this.get(x, y1) },
      { x: x + 1, y: y1, v: this.get(x + 1, y1) },
      { x, y: y2, v: this.get(x, y2) },
      { x: x + 1, y: y2, v: this.get(x + 1, y2) }
    ];

    if (cells.some(c => c.v === 0)) break;

    const countBlue = cells.filter(c => c.v === 1).length;

    if (countBlue === 0) {
      const countRed = cells.filter(c => c.v === 2).length;
      const countOrange = cells.filter(c => c.v === 3).length;
      if (countRed === 2 && countOrange === 2) {
        score++;
        k++;
        continue;
      }
      break;
    }

    const childPieces = [...new Set(
      this.bluePieces.filter(otherBP =>
        cells.some(c =>
          (otherBP.x === c.x && otherBP.y === c.y) ||
          (otherBP.x + 1 === c.x && otherBP.y === c.y)
        )
      )
    )];

    if (childPieces.length === 0) break;

    const childDepth = Math.max(
      ...childPieces.map(child => this.evaluateBluePiece(child, direction, visited))
    );

    if (childDepth === 0) break;

    score += childDepth + 1;
    break;
  }

  return score;
}
```

這版最重要的是把「只看第一個 `delegatedBP`」改成「同層所有藍子層一起看」,不再被單一路徑截斷。
如果你要更嚴格地做成「所有子層都滿,這一層才算滿」,我再幫你把 `childDepth` 那段改成 `every()` 版本。

---

## 三、歷史回退 / 前進:重寫成重播核心 history

你現在的 `commitMove()`、`undoMove()`、`redoMove()` 還是各管各的。

### 1) `commitMove()` 改成把資料寫進 `gameLogic.history`

位置:`function commitMove(pid, t1, t2, px, py, player)`。

把原本最後這段:

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

換成:

```js
const notation = `[(${t1.idx},${t1.N}),(${t2.idx},${t2.N})]`;
const coreMove = gameLogic.history[gameLogic.history.length - 1];
Object.assign(coreMove, { notation, pid, t1, t2, px, py, player });

moveHistory = gameLogic.history;
historyIndex = moveHistory.length - 1;
showNotification(`落子: ${notation}`);
```

### 2) 新增 `redrawFromHistory()`

位置:放在 `undoMove()` / `redoMove()` 下面或上面都可以。

```js
function redrawFromHistory() {
  const savedHistory = moveHistory.slice(0, historyIndex + 1);

  document.getElementById('etanidrop').innerHTML =
    '<g class="etboard"><circle r="6" cx="0" cy="0" fill="purple" stroke="none" stroke-width="0" class="bcenter" /></g>';
  drawGrid();

  gameLogic = new HexGridGame();
  moveHistory = gameLogic.history;

  piecesLeft = { 1: [6, 6, 6], 2: [6, 6, 6] };
  p1CurrentScore = 0;
  p2CurrentScore = 0;
  currentRound = historyIndex >= 36 ? 2 : 1;
  currentFirstPlayer = currentRound === 2 ? 2 : 1;
  currentPlayer = currentFirstPlayer;
  isFirstMove = true;

  for (let i = 0; i < savedHistory.length; i++) {
    const move = savedHistory[i];

    const result = gameLogic.tryPlacePiece(
      move.pid + 1,
      { x: move.t1.idx, y: move.t1.N },
      { x: move.t2.idx, y: move.t2.N },
      true
    );

    if (result.success) {
      p1CurrentScore = result.scores.p1Score;
      p2CurrentScore = result.scores.p2Score;
    }

    const tri = {
      idx: move.t1.idx,
      N: move.t1.N,
      isRight: isRightPointing(move.t1.idx, move.t1.N)
    };
    tri.cx = tri.isRight
      ? (tri.idx - 1) * 31.1769 + 10.392
      : (tri.idx - 1) * 31.1769 + 20.784;
    tri.cy = tri.N * 18;

    const { px, py } = calcDropPos(tri, move.pid);

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

    const coreMove = gameLogic.history[gameLogic.history.length - 1];
    Object.assign(coreMove, move);

    piecesLeft[move.player][move.pid]--;
    currentPlayer = move.player === 1 ? 2 : 1;
    isFirstMove = false;
  }

  updateUI();
}
```

### 3) `undoMove()` / `redoMove()` 改成只改索引,再重播

位置:原本的 `undoMove()`、`redoMove()` 兩段。

```js
function undoMove() {
  if (historyIndex >= 0) {
    hideRestoreGoBtn();
    historyIndex--;
    redrawFromHistory();
  }
}

function redoMove() {
  if (historyIndex < moveHistory.length - 1) {
    hideRestoreGoBtn();
    historyIndex++;
    redrawFromHistory();
  }
}
```

---

如果你要,我下一則可以直接把以上三段整理成「可直接貼進原檔的最小 diff 版」,每段都標好原位置與替換位置。

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

发表于 : 2026年 6月 30日 09:24
ejsoon
gemini 3.1 pro

代码: 全选

這裡為你提供針對三個問題的修復與改進代碼。你只需要替換或新增對應的函數即可,不需要修改全部代碼。

### 一、修改導出與導入格式

將原本的 JSON 格式改為純 `notation` 加上空格分隔的格式,並透過坐標關係自動推導棋子類型與精確位置。

請找到並**替換** `openExportModal` 與 `submitImport` 兩個函數:

```javascript
      function openExportModal() {
        let exportData = moveHistory.map(m => m.notation).join(' ');
        document.getElementById('export-text').value = exportData;
        document.getElementById('export-modal').style.display = 'flex';
      }

      function submitImport() {
        let txt = document.getElementById('import-text').value.trim();
        if (!txt) return;
        let tokens = txt.split(/\s+/);
        try {
          let importedHistory = [];
          let halfWidth = 31.1769;
          let cPlayer = 1; // 第一局從 P1 開始

          for (let i = 0; i < tokens.length; i++) {
            let match = tokens[i].match(/\[\((-?\d+),(-?\d+)\),\((-?\d+),(-?\d+)\)\]/);
            if (!match) throw new Error('格式錯誤');
            let t1_idx = parseInt(match[1]), t1_N = parseInt(match[2]);
            let t2_idx = parseInt(match[3]), t2_N = parseInt(match[4]);

            // 推導棋子種類 (0: 藍, 1: 紅, 2: 橙)
            let pid = 0;
            if (t1_N !== t2_N) {
              let isRight = isRightPointing(t1_idx, t1_N);
              let dn = t2_N - t1_N;
              pid = isRight ? (dn === -1 ? 1 : 2) : (dn === 1 ? 1 : 2);
            }

            let isRight1 = isRightPointing(t1_idx, t1_N);
            let isRight2 = isRightPointing(t2_idx, t2_N);
            let cx1 = isRight1 ? (t1_idx - 1) * halfWidth + 10.392 : (t1_idx - 1) * halfWidth + 20.784;

            let t1 = { idx: t1_idx, N: t1_N, isRight: isRight1, cx: cx1, cy: t1_N * 18 };
            let t2 = { idx: t2_idx, N: t2_N, isRight: isRight2 };
            let { px, py } = calcDropPos(t1, pid);

            // 如果遇到第 37 手 (index 36),代表進入第二局,首手為 P2
            if (i === 36) cPlayer = 2;

            importedHistory.push({ notation: tokens[i], pid, t1, t2, px, py, player: cPlayer });
            cPlayer = cPlayer === 1 ? 2 : 1;
          }

          document.getElementById('export-modal').style.display = 'none';

          // 使用重繪函數直接渲染盤面
          moveHistory = importedHistory;
          historyIndex = moveHistory.length - 1;
          currentRound = historyIndex >= 36 ? 2 : 1;
          currentFirstPlayer = currentRound === 2 ? 2 : 1;
          redrawFromHistory();
          
          showToast('導入成功!');
          
          let totalPieces = piecesLeft[1].reduce((a, b) => a + b) + piecesLeft[2].reduce((a, b) => a + b);
          if (totalPieces === 0) {
            gameState = 'ended';
            setTimeout(showRoundEnd, 300);
          }
        } catch (e) {
          alert('導入失敗:格式錯誤或資料損毀');
        }
      }

```

---

### 二、修復計分邏輯

原來的計分邏輯會造成「分支重複加分」的問題,並且在判斷斷層時對於是否補滿 `c1~c4` 的條件有漏洞。

請在 `class HexGridGame` 中找到並**替換** `calculateScores` 和 `evaluateBluePiece` 兩個方法:

```javascript
        calculateScores() {
          let p1Score = 0;
          let p2Score = 0;
          for (let bp of this.bluePieces) {
            p1Score += this.evaluateBluePiece(bp, 1);
            p2Score += this.evaluateBluePiece(bp, -1);
          }
          return { p1Score, p2Score };
        }

        evaluateBluePiece(bp, direction, visited = new Set()) {
          const key = `${bp.x},${bp.y},${direction}`;
          if (visited.has(key)) return 0;
          visited.add(key);

          let score = 0, k = 1;
          while (true) {
            let y1 = direction === 1 ? bp.y + 2 * k - 1 : bp.y - 2 * k + 1;
            let y2 = direction === 1 ? bp.y + 2 * k : bp.y - 2 * k;
            let x = bp.x;
            let c1 = this.get(x, y1), c2 = this.get(x + 1, y1);
            let c3 = this.get(x, y2), c4 = this.get(x + 1, y2);

            // 只要有任何一格為空,該層必定斷層
            if (c1 === 0 || c2 === 0 || c3 === 0 || c4 === 0) break;

            let countBlue = (c1 === 1) + (c2 === 1) + (c3 === 1) + (c4 === 1);

            if (countBlue === 0) {
              let countRed = (c1 === 2) + (c2 === 2) + (c3 === 2) + (c4 === 2);
              let countOrange = (c1 === 3) + (c2 === 3) + (c3 === 3) + (c4 === 3);
              if (countRed === 2 && countOrange === 2) {
                score++;
                k++;
              } else {
                break;
              }
            } else {
              // 接觸到藍色分層,尋找所有重疊的藍棋
              let delegatedBPs = [];
              for (let otherBP of this.bluePieces) {
                if ((otherBP.y === y1 || otherBP.y === y2) && Math.abs(otherBP.x - x) <= 1) {
                  delegatedBPs.push(otherBP);
                }
              }

              let allDelegatesFull = true;
              for (let delegated of delegatedBPs) {
                let delScore = this.evaluateBluePiece(delegated, direction, new Set(visited));
                if (delScore === 0) {
                  allDelegatesFull = false;
                  break;
                }
              }

              // 如果所有分層都是滿層,則當前層也視為滿層並得分,隨後結束柱體生長
              if (allDelegatesFull && delegatedBPs.length > 0) {
                score++;
              }
              break;
            }
          }
          return score;
        }

```

---

### 三、對接核心邏輯實現歷史回退與前進

我們將新增一個 `redrawFromHistory` 函數來處理跨局或大範圍的重繪,並重寫 `undoMove` 與 `redoMove`,讓它們直接呼叫 `gameLogic.undo()` 及 `tryPlacePiece` 進行高效的原地狀態回退。

請新增/替換以下這**三個**函數(放在腳本的最下方或是緊跟在 `redoMove` 原有位置):

```javascript
      function redrawFromHistory() {
        document.getElementById('etanidrop').innerHTML = '<g class="etboard"><circle r="6" cx="0" cy="0" fill="purple" stroke="none" stroke-width="0" class="bcenter" /></g>';
        drawGrid();

        gameLogic = new HexGridGame();
        piecesLeft = { 1: [6, 6, 6], 2: [6, 6, 6] };
        p1CurrentScore = 0; p2CurrentScore = 0;

        let roundStart = currentRound === 2 ? 36 : 0;
        for (let i = roundStart; i <= historyIndex; i++) {
          let move = moveHistory[i];
          piecesLeft[move.player][move.pid]--;
          let result = gameLogic.tryPlacePiece(move.pid + 1, { x: move.t1.idx, y: move.t1.N }, { x: move.t2.idx, y: move.t2.N }, true);
          if (result.success) {
            p1CurrentScore = result.scores.p1Score;
            p2CurrentScore = result.scores.p2Score;
          }
          const useNode = document.createElementNS('http://www.w3.org/2000/svg', 'use');
          useNode.setAttribute('href', `#tile${move.pid}`);
          useNode.setAttribute('class', 'tiledropped');
          useNode.setAttribute('fill', TILE_COLORS[move.pid]);
          useNode.setAttribute('transform', `translate(${move.px.toFixed(3)}, ${move.py.toFixed(3)})`);
          document.getElementById('etanidrop').appendChild(useNode);
        }

        isFirstMove = historyIndex < roundStart;
        currentPlayer = historyIndex >= roundStart ? (moveHistory[historyIndex].player === 1 ? 2 : 1) : currentFirstPlayer;
        updateUI();
      }

      function undoMove() {
        if (historyIndex >= 0) {
          hideRestoreGoBtn();
          if (currentRound === 2 && historyIndex === 36) {
            currentRound = 1;
            historyIndex--;
            redrawFromHistory();
            showRoundEnd();
          } else {
            let roundStart = currentRound === 2 ? 36 : 0;
            if (historyIndex < roundStart) return;

            let move = moveHistory[historyIndex];
            
            // 呼叫核心邏輯回退
            let scores = gameLogic.undo();
            p1CurrentScore = scores.p1Score;
            p2CurrentScore = scores.p2Score;
            piecesLeft[move.player][move.pid]++;
            historyIndex--;

            // 在畫面上移除最後一顆棋子
            let dropG = document.getElementById('etanidrop');
            if (dropG.lastChild && dropG.lastChild.classList.contains('tiledropped')) {
              dropG.removeChild(dropG.lastChild);
            }

            isFirstMove = historyIndex < roundStart;
            currentPlayer = move.player; // 退回該回合的玩家
            updateUI();
          }
        }
      }

      function redoMove() {
        if (historyIndex < moveHistory.length - 1) {
          hideRestoreGoBtn();
          if (currentRound === 1 && historyIndex === 35) {
            currentRound = 2;
            currentFirstPlayer = 2;
            redrawFromHistory();
            showNotification(getText('firstBluePrompt'));
            return;
          }

          historyIndex++;
          let move = moveHistory[historyIndex];

          // 寫入核心邏輯
          piecesLeft[move.player][move.pid]--;
          let result = gameLogic.tryPlacePiece(move.pid + 1, { x: move.t1.idx, y: move.t1.N }, { x: move.t2.idx, y: move.t2.N }, true);
          if (result.success) {
            p1CurrentScore = result.scores.p1Score;
            p2CurrentScore = result.scores.p2Score;
          }

          // 在畫面上新增棋子
          const useNode = document.createElementNS('http://www.w3.org/2000/svg', 'use');
          useNode.setAttribute('href', `#tile${move.pid}`);
          useNode.setAttribute('class', 'tiledropped');
          useNode.setAttribute('fill', TILE_COLORS[move.pid]);
          useNode.setAttribute('transform', `translate(${move.px.toFixed(3)}, ${move.py.toFixed(3)})`);
          document.getElementById('etanidrop').appendChild(useNode);

          isFirstMove = false;
          currentPlayer = move.player === 1 ? 2 : 1; // 切換給下一位玩家
          updateUI();

          let totalPieces = piecesLeft[1].reduce((a, b) => a + b) + piecesLeft[2].reduce((a, b) => a + b);
          if (totalPieces === 0) {
            gameState = 'ended';
            setTimeout(showRoundEnd, 300);
          }
        }
      }

```

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

发表于 : 2026年 6月 30日 12:39
ejsoon
修復與改進:
一,計分邏輯仍需修復

在以下局面:

[(0,0),(1,0)] [(1,-2),(1,-1)] [(2,-3),(1,-3)] [(2,-1),(2,-2)] [(0,-2),(0,-1)] [(-1,-3),(0,-3)] [(-1,-2),(-1,-1)] [(-1,0),(-1,1)] [(1,2),(1,1)] [(0,1),(0,2)] [(2,0),(2,1)] [(0,-4),(0,-5)] [(0,-6),(0,-7)] [(-1,-4),(-1,-5)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)] [(2,-4),(3,-4)] [(-2,-4),(-2,-5)] [(3,-3),(3,-2)] [(3,0),(3,-1)]

在最後一手棋[(3,0),(3,-1)]之前,我方的計分為7分,這是對的。但是加上[(3,0),(3,-1)]這手棋之後,分數卻仍是7分,這是不對的,這時我方的分數應為8分。因為[(2,-4),(3,-4)]在第一層的左方有同層,則它左方的滿層判定就在第一層往後都交給了左方的同層,但是右方沒有同層,則當[(3,0),(3,-1)]這手棋下去之後,[(2,-4),(3,-4)]在第二層的右方也滿層了,則如果左方的同層在第二層也是滿層,則它在第二層就是滿層,所以這時我方應該是8分。

在以下局面:

[(0,0),(1,0)] [(1,-2),(1,-1)] [(2,-3),(1,-3)] [(2,-1),(2,-2)] [(0,-2),(0,-1)] [(-1,-3),(0,-3)] [(-1,-2),(-1,-1)] [(-1,0),(-1,1)] [(1,2),(1,1)] [(0,1),(0,2)] [(2,0),(2,1)] [(0,-4),(0,-5)] [(0,-6),(0,-7)] [(-1,-4),(-1,-5)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)] [(2,-4),(3,-4)] [(-2,-4),(-2,-5)] [(3,-3),(3,-2)] [(3,0),(3,-1)] [(-2,-3),(-3,-3)] [(-4,-2),(-3,-2)] [(-5,-3),(-4,-3)] [(-3,-4),(-4,-4)] [(-5,-5),(-5,-4)] [(-4,-5),(-4,-6)] [(-3,-6),(-3,-5)] [(-5,-7),(-5,-6)] [(-4,-7),(-4,-8)] [(-3,-7),(-3,-8)]

在最後一手棋[(-3,-7),(-3,-8)]之前,對方的計分為8分,這是對的。但是加上[(-3,-7),(-3,-8)]這手棋之後,對方的分數卻只是9分,這是不對的,這時對方的分數應為12分。因為[(-2,-3),(-3,-3)]在第二層的左右同層都是滿層,所以它在第二層也是滿層,同理,[(-4,-2),(-3,-2)]的第二層,[(-5,-3),(-4,-3)]的第二層也都因為這手棋而成為了滿層,則對方的這時應該是12分。

二,debug模式改進

當處於debug模式時,點擊棋盤,將在消息框給出所點擊的三角形坐標。如果這個坐標上有棋子,則需給出該棋子所佔的另一個坐標,以及這個棋子的種類,第幾手下的。

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

代码: 全选

修復與改進:
一,計分邏輯仍需修復

在以下局面:

[(0,0),(1,0)] [(1,-2),(1,-1)] [(2,-3),(1,-3)] [(2,-1),(2,-2)] [(0,-2),(0,-1)] [(-1,-3),(0,-3)] [(-1,-2),(-1,-1)] [(-1,0),(-1,1)] [(1,2),(1,1)] [(0,1),(0,2)] [(2,0),(2,1)] [(0,-4),(0,-5)] [(0,-6),(0,-7)] [(-1,-4),(-1,-5)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)] [(2,-4),(3,-4)] [(-2,-4),(-2,-5)] [(3,-3),(3,-2)] [(3,0),(3,-1)]

在最後一手棋[(3,0),(3,-1)]之前,我方的計分為7分,這是對的。但是加上[(3,0),(3,-1)]這手棋之後,分數卻仍是7分,這是不對的,這時我方的分數應為8分。因為[(2,-4),(3,-4)]在第一層的左方有同層,則它左方的滿層判定就在第一層往後都交給了左方的同層,但是右方沒有同層,則當[(3,0),(3,-1)]這手棋下去之後,[(2,-4),(3,-4)]在第二層的右方也滿層了,則如果左方的同層在第二層也是滿層,則它在第二層就是滿層,所以這時我方應該是8分。

在以下局面:

[(0,0),(1,0)] [(1,-2),(1,-1)] [(2,-3),(1,-3)] [(2,-1),(2,-2)] [(0,-2),(0,-1)] [(-1,-3),(0,-3)] [(-1,-2),(-1,-1)] [(-1,0),(-1,1)] [(1,2),(1,1)] [(0,1),(0,2)] [(2,0),(2,1)] [(0,-4),(0,-5)] [(0,-6),(0,-7)] [(-1,-4),(-1,-5)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)] [(2,-4),(3,-4)] [(-2,-4),(-2,-5)] [(3,-3),(3,-2)] [(3,0),(3,-1)] [(-2,-3),(-3,-3)] [(-4,-2),(-3,-2)] [(-5,-3),(-4,-3)] [(-3,-4),(-4,-4)] [(-5,-5),(-5,-4)] [(-4,-5),(-4,-6)] [(-3,-6),(-3,-5)] [(-5,-7),(-5,-6)] [(-4,-7),(-4,-8)] [(-3,-7),(-3,-8)]

在最後一手棋[(-3,-7),(-3,-8)]之前,對方的計分為8分,這是對的。但是加上[(-3,-7),(-3,-8)]這手棋之後,對方的分數卻只是9分,這是不對的,這時對方的分數應為12分。因為[(-2,-3),(-3,-3)]在第二層的左右同層都是滿層,所以它在第二層也是滿層,同理,[(-4,-2),(-3,-2)]的第二層,[(-5,-3),(-4,-3)]的第二層也都因為這手棋而成為了滿層,則對方的這時應該是12分。

二,debug模式改進

當處於debug模式時,點擊棋盤,將在消息框給出所點擊的三角形坐標。如果這個坐標上有棋子,則需給出該棋子所佔的另一個坐標,以及這個棋子的種類,第幾手下的。

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

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

发表于 : 2026年 6月 30日 12:46
ejsoon
gemini 3.1 pro

代码: 全选

這兩個問題都非常核心,特別是計分邏輯中「同層委託」的判定。原先的代碼中,一旦某一層接觸到了藍棋,它會檢查該藍棋是否有分數,然後直接給予 1 分並 `break` 結束了該柱體的生長。

為了實現你描述的「左右兩側獨立委託與生長」的規則,我們需要徹底重構 `evaluateBluePiece` 的迴圈:讓左側 (`x`) 與右側 (`x+1`) 獨立判定。如果某一側遇到了同層(藍棋),就將該側的後續生長**完全交接**給該藍棋的最高層數,而另一側仍可繼續驗證紅橙相間,只要雙側都滿足條件,柱體就能持續往上長。

以下是針對這兩個問題的明確修改方案,請替換對應的代碼區塊即可。

### 一、修復計分邏輯(左右兩側獨立判定與同層委託)

請找到 `HexGridGame` 類別中的 `evaluateBluePiece` 函數,將其**完整替換**為以下代碼:

```javascript
        evaluateBluePiece(bp, direction, visited = new Set()) {
          const key = `${bp.x},${bp.y},${direction}`;
          if (visited.has(key)) return 0;
          visited.add(key);

          let score = 0, k = 1;
          let leftDelegate = null, rightDelegate = null;
          let leftMaxK = Infinity, rightMaxK = Infinity;

          while (true) {
            // 如果雙側都已經交給同層委託,只要兩邊的委託都還能支援目前的高度 k,就直接視為滿層
            if (leftDelegate && rightDelegate) {
              if (k <= leftMaxK && k <= rightMaxK) {
                score++; k++; continue;
              } else break;
            }

            let y1 = direction === 1 ? bp.y + 2 * k - 1 : bp.y - 2 * k + 1;
            let y2 = direction === 1 ? bp.y + 2 * k : bp.y - 2 * k;
            let x = bp.x;

            // 已經委託的側不需要再看當前物理座標,未委託的側則正常讀取
            let c1 = leftDelegate ? 1 : this.get(x, y1);
            let c3 = leftDelegate ? 1 : this.get(x, y2);
            let c2 = rightDelegate ? 1 : this.get(x + 1, y1);
            let c4 = rightDelegate ? 1 : this.get(x + 1, y2);

            // 檢查左側是否碰到藍棋並觸發委託
            if (!leftDelegate && (c1 === 1 || c3 === 1)) {
              for (let otherBP of this.bluePieces) {
                if ((otherBP.y === y1 || otherBP.y === y2) && (otherBP.x === x || otherBP.x === x - 1)) {
                  leftDelegate = otherBP;
                  let delScore = this.evaluateBluePiece(otherBP, direction, new Set(visited));
                  leftMaxK = k - 1 + delScore; // 該同層能支援的最大層數
                  break;
                }
              }
              if (!leftDelegate) break;
            }

            // 檢查右側是否碰到藍棋並觸發委託
            if (!rightDelegate && (c2 === 1 || c4 === 1)) {
              for (let otherBP of this.bluePieces) {
                if ((otherBP.y === y1 || otherBP.y === y2) && (otherBP.x === x + 1 || otherBP.x === x)) {
                  rightDelegate = otherBP;
                  // 如果左右側碰到的是同一塊藍棋,共享最大支援層數
                  if (rightDelegate === leftDelegate) rightMaxK = leftMaxK;
                  else {
                    let delScore = this.evaluateBluePiece(otherBP, direction, new Set(visited));
                    rightMaxK = k - 1 + delScore;
                  }
                  break;
                }
              }
              if (!rightDelegate) break;
            }

            // 如果某側已經委託,但該委託的同層高度不足以支援當前的 k 層,則斷層
            if (leftDelegate && k > leftMaxK) break;
            if (rightDelegate && k > rightMaxK) break;

            // 對於沒有委託的側,如果有空格則立刻斷層
            if (!leftDelegate && (c1 === 0 || c3 === 0)) break;
            if (!rightDelegate && (c2 === 0 || c4 === 0)) break;

            // 檢查尚未委託的側是否滿足紅橙相間
            let layerValid = true;
            if (!leftDelegate && !rightDelegate) {
              // 雙側皆未委託:必須剛好 2 紅 2 橙
              let countRed = (c1 === 2) + (c2 === 2) + (c3 === 2) + (c4 === 2);
              let countOrange = (c1 === 3) + (c2 === 3) + (c3 === 3) + (c4 === 3);
              if (countRed !== 2 || countOrange !== 2) layerValid = false;
            } else if (!leftDelegate && rightDelegate) {
              // 右側委託,左側需自己滿足 1 紅 1 橙
              if (!((c1 === 2 && c3 === 3) || (c1 === 3 && c3 === 2))) layerValid = false;
            } else if (leftDelegate && !rightDelegate) {
              // 左側委託,右側需自己滿足 1 紅 1 橙
              if (!((c2 === 2 && c4 === 3) || (c2 === 3 && c4 === 2))) layerValid = false;
            }

            if (layerValid) {
              score++; k++;
            } else break;
          }
          return score;
        }

```

---

### 二、改進 Debug 模式查詢

請找到 `processBoardClick(tx, ty)` 函數,並將裡面的 `if (bestTri) { ... }` 區塊(負責處理點擊後的邏輯)**完整替換**為以下代碼。這將攔截 Debug 模式下的點選,並從歷史記錄中推導出棋子的詳細資訊:

```javascript
        if (bestTri) {
          // Debug 模式點擊查詢
          if (isDebugMode && currentSelectedPiece === null) {
            let cx = bestTri.idx, cy = bestTri.N;
            let val = gameLogic.get(cx, cy);
            
            if (val === 0) {
              showNotification(`坐標: (${cx}, ${cy}) [空]`);
            } else {
              let found = null, mNum = 0;
              // 從歷史記錄尋找該棋子
              for (let i = 0; i <= historyIndex; i++) {
                let m = moveHistory[i];
                if ((m.t1.idx === cx && m.t1.N === cy) || (m.t2.idx === cx && m.t2.N === cy)) {
                  found = m;
                  mNum = i + 1; // 手數 (1-based)
                  break;
                }
              }
              if (found) {
                let ox = found.t1.idx === cx ? found.t2.idx : found.t1.idx;
                let oy = found.t1.N === cy ? found.t2.N : found.t1.N;
                let tName = found.pid === 0 ? '藍色' : (found.pid === 1 ? '紅色' : '橙色');
                showNotification(`坐標: (${cx}, ${cy})\n相連: (${ox}, ${oy})\n種類: ${tName}\n手數: 第 ${mNum} 手`);
              } else {
                showNotification(`坐標: (${cx}, ${cy}) [未知棋子]`);
              }
            }
            return;
          }

          // 正常的落子邏輯
          if (currentSelectedPiece !== null) {
            let tri2 = getPairTri(bestTri, currentSelectedPiece);
            let p1 = { x: bestTri.idx, y: bestTri.N };
            let p2 = { x: tri2.idx, y: tri2.N };
            let pieceType = currentSelectedPiece + 1; // 1藍, 2紅, 3橙

            // 檢查相連 (如果不是第一手)
            if (!isFirstMove) {
              let neighbors1 = gameLogic.getNeighbors(p1.x, p1.y);
              let neighbors2 = gameLogic.getNeighbors(p2.x, p2.y);
              let hasNeighbor = false;
              for (let n of [...neighbors1, ...neighbors2]) {
                if (gameLogic.get(n.x, n.y) !== 0) {
                  hasNeighbor = true;
                  break;
                }
              }
              if (!hasNeighbor) {
                showNotification(getText('mustConnect'));
                return;
              }
            }

            // 核心邏輯判定干涉與空洞
            let result = gameLogic.tryPlacePiece(pieceType, p1, p2);
            if (!result.success) {
              showNotification(result.reason === '空洞' ? getText('noHole') : getText('invalidDrop'));
              return;
            }

            // 驗證成功,為了配合原本的動畫流程,先復原核心狀態,等動畫播完再真正寫入
            gameLogic.undo();
            attemptDrop(bestTri);
          }
        }

```

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

发表于 : 2026年 6月 30日 13:31
ejsoon
修復與改進:
計分邏輯需要重寫

在以下局面:

[(0,0),(1,0)] [(1,-2),(1,-1)] [(2,-3),(1,-3)] [(2,-1),(2,-2)] [(0,-2),(0,-1)] [(-1,-3),(0,-3)] [(-1,-2),(-1,-1)] [(-1,0),(-1,1)] [(1,2),(1,1)] [(0,1),(0,2)] [(2,0),(2,1)] [(0,-4),(0,-5)] [(0,-6),(0,-7)] [(-1,-4),(-1,-5)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)] [(2,-4),(3,-4)] [(-2,-4),(-2,-5)] [(3,-3),(3,-2)] [(3,0),(3,-1)] [(-2,-3),(-3,-3)] [(-4,-2),(-3,-2)] [(-5,-3),(-4,-3)] [(-3,-4),(-4,-4)] [(-5,-5),(-5,-4)] [(-4,-5),(-4,-6)] [(-3,-6),(-3,-5)] [(-5,-7),(-5,-6)] [(-4,-7),(-4,-8)] [(-3,-7),(-3,-8)]

程式算得對方的分數是9分,我方的分數是7分,這是不對的,這時對方的分數應該是12分,我方的分數應該是8分。

按以下步驟重寫計分邏輯:

每一個藍色棋子都分別統計左層和右層。我方的藍色棋子的每一個左層指的是它左下方的兩個三角形,右層指的是它右下方的兩個三角形(如果是對方則是上方)。

如果在這一層中,左或右兩個三角形存在空位,則在這一層為斷層。

如果在這一層不是斷層,則如果左方兩個三角形同屬於一種棋子,則為左滿層,右方則為右滿層,左右都是滿層,則這一層為滿層。

只有第一層是滿層,才能往下算第二層,依此類推。

如果有一邊為滿層,另一邊不同屬於一種棋子,則將含有另一個藍色棋子,則另一藍子將為同層,則這一邊將從這一層開始委托給同層,只有同層的這一層是滿層,它在這一左層或右層才是左滿層或右滿層。如果另一邊同屬一種棋子,則這一邊也是左滿層或右滿層,則才可以確定這一層是滿層仍像之前那樣往下判斷。

有的藍色棋子從第一層開始,左右兩邊都委托給了兩個棋子,也有的藍色棋子從第二層開始委托給左邊藍色棋子,第三層開始委托給右邊藍色棋子。不要漏掉這種已經左右委托的藍色棋子,應先把受委托的藍色棋子的分數(滿層數)都計完,再計委托給它的藍色棋子的分數。

重寫高效正確都計分邏輯,要盡量用到位元運算,可能要用到遞歸,以及拓撲排序。不要用到字符串拼接,因為會很低效。

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

代码: 全选

修復與改進:
計分邏輯需要重寫

在以下局面:

[(0,0),(1,0)] [(1,-2),(1,-1)] [(2,-3),(1,-3)] [(2,-1),(2,-2)] [(0,-2),(0,-1)] [(-1,-3),(0,-3)] [(-1,-2),(-1,-1)] [(-1,0),(-1,1)] [(1,2),(1,1)] [(0,1),(0,2)] [(2,0),(2,1)] [(0,-4),(0,-5)] [(0,-6),(0,-7)] [(-1,-4),(-1,-5)] [(-1,-6),(-2,-6)] [(-2,-8),(-2,-7)] [(-1,-7),(-1,-8)] [(2,-4),(3,-4)] [(-2,-4),(-2,-5)] [(3,-3),(3,-2)] [(3,0),(3,-1)] [(-2,-3),(-3,-3)] [(-4,-2),(-3,-2)] [(-5,-3),(-4,-3)] [(-3,-4),(-4,-4)] [(-5,-5),(-5,-4)] [(-4,-5),(-4,-6)] [(-3,-6),(-3,-5)] [(-5,-7),(-5,-6)] [(-4,-7),(-4,-8)] [(-3,-7),(-3,-8)]

程式算得對方的分數是9分,我方的分數是7分,這是不對的,這時對方的分數應該是12分,我方的分數應該是8分。

按以下步驟重寫計分邏輯:

每一個藍色棋子都分別統計左層和右層。我方的藍色棋子的每一個左層指的是它左下方的兩個三角形,右層指的是它右下方的兩個三角形(如果是對方則是上方)。

如果在這一層中,左或右兩個三角形存在空位,則在這一層為斷層。

如果在這一層不是斷層,則如果左方兩個三角形同屬於一種棋子,則為左滿層,右方則為右滿層,左右都是滿層,則這一層為滿層。

只有第一層是滿層,才能往下算第二層,依此類推。

如果有一邊為滿層,另一邊不同屬於一種棋子,則將含有另一個藍色棋子,則另一藍子將為同層,則這一邊將從這一層開始委托給同層,只有同層的這一層是滿層,它在這一左層或右層才是左滿層或右滿層。如果另一邊同屬一種棋子,則這一邊也是左滿層或右滿層,則才可以確定這一層是滿層仍像之前那樣往下判斷。

有的藍色棋子從第一層開始,左右兩邊都委托給了兩個棋子,也有的藍色棋子從第二層開始委托給左邊藍色棋子,第三層開始委托給右邊藍色棋子。不要漏掉這種已經左右委托的藍色棋子,應先把受委托的藍色棋子的分數(滿層數)都計完,再計委托給它的藍色棋子的分數。

重寫高效正確都計分邏輯,要盡量用到位元運算,可能要用到遞歸,以及拓撲排序。不要用到字符串拼接,因為會很低效。

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

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

发表于 : 2026年 6月 30日 13:33
ejsoon
gemini 3.1 pro

代码: 全选

這是一個非常典型的動態規劃(Dynamic Programming)與拓撲排序(Topological Sort)**的應用場景。由於藍色棋子在計算滿層時會委託給其下方的另一個藍色棋子,我們可以利用**帶有記憶化的深度優先搜尋(Memoized DFS)來解決。

為保證最高效的執行且不使用字串拼接,我們可以使用 **32位元整數(Bitwise)** 將 `(x, y)` 座標壓縮為唯一的 `key` 進行雜湊表的快速查找。

請在你的 `HexGridGame` 類別中,找到並**完全替換**原有的 `calculateScores` 與 `evaluateBluePiece` 這兩個方法:

### 需要替換的代碼範圍

```javascript
        calculateScores() {
          let p1Score = 0;
          let p2Score = 0;
          for (let bp of this.bluePieces) {
            p1Score += this.evaluateBluePiece(bp, 1);
            p2Score += this.evaluateBluePiece(bp, -1);
          }
          return { p1Score, p2Score };
        }

        evaluateBluePiece(bp, direction, visited = new Set()) {
          // ... 原有的 evaluateBluePiece 代碼 ...
          return score;
        }

```

### 替換為以下高效計分邏輯

```javascript
        calculateScores() {
          let p1Score = 0;
          let p2Score = 0;
          
          // 使用 32 位元整數作為 key 進行記憶化,避免低效的字串拼接
          const memo1 = new Map(); 
          const memo2 = new Map();
          
          // 建立藍色棋子的快速查找表 (O(1) 查找)
          const bpLookup = new Map();
          for (let i = 0; i < this.bluePieces.length; i++) {
            let bp = this.bluePieces[i];
            // 藍棋為橫向,佔用 (bp.x, bp.y) 與 (bp.x + 1, bp.y) 兩個三角形
            let key1 = (bp.x & 0xFFFF) | ((bp.y & 0xFFFF) << 16);
            let key2 = ((bp.x + 1) & 0xFFFF) | ((bp.y & 0xFFFF) << 16);
            bpLookup.set(key1, bp);
            bpLookup.set(key2, bp);
          }

          // 分別計算雙方所有藍色棋子的分數 (包含拓撲依賴的深度計算)
          for (let i = 0; i < this.bluePieces.length; i++) {
            p1Score += this.evaluateBluePieceRec(this.bluePieces[i], 1, memo1, bpLookup);
            p2Score += this.evaluateBluePieceRec(this.bluePieces[i], -1, memo2, bpLookup);
          }

          return { p1Score, p2Score };
        }

        evaluateBluePieceRec(bp, direction, memo, bpLookup) {
          let key = (bp.x & 0xFFFF) | ((bp.y & 0xFFFF) << 16);
          if (memo.has(key)) return memo.get(key);

          // 標記為 0 防止潛在的環形死迴圈 (雖然由於 y 必定單調遞增/遞減,數學上無環)
          memo.set(key, 0);

          // --- 統計左層 (x = bp.x) ---
          let leftScore = 0;
          let k = 1;
          while (true) {
            let y1 = bp.y + (2 * k - 1) * direction;
            let y2 = bp.y + 2 * k * direction;
            let x = bp.x; 
            
            let c1 = this.get(x, y1);
            let c2 = this.get(x, y2);
            
            if (c1 === 0 || c2 === 0) {
              leftScore = k - 1; // 遇到空洞,產生斷層
              break;
            }
            if (c1 === 1 || c2 === 1) {
              // 遇到其他藍色棋子,開始同層委託
              let del_y = (c1 === 1) ? y1 : y2;
              let del_key = (x & 0xFFFF) | ((del_y & 0xFFFF) << 16);
              let del_bp = bpLookup.get(del_key);
              if (del_bp) {
                // 左層滿層數 = 當前已滿層數 + 受委託藍棋的滿層數
                leftScore = (k - 1) + this.evaluateBluePieceRec(del_bp, direction, memo, bpLookup);
              } else {
                leftScore = k - 1;
              }
              break;
            }
            if (c1 === c2) {
              k++; // 同屬於一種棋子 (滿層),繼續往下看
            } else {
              leftScore = k - 1; // 不同種類,產生斷層
              break;
            }
          }

          // --- 統計右層 (x = bp.x + 1) ---
          let rightScore = 0;
          k = 1;
          while (true) {
            let y1 = bp.y + (2 * k - 1) * direction;
            let y2 = bp.y + 2 * k * direction;
            let x = bp.x + 1; 
            
            let c3 = this.get(x, y1);
            let c4 = this.get(x, y2);
            
            if (c3 === 0 || c4 === 0) {
              rightScore = k - 1;
              break;
            }
            if (c3 === 1 || c4 === 1) {
              let del_y = (c3 === 1) ? y1 : y2;
              let del_key = (x & 0xFFFF) | ((del_y & 0xFFFF) << 16);
              let del_bp = bpLookup.get(del_key);
              if (del_bp) {
                rightScore = (k - 1) + this.evaluateBluePieceRec(del_bp, direction, memo, bpLookup);
              } else {
                rightScore = k - 1;
              }
              break;
            }
            if (c3 === c4) {
              k++;
            } else {
              rightScore = k - 1;
              break;
            }
          }

          // 只有左右都是滿層,這一層才算整體滿層 (取木桶效應的短板)
          let finalScore = leftScore < rightScore ? leftScore : rightScore;
          
          // 寫入記憶化結果並回傳
          memo.set(key, finalScore);
          return finalScore;
        }

```

### 改進點說明:

1. **隱式拓撲排序與記憶化**:利用遞歸(Recursion)的特性,自動做到「先把受委託的藍色棋子滿層數算完」這個行為。`memo` `Map` 則確保了相同的節點不會被重複運算,大幅度提高性能。
2. **位元運算加速**:廢棄了耗時的字串拼接座標,使用了 `(x & 0xFFFF) | ((y & 0xFFFF) << 16)` 進行 32 位元整數打包,用作雜湊表的鍵值。
3. **左右獨立判定**:嚴格落實了你要求的「左方兩個三角形為左層,右方兩個為右層」,在最後利用 `leftScore < rightScore ? leftScore : rightScore`(取最小值)來達成「左右皆滿,該層才算滿層」的邏輯。

這段程式碼將會徹底修復遇到大型組合時算分錯誤的問題!

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

发表于 : 2026年 6月 30日 13:43
ejsoon
trirhombus2741.html

好像這次對了。

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

发表于 : 2026年 6月 30日 15:44
ejsoon
這兩個函式是否高效?複雜度如何?

代码: 全选

        calculateScores() {
          let p1Score = 0;
          let p2Score = 0;
          
          // 使用 32 位元整數作為 key 進行記憶化,避免低效的字串拼接
          const memo1 = new Map(); 
          const memo2 = new Map();
          
          // 建立藍色棋子的快速查找表 (O(1) 查找)
          const bpLookup = new Map();
          for (let i = 0; i < this.bluePieces.length; i++) {
            let bp = this.bluePieces[i];
            // 藍棋為橫向,佔用 (bp.x, bp.y) 與 (bp.x + 1, bp.y) 兩個三角形
            let key1 = (bp.x & 0xFFFF) | ((bp.y & 0xFFFF) << 16);
            let key2 = ((bp.x + 1) & 0xFFFF) | ((bp.y & 0xFFFF) << 16);
            bpLookup.set(key1, bp);
            bpLookup.set(key2, bp);
          }

          // 分別計算雙方所有藍色棋子的分數 (包含拓撲依賴的深度計算)
          for (let i = 0; i < this.bluePieces.length; i++) {
            p1Score += this.evaluateBluePieceRec(this.bluePieces[i], 1, memo1, bpLookup);
            p2Score += this.evaluateBluePieceRec(this.bluePieces[i], -1, memo2, bpLookup);
          }

          return { p1Score, p2Score };
        }

        evaluateBluePieceRec(bp, direction, memo, bpLookup) {
          let key = (bp.x & 0xFFFF) | ((bp.y & 0xFFFF) << 16);
          if (memo.has(key)) return memo.get(key);

          // 標記為 0 防止潛在的環形死迴圈 (雖然由於 y 必定單調遞增/遞減,數學上無環)
          memo.set(key, 0);

          // --- 統計左層 (x = bp.x) ---
          let leftScore = 0;
          let k = 1;
          while (true) {
            let y1 = bp.y + (2 * k - 1) * direction;
            let y2 = bp.y + 2 * k * direction;
            let x = bp.x; 
            
            let c1 = this.get(x, y1);
            let c2 = this.get(x, y2);
            
            if (c1 === 0 || c2 === 0) {
              leftScore = k - 1; // 遇到空洞,產生斷層
              break;
            }
            if (c1 === 1 || c2 === 1) {
              // 遇到其他藍色棋子,開始同層委託
              let del_y = (c1 === 1) ? y1 : y2;
              let del_key = (x & 0xFFFF) | ((del_y & 0xFFFF) << 16);
              let del_bp = bpLookup.get(del_key);
              if (del_bp) {
                // 左層滿層數 = 當前已滿層數 + 受委託藍棋的滿層數
                leftScore = (k - 1) + this.evaluateBluePieceRec(del_bp, direction, memo, bpLookup);
              } else {
                leftScore = k - 1;
              }
              break;
            }
            if (c1 === c2) {
              k++; // 同屬於一種棋子 (滿層),繼續往下看
            } else {
              leftScore = k - 1; // 不同種類,產生斷層
              break;
            }
          }

          // --- 統計右層 (x = bp.x + 1) ---
          let rightScore = 0;
          k = 1;
          while (true) {
            let y1 = bp.y + (2 * k - 1) * direction;
            let y2 = bp.y + 2 * k * direction;
            let x = bp.x + 1; 
            
            let c3 = this.get(x, y1);
            let c4 = this.get(x, y2);
            
            if (c3 === 0 || c4 === 0) {
              rightScore = k - 1;
              break;
            }
            if (c3 === 1 || c4 === 1) {
              let del_y = (c3 === 1) ? y1 : y2;
              let del_key = (x & 0xFFFF) | ((del_y & 0xFFFF) << 16);
              let del_bp = bpLookup.get(del_key);
              if (del_bp) {
                rightScore = (k - 1) + this.evaluateBluePieceRec(del_bp, direction, memo, bpLookup);
              } else {
                rightScore = k - 1;
              }
              break;
            }
            if (c3 === c4) {
              k++;
            } else {
              rightScore = k - 1;
              break;
            }
          }

          // 只有左右都是滿層,這一層才算整體滿層 (取木桶效應的短板)
          let finalScore = leftScore < rightScore ? leftScore : rightScore;
          
          // 寫入記憶化結果並回傳
          memo.set(key, finalScore);
          return finalScore;
        }

這兩個函式是否高效?複雜度如何?


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

发表于 : 2026年 6月 30日 15:55
ejsoon
如果用這個方法去跑蒙地卡羅樹搜尋,會不會非常慢?

gemini 3.5 thinking

代码: 全选

答案是:**直接拿這段程式碼去跑蒙地卡羅樹搜尋(MCTS),確實會非常慢。**

但請放心,這**不是**因為你的邏輯寫得不好,而是因為 **MCTS 的運作機制**與 **JavaScript 的記憶體管理(GC)** 碰在一起時產生的硬傷。

MCTS 在進行模擬(Playout/Rollout)時,每秒鐘可能要跑幾萬次甚至幾十萬次的狀態轉移與評估。在這種極高頻率的場景下,原本快如閃電的程式碼會遇到以下兩個致命的效能瓶頸:

---

## 為什麼在 MCTS 中會變慢?

### 1. 嚴重的垃圾回收壓力(Garbage Collection Pressure)

這段程式碼每次被呼叫時,都會執行:

```javascript
const memo1 = new Map(); 
const memo2 = new Map();
const bpLookup = new Map();

```

在 MCTS 中,如果每秒呼叫 `calculateScores()` 10 萬次,意味著每秒會在記憶體中建立 **30 萬個 `Map` 物件**。
JavaScript 引擎(如 V8)必須頻繁啟動垃圾回收機制(GC)來清理這些死掉的物件。當 GC 啟動時,遊戲會出現微小的卡頓(Stop-the-world),這會讓你的 MCTS 每秒模擬次數(Iterations per second)大幅度雪崩。

### 2. 每一步都在重複做功(重新建立 Lookup Table)

在 MCTS 中,棋盤狀態通常是「一步一步改變」的(下一子、或是倒退一步)。
目前的寫法是每次算分都用 $O(N)$ 的時間重新跑一次 `this.bluePieces` 來建立 `bpLookup`。如果一局棋下了 50 步,每一次評估都要把這 50 顆棋子重新放進 Map 一次,這在高度重複的樹搜尋中是非常奢侈的浪費。

---

## 改造指南:如何讓它在 MCTS 中飛起來?

要讓這套邏輯適應 MCTS,我們需要將思維從「每次都重新計算」轉換為「狀態持久化與增量更新」。

### 改造一:把 `bpLookup` 變成全域/物件屬性(增量更新)

不要在函式內部建立 `bpLookup`。把它直接做成棋盤物件的屬性(`this.bpLookup`)。

* 當玩家或 MCTS **落子(Play)** 時:直接計算該棋子的 32 位元 Key,丟進 `this.bpLookup`($O(1)$)。
* 當 MCTS **還原棋步(Undo)** 時:把該棋子的 Key 從 `this.bpLookup` 中 `delete` 掉($O(1)$)。

這樣一來,`calculateScores()` 執行時就**完全不需要建立 `bpLookup**`,省去了 $O(N)$ 的建立時間。

### 改造二:重複使用(Reuse)記憶化 Map,而不是每次 `new`

你可以建立兩個全域或類別屬性的 `Map`,每次計算前只做 `clear()`,而不是 `new`:

```javascript
// 在棋盤初始化時建立一次,重複使用
this.memo1 = new Map();
this.memo2 = new Map();

calculateScores() {
  // clear() 通常比 new Map() 快得多,且不會觸發 GC
  this.memo1.clear();
  this.memo2.clear();
  
  // 接下來直接使用 this.memo1 與 this.memo2 ...
}

```

### 改造三:終極大絕 —— 用 Typed Array(型態陣列)取代 Map

如果你的棋盤邊界是固定的(例如 $30 \times 30$ 內),最頂級的 MCTS 優化是**完全不用 `Map**`,而是改用一維的 `Int32Array` 來當作棋盤網格。

因為 `Typed Array` 在 JavaScript 中是一塊連續的記憶體,

* 查詢速度是硬體級別的快。
* 記憶體佔用極小。
* **絕對不會產生垃圾回收(Zero GC)**。

你可以用一個 `Int32Array` 來記錄每個座標上是哪一個藍色棋子的 Index,這樣連位元運算壓縮 Key 的步驟都省了,直接透過 `index = x + y * width` 就能在極短時間內完成查找。

---

## 結論

你原本寫的演算法在**單次計算**或常規 AI(如 Alpha-Beta 剪枝,節點數較少時)已經非常優秀。但如果要挑戰 MCTS 這種動輒幾百萬次循環的野獸,就必須把 `new` 關鍵字趕出你的核心迴圈。

目前你的 MCTS 框架是用純 JavaScript 寫的嗎?如果進行了「重複使用 Map」的修改後,模擬速度有顯著提升嗎?
如果你的棋盤邊界是固定的(例如 30×30 內),最頂級的 MCTS 優化是完全不用 Map,而是改用一維的 Int32Array 來當作棋盤網格。

最大可以允許多大的棋盤?128*128可以嗎?

代码: 全选

**128 × 128 完全沒問題,而且速度會快到讓你驚豔!**

事實上,**128 × 128** 對於現代電腦的記憶體來說,小到連「塞牙縫」都不夠。以下為你拆解它的記憶體佔用,以及在 MCTS(蒙地卡羅樹搜尋)中真正影響效能的「硬體隱形界線」。

---

## 1. 記憶體數學計算:128 × 128 有多小?

我們來算一下 **128 × 128** 的棋盤會吃掉多少記憶體:

* **總格子數**:$128 \times 128 = 16,384$ 個格子。
* **每格大小**:一個 `Int32` 固定佔用 **4 Bytes**。
* **總記憶體容量**:$16,384 \times 4 \text{ Bytes} = 65,536 \text{ Bytes} =$ **剛好 64 KB**。

**64 KB 是什麼概念?** 隨便一張網頁圖片可能都有 2 MB(等於 2048 KB)。你把幾千個這種棋盤同時塞進記憶體,電腦都毫無壓力。

---

## 2. 理論上限 vs 實務極限

### 🚫 JavaScript 的理論上限

在 JavaScript(V8 引擎)中,`Int32Array` 的最大長度通常限制在 $2^{30} - 1$(約 10 億個元素)。

* 換算成正方形棋盤,理論最大可以開到約 **32,768 × 32,768**。
* 但這只是「不會報錯」的極限,拿來跑 MCTS 會慢到動不了。

### ⚡ MCTS 的「硬體快取」黃金線(真正的效能關鍵)

跑 MCTS 時,棋盤陣列會被**極高頻率地瘋狂讀寫**。這時候決定速度的不是記憶體(RAM)夠不夠大,而是 CPU 的快取記憶體(CPU Cache)裝不裝得下。

| 棋盤大小 | 記憶體佔用 | CPU 快取層級 | MCTS 效能表現 |
| --- | --- | --- | --- |
| **64 × 64 內** | ~16 KB | **L1 快取** (最快) | 🚀 終極速度(核心地帶,1~4 時脈週期完成) |
| **128 × 128** | **64 KB** | **L2 快取** (極快) | ⚡ 依然快到飛起來(完美適應現代 CPU) |
| **512 × 512** | 1 MB | **L3 快取** (快) | 穩定發揮,速度依然優於 Map |
| **2048 × 2048 以上** | 16 MB+ | 掉出快取,進入 **RAM** | 🐢 速度明顯變慢(發生 Cache Miss) |

因此,你的 **128 × 128** 剛好落在 CPU L2 快取的甜蜜點,對 MCTS 來說是非常完美的尺寸。

---

## 3. 隱藏版彩蛋:利用「2 的次方」再壓榨效能

既然你選擇了 128,這是一個非常漂亮的數字,因為 **128 剛好是 2 的 7 次方 ($2^7$)**。

平常我們把二維座標 `(x, y)` 轉換成一維陣列索引時,公式是:

```javascript
let index = x + y * 128; // 需要做一次乘法

```

在電腦底層,**乘法**比位元移位(Bitshift)慢。既然寬度是 128,你可以把乘法改成左移 7 位元(`<< 7`),這會直接動用 CPU 的硬體加速:

```javascript
// 頂級優化寫法
let index = x + (y << 7); 
let piece = this.boardArray[index];

```

這種寫法再搭配 `Int32Array`,在 MCTS 每秒幾十萬次的模擬(Playout)中,累積起來的效能增益會非常可怕!
你還能否找出其它能提高效能的地方,使其在MCTS搜尋上更快?

代码: 全选

要讓 MCTS(蒙地卡羅樹搜尋)達到極致的搜尋深度,除了前面提到的把 `Map` 改成 `Int32Array` 之外,在 JavaScript 的環境下,還有幾個**核彈級的優化點**。

MCTS 的效能瓶頸通常 90% 都在模擬階段(Rollout/Simulation)的無數次循環中。以下為你盤點進入「核心效能禁區」的 5 個頂級優化手段:

---

## 1. 增量更新(Delta Evaluation)—— 徹底消滅 $O(N)$

目前你的做法是每次算分,都重新遍歷所有藍色棋子。
在 MCTS 的 Rollout 中,每一步都只會「多放一顆棋子」或「拿掉一顆棋子」。真正頂級的優化是:**永遠不要重新計算整張棋盤**。

* **做法**:維護一個全域的 `currentScore`。當落下一顆新棋子時,只針對這顆新棋子周圍會受到影響的鄰居進行局部計算,然後把分數「加」或「減」到 `currentScore` 上。
* **效果**:將分數計算的複雜度直接從 $O(N)$ 降到 **$O(1)$ 常數時間**。

---

## 2. 物件池(Object Pooling)修復樹節點的 GC 災難

MCTS 在「擴展(Expansion)」階段會建立大量的樹節點(`new MCTSNode()`)。如果每秒建立幾十萬個節點物件,JavaScript 的垃圾回收(GC)會直接讓你的程式碼慢到崩潰。

* **做法**:不要在搜尋過程中 `new` 任何物件。在遊戲初始化時,預先配置一個超大的 `Int32Array` 作為「節點池」。
* **結構設計**:用固定長度的格子代表一個節點,例如每 8 個整數代表一個節點:
* `pool[i + 0]` = 訪問次數 (Visits)
* `pool[i + 1]` = 總得分 (Wins)
* `pool[i + 2]` = 第一個子節點的 Index
* `pool[i + 3]` = 兄弟節點的 Index


* **效果**:**達成「零物件建立(Zero-Allocation)」**。記憶體完全靜態化,GC 永遠不會啟動,速度可以提升數倍。

---

## 3. 函式內聯(Inlining)—— 移除 `this.get(x, y)`

在你的 `while(true)` 核心迴圈中,頻繁呼叫了 `this.get(x, y)`:

```javascript
let c1 = this.get(x, y1);

```

在普通程式中這樣寫很漂亮,但在 MCTS 的熱點路徑(Hot Path)上,**每一次函式呼叫都有代價**(壓入/彈出呼叫棧、參數傳遞)。

* **做法**:既然棋盤已經改成一維的 `Int32Array`,直接把這一行程式碼展開(Inline):
```javascript
let c1 = this.boardArray[x + (y1 << 7)]; // 假設寬度是 128

```


* **效果**:省去函式呼叫的開銷,讓 CPU 專注在純粹的陣列定址上。

---

## 4. 用 Xorshift 取代 `Math.random()`

MCTS 的 Simulation 階段需要大量的隨機落子。JavaScript 自帶的 `Math.random()` 為了保證隨機密碼學的安全或高精度,內部實作相對複雜,速度較慢。

* **做法**:自己寫一個簡單的 32 位元 **Xorshift 隨機數產生器**。它只需要幾次位元運算,速度比 `Math.random()` 快 5~10 倍:
```javascript
// 快速隨機數狀態(全域變數)
let randState = 123456789;
function fastRandom() {
  randState ^= randState << 13;
  randState ^= randState >> 17;
  randState ^= randState << 5;
  return (randState & 0x7FFFFFFF) / 2147483647; 
}

```



---

## 5. 位元棋盤(Bitboards,終極大絕)

如果你的三角形棋盤中,每格的狀態很簡單(例如:0 空、1 藍、2 紅),而且你想追求極致。
你可以把整張棋盤拆成數個 32 位的整數(或 JavaScript 的 `BigInt` 64 位整數)。

* **原理**:一個 64 位的 `BigInt` 可以同時代表 64 個格子的狀態(1 代表有棋子,0 代表空)。
* **優勢**:當你想檢查「某個方向是否有連續棋子」時,不需要用 `while` 迴圈一格一格查,而是直接用**位元交集(`&`)和位元移位(`<<`)**。一次 CPU 指令就能同時判斷 64 個格子!

---

## 🚀 優先級建議

如果你要著手優化,請按照以下順序進行,因為它們的投資報酬率最高:

1. **重構:** 將 `this.get(x, y)` 內聯改為 `Int32Array` 的直接存取(最容易做,馬上見效)。
2. **關鍵:** 實作**增量更新(Delta Evaluation)**,別再遍歷整張地圖。
3. **突破:** 使用 **Xorshift** 取代 `Math.random()`。
4. **修仙:** 如果節點數多到會卡頓,再考慮引進**節點物件池(Object Pool)**。

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

发表于 : 2026年 6月 30日 16:23
ejsoon
我的三角形網格是豎著排的,也就是說只有朝左三角形和朝右三角形。用横坐標和縱坐標的奇偶性判斷。你有什麼更好的辦法?

代码: 全选

這種利用「座標奇偶性」來定義三角形朝向的設計非常經典,在數位幾何中稱為 **「分形網格(Split-Grid)」或「交錯三角形網格」**。

你的直覺非常敏銳!用奇偶性判斷(例如 `(x + y) % 2 === 0`)在常規開發中完全可行,但在 MCTS(蒙地卡羅樹搜尋)的熱點路徑中,**「模運算(`%`)」是一個相對昂貴的 CPU 指令**,而且頻繁的條件分支(`if-else`)會破壞 CPU 的分支預測(Branch Prediction),導致管線停頓(Pipeline Stall)。

為了在 128 × 128 的 `Int32Array` 架構下追求極致速度,以下提供兩個比傳統奇偶性判斷更高效、更優雅的頂級解決方案:

---

## 方案一:位元運算與(Bitwise AND)—— 效能最高的微調

如果你不想改動目前的座標系統,只是想讓奇偶性判斷變得「快如閃電」,你應該徹底捨棄 `% 2`,改用**位元運算**。

在二進位中,一個數字是奇數還是偶數,完全由它的最後一個 bit(第 0 位)決定。

* **朝左/朝右判斷**:
```javascript
// 取代 if ((x + y) % 2 === 0)
const isLeft = ((x + y) & 1) === 0; 

```


`& 1` 是硬體層級的位元遮罩,比 `% 2` 快上數倍,且完全沒有額外的運算開銷。
* **進一步消除 `if-else`(分支預測優化)**:
朝左和朝右三角形的鄰居查找邏輯不同。你可以利用 `& 1` 的結果(非 `0` 即 `1`)作為索引,直接從**預設的陣列**中撈取鄰居的偏移量,連 `if` 都不用寫!

---

## 方案二:頂級優化 —— 查找表(Lookup Table, LUT)

在 MCTS 中,最快的計算就是「不計算」**。既然你的棋盤邊界固定是 128 × 128,且三角形網格的拓撲結構(誰是誰的鄰居)永遠不會改變,我們可以在**遊戲初始化時,把所有格子的鄰居直接算好、存起來。

### 1. 鄰居查找表的結構設計

一個三角形只有 3 個鄰居(左/右、左上/右上、左下/右下)。
我們可以使用一個一維的 `Int32Array` 來儲存整張地圖所有格子的鄰居 Index。

* 總格子數:$128 \times 128 = 16,384$。
* 每個格子有 3 個鄰居,所以總共需要 $16,384 \times 3 = 49,152$ 個格子。
* 記憶體佔用:$49,152 \times 4 \text{ Bytes} \approx 192 \text{ KB}$(依然完美躺在 CPU L2 快取裡!)。

### 2. 實作程式碼

```javascript
// --- 1. 遊戲初始化階段 (只執行一次) ---
const BOARD_WIDTH = 128;
const neighborLUT = new Int32Array(BOARD_WIDTH * BOARD_WIDTH * 3);

for (let y = 0; y < BOARD_WIDTH; y++) {
  for (let x = 0; x < BOARD_WIDTH; x++) {
    const currIdx = x + (y << 7);
    const lutOffset = currIdx * 3;
    
    // 判斷朝向 (使用方案一的位元優化)
    const isFacingLeft = ((x + y) & 1) === 0;
    
    if (isFacingLeft) {
      // 假設朝左的鄰居是:右、左上、左下 (根據你的幾何規則調整)
      neighborLUT[lutOffset + 0] = (x + 1) + (y << 7);         // 右
      neighborLUT[lutOffset + 1] = (x - 1) + ((y - 1) << 7);   // 左上
      neighborLUT[lutOffset + 2] = (x - 1) + ((y + 1) << 7);   // 左下
    } else {
      // 朝右的鄰居:左、右上、右下
      neighborLUT[lutOffset + 0] = (x - 1) + (y << 7);         // 左
      neighborLUT[lutOffset + 1] = (x + 1) + ((y - 1) << 7);   // 右上
      neighborLUT[lutOffset + 2] = (x + 1) + ((y + 1) << 7);   // 右下
    }
    
    // 註:邊界檢查(如果超出 128x128)可以在這裡統一處理,例如設為 -1
  }
}

// --- 2. MCTS 搜尋階段的核心迴圈 (每秒執行數十萬次) ---
function getNeighborsMCTS(currIdx) {
  const lutOffset = currIdx * 3;
  
  // 瞬間拿到三個鄰居的一維 Index,沒有任何 if-else,沒有任何數學計算!
  const n1 = neighborLUT[lutOffset];
  const n2 = neighborLUT[lutOffset + 1];
  const n3 = neighborLUT[lutOffset + 2];
  
  return [n1, n2, n3];
}

```

---

## 為什麼「查找表(LUT)」是 MCTS 的終極解法?

1. **零幾何計算**:進入 MCTS 核心後,程式碼完全不需要知道現在是「朝左」還是「朝右」,也不用關心 `(x, y)` 是多少。它只專注於一維的 `currIdx`,只要做一次乘法(或移位)就能秒殺鄰居座標。
2. **消滅分支預測失敗**:程式碼中沒有了 `if (isFacingLeft)`,CPU 可以毫不遲疑地全速前進,這會讓你的 Rollout 模擬速度產生質的飛躍。
3. **邊界處理超簡單**:在初始化時,你可以把撞到棋盤外面的鄰居直接賦值為 `-1`。在 MCTS 跑迴圈時,只要看到 `neighbor < 0` 就能直接 `continue` 跳過,連 `x >= 128` 這種邊界防禦程式碼都省了。

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

发表于 : 2026年 6月 30日 16:50
ejsoon
優化代碼效能,尤其是calculateScores和evaluateBluePieceRec這兩個函式,以便後來可以用來做MCTS(蒙地卡羅樹搜尋):
一,改用一個64×64大小的Int32Array來當作棋盤網格

以横31縱31的地方視為現在的(0,0),當保存和讀取時用位元運算找到坐標,棋子類型為:1藍色棋子,2紅色棋子,3橙色棋子。

二,如果用到new Map(),要改為map.clear()

因為clear() 通常比 new Map() 快得多,且不會觸發 GC。

三,不要在函式內部建立 `bpLookup`。把它直接做成棋盤物件的屬性(`this.bpLookup`)

當玩家或 MCTS **落子(Play)** 時:直接計算該棋子的 32 位元 Key,丟進 `this.bpLookup`($O(1)$)。

當 MCTS **還原棋步(Undo)** 時:把該棋子的 Key 從 `this.bpLookup` 中 `delete` 掉($O(1)$)。

這樣一來,`calculateScores()` 執行時就**完全不需要建立 `bpLookup**`,省去了 $O(N)$ 的建立時間。


四,增量更新(Delta Evaluation)—— 徹底消滅 O(N)

目前的做法是每次算分,都重新遍歷所有藍色棋子。

在 MCTS 的 Rollout 中,每一步都只會「多放一顆棋子」或「拿掉一顆棋子」。真正頂級的優化是:永遠不要重新計算整張棋盤。

維護一個全域的 `currentScore`。當落下一顆新棋子時,只針對這顆新棋子周圍會受到影響的鄰居進行局部計算,然後把分數「加」或「減」到 `currentScore` 上。

五,鄰居查找表的結構設計

一個三角形只有 3 個鄰居(左/右、左上/右上、左下/右下)。

使用一個一維的 `Int32Array` 來儲存整張地圖所有格子的鄰居 Index。

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

代码: 全选

優化代碼效能,尤其是calculateScores和evaluateBluePieceRec這兩個函式,以便後來可以用來做MCTS(蒙地卡羅樹搜尋):
一,改用一個64×64大小的Int32Array來當作棋盤網格

以横31縱31的地方視為現在的(0,0),當保存和讀取時用位元運算找到坐標,棋子類型為:1藍色棋子,2紅色棋子,3橙色棋子。

二,如果用到new Map(),要改為map.clear()

因為clear() 通常比 new Map() 快得多,且不會觸發 GC。

三,不要在函式內部建立 `bpLookup`。把它直接做成棋盤物件的屬性(`this.bpLookup`)

當玩家或 MCTS **落子(Play)** 時:直接計算該棋子的 32 位元 Key,丟進 `this.bpLookup`($O(1)$)。

當 MCTS **還原棋步(Undo)** 時:把該棋子的 Key 從 `this.bpLookup` 中 `delete` 掉($O(1)$)。

這樣一來,`calculateScores()` 執行時就**完全不需要建立 `bpLookup**`,省去了 $O(N)$ 的建立時間。


四,增量更新(Delta Evaluation)—— 徹底消滅 O(N)

目前的做法是每次算分,都重新遍歷所有藍色棋子。

在 MCTS 的 Rollout 中,每一步都只會「多放一顆棋子」或「拿掉一顆棋子」。真正頂級的優化是:永遠不要重新計算整張棋盤。

維護一個全域的 `currentScore`。當落下一顆新棋子時,只針對這顆新棋子周圍會受到影響的鄰居進行局部計算,然後把分數「加」或「減」到 `currentScore` 上。

五,鄰居查找表的結構設計

一個三角形只有 3 個鄰居(左/右、左上/右上、左下/右下)。

使用一個一維的 `Int32Array` 來儲存整張地圖所有格子的鄰居 Index。

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