共享会话
改進代碼以提升AI的思考能力。增加後臺運算功能,以及增量搜尋功能。同時還有哪些地方可以改進的一並說明和更改。明確給出所…
分享于 2026年9月3日 02:01
QuanAI我檢查了整個 trirhombus2894.html。目前 AI 的主要問題不只是演算法強度,而是存在幾個會直接拖慢搜尋、造成座標碰撞及讓主執行緒卡死的核心問題。
一、目前最重要的問題
-
棋盤陣列尺寸錯誤
- 程式使用 12 bit 儲存座標索引,理論上應支援
0~4095。 hasHoleAfterPlacement()也使用 64×64 的解碼方式。- 但目前實際只配置了 1024 格、32×32。
- 這會讓座標超過約 ±15 後產生碰撞或錯誤。
- 程式使用 12 bit 儲存座標索引,理論上應支援
-
getValidMoves()在測試落子時錯誤擴大邊界- 現有搜尋用
set()暫時放棋,又用set(..., 0)移除。 - 但是邊界只會擴大、不會縮回。
- MCTS 搜尋一段時間後,搜尋範圍會被擴展到整個棋盤。
- 現有搜尋用
-
每一次模擬落子都重新計算分數
tryPlacePiece()不論 UI 落子或 MCTS 模擬,均呼叫calculateScores()。- MCTS 一次 rollout 會呼叫很多次,浪費大量運算。
-
每次 clone 都重新建立鄰居表
- 現在每次
rootState.clone()都建立新的鄰居表。 - 鄰居表其實所有棋局完全相同,應共用一份。
- 現在每次
-
AI 在主執行緒運算
- Minimax 遞迴時,畫面、按鈕、動畫和「中止 AI」都可能無法即時反應。
await setTimeout(0)只能在遞迴返回後讓出主執行緒,不能解決深層搜尋阻塞。
-
MCTS 不保留之前搜尋結果
- 每一手都從零開始。
- 沒有跨局面共用節點統計,也沒有 Minimax transposition table。
-
restartGame()沒有重建gameLogic- 棋盤 SVG 雖然清空,但核心棋盤有機會保留上一局資料。
- 分數也沒有可靠地重設。
以下修改會加入:
- Web Worker 後臺執行。
- 增量式 MCTS,約每 12ms 一個批次。
- 迭代加深 Minimax。
- MCTS 節點統計跨回合重用。
- Minimax transposition table。
- 增量 frontier 合法著法生成。
- 共用鄰居表。
- 可跳過模擬過程中的分數計算。
- 即時中止後臺 Worker。
- 修正 64×64 棋盤索引。
- 修正重新開始及資源釋放問題。
二、修改 HexGridGame
修改位置
找到:
class HexGridGame {不必替換整個類別。依序替換以下方法;沒有列出的 calculateScores()、evaluateBluePieceRec()、isRight()、fastHoleCheck() 等原方法可以保留。
2.1 替換 constructor,並在其後加入共用表和增量 frontier
將原本的 constructor()、_getIndex()、_idxToCoord()、initNeighborsTable()、get()、set()、getNeighbors() 全部替換成:
constructor() {
const cellCount = HexGridGame.CELL_COUNT;
this.grid = new Int32Array(cellCount);
// 所有棋局共用同一份鄰居表,不再於每次 clone 時重建。
this.neighbors = HexGridGame.getSharedNeighbors();
this.bpLookup = new Array(cellCount).fill(null);
this.memo1 = new Map();
this.memo2 = new Map();
this.history = [];
this.bluePieces = [];
this.totalPieces = 0;
this.minX = Infinity;
this.maxX = -Infinity;
this.minY = Infinity;
this.maxY = -Infinity;
this.visited = new Uint8Array(cellCount);
this.bfsQueue = new Uint16Array(cellCount);
// 增量合法著法生成需要的 frontier。
this.frontierCount = new Uint8Array(cellCount);
this.frontier = new Set();
// 兩組 Zobrist hash,供 MCTS 節點重用及 Minimax TT 使用。
this.hashA = 0;
this.hashB = 0;
}
static get SIDE() {
return 64;
}
static get OFFSET() {
return 31;
}
static get MIN_COORD() {
return -31;
}
static get MAX_COORD() {
return 32;
}
static get CELL_COUNT() {
return 4096;
}
static coordToIndex(x, y) {
if (
x < HexGridGame.MIN_COORD ||
x > HexGridGame.MAX_COORD ||
y < HexGridGame.MIN_COORD ||
y > HexGridGame.MAX_COORD
) {
return -1;
}
return (y + HexGridGame.OFFSET) * HexGridGame.SIDE + (x + HexGridGame.OFFSET);
}
static getSharedNeighbors() {
if (HexGridGame._sharedNeighbors) {
return HexGridGame._sharedNeighbors;
}
const table = new Int32Array(HexGridGame.CELL_COUNT * 3);
table.fill(-1);
for (let idx = 0; idx < HexGridGame.CELL_COUNT; idx++) {
const y = Math.floor(idx / HexGridGame.SIDE) - HexGridGame.OFFSET;
const x = (idx % HexGridGame.SIDE) - HexGridGame.OFFSET;
const base = idx * 3;
const isRight = (y & 1) === ((x - 1) & 1);
table[base] = HexGridGame.coordToIndex(x, y - 1);
table[base + 1] = HexGridGame.coordToIndex(x, y + 1);
table[base + 2] = HexGridGame.coordToIndex(
isRight ? x - 1 : x + 1,
y
);
}
HexGridGame._sharedNeighbors = table;
return table;
}
static makeZobrist(seed) {
const result = new Uint32Array(HexGridGame.CELL_COUNT * 3);
let value = seed >>> 0;
for (let i = 0; i < result.length; i++) {
value ^= value << 13;
value ^= value >>> 17;
value ^= value << 5;
result[i] = value >>> 0;
}
return result;
}
static getZobristA() {
if (!HexGridGame._zobristA) {
HexGridGame._zobristA = HexGridGame.makeZobrist(0x9e3779b9);
}
return HexGridGame._zobristA;
}
static getZobristB() {
if (!HexGridGame._zobristB) {
HexGridGame._zobristB = HexGridGame.makeZobrist(0x85ebca6b);
}
return HexGridGame._zobristB;
}
_getIndex(x, y) {
return HexGridGame.coordToIndex(x, y);
}
_idxToCoord(idx) {
if (idx < 0 || idx >= HexGridGame.CELL_COUNT) {
return null;
}
return {
x: (idx % HexGridGame.SIDE) - HexGridGame.OFFSET,
y: Math.floor(idx / HexGridGame.SIDE) - HexGridGame.OFFSET
};
}
// 保留名稱,避免其他舊程式呼叫時出錯。
initNeighborsTable() {
this.neighbors = HexGridGame.getSharedNeighbors();
}
get(x, y) {
const idx = this._getIndex(x, y);
return idx < 0 ? 0 : this.grid[idx];
}
_writeIndex(idx, value, trackBounds = true) {
if (idx < 0 || idx >= HexGridGame.CELL_COUNT) {
return false;
}
const oldValue = this.grid[idx];
if (oldValue === value) {
return true;
}
const zobristA = HexGridGame.getZobristA();
const zobristB = HexGridGame.getZobristB();
if (oldValue !== 0) {
const zIndex = idx * 3 + oldValue - 1;
this.hashA = (this.hashA ^ zobristA[zIndex]) >>> 0;
this.hashB = (this.hashB ^ zobristB[zIndex]) >>> 0;
}
if (value !== 0) {
const zIndex = idx * 3 + value - 1;
this.hashA = (this.hashA ^ zobristA[zIndex]) >>> 0;
this.hashB = (this.hashB ^ zobristB[zIndex]) >>> 0;
}
const base = idx * 3;
if (oldValue === 0 && value !== 0) {
this.frontier.delete(idx);
this.frontierCount[idx] = 0;
this.grid[idx] = value;
for (let i = 0; i < 3; i++) {
const neighborIdx = this.neighbors[base + i];
if (
neighborIdx >= 0 &&
neighborIdx < HexGridGame.CELL_COUNT &&
this.grid[neighborIdx] === 0
) {
this.frontierCount[neighborIdx]++;
this.frontier.add(neighborIdx);
}
}
} else if (oldValue !== 0 && value === 0) {
this.grid[idx] = 0;
let occupiedNeighbors = 0;
for (let i = 0; i < 3; i++) {
const neighborIdx = this.neighbors[base + i];
if (neighborIdx < 0 || neighborIdx >= HexGridGame.CELL_COUNT) {
continue;
}
if (this.grid[neighborIdx] === 0) {
if (this.frontierCount[neighborIdx] > 0) {
this.frontierCount[neighborIdx]--;
}
if (this.frontierCount[neighborIdx] === 0) {
this.frontier.delete(neighborIdx);
}
} else {
occupiedNeighbors++;
}
}
this.frontierCount[idx] = occupiedNeighbors;
if (occupiedNeighbors > 0) {
this.frontier.add(idx);
} else {
this.frontier.delete(idx);
}
} else {
// 非零棋子類型直接替換。一般流程不會使用,但保留完整性。
this.grid[idx] = value;
}
if (trackBounds && value !== 0) {
const coord = this._idxToCoord(idx);
if (coord.x < this.minX) this.minX = coord.x;
if (coord.x > this.maxX) this.maxX = coord.x;
if (coord.y < this.minY) this.minY = coord.y;
if (coord.y > this.maxY) this.maxY = coord.y;
}
return true;
}
set(x, y, value, trackBounds = true) {
const idx = this._getIndex(x, y);
if (idx < 0) return false;
return this._writeIndex(idx, value, trackBounds);
}
getNeighbors(x, y) {
const idx = this._getIndex(x, y);
if (idx < 0) {
return [];
}
const result = [];
const base = idx * 3;
for (let i = 0; i < 3; i++) {
const coord = this._idxToCoord(this.neighbors[base + i]);
if (coord) result.push(coord);
}
return result;
}
positionKey(player, workPieces) {
return [
this.hashA.toString(36),
this.hashB.toString(36),
player,
workPieces[1][0],
workPieces[1][1],
workPieces[1][2],
workPieces[2][0],
workPieces[2][1],
workPieces[2][2]
].join(':');
}這項修改同時解決:
- 32×32 和 64×64 不一致。
- 座標越界碰撞。
- clone 重建鄰居表。
- 無法建立局面 hash。
- 每次掃描整個邊界才能產生合法著法。
2.2 替換 tryPlacePiece() 和 undo()
將原本兩個方法替換為:
tryPlacePiece(
pieceType,
p1,
p2,
isImport = false,
withScores = true
) {
const idx1 = this._getIndex(p1.x, p1.y);
const idx2 = this._getIndex(p2.x, p2.y);
if (
pieceType < 1 ||
pieceType > 3 ||
idx1 < 0 ||
idx2 < 0 ||
idx1 === idx2 ||
this.grid[idx1] !== 0 ||
this.grid[idx2] !== 0
) {
return {
success: false,
reason: '干涉',
scores: null
};
}
const previousBounds = {
minX: this.minX,
maxX: this.maxX,
minY: this.minY,
maxY: this.maxY
};
this._writeIndex(idx1, pieceType, true);
this._writeIndex(idx2, pieceType, true);
this.totalPieces++;
let hasHole = false;
if (isImport) {
hasHole = this.hasHoleAfterPlacement([p1, p2]);
} else {
hasHole =
this.totalPieces < 5
? false
: this.fastHoleCheck(pieceType, p1, p2);
}
if (hasHole) {
this._writeIndex(idx2, 0, false);
this._writeIndex(idx1, 0, false);
this.totalPieces--;
this.minX = previousBounds.minX;
this.maxX = previousBounds.maxX;
this.minY = previousBounds.minY;
this.maxY = previousBounds.maxY;
return {
success: false,
reason: '空洞',
scores: null
};
}
let bpObj = null;
if (pieceType === 1) {
const mainP = p1.x < p2.x ? p1 : p2;
bpObj = { x: mainP.x, y: mainP.y };
this.bluePieces.push(bpObj);
this.bpLookup[idx1] = bpObj;
this.bpLookup[idx2] = bpObj;
}
this.history.push({
pieceType,
p1: { x: p1.x, y: p1.y },
p2: { x: p2.x, y: p2.y },
idx1,
idx2,
bpObj,
previousBounds
});
return {
success: true,
scores: withScores ? this.calculateScores() : null
};
}
undo(withScores = true) {
if (this.history.length === 0) {
return withScores ? { p1Score: 0, p2Score: 0 } : null;
}
const lastMove = this.history.pop();
if (lastMove.pieceType === 1) {
this.bluePieces.pop();
this.bpLookup[lastMove.idx1] = null;
this.bpLookup[lastMove.idx2] = null;
}
// 先清除棋格,再恢復落子前的精確邊界。
this._writeIndex(lastMove.idx2, 0, false);
this._writeIndex(lastMove.idx1, 0, false);
this.totalPieces--;
this.minX = lastMove.previousBounds.minX;
this.maxX = lastMove.previousBounds.maxX;
this.minY = lastMove.previousBounds.minY;
this.maxY = lastMove.previousBounds.maxY;
return withScores ? this.calculateScores() : null;
}AI 模擬時使用:
state.tryPlacePiece(pieceType, p1, p2, false, false);
state.undo(false);如此不會在每個搜尋節點重新計算分數。
正常 UI 落子不必修改,因為 withScores 預設仍是 true。
2.3 替換 clone(),增加 snapshot 和 copyFrom
將原本 clone() 替換成以下整段:
exportSnapshot() {
return {
grid: this.grid.slice(),
bluePieces: this.bluePieces.map(piece => ({
x: piece.x,
y: piece.y
})),
totalPieces: this.totalPieces,
minX: this.minX,
maxX: this.maxX,
minY: this.minY,
maxY: this.maxY
};
}
loadSnapshot(snapshot) {
this.grid.fill(0);
this.frontierCount.fill(0);
this.frontier.clear();
this.bpLookup.fill(null);
this.bluePieces = [];
this.history = [];
this.hashA = 0;
this.hashB = 0;
for (let idx = 0; idx < HexGridGame.CELL_COUNT; idx++) {
const value = snapshot.grid[idx] || 0;
if (value !== 0) {
this._writeIndex(idx, value, false);
}
}
this.totalPieces = snapshot.totalPieces;
this.minX = snapshot.minX;
this.maxX = snapshot.maxX;
this.minY = snapshot.minY;
this.maxY = snapshot.maxY;
this.bluePieces = snapshot.bluePieces.map(piece => ({
x: piece.x,
y: piece.y
}));
for (const bp of this.bluePieces) {
const idx1 = this._getIndex(bp.x, bp.y);
const idx2 = this._getIndex(bp.x + 1, bp.y);
if (idx1 >= 0) this.bpLookup[idx1] = bp;
if (idx2 >= 0) this.bpLookup[idx2] = bp;
}
return this;
}
static fromSnapshot(snapshot) {
return new HexGridGame().loadSnapshot(snapshot);
}
copyFrom(other) {
this.grid.set(other.grid);
this.frontierCount.set(other.frontierCount);
this.frontier.clear();
for (const idx of other.frontier) {
this.frontier.add(idx);
}
this.totalPieces = other.totalPieces;
this.minX = other.minX;
this.maxX = other.maxX;
this.minY = other.minY;
this.maxY = other.maxY;
this.hashA = other.hashA;
this.hashB = other.hashB;
this.bluePieces = other.bluePieces.map(piece => ({
x: piece.x,
y: piece.y
}));
this.bpLookup.fill(null);
for (const bp of this.bluePieces) {
const idx1 = this._getIndex(bp.x, bp.y);
const idx2 = this._getIndex(bp.x + 1, bp.y);
if (idx1 >= 0) this.bpLookup[idx1] = bp;
if (idx2 >= 0) this.bpLookup[idx2] = bp;
}
this.history.length = 0;
this.memo1.clear();
this.memo2.clear();
return this;
}
clone() {
return new HexGridGame().copyFrom(this);
}copyFrom() 的用途是讓 Worker 的 MCTS 重複使用同一個模擬物件,減少垃圾回收。
2.4 替換 getValidMoves()
將原本整個 getValidMoves() 替換為:
getValidMoves(player, pPiecesLeft) {
const p1Remaining =
pPiecesLeft[1][0] +
pPiecesLeft[1][1] +
pPiecesLeft[1][2];
const p2Remaining =
pPiecesLeft[2][0] +
pPiecesLeft[2][1] +
pPiecesLeft[2][2];
const movesPlayed = 36 - (p1Remaining + p2Remaining);
// 第一手固定為中央藍棋。
if (movesPlayed === 0) {
if (pPiecesLeft[player][0] <= 0) {
return [];
}
const idx1 = this._getIndex(0, 0);
const idx2 = this._getIndex(1, 0);
return [(idx1 << 12) | idx2];
}
// 增量搜尋:只檢查目前棋群外圍相鄰的空格。
const candidates = Array.from(this.frontier);
const validMoves = [];
const seen = new Set();
for (let pid = 0; pid < 3; pid++) {
if (pPiecesLeft[player][pid] <= 0) {
continue;
}
// 第二手必須為紅色。
if (movesPlayed === 1 && pid !== 1) {
continue;
}
const pieceType = pid + 1;
for (let i = 0; i < candidates.length; i++) {
const firstIdx = candidates[i];
if (
firstIdx < 0 ||
firstIdx >= HexGridGame.CELL_COUNT ||
this.grid[firstIdx] !== 0
) {
continue;
}
const firstCoord = this._idxToCoord(firstIdx);
const base = firstIdx * 3;
const isRight = this.isRight(firstCoord.x, firstCoord.y);
let secondIdx;
if (pid === 0) {
secondIdx = this.neighbors[base + 2];
} else if (pid === 1) {
secondIdx = this.neighbors[base + (isRight ? 0 : 1)];
} else {
secondIdx = this.neighbors[base + (isRight ? 1 : 0)];
}
if (
secondIdx < 0 ||
secondIdx >= HexGridGame.CELL_COUNT ||
this.grid[secondIdx] !== 0
) {
continue;
}
const lowIdx = Math.min(firstIdx, secondIdx);
const highIdx = Math.max(firstIdx, secondIdx);
const encodedMove = (pid << 24) | (lowIdx << 12) | highIdx;
if (seen.has(encodedMove)) {
continue;
}
seen.add(encodedMove);
const p1 = this._idxToCoord(firstIdx);
const p2 = this._idxToCoord(secondIdx);
// 測試棋格時不更新邊界。
this._writeIndex(firstIdx, pieceType, false);
this._writeIndex(secondIdx, pieceType, false);
this.totalPieces++;
const hasHole =
this.totalPieces < 5
? false
: this.fastHoleCheck(pieceType, p1, p2);
this.totalPieces--;
this._writeIndex(secondIdx, 0, false);
this._writeIndex(firstIdx, 0, false);
if (!hasHole) {
validMoves.push(encodedMove);
}
}
}
return validMoves;
}這是真正的「增量合法著法搜尋」:不再掃描整個 minX~maxX 矩形,只檢查棋群 frontier。
2.5 修改 hasHoleAfterPlacement()
該方法裡目前有兩處:
nIdx < 1024全部改成:
nIdx < HexGridGame.CELL_COUNT原本這兩行解碼:
let cy = (currIdx >> 6) - 31;
let cx = (currIdx & 63) - 31;請保留。它們本來就是 64×64 的正確解碼。
2.6 避免 UI 預檢時重算分數
找到 processBoardClick() 裡:
gameLogic.undo();
attemptDrop(bestTri);改成:
gameLogic.undo(false);
attemptDrop(bestTri);三、加入 Web Worker 後臺 AI
架構
以下 Worker:
- 在背景執行,不阻塞動畫和按鈕。
- MCTS 每約 12ms 暫停一次,處理訊息。
- MCTS 保留同一手搜尋樹。
- Worker 不銷毀時,後續回合可重用相同局面的節點統計。
- Minimax 使用跨搜尋 transposition table。
- 使用 heuristic rollout,而不是完全隨機 rollout。
- 「中止 AI」可以直接終止 Worker。
3.1 替換舊 AI 引擎
找到:
// === 殘局絕對計算:Iterative Deepening Minimax 引擎 ===從這行開始,一直到舊的 triggerAITurn() 結束為止全部刪除。
也就是保留其後這行:
// === 玩家類型與 AI 設定面板邏輯 ===在這兩部分之間放入以下程式。
Worker 搜尋核心
function aiWorkerMain() {
const minimaxTable = new Map();
const mctsStats = new Map();
let activeRequestId = 0;
self.onmessage = event => {
const message = event.data;
if (message.type === 'stop') {
if (message.requestId === activeRequestId) {
activeRequestId = 0;
}
return;
}
if (message.type !== 'search') {
return;
}
activeRequestId = message.requestId;
runSearch(message).catch(error => {
self.postMessage({
type: 'error',
requestId: message.requestId,
message: error instanceof Error ? error.message : String(error)
});
});
};
async function runSearch(message) {
const state = HexGridGame.fromSnapshot(message.snapshot);
const pieces = {
1: Int8Array.from(message.pieces[1]),
2: Int8Array.from(message.pieces[2])
};
const config = {
time: Math.max(100, Number(message.config.time) || 4000),
c: Math.max(0.01, Number(message.config.c) || 2.4),
heuristicN: Math.max(
0,
Number(message.config.heuristicN) || 0
),
heuristicChecks: Math.max(
1,
Number(message.config.heuristicChecks) || 6
),
pureMinimaxN: Math.max(
0,
Number(message.config.pureMinimaxN) || 0
)
};
if (minimaxTable.size > 150000) {
minimaxTable.clear();
}
if (mctsStats.size > 200000) {
mctsStats.clear();
}
const remaining = countRemaining(pieces);
const mode =
remaining <= config.pureMinimaxN ? 'minimax' : 'mcts';
const context = {
requestId: message.requestId,
rootPlayer: message.player,
startTime: performance.now(),
deadline: performance.now() + config.time,
nodes: 0,
reused: 0,
aborted: false,
config
};
let result;
if (mode === 'minimax') {
result = runIterativeMinimax(
state,
message.player,
pieces,
context
);
} else {
result = await runIncrementalMCTS(
state,
message.player,
pieces,
context
);
}
if (activeRequestId !== message.requestId) {
return;
}
self.postMessage({
type: 'done',
requestId: message.requestId,
mode,
elapsed: performance.now() - context.startTime,
reused: context.reused,
...result
});
}
function countRemaining(pieces) {
return (
pieces[1][0] +
pieces[1][1] +
pieces[1][2] +
pieces[2][0] +
pieces[2][1] +
pieces[2][2]
);
}
function boardDiff(state) {
const scores = state.calculateScores();
return scores.p1Score - scores.p2Score;
}
function decodeMove(move) {
return {
pid: (move >>> 24) & 0xff,
p1Idx: (move >>> 12) & 0xfff,
p2Idx: move & 0xfff
};
}
function applyEncodedMove(state, move, player, pieces) {
const decoded = decodeMove(move);
if (pieces[player][decoded.pid] <= 0) {
return false;
}
const result = state.tryPlacePiece(
decoded.pid + 1,
state._idxToCoord(decoded.p1Idx),
state._idxToCoord(decoded.p2Idx),
false,
false
);
if (!result.success) {
return false;
}
pieces[player][decoded.pid]--;
return true;
}
function undoEncodedMove(state, move, player, pieces) {
const decoded = decodeMove(move);
pieces[player][decoded.pid]++;
state.undo(false);
}
function moveDelta(state, move, player, baseDiff = null) {
const before = baseDiff === null ? boardDiff(state) : baseDiff;
const decoded = decodeMove(move);
const result = state.tryPlacePiece(
decoded.pid + 1,
state._idxToCoord(decoded.p1Idx),
state._idxToCoord(decoded.p2Idx),
false,
false
);
if (!result.success) {
return -Infinity;
}
const after = boardDiff(state);
state.undo(false);
// 對目前行動者而言,越大越好。
return player === 1 ? after - before : before - after;
}
function orderMoves(state, moves, player, ttBestMove = null) {
if (moves.length <= 1) {
return moves.slice();
}
const result = [];
const selected = new Set();
const scoreLimit = Math.min(moves.length, 24);
const baseDiff = boardDiff(state);
if (ttBestMove !== null) {
const ttIndex = moves.indexOf(ttBestMove);
if (ttIndex >= 0) selected.add(ttIndex);
}
for (let i = 0; i < scoreLimit; i++) {
selected.add(
Math.floor((i * moves.length) / scoreLimit)
);
}
for (let i = 0; i < moves.length; i++) {
let score = -1000000;
if (selected.has(i)) {
score = moveDelta(state, moves[i], player, baseDiff);
}
if (moves[i] === ttBestMove) {
score += 1000000000;
}
result.push({
move: moves[i],
score
});
}
result.sort((a, b) => b.score - a.score);
return result.map(item => item.move);
}
function alphaBeta(
state,
depth,
alpha,
beta,
player,
pieces,
context
) {
context.nodes++;
if (
(context.nodes & 127) === 0 &&
performance.now() >= context.deadline
) {
context.aborted = true;
}
if (context.aborted) {
return boardDiff(state);
}
if (depth === 0) {
return boardDiff(state);
}
const key = state.positionKey(player, pieces);
const alphaOriginal = alpha;
const betaOriginal = beta;
const cached = minimaxTable.get(key);
if (cached && cached.depth >= depth) {
if (cached.flag === 'exact') {
return cached.score;
}
if (cached.flag === 'lower') {
alpha = Math.max(alpha, cached.score);
} else if (cached.flag === 'upper') {
beta = Math.min(beta, cached.score);
}
if (alpha >= beta) {
return cached.score;
}
}
const moves = state.getValidMoves(player, pieces);
if (moves.length === 0) {
return boardDiff(state);
}
const orderedMoves = orderMoves(
state,
moves,
player,
cached ? cached.bestMove : null
);
const nextPlayer = player === 1 ? 2 : 1;
let bestMove = orderedMoves[0];
let bestScore = player === 1 ? -Infinity : Infinity;
for (let i = 0; i < orderedMoves.length; i++) {
const move = orderedMoves[i];
if (!applyEncodedMove(state, move, player, pieces)) {
continue;
}
const score = alphaBeta(
state,
depth - 1,
alpha,
beta,
nextPlayer,
pieces,
context
);
undoEncodedMove(state, move, player, pieces);
if (context.aborted) {
break;
}
if (player === 1) {
if (score > bestScore) {
bestScore = score;
bestMove = move;
}
alpha = Math.max(alpha, bestScore);
} else {
if (score < bestScore) {
bestScore = score;
bestMove = move;
}
beta = Math.min(beta, bestScore);
}
if (alpha >= beta) {
break;
}
}
if (!context.aborted && Number.isFinite(bestScore)) {
let flag = 'exact';
if (bestScore <= alphaOriginal) {
flag = 'upper';
} else if (bestScore >= betaOriginal) {
flag = 'lower';
}
minimaxTable.set(key, {
depth,
score: bestScore,
flag,
bestMove
});
}
return Number.isFinite(bestScore)
? bestScore
: boardDiff(state);
}
function runIterativeMinimax(
rootState,
rootPlayer,
pieces,
context
) {
const rootMoves = rootState.getValidMoves(rootPlayer, pieces);
if (rootMoves.length === 0) {
return {
bestMove: null,
score: boardDiff(rootState),
nodes: 0,
completedDepth: 0
};
}
const maxDepth = countRemaining(pieces);
let bestMove = rootMoves[0];
let bestScore = boardDiff(rootState);
let completedDepth = 0;
let lastProgressTime = context.startTime;
for (let depth = 1; depth <= maxDepth; depth++) {
context.aborted = false;
const rootKey = rootState.positionKey(rootPlayer, pieces);
const cached = minimaxTable.get(rootKey);
const moves = orderMoves(
rootState,
rootMoves,
rootPlayer,
cached ? cached.bestMove : bestMove
);
let currentBestMove = null;
let currentBestScore =
rootPlayer === 1 ? -Infinity : Infinity;
let alpha = -Infinity;
let beta = Infinity;
const nextPlayer = rootPlayer === 1 ? 2 : 1;
for (let i = 0; i < moves.length; i++) {
const move = moves[i];
if (performance.now() >= context.deadline) {
context.aborted = true;
break;
}
if (!applyEncodedMove(rootState, move, rootPlayer, pieces)) {
continue;
}
const score = alphaBeta(
rootState,
depth - 1,
alpha,
beta,
nextPlayer,
pieces,
context
);
undoEncodedMove(rootState, move, rootPlayer, pieces);
if (context.aborted) {
break;
}
if (rootPlayer === 1) {
if (score > currentBestScore) {
currentBestScore = score;
currentBestMove = move;
}
alpha = Math.max(alpha, currentBestScore);
} else {
if (score < currentBestScore) {
currentBestScore = score;
currentBestMove = move;
}
beta = Math.min(beta, currentBestScore);
}
const now = performance.now();
if (now - lastProgressTime >= 100) {
self.postMessage({
type: 'progress',
requestId: context.requestId,
mode: 'minimax',
elapsed: now - context.startTime,
depth,
nodes: context.nodes,
score: Number.isFinite(currentBestScore)
? currentBestScore
: bestScore
});
lastProgressTime = now;
}
}
if (
!context.aborted &&
currentBestMove !== null &&
Number.isFinite(currentBestScore)
) {
bestMove = currentBestMove;
bestScore = currentBestScore;
completedDepth = depth;
minimaxTable.set(
rootState.positionKey(rootPlayer, pieces),
{
depth,
score: bestScore,
flag: 'exact',
bestMove
}
);
}
if (
context.aborted ||
performance.now() >= context.deadline
) {
break;
}
}
return {
bestMove,
score: bestScore,
nodes: context.nodes,
completedDepth
};
}
function getSharedStats(key, context) {
let stats = mctsStats.get(key);
if (stats) {
context.reused++;
return stats;
}
stats = {
visits: 0,
valueSum: 0
};
mctsStats.set(key, stats);
return stats;
}
class MCTSNode {
constructor(
move,
parent,
playerToMove,
key,
moves,
prior,
context
) {
this.move = move;
this.parent = parent;
this.playerToMove = playerToMove;
this.key = key;
this.children = [];
this.unexpanded = moves;
this.prior = prior;
this.stats = getSharedStats(key, context);
}
}
function selectChild(node, player, explorationConstant) {
let bestChild = null;
let bestValue = -Infinity;
const parentVisits = Math.max(1, node.stats.visits);
for (const child of node.children) {
const visits = child.stats.visits;
const qP1 =
visits > 0
? child.stats.valueSum / visits
: 0;
// P1 選擇較大的 P1 價值;P2 選擇較小的 P1 價值。
const exploitation = player === 1 ? qP1 : -qP1;
const exploration =
explorationConstant *
child.prior *
Math.sqrt(parentVisits + 1) /
(1 + visits);
const value = exploitation + exploration;
if (value > bestValue) {
bestValue = value;
bestChild = child;
}
}
return bestChild;
}
function chooseHeuristicIndex(
state,
moves,
player,
maxChecks
) {
if (moves.length <= 1) {
return {
index: 0,
delta: moves.length === 1
? moveDelta(state, moves[0], player)
: 0
};
}
const checkCount = Math.min(
moves.length,
Math.max(1, maxChecks)
);
const selected = new Set();
while (selected.size < checkCount) {
selected.add(
Math.floor(Math.random() * moves.length)
);
}
const baseDiff = boardDiff(state);
let bestIndex = selected.values().next().value;
let bestDelta = -Infinity;
for (const index of selected) {
const delta = moveDelta(
state,
moves[index],
player,
baseDiff
);
if (delta > bestDelta) {
bestDelta = delta;
bestIndex = index;
}
}
return {
index: bestIndex,
delta: bestDelta
};
}
function chooseRolloutMove(
state,
moves,
player,
pieces,
config
) {
const remaining = countRemaining(pieces);
const isEndgame = remaining <= config.heuristicN;
// 中前盤保留少量隨機性,避免 rollout 全部走相同路線。
if (!isEndgame && Math.random() < 0.22) {
return moves[Math.floor(Math.random() * moves.length)];
}
const maxChecks = isEndgame
? Math.min(config.heuristicChecks, 24)
: Math.min(config.heuristicChecks, 8);
const picked = chooseHeuristicIndex(
state,
moves,
player,
maxChecks
);
return moves[picked.index];
}
function copyPieces(source, target) {
target[1][0] = source[1][0];
target[1][1] = source[1][1];
target[1][2] = source[1][2];
target[2][0] = source[2][0];
target[2][1] = source[2][1];
target[2][2] = source[2][2];
}
function runOneMCTSIteration(
root,
rootState,
rootPlayer,
rootPieces,
scratchState,
simulationPieces,
context
) {
scratchState.copyFrom(rootState);
copyPieces(rootPieces, simulationPieces);
let node = root;
let simulationPlayer = rootPlayer;
// Selection
while (
node.unexpanded.length === 0 &&
node.children.length > 0
) {
const child = selectChild(
node,
simulationPlayer,
context.config.c
);
if (!child) break;
if (
!applyEncodedMove(
scratchState,
child.move,
simulationPlayer,
simulationPieces
)
) {
return false;
}
node = child;
simulationPlayer =
simulationPlayer === 1 ? 2 : 1;
}
// Expansion
if (node.unexpanded.length > 0) {
const picked = chooseHeuristicIndex(
scratchState,
node.unexpanded,
simulationPlayer,
Math.min(context.config.heuristicChecks, 10)
);
const move = node.unexpanded.splice(
picked.index,
1
)[0];
if (
!applyEncodedMove(
scratchState,
move,
simulationPlayer,
simulationPieces
)
) {
return false;
}
const nextPlayer =
simulationPlayer === 1 ? 2 : 1;
const childKey = scratchState.positionKey(
nextPlayer,
simulationPieces
);
const childMoves = scratchState.getValidMoves(
nextPlayer,
simulationPieces
);
const boundedDelta = Math.max(
-4,
Math.min(4, picked.delta)
);
const prior = Math.exp(boundedDelta * 0.3);
const child = new MCTSNode(
move,
node,
nextPlayer,
childKey,
childMoves,
prior,
context
);
node.children.push(child);
node = child;
simulationPlayer = nextPlayer;
}
// Rollout
while (true) {
const moves = scratchState.getValidMoves(
simulationPlayer,
simulationPieces
);
if (moves.length === 0) {
break;
}
const move = chooseRolloutMove(
scratchState,
moves,
simulationPlayer,
simulationPieces,
context.config
);
if (
!applyEncodedMove(
scratchState,
move,
simulationPlayer,
simulationPieces
)
) {
break;
}
simulationPlayer =
simulationPlayer === 1 ? 2 : 1;
}
const difference = boardDiff(scratchState);
// 固定以 P1 視角儲存,跨回合才可安全重用。
const rewardP1 = Math.tanh(difference / 6);
while (node) {
node.stats.visits++;
node.stats.valueSum += rewardP1;
node = node.parent;
}
return true;
}
function findBestMCTSChild(root, rootPlayer) {
let bestChild = null;
let bestVisits = -1;
let bestValue = -Infinity;
for (const child of root.children) {
const visits = child.stats.visits;
const qP1 =
visits > 0
? child.stats.valueSum / visits
: 0;
const playerValue = rootPlayer === 1 ? qP1 : -qP1;
if (
visits > bestVisits ||
(visits === bestVisits && playerValue > bestValue)
) {
bestVisits = visits;
bestValue = playerValue;
bestChild = child;
}
}
return {
child: bestChild,
value: bestValue
};
}
async function runIncrementalMCTS(
rootState,
rootPlayer,
rootPieces,
context
) {
const rootMoves = rootState.getValidMoves(
rootPlayer,
rootPieces
);
if (rootMoves.length === 0) {
return {
bestMove: null,
iterations: 0,
winRate: 50
};
}
const rootKey = rootState.positionKey(
rootPlayer,
rootPieces
);
const root = new MCTSNode(
null,
null,
rootPlayer,
rootKey,
rootMoves.slice(),
1,
context
);
const scratchState = new HexGridGame();
const simulationPieces = {
1: new Int8Array(3),
2: new Int8Array(3)
};
let iterations = 0;
let lastProgressTime = context.startTime;
while (
activeRequestId === context.requestId &&
performance.now() < context.deadline
) {
// 每個增量批次最多運算約 12ms。
const sliceEnd = Math.min(
context.deadline,
performance.now() + 12
);
while (performance.now() < sliceEnd) {
const success = runOneMCTSIteration(
root,
rootState,
rootPlayer,
rootPieces,
scratchState,
simulationPieces,
context
);
if (success) {
iterations++;
} else if (
root.unexpanded.length === 0 &&
root.children.length === 0
) {
break;
}
}
const now = performance.now();
if (now - lastProgressTime >= 100) {
const currentBest = findBestMCTSChild(
root,
rootPlayer
);
const value = Number.isFinite(currentBest.value)
? currentBest.value
: 0;
self.postMessage({
type: 'progress',
requestId: context.requestId,
mode: 'mcts',
elapsed: now - context.startTime,
iterations,
reused: context.reused,
winRate: ((value + 1) * 50)
});
lastProgressTime = now;
}
// 讓 Worker 處理 stop 或下一個 search 訊息。
await new Promise(resolve => setTimeout(resolve, 0));
}
const best = findBestMCTSChild(root, rootPlayer);
if (!best.child) {
return {
bestMove: rootMoves[0],
iterations,
winRate: 50
};
}
return {
bestMove: best.child.move,
iterations,
winRate: (best.value + 1) * 50
};
}
}3.2 主執行緒 Worker 控制器
緊接著上面的 aiWorkerMain() 後加入:
let aiWorker = null;
let aiWorkerRequestId = 0;
let activeAiSearch = null;
function createAiWorker() {
const source = `
'use strict';
const HexGridGame = ${HexGridGame.toString()};
(${aiWorkerMain.toString()})();
`;
const blob = new Blob([source], {
type: 'text/javascript'
});
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
URL.revokeObjectURL(url);
worker.onmessage = handleAiWorkerMessage;
worker.onerror = handleAiWorkerError;
return worker;
}
function getAiWorker() {
if (!aiWorker) {
aiWorker = createAiWorker();
}
return aiWorker;
}
function cancelAiWorkerSearch() {
abortAiSearch = true;
activeAiSearch = null;
isAiThinking = false;
aiWorkerRequestId++;
if (aiWorker) {
aiWorker.terminate();
aiWorker = null;
}
setAiThinkingState(false);
}
function handleAiWorkerError(event) {
console.error('AI Worker error:', event.message || event);
const search = activeAiSearch;
activeAiSearch = null;
isAiThinking = false;
if (aiWorker) {
aiWorker.terminate();
aiWorker = null;
}
if (!search) {
return;
}
const fallbackMoves = gameLogic.getValidMoves(
currentPlayer,
piecesLeft
);
if (fallbackMoves.length > 0) {
showNotification(
currentLang === 'zh'
? '後臺搜尋發生錯誤,使用備用著法'
: 'Background search failed; using fallback move'
);
playEncodedAiMove(fallbackMoves[0]);
} else {
finishGameBecauseNoMoves();
}
}
function handleAiWorkerMessage(event) {
const message = event.data;
const search = activeAiSearch;
if (
!search ||
message.requestId !== search.requestId
) {
return;
}
const box = document.getElementById('test-box');
if (message.type === 'progress') {
if (message.mode === 'minimax') {
box.innerText =
`${getText('minimaxSearch')}\n` +
`${getText('time')}: ${(message.elapsed / 1000).toFixed(1)}s / ${(search.timeLimit / 1000).toFixed(1)}s\n` +
`${getText('depth')}: ${message.depth}\n` +
`${getText('nodes')}: ${message.nodes.toLocaleString()}`;
} else {
const reusedText =
currentLang === 'zh' ? '重用統計' : 'Reused stats';
box.innerText =
`${getText('aiThinking')}\n` +
`${getText('time')}: ${(message.elapsed / 1000).toFixed(1)}s / ${(search.timeLimit / 1000).toFixed(1)}s\n` +
`${getText('simCount')}: ${message.iterations.toLocaleString()}\n` +
`${getText('winRate')}: ${message.winRate.toFixed(1)}%\n` +
`${reusedText}: ${message.reused.toLocaleString()}`;
}
box.style.opacity = '1';
return;
}
if (message.type === 'error') {
handleAiWorkerError({
message: message.message
});
return;
}
if (message.type !== 'done') {
return;
}
activeAiSearch = null;
isAiThinking = false;
// 防止搜尋完成前玩家已悔棋、導入棋譜或換人。
if (
abortAiSearch ||
gameState !== 'playing' ||
currentPlayer !== search.player ||
historyIndex !== search.historyIndex
) {
return;
}
if (message.bestMove === null) {
finishGameBecauseNoMoves();
return;
}
if (message.mode === 'minimax') {
const score = Number(message.score) || 0;
const scoreText =
score > 0
? `${getText('p1Lead')} ${score}`
: score < 0
? `${getText('p2Lead')} ${-score}`
: `${getText('tie')} 0`;
box.innerText =
`${getText('searchDone')}: ${(message.elapsed / 1000).toFixed(1)}s\n` +
`${getText('depth')}: ${message.completedDepth}\n` +
`${getText('totalNodes')}: ${message.nodes.toLocaleString()}\n` +
`${getText('bestResult')}: ${scoreText}`;
} else {
box.innerText =
`${getText('searchDone')}: ${(message.elapsed / 1000).toFixed(1)}s\n` +
`${getText('simCount')}: ${message.iterations.toLocaleString()}\n` +
`${getText('winRate')}: ${message.winRate.toFixed(1)}%`;
}
box.style.opacity = '1';
playEncodedAiMove(message.bestMove);
}
function createTriFromIndex(idx) {
const coord = gameLogic._idxToCoord(idx);
if (!coord) return null;
const halfWidth = 31.1769;
const isRight = gameLogic.isRight(coord.x, coord.y);
const cx = isRight
? (coord.x - 1) * halfWidth + 10.392
: (coord.x - 1) * halfWidth + 20.784;
return {
idx: coord.x,
N: coord.y,
isRight,
cx,
cy: coord.y * 18
};
}
function playEncodedAiMove(move) {
const pid = (move >>> 24) & 0xff;
const p1Idx = (move >>> 12) & 0xfff;
const p2Idx = move & 0xfff;
if (
pid < 0 ||
pid > 2 ||
piecesLeft[currentPlayer][pid] <= 0
) {
return false;
}
let tri = createTriFromIndex(p1Idx);
if (!tri) return false;
let pair = getPairTri(tri, pid);
let pairIdx = gameLogic._getIndex(pair.idx, pair.N);
// 編碼中的第一個索引經過排序,未必是原始基準三角形。
if (pairIdx !== p2Idx) {
tri = createTriFromIndex(p2Idx);
if (!tri) return false;
pair = getPairTri(tri, pid);
pairIdx = gameLogic._getIndex(pair.idx, pair.N);
if (pairIdx !== p1Idx) {
console.error('AI returned invalid piece geometry:', move);
return false;
}
}
const tri2 = getPairTri(tri, pid);
// 在主棋盤再驗證一次,防止收到過期 Worker 結果。
const validation = gameLogic.tryPlacePiece(
pid + 1,
{ x: tri.idx, y: tri.N },
{ x: tri2.idx, y: tri2.N },
false,
false
);
if (!validation.success) {
return false;
}
gameLogic.undo(false);
currentSelectedPiece = pid;
selectedBoxElement = null;
isFirstMove = false;
attemptDrop(tri);
return true;
}
function finishGameBecauseNoMoves() {
isAiThinking = false;
setAiThinkingState(false);
showNotification(getText('noValidMoves'));
/*
* 如果規則是「無合法著法直接結束」,保留以下處理。
* 如果規則是允許 pass,則應改為切換 currentPlayer。
*/
gameState = 'ended';
updateUI();
setTimeout(showRoundEnd, 300);
}
function triggerAITurn() {
if (
gameState !== 'playing' ||
isAiThinking ||
playerTypes[currentPlayer] === 'human'
) {
return;
}
isAiThinking = true;
abortAiSearch = false;
setAiThinkingState(true);
const roundStart = currentRound === 2 ? 36 : 0;
const movesInRound = historyIndex - roundStart + 1;
if (movesInRound === 0) {
isAiThinking = false;
autoPlayFirstBlue(currentPlayer);
return;
}
const playerType = playerTypes[currentPlayer];
const config = aiConfigs[playerType] || aiConfigs.custom;
const requestId = ++aiWorkerRequestId;
activeAiSearch = {
requestId,
player: currentPlayer,
historyIndex,
timeLimit: config.time
};
const snapshot = gameLogic.exportSnapshot();
try {
getAiWorker().postMessage(
{
type: 'search',
requestId,
player: currentPlayer,
pieces: {
1: Array.from(piecesLeft[1]),
2: Array.from(piecesLeft[2])
},
config: {
time: config.time,
c: config.c,
heuristicN: config.heuristicN,
heuristicChecks: config.heuristicChecks,
pureMinimaxN: config.pureMinimaxN
},
snapshot
},
[snapshot.grid.buffer]
);
} catch (error) {
handleAiWorkerError({
message: error instanceof Error
? error.message
: String(error)
});
}
}四、替換「中止 AI」函數
將原本的 forceHumanPlayer() 替換為:
function forceHumanPlayer() {
cancelAiWorkerSearch();
playerTypes[1] = 'human';
playerTypes[2] = 'human';
document.getElementById('ptype-1').innerHTML =
aiConfigs.human.icon;
document.getElementById('ptype-2').innerHTML =
aiConfigs.human.icon;
document.getElementById('ptype-1').style.color =
'#0056b3';
document.getElementById('ptype-2').style.color =
'#a70000';
updateUI();
}這樣按下中止按鈕時會直接終止 Worker,而不是只修改一個主執行緒無法及時讀到的布林值。
五、修正重新開始遊戲
目前 restartGame() 沒有可靠重建 gameLogic。
將整個函數替換成:
function restartGame() {
cancelAiWorkerSearch();
document.getElementById(
'game-over-screen'
).style.display = 'none';
gameState = 'playing';
currentScale = 1;
panX = 240;
panY = 240;
initBoardState(false);
applyTransform();
if (playerTypes[currentPlayer] !== 'human') {
setAiThinkingState(true);
setTimeout(triggerAITurn, 100);
} else {
showNotification(getText('firstBluePrompt'));
}
}六、修正滑鼠滾輪縮放方向
找到:
zoomAtCursor(e.clientX, e.clientY, e.deltaY < 0 ? -0.1 : 0.1);替換為:
zoomAtCursor(
e.clientX,
e.clientY,
e.deltaY < 0 ? 0.1 : -0.1
);原本滑鼠向上滾反而縮小,與「+」按鈕方向相反。
七、修正語言切換
找到 toggleLanguage() 中:
currentLang = currentLang === 'zh' ? 'en' : 'zh';替換為:
currentLang = inputLang;否則未來若增加第三種語言,或由程式主動指定語言時會出錯。
八、修正 SVG 下載的 Object URL 洩漏
將 confirmDownloadSVG() 裡這部分:
const a = document.createElement('a');
a.href = URL.createObjectURL(
new Blob([currentDlSvgSource], {
type: 'image/svg+xml;charset=utf-8'
})
);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);替換為:
const blob = new Blob([currentDlSvgSource], {
type: 'image/svg+xml;charset=utf-8'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 0);九、更新複製棋譜功能
document.execCommand('copy') 已被淘汰。
將 copyExport() 替換為:
async function copyExport() {
const input = document.getElementById('export-text');
try {
if (
navigator.clipboard &&
window.isSecureContext
) {
await navigator.clipboard.writeText(input.value);
} else {
input.focus();
input.select();
const success = document.execCommand('copy');
if (!success) {
throw new Error('copy failed');
}
}
showToast(getText('copySuccess'));
} catch (error) {
input.focus();
input.select();
showToast(
currentLang === 'zh'
? '無法自動複製,請手動複製'
: 'Automatic copy failed; please copy manually'
);
}
}十、加入棋譜導入合法性驗證
現在的導入功能只解析文字,沒有完整驗證:
- 棋子是否重疊。
- 是否超過庫存。
- 第一手是否中央藍棋。
- 第二手是否紅棋。
- 是否與盤面相連。
- 是否形成空洞。
- 玩家順序是否正確。
在 submitImport() 前加入:
function validateImportedHistory(history) {
if (history.length > 72) {
throw new Error('棋譜超過兩局最大手數');
}
let logic = new HexGridGame();
let inventory = {
1: [6, 6, 6],
2: [6, 6, 6]
};
for (let i = 0; i < history.length; i++) {
if (i === 36) {
logic = new HexGridGame();
inventory = {
1: [6, 6, 6],
2: [6, 6, 6]
};
}
const move = history[i];
const roundMove = i % 36;
const firstPlayer = i < 36 ? 1 : 2;
const expectedPlayer =
roundMove % 2 === 0
? firstPlayer
: firstPlayer === 1
? 2
: 1;
if (move.player !== expectedPlayer) {
throw new Error(`第 ${i + 1} 手玩家順序錯誤`);
}
if (inventory[move.player][move.pid] <= 0) {
throw new Error(`第 ${i + 1} 手棋子庫存不足`);
}
if (roundMove === 0) {
const isCentralBlue =
move.pid === 0 &&
(
(
move.t1.idx === 0 &&
move.t1.N === 0 &&
move.t2.idx === 1 &&
move.t2.N === 0
) ||
(
move.t2.idx === 0 &&
move.t2.N === 0 &&
move.t1.idx === 1 &&
move.t1.N === 0
)
);
if (!isCentralBlue) {
throw new Error(`第 ${i + 1} 手必須是中央藍棋`);
}
}
if (roundMove === 1 && move.pid !== 1) {
throw new Error(`第 ${i + 1} 手必須是紅棋`);
}
const makeTri = source => ({
idx: source.idx,
N: source.N,
isRight: logic.isRight(source.idx, source.N)
});
const firstPair = getPairTri(
makeTri(move.t1),
move.pid
);
const secondPair = getPairTri(
makeTri(move.t2),
move.pid
);
const geometryValid =
(
firstPair.idx === move.t2.idx &&
firstPair.N === move.t2.N
) ||
(
secondPair.idx === move.t1.idx &&
secondPair.N === move.t1.N
);
if (!geometryValid) {
throw new Error(`第 ${i + 1} 手棋子幾何不正確`);
}
if (roundMove > 0) {
const neighbors = [
...logic.getNeighbors(move.t1.idx, move.t1.N),
...logic.getNeighbors(move.t2.idx, move.t2.N)
];
const connected = neighbors.some(neighbor =>
logic.get(neighbor.x, neighbor.y) !== 0
);
if (!connected) {
throw new Error(`第 ${i + 1} 手未與棋盤相連`);
}
}
const result = logic.tryPlacePiece(
move.pid + 1,
{
x: move.t1.idx,
y: move.t1.N
},
{
x: move.t2.idx,
y: move.t2.N
},
true,
false
);
if (!result.success) {
throw new Error(
`第 ${i + 1} 手不合法:${result.reason}`
);
}
inventory[move.player][move.pid]--;
}
return true;
}然後在 submitImport() 裡,找到:
document.getElementById('export-modal').style.display = 'none';
// 使用重繪函數直接渲染盤面
moveHistory = importedHistory;替換成:
// 先完整驗證,驗證通過後才修改正式棋局。
validateImportedHistory(importedHistory);
document.getElementById(
'export-modal'
).style.display = 'none';
moveHistory = importedHistory;並在 submitImport() 取得 tokens 後加入:
if (tokens.length > 72) {
throw new Error('棋譜超過 72 手');
}導入前也應直接中止 Worker,因此原本這行可以保留:
forceHumanPlayer();十一、建議調整 AI 預設參數
目前簡單 AI 也思考 12 秒,對手機比較長。建議改為:
simple: {
time: 2500,
c: 2.0,
heuristicN: 4,
heuristicChecks: 5,
pureMinimaxN: 3,
icon: '...'
},
hard: {
time: 6000,
c: 2.6,
heuristicN: 8,
heuristicChecks: 10,
pureMinimaxN: 4,
icon: '...'
},
expert: {
time: 12000,
c: 3.1,
heuristicN: 12,
heuristicChecks: 18,
pureMinimaxN: 6,
icon: '...'
},
custom: {
time: 20000,
c: 3.6,
heuristicN: 16,
heuristicChecks: 24,
pureMinimaxN: 7,
icon: '...'
}目前的探索常數最高到 6.0,容易讓 AI 過度探索。改用 heuristic PUCT 後,通常不需要這麼高。
十二、預期改善
修改後通常可得到以下效果:
- AI 思考時棋盤仍可平移、縮放,介面不會凍結。
- 「中止 AI」可以立即生效。
- MCTS 在同一次思考中持續增量累積,不會每批重新開始。
- 後續相同或轉置局面可重用 MCTS 統計。
- Minimax 可以重用已分析過的局面。
- 合法著法生成由掃描矩形改為只掃描 frontier。
- 模擬落子不再每一步重新計分。
- clone 不再重建鄰居表。
- 支援與現有 12-bit 編碼一致的 4096 格棋盤。
- 修正搜尋過程中邊界不斷擴張的問題。
- 修正重新開始後核心棋盤未清空的問題。
最後應至少測試:
- 人類對人類的落子、悔棋、前進。
- 人類對 AI。
- AI 對 AI。
- AI 搜尋時按下中止。
- 第一局結束後進入第二局。
- 重新開始後分數和盤面是否為零。
- 導入非法重疊棋譜。
- 導入形成空洞的棋譜。
- 在
x/y > 15的位置落子,確認不再發生座標碰撞。 - 專家 AI 搜尋期間確認主介面仍流暢。
如果網頁部署時使用 CSP,還需要允許 Blob Worker,例如在 CSP 的 worker-src 中允許 blob:;本地直接開啟這個單檔 HTML 通常不需要額外設定。
本遊戲只需1024 格、32×32就足夠了
QuanAI對,若本遊戲的有效座標限定在:
x: -15 ~ 16
y: -15 ~ 16那麼 32 × 32 = 1024 格完全足夠。上一版建議擴大到 4096 格不必要,應撤回。現有程式真正的問題,是部分程式按 32×32 編碼,部分空洞檢查卻按 64×64 解碼;正確做法是統一為 32×32。
Web Worker、增量搜尋、MCTS 重用和 Minimax 快取等改進仍然有效,只需全部使用 1024 格版本。
一、保留 1024 格,但必須修正座標越界碰撞
原本:
_getIndex(x, y) {
return ((y + 15) << 5) | (x + 15);
}存在問題:沒有檢查 x、y 是否超出範圍。像 x = 17 之類的座標可能透過位元運算映射到其他合法格子,造成不同座標指向同一索引。
替換為
_getIndex(x, y) {
if (
!Number.isInteger(x) ||
!Number.isInteger(y) ||
x < -15 ||
x > 16 ||
y < -15 ||
y > 16
) {
return -1;
}
return ((y + 15) << 5) + (x + 15);
}
_idxToCoord(idx) {
if (idx < 0 || idx >= 1024) {
return null;
}
return {
x: (idx & 31) - 15,
y: (idx >>> 5) - 15
};
}注意:
32格需要左移5位。x使用idx & 31。y使用idx >>> 5。- 範圍是
-15~16,剛好對應索引0~1023。
二、修正空洞檢查的 64×64 解碼錯誤
在 hasHoleAfterPlacement() 中找到:
let cy = (currIdx >> 6) - 31;
let cx = (currIdx & 63) - 31;這是 64×64 的解碼,和目前 32×32 棋盤不一致。
替換為
let cy = (currIdx >>> 5) - 15;
let cx = (currIdx & 31) - 15;以下判斷:
nIdx >= 0 && nIdx < 1024則保持不變。
三、建議直接替換整個空洞檢查方法
原本的空洞 BFS 在碰到外部區域後會立即 break,但共用的 visited 陣列尚未標記完整連通區。下一個起點可能因為無法穿過已訪問節點而誤判成空洞。
建議將整個 hasHoleAfterPlacement() 替換為:
hasHoleAfterPlacement(coords) {
if (this.totalPieces <= 1) {
return false;
}
this.visited.fill(0);
const emptyStarts = [];
const startSet = new Set();
// 收集新落下棋子的相鄰空格。
for (let i = 0; i < coords.length; i++) {
const idx = this._getIndex(coords[i].x, coords[i].y);
if (idx < 0) {
continue;
}
const base = idx * 3;
for (let j = 0; j < 3; j++) {
const neighborIdx = this.neighbors[base + j];
if (
neighborIdx >= 0 &&
neighborIdx < 1024 &&
this.grid[neighborIdx] === 0 &&
!startSet.has(neighborIdx)
) {
startSet.add(neighborIdx);
emptyStarts.push(neighborIdx);
}
}
}
if (emptyStarts.length === 0) {
return false;
}
const queue = this.bfsQueue;
for (let i = 0; i < emptyStarts.length; i++) {
const startIdx = emptyStarts[i];
if (this.visited[startIdx] !== 0) {
continue;
}
let head = 0;
let tail = 0;
let reachedOutside = false;
queue[tail++] = startIdx;
this.visited[startIdx] = 1;
while (head < tail) {
const currentIdx = queue[head++];
// 正確的 32×32 解碼。
const cy = (currentIdx >>> 5) - 15;
const cx = (currentIdx & 31) - 15;
/*
* 只要空白區域能到達目前棋群包圍矩形以外,
* 就代表它和外部連通,不是空洞。
*
* 不要立即 break,應繼續標記完整連通區,
* 避免後續起點發生錯誤判斷。
*/
if (
cx < this.minX ||
cx > this.maxX ||
cy < this.minY ||
cy > this.maxY
) {
reachedOutside = true;
}
const base = currentIdx * 3;
for (let j = 0; j < 3; j++) {
const neighborIdx = this.neighbors[base + j];
if (
neighborIdx >= 0 &&
neighborIdx < 1024 &&
this.grid[neighborIdx] === 0 &&
this.visited[neighborIdx] === 0
) {
this.visited[neighborIdx] = 1;
queue[tail++] = neighborIdx;
}
}
}
if (!reachedOutside) {
return true;
}
}
return false;
}四、1024 格版本的共用鄰居表
仍建議讓所有 HexGridGame 實例共用鄰居表。MCTS 會頻繁 clone,如果每次建構都重新產生鄰居表會浪費運算。
替換 constructor 中相關部分
原本:
this.grid = new Int32Array(1024);
this.neighbors = new Int32Array(3072);
this.initNeighborsTable();替換為:
this.grid = new Int32Array(1024);
this.neighbors = HexGridGame.getSharedNeighbors();在類別中加入
static getSharedNeighbors() {
if (HexGridGame._sharedNeighbors) {
return HexGridGame._sharedNeighbors;
}
const table = new Int32Array(1024 * 3);
table.fill(-1);
const indexOf = (x, y) => {
if (x < -15 || x > 16 || y < -15 || y > 16) {
return -1;
}
return ((y + 15) << 5) + (x + 15);
};
for (let y = -15; y <= 16; y++) {
for (let x = -15; x <= 16; x++) {
const idx = indexOf(x, y);
const base = idx * 3;
const isRight = (y & 1) === ((x - 1) & 1);
table[base] = indexOf(x, y - 1);
table[base + 1] = indexOf(x, y + 1);
table[base + 2] = indexOf(
isRight ? x - 1 : x + 1,
y
);
}
}
HexGridGame._sharedNeighbors = table;
return table;
}原本的 initNeighborsTable() 可以刪除;或者為了相容其他呼叫,替換為:
initNeighborsTable() {
this.neighbors = HexGridGame.getSharedNeighbors();
}五、上一版增量搜尋常數改回 1024 格
如果套用上一版增量 frontier、Zobrist hash 和 Worker 修改,將棋盤常數統一替換成:
static get SIDE() {
return 32;
}
static get OFFSET() {
return 15;
}
static get MIN_COORD() {
return -15;
}
static get MAX_COORD() {
return 16;
}
static get CELL_COUNT() {
return 1024;
}
static coordToIndex(x, y) {
if (
!Number.isInteger(x) ||
!Number.isInteger(y) ||
x < HexGridGame.MIN_COORD ||
x > HexGridGame.MAX_COORD ||
y < HexGridGame.MIN_COORD ||
y > HexGridGame.MAX_COORD
) {
return -1;
}
return (
(y + HexGridGame.OFFSET) * HexGridGame.SIDE +
(x + HexGridGame.OFFSET)
);
}對應的反向轉換應為:
_idxToCoord(idx) {
if (idx < 0 || idx >= HexGridGame.CELL_COUNT) {
return null;
}
return {
x: (idx & 31) - HexGridGame.OFFSET,
y: (idx >>> 5) - HexGridGame.OFFSET
};
}以下陣列也應維持 1024:
this.grid = new Int32Array(1024);
this.bpLookup = new Array(1024).fill(null);
this.visited = new Uint8Array(1024);
this.bfsQueue = new Uint16Array(1024);
this.frontierCount = new Uint8Array(1024);鄰居表:
new Int32Array(1024 * 3);Zobrist 表:
new Uint32Array(1024 * 3);六、修正搜尋期間邊界持續擴大的問題
即使只有 1024 格,這個問題仍然存在。
原本 getValidMoves() 測試著法時使用:
this.set(p1.x, p1.y, pieceType);
this.set(p2.x, p2.y, pieceType);
this.totalPieces++;
let hasHole =
this.totalPieces < 5
? false
: this.fastHoleCheck(pieceType, p1, p2);
this.set(p1.x, p1.y, 0);
this.set(p2.x, p2.y, 0);
this.totalPieces--;set() 會擴大 minX/maxX/minY/maxY,但移除測試棋子時不會縮回。
在未加入 frontier/hash 的版本中,替換為
this.grid[p1Idx] = pieceType;
this.grid[p2Idx] = pieceType;
this.totalPieces++;
const hasHole =
this.totalPieces < 5
? false
: this.fastHoleCheck(pieceType, p1, p2);
this.totalPieces--;
this.grid[p2Idx] = 0;
this.grid[p1Idx] = 0;因為這只是模擬測試,不應改變真實邊界。
如果已套用上一版 _writeIndex(),則使用:
this._writeIndex(p1Idx, pieceType, false);
this._writeIndex(p2Idx, pieceType, false);
this.totalPieces++;
const hasHole =
this.totalPieces < 5
? false
: this.fastHoleCheck(pieceType, p1, p2);
this.totalPieces--;
this._writeIndex(p2Idx, 0, false);
this._writeIndex(p1Idx, 0, false);最後一個參數 false 表示不更新棋盤邊界。
七、撤銷落子時也要恢復邊界
在 tryPlacePiece() 正式落子前保存:
const previousBounds = {
minX: this.minX,
maxX: this.maxX,
minY: this.minY,
maxY: this.maxY
};發現空洞、清除測試棋子後加入:
this.minX = previousBounds.minX;
this.maxX = previousBounds.maxX;
this.minY = previousBounds.minY;
this.maxY = previousBounds.maxY;原本:
this.history.push({ pieceType, p1, p2, bpObj });替換為:
this.history.push({
pieceType,
p1,
p2,
bpObj,
previousBounds
});在 undo() 清除棋子後加入:
if (lastMove.previousBounds) {
this.minX = lastMove.previousBounds.minX;
this.maxX = lastMove.previousBounds.maxX;
this.minY = lastMove.previousBounds.minY;
this.maxY = lastMove.previousBounds.maxY;
}八、移動編碼不用修改
目前:
(pid << 24) | (p1Idx << 12) | p2Idx1024 格索引實際只需要 10 bit,但目前預留 12 bit 不會造成問題,因此可以保留,避免同時修改所有編碼和解碼位置。
繼續使用:
const pid = (move >>> 24) & 0xff;
const p1Idx = (move >>> 12) & 0xfff;
const p2Idx = move & 0xfff;雖然 0xfff 可讀出 12 bit,但實際合法值仍應為 0~1023。
可以額外加入驗證:
if (p1Idx >= 1024 || p2Idx >= 1024) {
return false;
}九、Web Worker 和增量搜尋仍應保留
棋盤只有 1024 格,不代表主執行緒不會卡頓。真正耗時的是:
- MCTS 大量 rollout。
- 重複產生合法著法。
- Minimax 遞迴。
- 每個節點計算分數。
- clone 和記憶體配置。
所以以下改進不需要撤銷:
- Web Worker 後臺運算。
- MCTS 分批增量搜尋。
- frontier 增量合法著法生成。
- Minimax transposition table。
- Zobrist hash。
- MCTS 局面統計重用。
- 模擬落子時使用
withScores = false。 - 中止 AI 時直接終止 Worker。
- 重用模擬棋盤物件,減少垃圾回收。
只要把 Worker 中的 HexGridGame 統一為上述 1024 格版本即可。
最終應保持的尺寸
const BOARD_SIDE = 32;
const BOARD_OFFSET = 15;
const BOARD_CELL_COUNT = 1024;
const BOARD_MIN = -15;
const BOARD_MAX = 16;總結來說,不需要擴大棋盤;應將錯誤的 64×64 空洞解碼改回 32×32,並為 _getIndex() 加入嚴格邊界檢查。這兩項是保留 1024 格時最重要的正確性修復。