Chess Master 是遊戲大廳裡的「八合一棋類大全」:象棋、圍棋、五子棋、暗棋、飛行棋、跳棋、日式麻將、台式麻將。看似八款獨立遊戲,實際是一個共用了 70% 程式碼的單一 React 架構。這篇講當初怎麼把這個架構切出來,包括幾個踩過的雷與最後的設計取捨。
為什麼合在一起做
原本想各做一個獨立 .jsx 檔(八個檔案),但寫到第三款(圍棋)時開始重複的東西很多:棋盤渲染、回合切換、悔棋按鈕、計時器、AI 對手骨架、勝負判定流程⋯⋯ 每款獨立寫只是把同樣的 bug 重新踩一遍。最後決定停下來重構,把「棋類遊戲」抽成一個共用的 framework。
架構分層
整個 Chess Master 切成 四層:
┌──────────────────────────────────┐ │ Layer 4: GameSelector (UI) │ 選哪款棋 ├──────────────────────────────────┤ │ Layer 3: Game Definitions │ 象棋規則 / 圍棋規則 / ... ├──────────────────────────────────┤ │ Layer 2: BoardEngine (共用) │ 棋盤渲染、互動、計時器、悔棋 ├──────────────────────────────────┤ │ Layer 1: AIRunner (共用) │ Web Worker 跑 minimax └──────────────────────────────────┘
每款棋只需要實作 Game Definition(Layer 3)— 一個約 200~400 行的 JS 物件,描述:
const Xiangqi = {
name: '象棋',
boardSize: { rows: 10, cols: 9 },
initialPieces: [/* 32 顆棋子的初始位置 */],
legalMoves(board, piece) { /* ... */ },
evaluateState(board, currentTurn) { /* ... */ },
scoreForAI(board, side) { /* ... */ },
renderPiece(piece, ctx) { /* ... */ },
};
Layer 2 的 BoardEngine 接到 Game Definition 後,就能把「使用者點擊棋盤」翻譯成「呼叫 legalMoves → 顯示 highlight → 等待第二次點擊 → 呼叫 evaluateState」的標準流程。每款棋的差異收斂到 Layer 3 的純資料 + 純函式。
共用 hooks
BoardEngine 把通用行為包成 hooks,每款棋遊戲都拿同一份:
const turn = useTurnState({ players: ['red', 'black'] });
const history = useGameHistory(initialBoard);
useAIOpponent({
enabled: opponent === 'ai',
side: 'black',
thinkingTimeMs: 1500,
onMove: (move) => history.applyMove(move),
});
const timer = useGameTimer({ totalMs: 600000, side: turn.current });
這套 hooks 寫了大約 600 行,但讓每款新棋從「800 行起跳」降到「200~400 行」。投資報酬率非常高。
處理「棋類規則差異」的策略
八款棋的差異比表面複雜得多。舉幾個例子:
| 差異點 | 例子 |
|---|---|
| 棋盤形狀 | 象棋有「楚河漢界」、跳棋是六角格、麻將沒棋盤只有手牌 |
| 勝負規則 | 圍棋數目、五子棋連珠、暗棋吃光、麻將胡牌 |
| 移動方式 | 象棋逐格、跳棋可連跳、飛行棋擲骰 |
| 每方棋子數 | 象棋固定 16、圍棋無限、暗棋未定(隨翻隨多) |
策略是 把差異收斂到「函式」,而不是「flag」。一旦看到 if (gameType === 'go') { ... } else if (gameType === 'mahjong') { ... } 就拒絕,全部改成「Game Definition 上的方法」。BoardEngine 只呼叫 game.legalMoves(),不需要知道是哪款棋。
麻將是異類,獨立處理
原本想把麻將也塞進同一個 BoardEngine,但麻將跟其他七款棋的差距太大:
- 沒有「棋盤」,只有手牌 + 牌堆 + 棄牌池
- 四人同時、不是嚴格回合制(吃碰槓胡可中斷他人回合)
- 勝負判定要算番數,不是單純連線
強塞進共用框架反而會讓兩邊都醜。最後決定:麻將分支獨立。共用 Layer 1 的 AIRunner 與 Layer 4 的 GameSelector,但有自己的 MahjongEngine。日麻與台麻則共用 MahjongEngine,差別只在「番數計算」與「役種定義」。
這次經驗讓我學到:抽象的價值在於減少重複勞動,不在於追求形式上的整齊。當強制統一會讓兩邊都更難寫,就該停手。
AI 對手分三種強度
AIRunner 的核心是 minimax + alpha-beta pruning,跑在 Web Worker 裡避免阻塞主執行緒。三個強度的差異不是換演算法,而是調幾個參數:
const AI_LEVELS = {
easy: { depth: 2, randomness: 0.4, thinkingMs: 800 },
medium: { depth: 4, randomness: 0.15, thinkingMs: 1500 },
hard: { depth: 6, randomness: 0, thinkingMs: 2500 },
};
- depth:搜尋幾步深。每深一步,計算量約 ×10。
- randomness:在「分數差距 < X」的候選裡隨機選一個。讓初級 AI 偶爾犯錯,玩家有勝算才有趣。
- thinkingMs:強制至少思考 X ms 才出招。即使一秒內算完也要等到時間到,避免「AI 一瞬間就出招」破壞節奏。
圍棋例外,因為 minimax 在 19×19 的盤面上完全爆炸(每一步約 200 個合法位置)。圍棋的 AI 改用「pattern matching + 啟發式 move ordering」的簡化版,強度遠不如真正的 AlphaGo,但對休閒玩家夠了。
三個踩過的坑
1. 過早抽象 Board 元件
第一版的 Board 試圖用一個泛型物件描述「任意棋盤」(行、列、格子形狀、座標系統),結果 props 多到 14 個,每款棋都得傳完整對應。重構後改成「Board 接受 children」,每款棋自己渲染棋格,靠 useBoardCoords() hook 共用座標換算邏輯。
2. AIRunner 的記憶體洩漏
切換棋類時 Web Worker 沒有正確 terminate,每換一次就多一個 worker 在背景跑。3 個小時後 Chrome 開始卡。修法:在 BoardEngine 的 useEffect cleanup 裡 worker.terminate(),並且把 worker 的建立放進 ref 而非 state,避免 re-render 時被 stale closure 捕捉。
3. 悔棋造成 AI 思考被取消
玩家悔棋時,AI 還在 worker 裡跑 minimax。悔棋完盤面 state 改變了,AI 算出來的招會作用在舊盤面,導致非法移動。修法:每次玩家動作前先發 { type: 'cancel' } 訊息給 worker,worker 收到後丟棄當前任務並回 ack。
未來規劃
下一階段想加的:
- 線上對弈:靠 Cloudflare Durable Objects 做 room 管理,不用自己架 WebSocket server。
- 棋譜匯出:象棋 / 圍棋 / 五子棋輸出標準棋譜檔案(PGN / SGF),讓玩家可以分享或復盤。
- AI 強度可調:把 depth 與 randomness 開放給玩家,自由微調對手強弱。
結語
「八合一」聽起來像是工程量乘以八,實際做完是「核心一份 + 八份規則資料」。前期重構的痛苦是真的,但中期之後加新棋類的速度大概是「兩天上一款」。對小工作室而言,共用架構 = 產品線擴張的槓桿。
有想交流棋類遊戲架構的朋友,歡迎寄信 lo246179268@gmail.com。