共享会话
🎮 遊戲規則修改
分享于 2026年8月31日 02:27更改規則 落子階段有「法諾、米諾、維諾」。在每一個落子階段,都必須至少放置一個本階段的棋子,否則直接判負(兩局的總勝負)。當場上已經存在一個本階段的棋子,則玩家可以選擇跳到下一個階段。當在維諾階段,場上已經存在至少一個維諾時,玩家可以選擇飛子以結束本局。
程式實現:
當一方在法諾放置階段,如果場上已經至少有一個他的法諾,則他可以點擊米諾棋子使之選中,在落子前他可以再選回法諾,但當他落下一個米諾時,他將無法再選中法諾。
當一方在米諾放置階段,如果場上已經至少有一個他的米諾,則他可以點擊維諾棋子使之選中,在落子前他可以再選回米諾,但當他落下一個維諾時,他將無法再選中米諾。當他移動一個場上的米諾時,棋子選中將自動切換到維諾。
之前的「當處於法諾階段,不能再放置一個法諾,就自動跳到米諾階段;當處於米諾階段,不能再放置一個米諾,就自動跳到米諾階段;當處於維諾階段,不能再放置一個維諾,就自動跳到飛子階段」仍然保留。
明確給出所要修改的地方和所要替換的代碼。
QuanAI以下修改以這個判定為準:
-
法諾/米諾/維諾每個階段都至少要成功放置一個該類棋子。
-
如果輪到該玩家進入某階段,而他:
- 尚未放過任何該階段棋子;
- 且已經沒有任何合法落點;
則不是跳階,而是直接輸掉整場兩局比賽。
-
已經放過至少一個目前階段棋子後,可以暫時選取下一階段棋子:
- 放下下一階段棋子後,前一階段永久結束。
- 在落子前可以切回原階段棋子。
-
米諾階段已有至少一個米諾時,可以選取並移動場上的米諾;完成移動後自動選中維諾。
-
只有在真正進入維諾階段且已至少放過一個維諾後,才可主動飛子。
-
原有「無法再放置時自動跳到下一階段」保留,但前提是該階段已經放過至少一個棋子。
一、增加翻譯文字
1.1 英文 I18N.en
找到:
phaseVinoToFlight:
'{player} cannot place another Vino, even after a normal Mino move. Mino flight phase begins.',在它後面加入:
phaseRequiredLoss:
'{player} cannot legally place even one {piece} in this phase and loses the entire two-round match.',
flightNeedsVino:
'At least one Vino must be placed before choosing Mino flight to end the round.',
forfeitSummary:
'{loser} placed no {piece} during that placement phase and therefore loses immediately. {winner} wins the entire two-round match.',1.2 中文 I18N.zh
找到:
phaseVinoToFlight: '{player}即使正常移動一個米諾也無法再放置維諾,自動進入米諾飛行階段。',在它後面加入:
phaseRequiredLoss:
'{player}在{piece}階段連一個{piece}都無法合規放置,因此直接判負整場兩局比賽。',
flightNeedsVino:
'維諾階段至少要先放置一個維諾,才能選擇飛子結束本局。',
forfeitSummary:
'{loser}在{piece}階段沒有放置任何{piece},因此直接判負;{winner}贏得兩局的總勝負。',二、增加「目前選中的放置棋子」狀態
找到:
function createPhaseSkipped() {
return {
1: { fino: false, mino: false },
2: { fino: false, mino: false }
};
}在它後面加入:
function createPlacementSelection() {
return {
1: null,
2: null
};
}在 state 中找到:
gameStarted: false,
gameOver: false,替換為:
gameStarted: false,
gameOver: false,
matchForfeit: null,再找到:
inventory: createInventory(),
phaseSkipped: createPhaseSkipped(),替換為:
inventory: createInventory(),
phaseSkipped: createPhaseSkipped(),
placementSelection: createPlacementSelection(),三、替換階段及可選棋子的判斷函式
找到從:
function isMinoFlightPhase(player) {一直到 getActivePiece() 結束的整段,也就是替換原來的:
isMinoFlightPhasegetRequiredPiecegetActivePiece
改成下面完整程式碼:
function isMinoFlightPhase(player) {
return Boolean(player && state.minoFlightMode[player] && !state.playerEnded[player]);
}
function countPlacedPieces(player, type) {
return state.pieces.filter(piece => piece.owner === player && piece.type === type).length;
}
function hasPlacedPiece(player, type) {
return countPlacedPieces(player, type) > 0;
}
/*
* 回傳玩家目前真正所在的放置階段。
*
* phaseSkipped.fino === true 表示法諾階段已結束;
* phaseSkipped.mino === true 表示米諾階段已結束。
*/
function getRequiredPiece(player) {
if (
!player ||
!state.inventory[player] ||
state.playerEnded[player] ||
isMinoFlightPhase(player)
) {
return null;
}
for (const type of PIECE_ORDER) {
if (type !== 'vino' && state.phaseSkipped[player][type]) {
continue;
}
if (state.inventory[player][type] > 0) {
return type;
}
}
return null;
}
function getNextPieceType(type) {
const index = PIECE_ORDER.indexOf(type);
if (index < 0 || index >= PIECE_ORDER.length - 1) {
return null;
}
return PIECE_ORDER[index + 1];
}
/*
* 回傳控制區中目前允許點選的放置棋子。
*
* 例如:
* - 法諾階段尚未放過法諾:只能選法諾。
* - 法諾階段已放過法諾:可以選法諾或米諾。
* - 米諾階段已放過米諾:可以選米諾或維諾。
*/
function getSelectablePlacementTypes(player) {
const required = getRequiredPiece(player);
if (
!required ||
state.playerEnded[player] ||
isMinoFlightPhase(player)
) {
return [];
}
const selectable = [required];
const nextType = getNextPieceType(required);
if (
nextType &&
hasPlacedPiece(player, required) &&
state.inventory[player][nextType] > 0
) {
selectable.push(nextType);
}
return selectable;
}
function getSelectedPlacementType(player) {
const required = getRequiredPiece(player);
if (!required) {
return null;
}
const selectable = getSelectablePlacementTypes(player);
const requested = state.placementSelection[player];
if (requested && selectable.includes(requested)) {
return requested;
}
return required;
}
/*
* 米諾階段已有至少一個米諾,或真正處於維諾階段時,
* 可以點擊場上的米諾進行正常移動。
*/
function canSelectBoardMinoForMovement(player) {
if (
!player ||
player !== state.currentPlayer ||
state.playerEnded[player] ||
state.roundEnded ||
state.gameOver ||
state.movedThisTurn ||
isMinoFlightPhase(player)
) {
return false;
}
const phase = getRequiredPiece(player);
if (phase === 'mino') {
return hasPlacedPiece(player, 'mino');
}
return phase === 'vino';
}
/*
* 主動飛子只允許在真正的維諾階段,
* 且該玩家已經至少放置一個維諾。
*/
function canManuallyFlyMino(player) {
return (
getRequiredPiece(player) === 'vino' &&
hasPlacedPiece(player, 'vino') &&
!isMinoFlightPhase(player)
);
}
/*
* 當玩家實際放下下一階段棋子時,
* 正式結束前一個階段。
*/
function commitPhaseAdvanceForPlacement(player, placedType) {
const required = getRequiredPiece(player);
if (!required) {
return false;
}
if (required === placedType) {
return true;
}
const selectable = getSelectablePlacementTypes(player);
if (!selectable.includes(placedType)) {
return false;
}
if (required === 'fino' && placedType === 'mino') {
state.phaseSkipped[player].fino = true;
return true;
}
if (required === 'mino' && placedType === 'vino') {
state.phaseSkipped[player].mino = true;
return true;
}
return false;
}
function getActivePiece() {
if (
!state.gameStarted ||
state.roundEnded ||
state.gameOver ||
!state.currentPlayer ||
isMinoFlightPhase(state.currentPlayer)
) {
return null;
}
const type = getSelectedPlacementType(state.currentPlayer);
if (!type) {
return null;
}
return {
player: state.currentPlayer,
type,
orientation:
state.orientationMemory[state.currentPlayer][type] ||
(type === 'mino' ? 'single' : 'tl')
};
}四、增加整場直接判負,並修改自動跳階
4.1 替換 enterMinoFlightPhase
找到原本的:
function enterMinoFlightPhase(player) {
state.minoFlightMode[player] = true;
state.selectedMinoId = null;
state.pendingMinoMove = null;
state.movedThisTurn = false;
state.hoverCell = null;
showToast('phaseVinoToFlight', { player: playerToken(player) }, 1000);
}替換為:
function enterMinoFlightPhase(player) {
state.minoFlightMode[player] = true;
state.placementSelection[player] = null;
state.selectedMinoId = null;
state.pendingMinoMove = null;
state.movedThisTurn = false;
state.hoverCell = null;
showToast('phaseVinoToFlight', { player: playerToken(player) }, 1000);
}4.2 在 enterMinoFlightPhase 後加入直接判負函式
function forfeitMatchBecausePhaseUnavailable(loser, requiredType) {
const winner = otherPlayer(loser);
hideFlightConfirmDialog();
state.matchForfeit = {
winner,
loser,
requiredType
};
state.gameOver = true;
state.roundEnded = true;
state.currentPlayer = 0;
state.playerEnded[loser] = true;
state.minoFlightMode[loser] = false;
state.placementSelection[loser] = null;
state.selectedMinoId = null;
state.pendingMinoMove = null;
state.pendingFlight = null;
state.movedThisTurn = false;
state.hoverCell = null;
state.endInfo = {
reason: 'phase-forfeit',
winner,
loser,
requiredType
};
showToast(
'phaseRequiredLoss',
{
player: playerToken(loser),
piece: pieceToken(requiredType)
},
1800
);
window.setTimeout(() => {
if (
state.gameOver &&
state.matchForfeit &&
state.matchForfeit.loser === loser
) {
renderAll();
showFinalModal();
}
}, 150);
}4.3 完整替換 preparePlayerPhase
找到原本:
function preparePlayerPhase(player) {將整個函式替換為:
function preparePlayerPhase(player) {
if (!player || state.playerEnded[player] || state.roundEnded || state.gameOver) {
return;
}
while (true) {
const required = getRequiredPiece(player);
if (required === 'fino') {
if (hasAnyLegalPlacement(player, 'fino')) {
return;
}
/*
* 一個法諾都沒有放過,而且已經無法放置:
* 直接輸掉整場兩局比賽。
*/
if (!hasPlacedPiece(player, 'fino')) {
forfeitMatchBecausePhaseUnavailable(player, 'fino');
return;
}
/*
* 已經至少放過一個法諾:
* 保留原有自動跳到米諾階段的規則。
*/
state.phaseSkipped[player].fino = true;
state.placementSelection[player] = null;
showToast(
'phaseFinoToMino',
{ player: playerToken(player) },
1000
);
continue;
}
if (required === 'mino') {
if (hasAnyLegalPlacement(player, 'mino')) {
return;
}
/*
* 一個米諾都沒有放過,而且已經無法放置:
* 直接輸掉整場兩局比賽。
*/
if (!hasPlacedPiece(player, 'mino')) {
forfeitMatchBecausePhaseUnavailable(player, 'mino');
return;
}
/*
* 已經至少放過一個米諾:
* 保留原有自動跳到維諾階段的規則。
*/
state.phaseSkipped[player].mino = true;
state.placementSelection[player] = null;
showToast(
'phaseMinoToVino',
{ player: playerToken(player) },
1000
);
continue;
}
if (required === 'vino') {
/*
* 可以直接放維諾,或者某次正常移動米諾後可以放維諾,
* 就繼續留在維諾階段。
*/
if (canContinueVinoPhase(player)) {
return;
}
/*
* 一個維諾都沒有放過,而且即使移動米諾也無法放置:
* 直接輸掉整場兩局比賽。
*/
if (!hasPlacedPiece(player, 'vino')) {
forfeitMatchBecausePhaseUnavailable(player, 'vino');
return;
}
/*
* 已經至少放過一個維諾,但再也無法放置:
* 保留原有自動進入飛子階段的規則。
*/
enterMinoFlightPhase(player);
return;
}
/*
* 維諾已全部用完。
* 如果曾經放過維諾,就進入飛子階段;
* 否則直接判負。
*/
if (!hasPlacedPiece(player, 'vino')) {
forfeitMatchBecausePhaseUnavailable(player, 'vino');
return;
}
enterMinoFlightPhase(player);
return;
}
}五、修改棋子控制區,使相鄰兩階段棋子可以切換
完整替換 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 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 selectableTypes =
activePlayer && !inFlightPhase
? getSelectablePlacementTypes(player)
: [];
const selectable = selectableTypes.includes(type);
const selectedType =
activePlayer && !inFlightPhase
? getSelectedPlacementType(player)
: null;
/*
* 點選場上米諾時,控制區暫時不顯示任何放置棋子為選中。
*/
const blockedByBoardMinoSelection =
activePlayer && state.selectedMinoId !== null;
const selected =
activePlayer &&
!inFlightPhase &&
selectedType === type &&
!blockedByBoardMinoSelection;
const locked =
count > 0 &&
(
inFlightPhase ||
(activePlayer && !selectable)
);
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);
/*
* 米諾按鈕現在也可以點擊,用來選擇米諾放置。
* 不再使用原本的 type !== 'mino' 限制。
*/
frame.disabled =
!activePlayer ||
inFlightPhase ||
!selectable ||
count === 0;
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);
});
}六、修改棋子控制區點擊事件
完整替換 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 selectableTypes = getSelectablePlacementTypes(player);
if (!selectableTypes.includes(type)) {
return;
}
/*
* 在真正落下下一階段棋子之前,
* 只改變目前選中棋子,不結束原階段。
*/
state.placementSelection[player] = type;
state.selectedMinoId = null;
state.hoverCell = null;
/*
* 法諾和維諾仍可透過點擊不同象限改變方向。
* 米諾沒有方向,只改變選中狀態。
*/
if (type !== 'mino') {
state.orientationMemory[player][type] =
getOrientationFromFrameClick(event, frame);
}
refreshMinoSelectionUI();
}七、修改狀態文字
7.1 renderStatus()
找到:
} else if (active && active.type === 'vino' && state.selectedMinoId !== null) {
els.turnText.textContent = translate('turnMinoSelected', {
player: playerToken(active.player)
});替換為:
} else if (active && state.selectedMinoId !== null) {
els.turnText.textContent = translate('turnMinoSelected', {
player: playerToken(active.player)
});這樣在米諾放置階段選取場上的米諾時,也會顯示正確提示。
7.2 renderBoard()
找到 renderBoard() 中的:
} else {
const active = getActivePiece();
status = active
? translate('turnAuto', {
player: playerToken(active.player),
piece: pieceToken(active.type)
})
: '';
}替換為:
} else {
const active = getActivePiece();
if (active && state.selectedMinoId !== null) {
status = translate('turnMinoSelected', {
player: playerToken(active.player)
});
} else {
status = active
? translate('turnAuto', {
player: playerToken(active.player),
piece: pieceToken(active.type)
})
: '';
}
}八、實際放下下一階段棋子時才鎖定前一階段
完整替換 placePiece():
function placePiece(validation, active) {
/*
* 如果放的是下一階段棋子,
* 在這裡正式關閉上一階段。
*/
if (!commitPhaseAdvanceForPlacement(active.player, active.type)) {
return;
}
const piece = {
id: state.nextPieceId,
owner: active.player,
type: active.type,
orientation: active.orientation,
cells: validation.cells.map(cell => ({ ...cell }))
};
state.nextPieceId += 1;
state.pieces.push(piece);
state.inventory[active.player][active.type] -= 1;
rebuildBoard();
recalculateScores();
state.placementSelection[active.player] = null;
state.hoverCell = null;
state.selectedMinoId = null;
state.pendingMinoMove = null;
state.movedThisTurn = false;
resolveNextTurn(active.player);
pushHistorySnapshot();
renderAll();
if (state.roundEnded && !state.gameOver) {
window.setTimeout(() => {
if (state.roundEnded && !state.gameOver) {
showRoundModal();
}
}, 120);
}
}九、修改換手邏輯
完整替換 resolveNextTurn():
function resolveNextTurn(playerWhoActed) {
state.placementSelection[playerWhoActed] = null;
if (state.playerEnded[1] && state.playerEnded[2]) {
completeRoundByScore();
return;
}
const opponent = otherPlayer(playerWhoActed);
if (!state.playerEnded[opponent]) {
state.currentPlayer = opponent;
} else if (!state.playerEnded[playerWhoActed]) {
state.currentPlayer = playerWhoActed;
} else {
completeRoundByScore();
return;
}
state.movedThisTurn = false;
state.selectedMinoId = null;
state.pendingMinoMove = null;
state.hoverCell = null;
if (state.currentPlayer) {
state.placementSelection[state.currentPlayer] = null;
}
preparePlayerPhase(state.currentPlayer);
}十、允許米諾階段選取並移動場上的米諾
10.1 替換 selectMino()
function selectMino(pieceId) {
const piece = findPiece(pieceId);
if (
!piece ||
piece.owner !== state.currentPlayer ||
piece.type !== 'mino'
) {
return;
}
const flightPhase = isMinoFlightPhase(state.currentPlayer);
if (
!flightPhase &&
!canSelectBoardMinoForMovement(state.currentPlayer)
) {
return;
}
if (state.movedThisTurn) {
showToast('movedAlready');
return;
}
state.hoverCell = null;
if (state.selectedMinoId === pieceId) {
state.selectedMinoId = null;
refreshMinoSelectionUI();
return;
}
state.selectedMinoId = pieceId;
refreshMinoSelectionUI();
if (!flightPhase) {
const hasDirection = getLegalMinoDestinations(pieceId).size > 0;
if (!hasDirection) {
showToast('noMinoDirection');
}
}
}10.2 替換 performNormalMinoMove()
function performNormalMinoMove(pieceId, destination) {
const piece = findPiece(pieceId);
const player = state.currentPlayer;
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 previousPlacementSelection =
state.placementSelection[player];
piece.cells = [{ ...to }];
rebuildBoard();
recalculateScores();
state.pendingMinoMove = {
player,
pieceId,
from,
to,
previousPlacementSelection
};
state.movedThisTurn = true;
state.selectedMinoId = null;
state.hoverCell = null;
/*
* 新規則:移動場上的米諾後,自動選中維諾。
*
* 這裡只切換選中棋子,不立即設定 phaseSkipped.mino;
* 只有真正放下維諾時,米諾階段才永久結束。
*/
state.placementSelection[player] = 'vino';
pushHistorySnapshot();
renderAll();
}10.3 替換 revertPendingMinoMove()
function revertPendingMinoMove() {
const pendingMove = state.pendingMinoMove;
if (!pendingMove) {
return;
}
const piece = findPiece(pendingMove.pieceId);
if (!piece || piece.type !== 'mino') {
state.pendingMinoMove = null;
state.movedThisTurn = false;
renderAll();
return;
}
piece.cells = [{ ...pendingMove.from }];
rebuildBoard();
recalculateScores();
state.placementSelection[pendingMove.player] =
pendingMove.previousPlacementSelection || null;
state.pendingMinoMove = null;
state.movedThisTurn = false;
state.selectedMinoId = null;
state.hoverCell = null;
pushHistorySnapshot();
renderAll();
}10.4 替換 executeMinoMove()
function executeMinoMove(directionName) {
const active = getActivePiece();
if (
!active ||
!state.selectedMinoId ||
state.movedThisTurn ||
isMinoFlightPhase(state.currentPlayer) ||
!canSelectBoardMinoForMovement(state.currentPlayer)
) {
return;
}
const piece = findPiece(state.selectedMinoId);
const plan = planMinoMove(
state.selectedMinoId,
directionName
);
if (!piece || !plan.ok) {
showToast('cannotMoveMino');
return;
}
performNormalMinoMove(piece.id, {
row: piece.cells[0].row + plan.dr,
col: piece.cells[0].col + plan.dc
});
}十一、限制主動飛子必須先放過維諾
完整替換 attemptMinoFlight():
function attemptMinoFlight(pieceId, destination) {
const player = state.currentPlayer;
const piece = findPiece(pieceId);
if (
!piece ||
piece.owner !== player ||
piece.type !== 'mino' ||
piece.cells.length !== 1 ||
state.board[destination.row][destination.col]
) {
return;
}
const wasAutomaticFlightPhase =
isMinoFlightPhase(player);
/*
* 自動飛行階段不受這個限制;
* 主動飛子則必須真正處於維諾階段,
* 且至少已經放過一個維諾。
*/
if (
!wasAutomaticFlightPhase &&
!canManuallyFlyMino(player)
) {
if (getRequiredPiece(player) === 'vino') {
showToast('flightNeedsVino', {}, 1200);
} else {
showToast('cannotMoveMino', {}, 1200);
}
return;
}
const from = { ...piece.cells[0] };
const to = {
row: destination.row,
col: destination.col
};
piece.cells = [{ ...to }];
rebuildBoard();
recalculateScores();
state.selectedMinoId = null;
state.pendingMinoMove = null;
state.movedThisTurn = false;
state.hoverCell = null;
/*
* 自動飛行階段不需要再次確認。
* 維諾階段主動飛子時,只有飛子後仍能直接放置維諾才確認。
*/
const needsConfirmation =
!wasAutomaticFlightPhase &&
state.inventory[player].vino > 0 &&
hasAnyLegalPlacement(player, 'vino');
if (needsConfirmation) {
state.pendingFlight = {
player,
pieceId,
from,
to
};
renderAll();
showFlightConfirmDialog();
return;
}
finishPlayerByFlight(player);
}十二、完整替換棋盤點擊處理
完整替換 handleBoardClick():
function handleBoardClick(event) {
if (!state.gameStarted) {
return;
}
if (state.roundEnded || state.gameOver) {
openCurrentResult();
return;
}
const arrow = event.target.closest('[data-move-dir]');
if (arrow) {
executeMinoMove(arrow.dataset.moveDir);
return;
}
const cell = getBoardCellFromPointer(event);
if (!cell) {
return;
}
const occupied = state.board[cell.row][cell.col];
/*
* 自動飛子階段使用原本的專用處理。
*/
if (isMinoFlightPhase(state.currentPlayer)) {
handleMinoFlightBoardClick(cell, occupied);
return;
}
const active = getActivePiece();
if (!active) {
return;
}
/*
* 正常移動後再次點擊被移動的米諾,
* 使它退回原來的位置。
*/
if (
occupied &&
occupied.owner === active.player &&
occupied.type === 'mino' &&
state.pendingMinoMove &&
occupied.pieceId === state.pendingMinoMove.pieceId
) {
revertPendingMinoMove();
return;
}
/*
* 米諾或維諾階段點擊本方場上米諾:
* - 米諾階段必須已經至少放過一個米諾;
* - 維諾階段維持原有正常移動功能。
*/
if (
occupied &&
occupied.owner === active.player &&
occupied.type === 'mino' &&
canSelectBoardMinoForMovement(active.player)
) {
selectMino(occupied.pieceId);
return;
}
/*
* 已經移動過米諾後點擊另一個米諾,不允許再次移動。
*/
if (
occupied &&
occupied.owner === active.player &&
occupied.type === 'mino' &&
state.movedThisTurn
) {
showToast('movedAlready');
return;
}
/*
* 場上米諾已被選中時,空位會先被解釋為米諾目的地。
*/
if (state.selectedMinoId !== null) {
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
);
return;
}
/*
* 只有真正處於維諾階段且已放過維諾,
* 不可正常到達的空位才可作為飛子目的地。
*/
if (canManuallyFlyMino(active.player)) {
attemptMinoFlight(state.selectedMinoId, cell);
return;
}
if (getRequiredPiece(active.player) === 'vino') {
showToast('flightNeedsVino', {}, 1200);
} else {
showToast('cannotMoveMino', {}, 1200);
}
return;
}
const validation = validatePlacement(
active.player,
active.type,
active.orientation,
cell.row,
cell.col
);
if (!validation.ok) {
const toastKey =
{
bounds: 'outOfBounds',
overlap: 'overlap',
contact: 'ownContact'
}[validation.reason] || 'overlap';
showToast(toastKey);
renderMoveArrows();
return;
}
placePiece(validation, active);
}十三、修改歷史記錄,保存選中的放置棋子
13.1 替換 clonePendingMinoMove()
function clonePendingMinoMove(move) {
if (!move) {
return null;
}
return {
player: move.player,
pieceId: move.pieceId,
from: { ...move.from },
to: { ...move.to },
previousPlacementSelection:
move.previousPlacementSelection || null
};
}13.2 修改 captureSnapshot()
在:
phaseSkipped: {
1: { ...state.phaseSkipped[1] },
2: { ...state.phaseSkipped[2] }
},後面加入:
placementSelection: {
1: state.placementSelection[1],
2: state.placementSelection[2]
},13.3 修改 restoreSnapshot()
在:
state.phaseSkipped = {
1: { ...snapshot.phaseSkipped[1] },
2: { ...snapshot.phaseSkipped[2] }
};後面加入:
state.placementSelection = snapshot.placementSelection
? {
1: snapshot.placementSelection[1] || null,
2: snapshot.placementSelection[2] || null
}
: createPlacementSelection();十四、修改每局初始化
在 startRound(roundNumber) 中找到:
state.inventory = createInventory();
state.phaseSkipped = createPhaseSkipped();替換為:
state.inventory = createInventory();
state.phaseSkipped = createPhaseSkipped();
state.placementSelection = createPlacementSelection();
if (roundNumber === 1) {
state.matchForfeit = null;
}在 startGame() 中找到:
state.gameStarted = true;
state.gameOver = false;
state.roundResults = [];替換為:
state.gameStarted = true;
state.gameOver = false;
state.matchForfeit = null;
state.roundResults = [];在 finishPlayerByFlight(player) 中找到:
state.playerEnded[player] = true;
state.selectedMinoId = null;替換為:
state.playerEnded[player] = true;
state.placementSelection[player] = null;
state.selectedMinoId = null;十五、增加「直接判負整場」結果視窗
在 showFinalModal() 前面加入:
function showForfeitFinalModal(focusPrimary = true) {
const info = state.matchForfeit;
if (!info) {
return;
}
state.resultMode = 'final';
els.resultEyebrow.textContent =
translate('finalEyebrow');
els.resultTitle.textContent =
translate('winsMatch', {
player: playerToken(info.winner)
});
els.resultSummary.textContent =
translate('forfeitSummary', {
loser: playerToken(info.loser),
winner: playerToken(info.winner),
piece: pieceToken(info.requiredType)
});
/*
* 直接判負是整場結果,不再以兩局分數決定,
* 因此隱藏分數區。
*/
els.resultSectionLabel.hidden = true;
els.resultScores.hidden = true;
els.resultScores.replaceChildren();
els.resultBreakdown.hidden = true;
els.resultBreakdown.replaceChildren();
els.resultPrimary.textContent =
translate('playAgain');
els.resultSecondary.textContent =
translate('backToTitle');
els.resultSecondary.hidden = false;
els.resultPrimary.onclick = () => {
state.roundResults = [];
state.matchForfeit = null;
state.gameOver = false;
state.gameStarted = true;
startRound(1);
};
els.resultSecondary.onclick = returnToTitle;
els.resultBackdrop.hidden = false;
if (focusPrimary) {
requestAnimationFrame(() => {
els.resultPrimary.focus();
});
}
}修改 showFinalModal() 開頭
找到:
function showFinalModal(focusPrimary = true) {
const results = [...state.roundResults].sort((a, b) => a.round - b.round);
if (!results.length) {
return;
}
state.resultMode = 'final';替換為:
function showFinalModal(focusPrimary = true) {
if (state.matchForfeit) {
showForfeitFinalModal(focusPrimary);
return;
}
const results = [...state.roundResults].sort(
(a, b) => a.round - b.round
);
if (!results.length) {
return;
}
state.resultMode = 'final';
/*
* 如果上一場是直接判負,這些元素可能仍處於 hidden。
*/
els.resultSectionLabel.hidden = false;
els.resultScores.hidden = false;函式其餘部分保留。
修改一般最終結果的「再玩一次」
在 showFinalModal() 中找到:
els.resultPrimary.onclick = () => {
state.roundResults = [];
state.gameOver = false;
state.gameStarted = true;
startRound(1);
};替換為:
els.resultPrimary.onclick = () => {
state.roundResults = [];
state.matchForfeit = null;
state.gameOver = false;
state.gameStarted = true;
startRound(1);
};修改 showRoundModal()
在:
state.resultMode = 'round';後面加入:
els.resultSectionLabel.hidden = false;
els.resultScores.hidden = false;避免前一次直接判負隱藏分數區後,新遊戲的本局結果仍然隱藏。
修改 finishMatch()
完整替換為:
function finishMatch() {
commitPendingRoundResult();
state.matchForfeit = null;
state.gameOver = true;
state.roundEnded = true;
renderAll();
showFinalModal();
}修改 returnToTitle()
找到:
state.pendingFlight = null;
state.pendingMinoMove = null;
state.gameStarted = false;
state.gameOver = false;替換為:
state.pendingFlight = null;
state.pendingMinoMove = null;
state.placementSelection = createPlacementSelection();
state.matchForfeit = null;
state.gameStarted = false;
state.gameOver = false;十六、更新規則視窗文字
邏輯修改完成後,原規則視窗中的第 01~06 條也應同步更新。至少把中文 rulesHtml 中相關內容改成以下說明:
<div class="rules-grid">
<article class="rule-block">
<span class="rule-index">01</span>
<h3>棋子與放置階段</h3>
<p>
每方擁有法諾 5 個、米諾 3 個、維諾 7 個,依照
<strong>法諾 → 米諾 → 維諾</strong>的順序進行。
每個放置階段都必須至少成功放置一個該階段棋子;
如果連第一個都無法合規放置,該玩家直接輸掉整場兩局比賽。
</p>
</article>
<article class="rule-block">
<span class="rule-index">02</span>
<h3>法諾階段</h3>
<p>
法諾不能與本方既有棋子接觸,包括共用邊及角對角接觸。
場上已有至少一個本方法諾後,可以選取米諾;
在米諾實際落子前仍可重新選回法諾。
一旦放下米諾,便不能再選取法諾。
如果已放過法諾但無法再放置,系統自動進入米諾階段。
</p>
</article>
<article class="rule-block">
<span class="rule-index">03</span>
<h3>米諾階段</h3>
<p>
新放置的米諾同樣不能與本方既有棋子接觸。
場上已有至少一個本方米諾後,可以選取維諾;
在維諾實際落子前仍可重新選回米諾。
一旦放下維諾,便不能再選取米諾。
如果已放過米諾但無法再放置,系統自動進入維諾階段。
</p>
</article>
<article class="rule-block">
<span class="rule-index">04</span>
<h3>米諾正常移動</h3>
<p>
米諾階段已有至少一個米諾時,可以點擊場上的本方米諾進行正常移動。
移動完成後,控制區會自動選中維諾。
同一回合只能正常移動一次米諾;再次點擊被移動的米諾,
可使它退回原來的位置。
</p>
</article>
<article class="rule-block">
<span class="rule-index">05</span>
<h3>維諾與飛子</h3>
<p>
維諾階段至少成功放置一個維諾後,玩家可以選擇飛子結束本方遊戲,
也就是把一個本方米諾移到它目前不能正常到達的空位。
如果仍可放置維諾,系統會先顯示確認窗口。
</p>
</article>
<article class="rule-block">
<span class="rule-index">06</span>
<h3>自動跳階</h3>
<p>
如果一個階段已經至少放置過一個棋子,但無法再合規放置,
系統會自動進入下一階段。維諾階段中,如果直接放置或正常移動
米諾後都無法再放置維諾,系統會自動進入飛子階段。
如果該階段連第一個棋子都沒有放置,則不是跳階,而是直接判負。
</p>
</article>
<article class="rule-block wide">
<span class="rule-index">07</span>
<h3>計分</h3>
<p>
每個由同一玩家完整佔用的正方形,都會按照面積計分:
1 × 1 計 1 分、2 × 2 計 4 分、3 × 3 計 9 分,依此類推。
不同位置及互相重疊的正方形會分別計算。
</p>
</article>
</div>英文 rulesHtml 也應按相同規則更新,否則切換到英文後仍會顯示舊規則。
修改後的關鍵行為
| 當前真正階段 | 條件 | 可選控制區棋子 | 特殊行為 |
|---|---|---|---|
| 法諾 | 尚未放過法諾 | 法諾 | 無合法法諾時整場判負 |
| 法諾 | 已放過法諾 | 法諾、米諾 | 放下米諾後永久進入米諾階段 |
| 米諾 | 尚未放過米諾 | 米諾 | 無合法米諾時整場判負 |
| 米諾 | 已放過米諾 | 米諾、維諾 | 可移動場上米諾;移動後自動選中維諾 |
| 維諾 | 尚未放過維諾 | 維諾 | 不可主動飛子;完全無法放第一個時整場判負 |
| 維諾 | 已放過維諾 | 維諾 | 可以正常移動米諾或主動飛子 |
| 自動飛子 | 已放過維諾且再也不能放置 | 無 | 必須飛子結束本方遊戲 |