QuanAI只读快照

共享会话

🎮 遊戲規則修改

分享于 2026年8月31日 02:27
提问者

更改規則 落子階段有「法諾、米諾、維諾」。在每一個落子階段,都必須至少放置一個本階段的棋子,否則直接判負(兩局的總勝負)。當場上已經存在一個本階段的棋子,則玩家可以選擇跳到下一個階段。當在維諾階段,場上已經存在至少一個維諾時,玩家可以選擇飛子以結束本局。

程式實現:

當一方在法諾放置階段,如果場上已經至少有一個他的法諾,則他可以點擊米諾棋子使之選中,在落子前他可以再選回法諾,但當他落下一個米諾時,他將無法再選中法諾。

當一方在米諾放置階段,如果場上已經至少有一個他的米諾,則他可以點擊維諾棋子使之選中,在落子前他可以再選回米諾,但當他落下一個維諾時,他將無法再選中米諾。當他移動一個場上的米諾時,棋子選中將自動切換到維諾。

之前的「當處於法諾階段,不能再放置一個法諾,就自動跳到米諾階段;當處於米諾階段,不能再放置一個米諾,就自動跳到米諾階段;當處於維諾階段,不能再放置一個維諾,就自動跳到飛子階段」仍然保留。

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

vino2245.htmltext/plain · 134.4 KB预览下载
QuanAI

以下修改以這個判定為準:

  1. 法諾/米諾/維諾每個階段都至少要成功放置一個該類棋子。

  2. 如果輪到該玩家進入某階段,而他:

    • 尚未放過任何該階段棋子;
    • 且已經沒有任何合法落點;

    則不是跳階,而是直接輸掉整場兩局比賽

  3. 已經放過至少一個目前階段棋子後,可以暫時選取下一階段棋子:

    • 放下下一階段棋子後,前一階段永久結束。
    • 在落子前可以切回原階段棋子。
  4. 米諾階段已有至少一個米諾時,可以選取並移動場上的米諾;完成移動後自動選中維諾。

  5. 只有在真正進入維諾階段且已至少放過一個維諾後,才可主動飛子。

  6. 原有「無法再放置時自動跳到下一階段」保留,但前提是該階段已經放過至少一個棋子。


一、增加翻譯文字

1.1 英文 I18N.en

找到:

js
phaseVinoToFlight:
  '{player} cannot place another Vino, even after a normal Mino move. Mino flight phase begins.',

在它後面加入:

js
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

找到:

js
phaseVinoToFlight: '{player}即使正常移動一個米諾也無法再放置維諾,自動進入米諾飛行階段。',

在它後面加入:

js
phaseRequiredLoss:
  '{player}在{piece}階段連一個{piece}都無法合規放置,因此直接判負整場兩局比賽。',

flightNeedsVino:
  '維諾階段至少要先放置一個維諾,才能選擇飛子結束本局。',

forfeitSummary:
  '{loser}在{piece}階段沒有放置任何{piece},因此直接判負;{winner}贏得兩局的總勝負。',

二、增加「目前選中的放置棋子」狀態

找到:

js
function createPhaseSkipped() {
  return {
    1: { fino: false, mino: false },
    2: { fino: false, mino: false }
  };
}

在它後面加入:

js
function createPlacementSelection() {
  return {
    1: null,
    2: null
  };
}

state 中找到:

js
gameStarted: false,
gameOver: false,

替換為:

js
gameStarted: false,
gameOver: false,
matchForfeit: null,

再找到:

js
inventory: createInventory(),
phaseSkipped: createPhaseSkipped(),

替換為:

js
inventory: createInventory(),
phaseSkipped: createPhaseSkipped(),
placementSelection: createPlacementSelection(),

三、替換階段及可選棋子的判斷函式

找到從:

