共享会话
♟️ 棋盤程式碼修改建議
分享于 2026年9月7日 06:13改進: 一,更改規則
之前的規則是「某個階段只要放了一個棋子,就可以選擇跳到下個階段」,現在改為「在本階段必須盡量落完所有棋子,只有所有本階段棋子都落完,或者證明已經無法再落下一個,才可以跳到下個階段」。
更改規則說明,包括中文和英文。
更改MCTS和alpha-beta邏輯,使之符合更新的規則。
更改控制區的點擊邏輯,棋子的選取將只能由程式來決定,不再能自由選擇。
二,MCTS會從頭開始搜尋的問題
當MCTS搜尋到時限而沒有執行,然後又要從頭開始搜尋時,要先彈出一個對話框,說明原因,點擊確定後才再從頭開始搜尋。
三,圓點閃爍的問題
當先手第一手棋沒有落對位置,會出現一個閃爍的圓點。現在要修改這個圓點的閃爍,在固定的時間內,只要「變大—變小—變大—變小」,然後就消失。
四,svg棋盤的問題
當前的svg棋盤,很多的svg屬性都寫在css中。現在要改為,所有的svg屬性,包括fill, stroke等等,都要寫到svg元素中,不要再寫到css。
回答要求:明確給出所要修改的地方和所要替換和代碼。
QuanAI我會先定位附件中的規則文字、階段切換、MCTS/alpha-beta、控制區點擊、首手提示圓點及 SVG/CSS 定義,再按原檔函式與區塊提供可直接替換的代碼。
以下按 vino2277.html 中可搜尋的唯一代碼錨點說明。修改後的核心原則是:
- 法諾、米諾、維諾階段均不得主動跳過。
- 只有該類棋子全部用完,或程式完整檢查後確認沒有任何合法落法,才自動進入下一階段。
- 維諾階段不能主動飛子;必須等程式自動進入飛子階段。
- MCTS 與 Alpha-Beta 共用的動作生成器不再生成
SKIP或提前飛子。 - 控制區只顯示程式自動選中的棋子類型;點擊只可調整法諾/維諾方向,不能切換階段。
- 首手提示圓點只進行兩次放大、縮小後消失。
- 棋盤 SVG 的
fill、stroke、stroke-width、vector-effect等全部改成 SVG 元素屬性。
一、更改遊戲規則
1.1 新增提示文字
在 I18N.en 中,放在 phaseVinoToFlight 後面加入:
phaseMustContinue:
'You must continue the {piece} phase while at least one legal placement remains.',
vinoNormalMoveOnly:
'During the Vino phase, the selected Mino may only make a normal move. Flight begins automatically only after no further Vino can be placed.',在 I18N.zh 中,放在 phaseVinoToFlight 後面加入:
phaseMustContinue:
'只要{piece}仍有至少一個合法落點,就必須繼續本階段,不能提前跳到下一階段。',
vinoNormalMoveOnly:
'維諾階段選中的米諾只能正常移動;只有程式確認已無法再放置維諾後,才會自動進入飛子階段。',1.2 英文規則替換
在英文 rulesHtml 中,將編號 04 至 08 的規則替換為:
<article class="rule-block wide">
<span class="rule-index">04</span>
<h3>Turn structure</h3>
<p>
Players alternate turns. Each player's game proceeds through
four phases in a fixed order:
<strong>Fino → Mino → Vino → Flight</strong>.
The program automatically selects the piece type required by
the current phase. A player must continue placing that type
whenever at least one legal placement remains. The player
advances only after every piece of that type has been placed,
or after an exhaustive legal-move check proves that no further
piece of that type can be placed. A player may not voluntarily
skip a phase or return to an earlier phase.
</p>
</article>
<article class="rule-block">
<span class="rule-index">05</span>
<h3>Fino phase</h3>
<p>
Place one Fino on empty cells. A newly placed Fino may not
touch another piece belonging to the same player,
orthogonally or diagonally. As long as another legal Fino
placement exists, the player must remain in the Fino phase.
The Mino phase begins only after all Fino pieces have been
placed or no legal Fino placement remains.
</p>
<p>
On the first move of each round, the first player's Fino must
occupy three of the four central cells.
</p>
</article>
<article class="rule-block">
<span class="rule-index">06</span>
<h3>Mino phase</h3>
<p>
Place one Mino on an empty cell. A newly placed Mino may not
touch another piece belonging to the same player,
orthogonally or diagonally. As long as another legal Mino
placement exists, the player must remain in the Mino phase.
The Vino phase begins only after all Mino pieces have been
placed or no legal Mino placement remains.
</p>
</article>
<div class="rule-pair">
<article class="rule-block">
<span class="rule-index">07</span>
<h3>Vino phase</h3>
<p>
Place one Vino on empty cells. Before placing it, the player
may move one of their own Mino pieces in one orthogonal
direction. If occupied cells block that direction, the Mino
jumps to the nearest empty cell in that direction.
</p>
<p>
The player must continue placing Vino whenever a legal Vino
placement exists, either directly or after any legal normal
Mino move. The Flight phase begins only after all Vino
pieces have been placed, or after the program proves that
no Vino can be placed even after every possible normal Mino
move has been considered.
</p>
</article>
<article class="rule-block">
<span class="rule-index">08</span>
<h3>Flight phase</h3>
<p>
The Flight phase begins automatically; a player may not
enter it early. Pick up one of your own Mino pieces and
place it — “fly” it — onto an empty cell that it could not
reach from its current position by a normal move. Completing
this flight ends that player's sequence for the round.
</p>
</article>
</div>1.3 中文規則替換
在中文 rulesHtml 中,將編號 04 至 08 替換為:
<article class="rule-block wide">
<span class="rule-index">04</span>
<h3>回合流程</h3>
<p>
雙方輪流行動。每方依序進行
<strong>法諾 → 米諾 → 維諾 → 飛子</strong>
四個階段,當前要使用的棋子由程式自動選取。只要本階段
仍有至少一個合法落法,就必須繼續放置本階段的棋子。
只有本階段棋子已全部落完,或者程式完整檢查後證明已經
無法再合法落下一個,才會自動進入下一階段。玩家不能
主動提前跳過階段,也不能切回上一階段。
</p>
</article>
<article class="rule-block">
<span class="rule-index">05</span>
<h3>法諾階段</h3>
<p>
將一個法諾落在棋盤空位上,且新落下的法諾不能與場上
任何己方棋子相鄰,包括上下左右及角對角相鄰。只要仍有
合法的法諾落點,就必須維持在法諾階段。只有所有法諾
已經落完,或者程式證明已無合法法諾落點,才會自動進入
米諾階段。
</p>
<p>
每局先手第一步落下的法諾,必須佔據棋盤中心四格中的三格。
</p>
</article>
<article class="rule-block">
<span class="rule-index">06</span>
<h3>米諾階段</h3>
<p>
將一個米諾落在棋盤空位上,且新落下的米諾不能與場上
任何己方棋子相鄰,包括上下左右及角對角相鄰。只要仍有
合法的米諾落點,就必須維持在米諾階段。只有所有米諾
已經落完,或者程式證明已無合法米諾落點,才會自動進入
維諾階段。
</p>
</article>
<div class="rule-pair">
<article class="rule-block">
<span class="rule-index">07</span>
<h3>維諾階段</h3>
<p>
將一個維諾落在棋盤空位上。落子前,可以選擇往上下左右
其中一個方向正常移動一個己方米諾;若該方向上有棋子
阻擋,米諾便跳到該方向上最接近的空位。
</p>
<p>
只要維諾仍能直接落下,或者在任何一次合法的米諾正常
移動後仍能落下,就必須繼續維諾階段。只有全部維諾
已經落完,或者程式檢查所有米諾正常移動後仍證明無法
再放置維諾,才會自動進入飛子階段。
</p>
</article>
<article class="rule-block">
<span class="rule-index">08</span>
<h3>飛子階段</h3>
<p>
飛子階段只能由程式自動進入,玩家不能提前飛子。拿起
一個己方米諾,將它放到目前位置無法透過正常移動到達的
空位,稱作「飛子」。完成飛子後,即結束本方本局的行動。
</p>
</article>
</div>二、取消控制區手動選擇/跳階段
2.1 刪除手動跳階段函式
搜尋以下註解:
// 法諾之後是米諾,米諾之後是維諾。從這一行開始,一直刪除到 getManualPhaseAction() 結束,也就是刪除:
const NEXT_PIECE_TYPE = ...
function canManualSkip(...) ...
function canManualRevert(...) ...
function getManualPhaseAction(...) ...piecesPlacedCount() 要保留,其他地方仍然需要它。
2.2 替換 renderPieces()
用以下完整函式替換原來的 renderPieces():
function renderPieces() {
document.querySelectorAll('.piece-item').forEach(item => {
const player = Number(item.dataset.player);
const type = item.dataset.type;
const count = state.inventory[player][type];
const requiredPiece = getRequiredPiece(player);
const frame = item.querySelector('.piece-frame');
const nameElement = item.querySelector('.piece-name');
const countElement = item.querySelector('.piece-count');
const orientation =
state.orientationMemory[player][type] ||
(type === 'mino' ? 'single' : 'tl');
const activePlayer =
state.gameStarted &&
!state.roundEnded &&
!state.gameOver &&
state.currentPlayer === player &&
!state.playerEnded[player];
const inFlightPhase = isMinoFlightPhase(player);
const vinoBlockedByMinoSelection =
activePlayer &&
type === 'vino' &&
state.selectedMinoId !== null;
/*
* 棋子類型完全由 getRequiredPiece() 自動決定。
* 控制區不再提供提前切換或退回上一階段。
*/
const selected =
activePlayer &&
!inFlightPhase &&
requiredPiece === type &&
!vinoBlockedByMinoSelection;
const locked =
count > 0 &&
(
inFlightPhase ||
requiredPiece === null ||
requiredPiece !== type
);
nameElement.textContent = pieceName(type);
countElement.textContent = `×${count}`;
item.classList.toggle('selected', selected);
item.classList.toggle('locked', locked);
item.classList.toggle('not-current', !activePlayer);
item.classList.toggle('ended', state.playerEnded[player]);
item.classList.toggle('depleted', count === 0);
item.classList.remove('manual-next', 'manual-back');
/*
* 法諾、維諾的按鈕仍可用來調整方向,
* 但不能用來改變目前選中的棋子類型。
* 米諾沒有方向,因此控制區按鈕保持 disabled。
*/
frame.disabled =
!selected ||
(type !== 'fino' && type !== 'vino');
frame.setAttribute('aria-pressed', String(selected));
frame.setAttribute(
'aria-label',
translate('pieceCountAria', {
player: playerToken(player),
piece: pieceToken(type),
count
})
);
frame.title = frame.getAttribute('aria-label');
renderMiniPiece(frame, type, orientation, player);
});
}2.3 替換 handlePieceFrameClick()
用以下完整函式替換:
function handlePieceFrameClick(event, frame) {
if (!state.gameStarted || state.roundEnded || state.gameOver) {
return;
}
const player = Number(frame.dataset.player);
const type = frame.dataset.type;
if (player !== state.currentPlayer) {
showToast('notTurn', {
player: playerToken(state.currentPlayer)
});
return;
}
const active = getActivePiece();
/*
* 棋子類型由程式自動決定。
* 點擊非當前棋子不再執行跳階段或返回上一階段。
*/
if (
!active ||
active.player !== player ||
active.type !== type ||
(type !== 'fino' && type !== 'vino')
) {
return;
}
/*
* 點擊當前法諾/維諾只調整方向,不改變棋子類型。
*/
const orientation = getOrientationFromFrameClick(event, frame);
state.orientationMemory[player][type] = orientation;
renderPieces();
renderPreview();
renderOpeningRuleHint();
}2.4 刪除手動切換 CSS
刪除以下 CSS:
.piece-item.manual-next .piece-frame,
.piece-item.manual-back .piece-frame {
opacity: 0.85;
filter: none;
cursor: pointer;
border-style: dashed;
}
.piece-item.manual-next .piece-frame:hover,
.piece-item.manual-back .piece-frame:hover {
border-color: var(--owner-color);
}三、禁止維諾階段提前飛子
3.1 修改棋盤點擊邏輯
在 handleBoardClick() 中搜尋:
if (active.type === 'vino' && state.selectedMinoId) {將這整個區塊替換成:
if (active.type === 'vino' && state.selectedMinoId) {
if (occupied) {
showToast('overlap');
return;
}
const legalDestinations =
getLegalMinoDestinations(state.selectedMinoId);
const normalDestination =
legalDestinations.get(cellKey(cell.row, cell.col));
if (normalDestination) {
performNormalMinoMove(
state.selectedMinoId,
normalDestination
);
} else {
/*
* 新規則:維諾階段不能藉由點擊一個正常走不到的位置
* 提前飛子。只有 preparePlayerPhase() 證明無法再放置
* 維諾後,才會自動進入飛子階段。
*/
showToast('vinoNormalMoveOnly', {}, 1800);
}
return;
}3.2 替換 attemptMinoFlight()
用以下函式替換原函式:
function attemptMinoFlight(pieceId, destination) {
const player = state.currentPlayer;
const piece = findPiece(pieceId);
/*
* 新規則:只有程式已經自動進入飛子階段時才能飛子。
*/
if (!isMinoFlightPhase(player)) {
showToast('phaseMustContinue', {
piece: pieceToken('vino')
}, 1800);
return;
}
if (
!piece ||
piece.owner !== player ||
piece.type !== 'mino' ||
piece.cells.length !== 1 ||
state.board[destination.row][destination.col]
) {
return;
}
const from = { ...piece.cells[0] };
const to = {
row: destination.row,
col: destination.col
};
const legalDestinations =
getLegalMinoDestinations(pieceId);
if (legalDestinations.has(cellKey(to.row, to.col))) {
showToast('flightReachableCell', {}, 1000);
return;
}
piece.cells = [{ ...to }];
rebuildBoard();
recalculateScores();
state.selectedMinoId = null;
state.pendingMinoMove = null;
state.pendingFlight = null;
state.movedThisTurn = false;
state.hoverCell = null;
/*
* 飛子階段已是強制階段,不再需要「提前結束」確認窗口。
*/
finishPlayerByFlight(player, {
player,
pieceId,
from,
to
});
}原來的飛子確認對話框將不再被正常流程呼叫。可以保留其 HTML 和函式,不影響結果;也可以後續刪除。
3.3 修改棋譜導入規則
替換 prepareImportedPieceType()
新規則下,棋譜不能再靠「已落過一枚」恢復提前跳階段。用以下函式替換:
function prepareImportedPieceType(player, targetType) {
/*
* preparePlayerPhase() 只會在以下情況自動推進:
* 1. 本階段棋子已全部用完;
* 2. 本階段已沒有任何合法落法。
*
* 如果執行後 requiredType 仍不是 targetType,
* 代表棋譜企圖在仍有合法落法時提前跳階段。
*/
preparePlayerPhase(player);
if (
state.gameOver ||
state.roundEnded ||
!state.currentPlayer
) {
throw makeRecordImportError(
'目前階段已經結束,不能再導入這枚棋子。',
'The current phase has already ended; this piece cannot be imported.'
);
}
const requiredType = getRequiredPiece(player);
if (requiredType === targetType) {
return;
}
throw makeRecordImportError(
`目前必須繼續放置${pieceName(requiredType || targetType)};仍有合法落法時不能提前導入${pieceName(targetType)}。`,
`The ${pieceName(requiredType || targetType)} phase must continue; ${pieceName(targetType)} cannot be imported while a legal placement remains.`
);
}修改 importFlight()
在 importFlight() 中找到:
if (!automaticFlightPhase && requiredType !== 'vino') {將從這個判斷開始,到檢查 piecesPlacedCount(player, 'vino') 的兩個舊判斷全部替換為:
if (!automaticFlightPhase) {
throw makeRecordImportError(
'程式尚未確認維諾階段結束,不能提前執行飛子。',
'The Vino phase has not ended; a flight cannot be performed early.'
);
}requiredType 變數不再使用,可以刪除:
const requiredType = getRequiredPiece(player);四、修改 MCTS 與 Alpha-Beta
MCTS 和 Alpha-Beta 都使用 generateCoreActions() 和 applyCoreAction(),因此應從共用核心移除 ACTION_SKIP 和維諾階段的提前飛子。
4.1 刪除 ACTION_SKIP
搜尋:
const ACTION_PLACE = 0;
const ACTION_MOVE_VINO = 1;
const ACTION_FLIGHT = 2;
const ACTION_SKIP = 3;替換成:
const ACTION_PLACE = 0;
const ACTION_MOVE_VINO = 1;
const ACTION_FLIGHT = 2;4.2 刪除 Skip 編碼函式
刪除:
function encodeSkip(type) {
return (ACTION_SKIP << 28) | type;
}以及:
function actionSkipType(action) {
return action & 3;
}4.3 刪除 applyCoreAction() 的 Skip 分支
在 applyCoreAction() 中刪除:
if (kind === ACTION_SKIP) {
setPhaseSkippedCore(position, player, actionSkipType(action));
normalizeCorePosition(position);
}setPhaseSkippedCore() 必須保留,因為 normalizeCorePosition() 在證明沒有合法落法後仍會用它自動推進階段。
4.4 刪除法諾/米諾的 Skip 動作生成
在 generateCoreActions() 中刪除:
if (corePlacedCount(position, player, requiredType) >= 1) {
count = appendGeneratedAction(
actions,
scores,
count,
encodeSkip(requiredType),
-900,
limit
);
}修改後,法諾/米諾分支的結尾直接是:
return finishGeneratedActions(
actions,
scores,
count,
limit
);這樣 MCTS rollout、MCTS tree expansion 和 Alpha-Beta 都不可能再選擇提前跳階段。
4.5 刪除維諾階段提前飛子
在 generateCoreActions() 的最後刪除:
// Vino 階段已有至少一個 Vino 時,也可以主動飛子結束。
if (corePlacedCount(position, player, CORE_VINO) >= 1) {
count = generateFlightActions(
position,
player,
actions,
scores,
count,
limit
);
}結尾直接保留:
return finishGeneratedActions(
actions,
scores,
count,
limit
);飛子動作現在只會由函式前段:
if (isCoreFlightPhase(position, player)) {
count = generateFlightActions(
position,
player,
actions,
scores,
count,
limit
);
return finishGeneratedActions(
actions,
scores,
count,
limit
);
}生成。
4.6 刪除 applyAIAction() 的 Skip 分支
在 applyAIAction() 中刪除:
if (kind === ACTION_SKIP) {
const type = actionSkipType(action);
const typeName = TYPE_ID_TO_NAME[type];
if (!typeName || !canManualSkip(player, typeName)) {
return false;
}
state.phaseSkipped[player][typeName] = true;
state.hoverCell = null;
state.selectedMinoId = null;
preparePlayerPhase(player);
renderAll();
observeCommittedAction(action);
return true;
}4.7 替換 AI 包裝後的控制區點擊函式
在 AI IIFE 後段找到:
const originalHandlePieceFrameClick = handlePieceFrameClick;將它和後面的 handlePieceFrameClickWithAI 整段替換成:
const originalHandlePieceFrameClick = handlePieceFrameClick;
handlePieceFrameClick =
function handlePieceFrameClickWithAI(event, frame) {
if (!runtime.applying && isCurrentTurnAI()) {
return;
}
/*
* 控制區點擊現在只能修改當前法諾/維諾方向,
* 不再產生 SKIP 動作,也不再重設搜尋樹。
*/
originalHandlePieceFrameClick(event, frame);
};以上修改同時作用於:
- MCTS 節點展開;
- MCTS rollout;
- MCTS RAVE;
- Alpha-Beta;
- Alpha-Beta transposition table 中的新搜索;
- AI 搜尋失敗後的合法動作恢復流程。
五、MCTS 到時但未執行時先顯示對話框
5.1 在 AI_TEXT.en 加入
mctsRestartTitle: 'MCTS search must restart',
mctsRestartSummary:
'MCTS reached its time limit, but no move was executed. {reason} The previous search tree cannot safely be reused. Click OK to restart the search from the current position.',
mctsRestartPositionChanged:
'The board position changed before the selected move could be executed.',
mctsRestartActionRejected:
'The selected move could not be applied to the current board.',
mctsRestartConfirm: 'OK · Restart search',5.2 在 AI_TEXT.zh 加入
mctsRestartTitle: 'MCTS 必須重新搜尋',
mctsRestartSummary:
'MCTS 已到達搜尋時限,但沒有執行任何行動。{reason}先前的搜尋樹已不能安全沿用。請點擊確定,程式才會從目前局面重新開始搜尋。',
mctsRestartPositionChanged:
'搜尋完成前棋盤局面已經改變,原本選出的行動無法執行。',
mctsRestartActionRejected:
'搜尋選出的行動無法套用到目前棋盤。',
mctsRestartConfirm: '確定並重新搜尋',5.3 擴充 runtime
在:
forcedAction: null後面改成:
forcedAction: null,
/*
* 等待使用者確認 MCTS 重新搜尋時,
* 禁止 queueAIForCurrentTurn() 自動重新開始。
*/
waitingMCTSRestartConfirm: false5.4 建立 MCTS 重搜對話框
在:
document.body.appendChild(aiBackdrop);後面加入:
const mctsRestartBackdrop = document.createElement('div');
mctsRestartBackdrop.id = 'mctsRestartBackdrop';
mctsRestartBackdrop.className = 'modal-backdrop';
mctsRestartBackdrop.hidden = true;
mctsRestartBackdrop.innerHTML = `
<section
class="dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="mctsRestartTitle"
aria-describedby="mctsRestartSummary"
>
<p class="modal-eyebrow">MCTS</p>
<h2 id="mctsRestartTitle"></h2>
<p id="mctsRestartSummary" class="modal-summary"></p>
<div class="modal-actions">
<button
id="mctsRestartConfirm"
type="button"
class="primary-button"
></button>
</div>
</section>
`;
document.body.appendChild(mctsRestartBackdrop);
const mctsRestartUI = {
title: document.getElementById('mctsRestartTitle'),
summary: document.getElementById('mctsRestartSummary'),
confirm: document.getElementById('mctsRestartConfirm')
};
let mctsRestartReasonKey = null;
let mctsRestartWaiter = null;
function renderMCTSRestartDialog() {
if (!mctsRestartReasonKey) {
return;
}
mctsRestartUI.title.textContent =
aiText('mctsRestartTitle');
mctsRestartUI.summary.textContent =
aiText('mctsRestartSummary', {
reason: aiText(mctsRestartReasonKey)
});
mctsRestartUI.confirm.textContent =
aiText('mctsRestartConfirm');
}
function settleMCTSRestartDialog(error = null) {
const waiter = mctsRestartWaiter;
mctsRestartWaiter = null;
mctsRestartReasonKey = null;
runtime.waitingMCTSRestartConfirm = false;
mctsRestartBackdrop.hidden = true;
if (!waiter) {
return;
}
waiter.signal.removeEventListener(
'abort',
waiter.onAbort
);
if (error) {
waiter.reject(error);
} else {
waiter.resolve();
}
}
function showMCTSRestartDialog(reasonKey, signal) {
if (signal.aborted) {
return Promise.reject(createAbortError());
}
runtime.waitingMCTSRestartConfirm = true;
mctsRestartReasonKey = reasonKey;
renderMCTSRestartDialog();
mctsRestartBackdrop.hidden = false;
return new Promise((resolve, reject) => {
const onAbort = () => {
settleMCTSRestartDialog(createAbortError());
};
mctsRestartWaiter = {
resolve,
reject,
signal,
onAbort
};
signal.addEventListener('abort', onAbort, {
once: true
});
requestAnimationFrame(() => {
mctsRestartUI.confirm.focus();
});
});
}
mctsRestartUI.confirm.addEventListener('click', () => {
settleMCTSRestartDialog();
});這個對話框沒有取消按鈕,也不允許點擊背景關閉,符合「點擊確定後才再從頭搜尋」。
5.5 阻止對話框確認前自動重搜
在 queueAIForCurrentTurn() 的判斷中加入:
runtime.waitingMCTSRestartConfirm ||完整前段應為:
function queueAIForCurrentTurn() {
if (
runtime.suspendQueue > 0 ||
runtime.running ||
runtime.applying ||
runtime.scheduled ||
runtime.waitingMCTSRestartConfirm ||
!isCurrentTurnAI() ||
!els.flightConfirmBackdrop.hidden ||
!aiBackdrop.hidden
) {
return;
}5.6 語言切換時更新對話框
將:
applyLanguage = function applyLanguageWithAI(language) {
originalApplyLanguage(language);
renderAISettings();
};替換為:
applyLanguage = function applyLanguageWithAI(language) {
originalApplyLanguage(language);
renderAISettings();
if (!mctsRestartBackdrop.hidden) {
renderMCTSRestartDialog();
}
};5.7 修改 runCurrentAITurn()
在 try 前、let result 附近加入一個標記:
let completedWithMCTS = false;所以變成:
try {
let result;
let completedWithMCTS = false;
const randomOpeningAction =
pickRandomCoreOpeningAction(searchPosition);在 Alpha-Beta timeout 回退到 MCTS 的:
result = await mctsEngine.search(完成之後加入:
completedWithMCTS = true;即:
result = await mctsEngine.search(
searchPosition,
() =>
Math.max(
1,
controls[player].timeSeconds
) * 1000,
signal,
(elapsed, visits) => {
if (!signal.aborted) {
setThinkingView({
kind: 'alpha-fallback',
player,
elapsed,
visits
});
}
}
);
completedWithMCTS = true;普通 MCTS 搜尋後也加入:
result = await mctsEngine.search(
searchPosition,
() =>
Math.max(
1,
controls[player].timeSeconds
) * 1000,
signal,
(elapsed, visits) => {
if (!signal.aborted) {
setThinkingView({
kind: 'mcts',
player,
elapsed,
visits
});
}
}
);
completedWithMCTS = true;替換局面不一致判斷
找到:
if (state.currentPlayer !== player || !sameCorePosition(searchPosition, currentPosition)) {
return;
}替換為:
if (
state.currentPlayer !== player ||
!sameCorePosition(searchPosition, currentPosition)
) {
/*
* MCTS 已經跑完時限,但搜尋局面在執行前失效。
* 如果仍輪到同一位 AI,就先說明原因,
* 等使用者按確定後才重建 root。
*/
if (
completedWithMCTS &&
state.currentPlayer === player &&
isAIControl(player) &&
!state.roundEnded &&
!state.gameOver
) {
hideThinkingImmediately();
await showMCTSRestartDialog(
'mctsRestartPositionChanged',
signal
);
if (signal.aborted) {
throw createAbortError();
}
mctsEngine.resetTo(captureCorePosition());
}
return;
}替換 appliedAction === null 分支
找到:
if (appliedAction === null) {
console.error('AI could not apply any legal action to the current board.');
mctsEngine.resetTo(captureCorePosition());
controls[player].mode = AI_MODE_HUMAN;
hideThinkingImmediately();
renderAISettings();
return;
}替換為:
if (appliedAction === null) {
console.error(
'AI could not apply any legal action to the current board.'
);
runtime.applying = false;
hideThinkingImmediately();
/*
* MCTS 到時但沒有行動:不立即偷偷重搜,
* 必須先顯示原因並等待確定。
*/
if (
completedWithMCTS &&
state.currentPlayer === player &&
isAIControl(player) &&
!state.roundEnded &&
!state.gameOver
) {
await showMCTSRestartDialog(
'mctsRestartActionRejected',
signal
);
if (signal.aborted) {
throw createAbortError();
}
mctsEngine.resetTo(captureCorePosition());
return;
}
/*
* 非 MCTS 的不可恢復錯誤仍使用原來的安全處理。
*/
mctsEngine.resetTo(captureCorePosition());
controls[player].mode = AI_MODE_HUMAN;
renderAISettings();
return;
}使用者按下確定後,本次 runCurrentAITurn() 會進入 finally,然後由:
queueAIForCurrentTurn();從新 root 重新搜尋。
六、首手提示圓點只閃爍兩次
建議完全改用 SVG <animate>,不再使用 CSS keyframes,這也符合 SVG 屬性不要放在 CSS 的要求。
6.1 新增固定時間常數
在:
const PIECE_SCALE = 0.86;後面加入:
const OPENING_HINT_DURATION = 1200;6.2 刪除提示圓點 CSS
刪除以下全部 CSS:
.opening-hint-dot {
stroke-width: 2.4;
vector-effect: non-scaling-stroke;
pointer-events: none;
transform-box: fill-box;
transform-origin: center;
animation: opening-hint-pulse 620ms ease-in-out infinite;
}
.opening-hint-dot.owner-1 {
fill: var(--p1);
stroke: var(--p1-dark);
filter: drop-shadow(0 0 7px rgba(255, 128, 111, 0.88));
}
.opening-hint-dot.owner-2 {
fill: var(--p2);
stroke: var(--p2-dark);
filter: drop-shadow(0 0 7px rgba(96, 172, 248, 0.88));
}
@keyframes opening-hint-pulse {
0%,
100% {
opacity: 0.3;
transform: scale(0.72);
}
50% {
opacity: 1;
transform: scale(1.24);
}
}6.3 替換 showOpeningRuleHint()
function showOpeningRuleHint(active) {
clearOpeningRuleHint();
if (
!active ||
active.type !== 'fino' ||
!isStarterOpeningPlacement(
active.player,
active.type
)
) {
return;
}
state.openingHintPlayer = active.player;
renderOpeningRuleHint();
state.openingHintTimer = window.setTimeout(() => {
state.openingHintTimer = null;
state.openingHintPlayer = 0;
renderOpeningRuleHint();
}, OPENING_HINT_DURATION);
}在 handleBoardClick() 中將:
showOpeningRuleHint(active, 2000);改成:
showOpeningRuleHint(active);Toast 仍然可以顯示 2000 毫秒,不影響圓點只播放 1200 毫秒。
6.4 renderOpeningRuleHint() 的圓點部分
完整替換成:
function renderOpeningRuleHint() {
els.openingHintLayer.replaceChildren();
if (!state.openingHintPlayer) {
return;
}
const active = getActivePiece();
if (
!active ||
active.player !== state.openingHintPlayer ||
active.type !== 'fino' ||
!isStarterOpeningPlacement(
active.player,
active.type
)
) {
return;
}
const orientation =
state.orientationMemory[active.player].fino ||
active.orientation ||
'tl';
const anchor =
OPENING_FINO_ANCHORS[orientation] ||
OPENING_FINO_ANCHORS.tl;
const dot = document.createElementNS(
SVG_NS,
'circle'
);
dot.setAttribute(
'cx',
(anchor.col + 0.5) * CELL_SIZE
);
dot.setAttribute(
'cy',
(anchor.row + 0.5) * CELL_SIZE
);
dot.setAttribute('r', '6');
dot.setAttribute(
'fill',
active.player === 1 ? '#ff806f' : '#60acf8'
);
dot.setAttribute(
'stroke',
active.player === 1 ? '#a73e37' : '#2866a4'
);
dot.setAttribute('stroke-width', '2.4');
dot.setAttribute(
'vector-effect',
'non-scaling-stroke'
);
dot.setAttribute('pointer-events', 'none');
dot.setAttribute(
'filter',
active.player === 1
? 'url(#openingHintShadow1)'
: 'url(#openingHintShadow2)'
);
/*
* 半徑:
* 小 → 大 → 小 → 大 → 小
*
* 視覺動作即:
* 變大 → 變小 → 變大 → 變小 → 消失
*/
const radiusAnimation =
document.createElementNS(SVG_NS, 'animate');
radiusAnimation.setAttribute(
'attributeName',
'r'
);
radiusAnimation.setAttribute(
'values',
'6;10;6;10;6'
);
radiusAnimation.setAttribute(
'keyTimes',
'0;0.25;0.5;0.75;1'
);
radiusAnimation.setAttribute(
'dur',
`${OPENING_HINT_DURATION}ms`
);
radiusAnimation.setAttribute(
'repeatCount',
'1'
);
radiusAnimation.setAttribute('fill', 'freeze');
const opacityAnimation =
document.createElementNS(SVG_NS, 'animate');
opacityAnimation.setAttribute(
'attributeName',
'opacity'
);
opacityAnimation.setAttribute(
'values',
'0.45;1;0.45;1;0'
);
opacityAnimation.setAttribute(
'keyTimes',
'0;0.25;0.5;0.75;1'
);
opacityAnimation.setAttribute(
'dur',
`${OPENING_HINT_DURATION}ms`
);
opacityAnimation.setAttribute(
'repeatCount',
'1'
);
opacityAnimation.setAttribute('fill', 'freeze');
dot.append(
radiusAnimation,
opacityAnimation
);
els.openingHintLayer.appendChild(dot);
}七、將棋盤 SVG 屬性移出 CSS
以下只針對棋盤及棋盤內動態圖形;工具列按鈕 SVG 的版面 CSS可以保留。
7.1 修改棋盤 <defs>
將原本:
<defs>
<clipPath id="boardClip">
<rect x="0" y="0" width="480" height="480" rx="16"></rect>
</clipPath>
</defs>替換為:
<defs>
<clipPath id="boardClip">
<rect
x="0"
y="0"
width="480"
height="480"
rx="16"
></rect>
</clipPath>
<filter
id="minoMarkerShadow"
x="-50%"
y="-50%"
width="200%"
height="200%"
color-interpolation-filters="sRGB"
>
<feDropShadow
dx="0"
dy="2"
stdDeviation="2"
flood-color="#000000"
flood-opacity="0.34"
></feDropShadow>
</filter>
<filter
id="moveArrowShadow"
x="-50%"
y="-50%"
width="200%"
height="200%"
color-interpolation-filters="sRGB"
>
<feDropShadow
dx="0"
dy="4"
stdDeviation="3.5"
flood-color="#000000"
flood-opacity="0.34"
></feDropShadow>
</filter>
<filter
id="openingHintShadow1"
x="-100%"
y="-100%"
width="300%"
height="300%"
color-interpolation-filters="sRGB"
>
<feDropShadow
dx="0"
dy="0"
stdDeviation="4"
flood-color="#ff806f"
flood-opacity="0.88"
></feDropShadow>
</filter>
<filter
id="openingHintShadow2"
x="-100%"
y="-100%"
width="300%"
height="300%"
color-interpolation-filters="sRGB"
>
<feDropShadow
dx="0"
dy="0"
stdDeviation="4"
flood-color="#60acf8"
flood-opacity="0.88"
></feDropShadow>
</filter>
</defs>7.2 修改棋盤底色及邊框
將:
<rect class="board-base" x="0" y="0" width="480" height="480"></rect>替換成:
<rect
class="board-base"
x="0"
y="0"
width="480"
height="480"
fill="#f2ead8"
></rect>將棋盤邊框替換成:
<rect
class="board-border"
x="1.5"
y="1.5"
width="477"
height="477"
rx="15"
fill="none"
stroke="#8b826f"
stroke-width="3"
vector-effect="non-scaling-stroke"
pointer-events="none"
></rect>7.3 新增 SVG 屬性工具函式
放在 buildBoardGrid() 前:
const SVG_OWNER_COLORS = Object.freeze({
1: Object.freeze({
fill: '#ff806f',
stroke: '#a73e37'
}),
2: Object.freeze({
fill: '#60acf8',
stroke: '#2866a4'
})
});
function setSvgAttributes(element, attributes) {
Object.entries(attributes).forEach(([name, value]) => {
if (value !== null && value !== undefined) {
element.setAttribute(name, String(value));
}
});
return element;
}
function applyShapeSvgAttributes(
element,
shapeClass,
owner
) {
const classes = new Set(
String(shapeClass).split(/\s+/)
);
if (classes.has('preview-shape')) {
const invalid = classes.has('invalid');
setSvgAttributes(element, {
fill: invalid
? 'rgba(255, 85, 112, 0.52)'
: 'rgba(82, 215, 154, 0.57)',
stroke: invalid ? '#be304a' : '#238c62',
'stroke-width': 4,
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'vector-effect': 'non-scaling-stroke'
});
return;
}
const colors =
SVG_OWNER_COLORS[owner] ||
SVG_OWNER_COLORS[1];
setSvgAttributes(element, {
fill: colors.fill,
stroke: colors.stroke,
'stroke-width': 3,
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'vector-effect': 'non-scaling-stroke'
});
if (classes.has('piece-shape')) {
element.setAttribute('fill-opacity', '0.777');
}
}
function applyDividerSvgAttributes(
element,
dividerClass,
owner
) {
const classes = new Set(
String(dividerClass).split(/\s+/)
);
if (classes.has('preview-divider')) {
setSvgAttributes(element, {
fill: 'none',
stroke: classes.has('invalid')
? 'rgba(92, 19, 33, 0.72)'
: 'rgba(24, 50, 44, 0.7)',
'stroke-width': 2,
'stroke-linecap': 'round',
'vector-effect': 'non-scaling-stroke'
});
return;
}
const colors =
SVG_OWNER_COLORS[owner] ||
SVG_OWNER_COLORS[1];
setSvgAttributes(element, {
fill: 'none',
stroke: colors.stroke,
'stroke-width': classes.has('mini-divider')
? 1.7
: 2,
'stroke-linecap': 'round',
'vector-effect': 'non-scaling-stroke'
});
}7.4 替換 buildBoardGrid()
function buildBoardGrid() {
let pathData = '';
for (let index = 1; index < BOARD_SIZE; index += 1) {
const position = index * CELL_SIZE;
pathData += `M ${position} 0 V 480 `;
pathData += `M 0 ${position} H 480 `;
}
const normalPath =
document.createElementNS(SVG_NS, 'path');
setSvgAttributes(normalPath, {
d: pathData.trim(),
class: 'grid-lines',
fill: 'none',
stroke: 'rgba(43, 48, 58, 0.34)',
'stroke-width': 1.2,
'vector-effect': 'non-scaling-stroke'
});
const centerPositions = [4, 5, 6].map(
index => index * CELL_SIZE
);
let centerPathData = '';
centerPositions.forEach(position => {
centerPathData +=
`M ${position} ${4 * CELL_SIZE} ` +
`V ${6 * CELL_SIZE} `;
centerPathData +=
`M ${4 * CELL_SIZE} ${position} ` +
`H ${6 * CELL_SIZE} `;
});
const centerPath =
document.createElementNS(SVG_NS, 'path');
setSvgAttributes(centerPath, {
d: centerPathData.trim(),
class: 'center-grid-lines',
fill: 'none',
stroke: 'rgba(34, 38, 46, 0.76)',
'stroke-width': 2.6,
'stroke-linecap': 'square',
'vector-effect': 'non-scaling-stroke',
'pointer-events': 'none'
});
els.gridLayer.replaceChildren(
normalPath,
centerPath
);
}7.5 修改 appendShape()
在建立單格 rect 時,於:
rect.setAttribute('class', `${shapeClass} owner-${owner}`);後加入:
applyShapeSvgAttributes(
rect,
shapeClass,
owner
);在建立多格 shape 時,於:
shape.setAttribute('class', `${shapeClass} owner-${owner}`);後加入:
applyShapeSvgAttributes(
shape,
shapeClass,
owner
);在建立 separators 時,於:
separators.setAttribute('class', `${dividerClass} owner-${owner}`);後加入:
applyDividerSvgAttributes(
separators,
dividerClass,
owner
);7.6 修改各種圓點和標記
renderMiniPiece() 的 anchor dot
在:
dot.setAttribute('class', 'anchor-dot');後加入:
setSvgAttributes(dot, {
fill: '#fff8ed',
stroke: 'rgba(0, 0, 0, 0.28)',
'stroke-width': 1,
'vector-effect': 'non-scaling-stroke'
});renderPreview() 的 preview anchor
在:
dot.setAttribute('class', 'preview-anchor');後加入:
setSvgAttributes(dot, {
fill: 'rgba(255, 255, 255, 0.92)',
stroke: 'rgba(0, 0, 0, 0.4)',
'stroke-width': 1.5,
'vector-effect': 'non-scaling-stroke'
});appendMinoMoveMarker()
替換為:
function appendMinoMoveMarker(row, col, owner) {
const marker =
document.createElementNS(SVG_NS, 'circle');
setSvgAttributes(marker, {
cx: (col + 0.5) * CELL_SIZE,
cy: (row + 0.5) * CELL_SIZE,
r: 6,
class: `mino-move-marker owner-${owner}`,
fill: owner === 1 ? '#ff806f' : '#60acf8',
stroke: '#fff8ed',
'stroke-width': 2.2,
'vector-effect': 'non-scaling-stroke',
'pointer-events': 'none',
filter: 'url(#minoMarkerShadow)'
});
els.moveLayer.appendChild(marker);
}appendLastTurnMarker()
替換為:
function appendLastTurnMarker(row, col) {
const marker =
document.createElementNS(SVG_NS, 'circle');
setSvgAttributes(marker, {
cx: (col + 0.5) * CELL_SIZE,
cy: (row + 0.5) * CELL_SIZE,
r: 6,
class: 'last-turn-marker',
fill: '#ebc3f9',
'pointer-events': 'none'
});
els.lastMoveLayer.appendChild(marker);
}7.7 修改選中米諾外框
在 renderMoveArrows() 中,將建立 ring 的部分替換為:
const ring =
document.createElementNS(SVG_NS, 'rect');
setSvgAttributes(ring, {
x: cell.col * CELL_SIZE + 5,
y: cell.row * CELL_SIZE + 5,
width: CELL_SIZE - 10,
height: CELL_SIZE - 10,
rx: 10,
class: 'selected-mino-ring',
fill: 'none',
stroke: '#fff8ed',
'stroke-width': 4,
'stroke-dasharray': '8 5',
'vector-effect': 'non-scaling-stroke',
'pointer-events': 'none'
});
els.moveLayer.appendChild(ring);7.8 替換 createMoveArrow()
function createMoveArrow(directionName, row, col) {
const direction = DIRECTIONS[directionName];
const centerX =
(col + 0.5 + direction.dc * 0.72) *
CELL_SIZE;
const centerY =
(row + 0.5 + direction.dr * 0.72) *
CELL_SIZE;
const group =
document.createElementNS(SVG_NS, 'g');
setSvgAttributes(group, {
class: 'move-arrow',
cursor: 'pointer',
filter: 'url(#moveArrowShadow)'
});
group.dataset.moveDir = directionName;
const circle =
document.createElementNS(SVG_NS, 'circle');
setSvgAttributes(circle, {
cx: centerX,
cy: centerY,
r: 14,
fill: 'rgba(13, 23, 34, 0.92)',
stroke: '#fff8ed',
'stroke-width': 2,
'vector-effect': 'non-scaling-stroke'
});
group.appendChild(circle);
const lineLength = 8;
const backX =
centerX - direction.dc * lineLength;
const backY =
centerY - direction.dr * lineLength;
const frontX =
centerX + direction.dc * lineLength;
const frontY =
centerY + direction.dr * lineLength;
const perpendicularX = -direction.dr;
const perpendicularY = direction.dc;
const wing1X =
frontX -
direction.dc * 6 +
perpendicularX * 5;
const wing1Y =
frontY -
direction.dr * 6 +
perpendicularY * 5;
const wing2X =
frontX -
direction.dc * 6 -
perpendicularX * 5;
const wing2Y =
frontY -
direction.dr * 6 -
perpendicularY * 5;
const path =
document.createElementNS(SVG_NS, 'path');
setSvgAttributes(path, {
d:
`M ${backX} ${backY} ` +
`L ${frontX} ${frontY} ` +
`M ${wing1X} ${wing1Y} ` +
`L ${frontX} ${frontY} ` +
`L ${wing2X} ${wing2Y}`,
fill: 'none',
stroke: '#fff8ed',
'stroke-width': 3,
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'vector-effect': 'non-scaling-stroke'
});
group.appendChild(path);
return group;
}7.9 刪除棋盤圖形的 presentation CSS
從 CSS 中刪除以下選擇器及其內容:
.board-base
.grid-lines
.center-grid-lines
.board-border
.piece-shape,
.mini-shape
.piece-shape
.mini-shape
.owner-1.piece-shape,
.owner-1.mini-shape
.owner-2.piece-shape,
.owner-2.mini-shape
.piece-divider,
.mini-divider
.piece-divider
.mini-divider
.owner-1.piece-divider,
.owner-1.mini-divider
.owner-2.piece-divider,
.owner-2.mini-divider
.anchor-dot
.preview-shape
.preview-shape.valid
.preview-shape.invalid
.preview-divider
.preview-divider.invalid
.preview-anchor
.selected-mino-ring
.mino-move-marker
.mino-move-marker.owner-1
.mino-move-marker.owner-2
.last-turn-marker
.opening-hint-dot
.opening-hint-dot.owner-1
.opening-hint-dot.owner-2
.move-arrow
.move-arrow circle
.move-arrow path以及:
@keyframes opening-hint-pulse#boardSvg 的版面/輸入 CSS 可以保留,例如:
#boardSvg {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: manipulation;
user-select: none;
}這部分控制 HTML 版面及指標操作,不再控制棋盤圖形的 fill、stroke 等 SVG presentation 屬性。
八、修改後應驗證的項目
- 法諾只落一個後,點米諾圖示不能跳到米諾階段。
- 法諾仍有合法落點時,MCTS 和 Alpha-Beta 的動作清單中沒有 Skip。
- 法諾用完或沒有合法落點後,程式自動選取米諾。
- 米諾同樣必須用完或沒有合法落點才進入維諾。
- 維諾階段選中米諾後,點正常可達位置只能正常移動。
- 維諾階段點正常不可達位置不能提前飛子。
- 只有
canContinueVinoPhase()/canContinueCoreVino()回傳false,或維諾用完後,才進入飛子階段。 - 舊棋譜若包含仍有合法落點時的提前換棋子,導入必須失敗。
- MCTS 到時但沒有執行行動時,不會自動立即重搜;先顯示原因對話框。
- 按下對話框「確定」後才呼叫
mctsEngine.resetTo(),再重新排程。 - 首手落錯時,圓點只播放「大—小—大—小」一次,約 1.2 秒後消失。
- 開發者工具檢查棋盤 SVG 元素時,
fill、stroke、stroke-width、vector-effect等都直接存在元素屬性中,不再由棋盤 CSS 選擇器提供。