js
function isMinoFlightPhase(player) {

一直到 getActivePiece() 結束的整段,也就是替換原來的:

  • isMinoFlightPhase
  • getRequiredPiece
  • getActivePiece

改成下面完整程式碼:

js
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

找到原本的:

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

替換為:

js
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 後加入直接判負函式

js
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

找到原本:

js
function preparePlayerPhase(player) {

將整個函式替換為:

js
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()

js
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()

js
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()

找到:

js
} else if (active && active.type === 'vino' && state.selectedMinoId !== null) {
  els.turnText.textContent = translate('turnMinoSelected', {
    player: playerToken(active.player)
  });

替換為:

js
} else if (active && state.selectedMinoId !== null) {
  els.turnText.textContent = translate('turnMinoSelected', {
    player: playerToken(active.player)
  });

這樣在米諾放置階段選取場上的米諾時,也會顯示正確提示。


7.2 renderBoard()

找到 renderBoard() 中的:

js
} else {
  const active = getActivePiece();

  status = active
    ? translate('turnAuto', {
        player: playerToken(active.player),
        piece: pieceToken(active.type)
      })
    : '';
}

替換為:

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

js
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()

js
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()

js
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()

js
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()

js
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()

js
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()

js
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()

js
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()

js
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()

在:

js
phaseSkipped: {
  1: { ...state.phaseSkipped[1] },
  2: { ...state.phaseSkipped[2] }
},

後面加入:

js
placementSelection: {
  1: state.placementSelection[1],
  2: state.placementSelection[2]
},

13.3 修改 restoreSnapshot()

在:

js
state.phaseSkipped = {
  1: { ...snapshot.phaseSkipped[1] },
  2: { ...snapshot.phaseSkipped[2] }
};

後面加入:

js
state.placementSelection = snapshot.placementSelection
  ? {
      1: snapshot.placementSelection[1] || null,
      2: snapshot.placementSelection[2] || null
    }
  : createPlacementSelection();

十四、修改每局初始化

startRound(roundNumber) 中找到:

js
state.inventory = createInventory();
state.phaseSkipped = createPhaseSkipped();

替換為:

js
state.inventory = createInventory();
state.phaseSkipped = createPhaseSkipped();
state.placementSelection = createPlacementSelection();

if (roundNumber === 1) {
  state.matchForfeit = null;
}

startGame() 中找到:

js
state.gameStarted = true;
state.gameOver = false;
state.roundResults = [];

替換為:

js
state.gameStarted = true;
state.gameOver = false;
state.matchForfeit = null;
state.roundResults = [];

finishPlayerByFlight(player) 中找到:

js
state.playerEnded[player] = true;
state.selectedMinoId = null;

替換為:

js
state.playerEnded[player] = true;
state.placementSelection[player] = null;
state.selectedMinoId = null;

十五、增加「直接判負整場」結果視窗

showFinalModal() 前面加入:

js
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() 開頭

找到:

js
function showFinalModal(focusPrimary = true) {
  const results = [...state.roundResults].sort((a, b) => a.round - b.round);

  if (!results.length) {
    return;
  }

  state.resultMode = 'final';

替換為:

js
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() 中找到:

js
els.resultPrimary.onclick = () => {
  state.roundResults = [];
  state.gameOver = false;
  state.gameStarted = true;
  startRound(1);
};

替換為:

js
els.resultPrimary.onclick = () => {
  state.roundResults = [];
  state.matchForfeit = null;
  state.gameOver = false;
  state.gameStarted = true;
  startRound(1);
};

修改 showRoundModal()

在:

js
state.resultMode = 'round';

後面加入:

js
els.resultSectionLabel.hidden = false;
els.resultScores.hidden = false;

避免前一次直接判負隱藏分數區後,新遊戲的本局結果仍然隱藏。


修改 finishMatch()

完整替換為:

js
function finishMatch() {
  commitPendingRoundResult();

  state.matchForfeit = null;
  state.gameOver = true;
  state.roundEnded = true;

  renderAll();
  showFinalModal();
}

修改 returnToTitle()

找到:

js
state.pendingFlight = null;
state.pendingMinoMove = null;
state.gameStarted = false;
state.gameOver = false;

替換為:

js
state.pendingFlight = null;
state.pendingMinoMove = null;
state.placementSelection = createPlacementSelection();
state.matchForfeit = null;
state.gameStarted = false;
state.gameOver = false;

十六、更新規則視窗文字

邏輯修改完成後,原規則視窗中的第 01~06 條也應同步更新。至少把中文 rulesHtml 中相關內容改成以下說明:

html
<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 也應按相同規則更新,否則切換到英文後仍會顯示舊規則。


修改後的關鍵行為

當前真正階段條件可選控制區棋子特殊行為
法諾尚未放過法諾法諾無合法法諾時整場判負
法諾已放過法諾法諾、米諾放下米諾後永久進入米諾階段
米諾尚未放過米諾米諾無合法米諾時整場判負
米諾已放過米諾米諾、維諾可移動場上米諾;移動後自動選中維諾
維諾尚未放過維諾維諾不可主動飛子;完全無法放第一個時整場判負
維諾已放過維諾維諾可以正常移動米諾或主動飛子
自動飛子已放過維諾且再也不能放置必須飛子結束本方遊戲