QuanAI只读快照

共享会话

更改規則及邏輯實現

分享于 2026年8月30日 03:43
提问者

更改規則及邏輯實現 更改規則

之前的規則是「如果一方無法合規放完五個法諾,該方立即判負。」現在把這個規則改為「如果一方無法再合規放置一個法諾,該方將直接開始放置米諾。」

之前的規則是「結束本方遊戲:進入維諾階段後,輪到的一方可以按下『結束本方遊戲』。玩家可在按下前最後移動一次米諾。結束後該方不能再行動,另一方仍可自行繼續, 直到他也結束或放完所有維諾。」現在改為「當一位玩家想要結束遊戲,則他將飛走一個米諾,即拿起其中一個米諾放到任意一個它當前走不到的空位。之後他將不能再行動。另一方仍可自行繼續行動,直到他也作出飛子結束本局。」

程式邏輯實現

當檢查出一方無法再放置一個法諾,則自動切換到米諾放置階段,並給出1s消息提示。當檢查出一方無法再放置一個米諾,則自動切換到維諾放置階段,並給出1s消息提示。

當檢查出一方無法再放置一個維諾,包括移走一個米諾也無法放置,則自動切換到米諾飛行階段,維諾棋子的「選中」將消失,並給出1s消息提示。這時點擊選中一個米諾時,將不會再有移動箭頭,也不能放到能走到的空位上。

當在維諾放置階段,如果點擊選取了一個米諾,則他將不能再放置一個維諾(除非米諾走完或再次點擊取消選擇)。這時若點擊棋盤空位使米諾落下(不是點擊行走箭頭),如果這個空位是當前位置所能走到的地方,則算它是一次行走,否則算是飛子。

當一方的米諾在移走之後,他將繼續放置一個維諾。這時這個米諾要用一個藍點或紅點作標記,它原來的地方也用相同的點作標記。這時如果再次點擊這個米諾,則它將退回原來的地方。

當一方飛子時,如果檢查出他仍能放置一個維諾,則彈出「確認或取消提示窗口」,詢問是否執行飛子以使本局結束,如果點否,則退回這次飛子。

當一方因為不能再落下一個維諾而自動進入子階段時,如果他點擊錯誤的棋子,或者沒選取米諾就點擊棋盤空位,或米諾在選取後所點擊的空位是它能走到的地方,都將彈出1s消息提示出錯。但可以在已經選取一個米諾時,點擊另一個米諾,以切換所選取的米諾。

去掉「結束本方遊戲」按鈕,以及相關的代碼。

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

vino2242.htmltext/plain · 116.6 KB预览下载
QuanAI

以下修改以附件 vino2242.html 的現有結構為基礎。為避免大量行號因編輯器不同而偏移,均以「搜尋現有函式/選擇器名稱」定位。

實作定義

這份修改採用以下邏輯:

  1. 法諾無合法位置時,把該玩家剩餘法諾數量設為 0,立即進入米諾階段。
  2. 米諾無合法位置時,把該玩家剩餘米諾數量設為 0,立即進入維諾階段。
  3. 判定「還能否放維諾」時,同時檢查:
    • 當前棋盤能否直接放維諾;
    • 任意一個本方米諾作一次正常移動後,能否放維諾。
  4. 若兩種方式都不能放維諾,進入該玩家的米諾飛行階段。
  5. 「米諾正常能走到的位置」定義為目前 planMinoMove() 算出的四個方向落點;其他空位均視為飛子位置。
  6. 正常移動米諾後,用玩家顏色在原位及新位置各畫一個圓點;再次點擊該米諾可退回。
  7. 飛子確認前不寫入歷史;取消會完整退回,確認後才結束該玩家的遊戲。

一、刪除「結束本方遊戲」按鈕

1. 刪除兩個 HTML 區塊

搜尋並刪除玩家二的:

html
<div class="end-slot">
  <button
    id="endPlayer2Button"
    type="button"
    class="end-player-button"
    data-end-player="2"
    hidden
  ></button>
</div>

再刪除玩家一的:

html
<div class="end-slot">
  <button
    id="endPlayer1Button"
    type="button"
    class="end-player-button"
    data-end-player="1"
    hidden
  ></button>
</div>

2. 刪除相關 CSS

完整刪除以下選擇器的規則:

css
.end-slot
.end-player-button
.player-two .end-player-button
.end-player-button:hover

同時刪除各個媒體查詢內的:

css
.end-player-button

3. 修改玩家面板網格

搜尋:

css
grid-template-columns: 78px minmax(0, 1fr) 76px;

替換為:

css
grid-template-columns: 78px minmax(0, 1fr);

@media (max-width: 370px) 中搜尋:

css
grid-template-columns: 71px minmax(0, 1fr) 67px;

替換為:

css
grid-template-columns: 71px minmax(0, 1fr);

@media (min-width: 900px).player-panel 中搜尋:

css
grid-template-rows:
  minmax(94px, 0.25fr)
  minmax(0, 1fr)
  52px;

替換為:

css
grid-template-rows:
  minmax(94px, 0.25fr)
  minmax(0, 1fr);

二、加入米諾移動標記 CSS

.selected-mino-ring 規則之後加入:

css
.mino-move-marker {
  fill: var(--p1);
  stroke: #fff8ed;
  stroke-width: 2.2;
  vector-effect: non-scaling-stroke;
  pointer-events: none;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.34));
}

.mino-move-marker.owner-1 {
  fill: var(--p1);
}

.mino-move-marker.owner-2 {
  fill: var(--p2);
}

#flightConfirmBackdrop {
  z-index: 170;
}

.flight-confirm-dialog {
  width: min(100%, 520px);
}

三、加入飛子確認視窗

<!-- Result dialog --> 的完整區塊之後、<script> 之前加入:

html
<!-- Mino flight confirmation dialog -->
<div id="flightConfirmBackdrop" class="modal-backdrop" hidden>
  <section
    class="dialog flight-confirm-dialog"
    role="dialog"
    aria-modal="true"
    aria-labelledby="flightConfirmTitle"
    aria-describedby="flightConfirmSummary"
  >
    <p id="flightConfirmEyebrow" class="modal-eyebrow"></p>
    <h2 id="flightConfirmTitle"></h2>
    <p id="flightConfirmSummary" class="modal-summary"></p>

    <div class="modal-actions">
      <button
        id="flightConfirmCancel"
        type="button"
        class="secondary-button"
      ></button>

      <button
        id="flightConfirmAccept"
        type="button"
        class="danger-button"
      ></button>
    </div>
  </section>
</div>

四、修改翻譯文字

1. 英文翻譯

I18N.en 中刪除:

js
endOwnGame: "End My Game",
playerEnded: "{player} has ended their game.",
endOnlyVino:
  "You can end your game only during your Vino phase.",
noLegalFinoSummary: ...,
noLegalRequiredSummary: ...,

turnVinoMoved 後加入:

js
turnMinoSelected:
  "{player} · Mino selected; choose a destination or click it again to cancel",
turnMinoFlight:
  "{player} · fly a Mino to an unreachable empty cell",

在錯誤訊息區加入:

js
phaseFinoToMino:
  "{player} has no legal Fino placement. Mino phase begins.",
phaseMinoToVino:
  "{player} has no legal Mino placement. Vino phase begins.",
phaseVinoToFlight:
  "{player} cannot place another Vino, even after a normal Mino move. Mino flight phase begins.",

flightWrongPiece:
  "Select one of your own Mino pieces.",
flightSelectMinoFirst:
  "Select one of your own Mino pieces before choosing an empty cell.",
flightReachableCell:
  "That cell is reachable by a normal Mino move and cannot be used as a flight destination.",

flightConfirmEyebrow: "End this player's game",
flightConfirmTitle: "Confirm Mino flight?",
flightConfirmSummary:
  "Flying this Mino will immediately end {player}'s game, although a Vino can still be placed. Continue?",
cancelFlight: "Cancel and return",
confirmFlight: "Fly and end",

把:

js
finishedWinSummary:
  "Both players have finished. {winner} has the higher score.",
finishedTieSummary:
  "Both players have finished with equal scores.",

替換為:

js
finishedWinSummary:
  "Both players have ended by flying a Mino. {winner} has the higher score.",
finishedTieSummary:
  "Both players have ended by flying a Mino and have equal scores.",

2. 中文翻譯

I18N.zh 中刪除:

js
endOwnGame: "結束本方遊戲",
playerEnded: "{player}已結束本方遊戲。",
endOnlyVino: "只有進入維諾階段後才能結束本方遊戲。",
noLegalFinoSummary: ...,
noLegalRequiredSummary: ...,

turnVinoMoved 後加入:

js
turnMinoSelected:
  "輪到{player}・已選取米諾,請選擇落點或再次點擊取消",
turnMinoFlight:
  "輪到{player}・請把一個米諾飛到它目前走不到的空位",

在錯誤訊息區加入:

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

flightWrongPiece:
  "選取錯誤:請選取一個本方米諾。",
flightSelectMinoFirst:
  "請先選取一個本方米諾,再點擊飛子落點。",
flightReachableCell:
  "這個空位是該米諾目前能正常走到的位置,不能作為飛子落點。",

flightConfirmEyebrow: "結束本方遊戲",
flightConfirmTitle: "確認執行飛子?",
flightConfirmSummary:
  "目前仍能放置一個維諾。若執行這次飛子,{player}將立即結束本局,是否繼續?",
cancelFlight: "取消並退回",
confirmFlight: "確認飛子並結束",

把:

js
finishedWinSummary:
  "雙方均已結束本方遊戲,{winner}的分數較高。",
finishedTieSummary:
  "雙方均已結束本方遊戲,而且分數相同。",

替換為:

js
finishedWinSummary:
  "雙方均已透過飛走一個米諾結束本方遊戲,{winner}的分數較高。",
finishedTieSummary:
  "雙方均已透過飛走一個米諾結束本方遊戲,而且分數相同。",

五、修改規則視窗文字

在英文 rulesHtml 中,將編號 0206 的五個 <article> 替換為:

html
<article class="rule-block">
  <span class="rule-index">02</span>
  <h3>Fino placement</h3>
  <p>
    A newly placed Fino may not touch another piece owned by the same
    player, either by an edge or diagonally. If a player cannot legally
    place another Fino, their remaining Fino pieces are skipped and that
    player immediately begins placing Mino pieces.
  </p>
</article>

<article class="rule-block">
  <span class="rule-index">03</span>
  <h3>Mino placement</h3>
  <p>
    A newly placed Mino also may not touch any existing piece owned by
    the same player, including diagonally. If a player cannot legally
    place another Mino, their remaining Mino pieces are skipped and that
    player immediately begins placing Vino pieces.
  </p>
</article>

<article class="rule-block">
  <span class="rule-index">04</span>
  <h3>Vino and normal Mino movement</h3>
  <p>
    Before placing a Vino, a player may select one of their Mino pieces.
    While a Mino is selected, a Vino cannot be placed until the Mino has
    moved or the selection is cancelled. Click the selected Mino again
    to cancel. Clicking an empty cell reachable by the normal movement
    rule counts as a normal move. Movement arrows may also be used.
  </p>
</article>

<article class="rule-block">
  <span class="rule-index">05</span>
  <h3>Flying a Mino to finish</h3>
  <p>
    To end their game, a player must fly one Mino: move it to any empty
    cell that it cannot currently reach by a normal Mino move. If a Vino
    can still be placed after the flight, a confirmation window is shown.
    Cancelling returns the Mino to its original cell. After confirming,
    that player cannot act again. The other player continues until they
    also end by flying a Mino.
  </p>
</article>

<article class="rule-block">
  <span class="rule-index">06</span>
  <h3>Automatic flight phase and move markers</h3>
  <p>
    If a player cannot place another Vino either directly or after any
    one normal Mino move, the game automatically enters that player's
    Mino flight phase. Vino is no longer selected and movement arrows
    are hidden. A normal Mino move during the Vino phase is marked at
    both its original and new cells. Clicking that moved Mino again
    returns it to its original cell.
  </p>
</article>

在中文 rulesHtml 中,將編號 0206 替換為:

html
<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>

六、擴充 state

在:

js
selectedMinoId: null,
movedThisTurn: false,

之後加入:

js
pendingMinoMove: null,
pendingFlight: null,

minoFlightMode: {
  1: false,
  2: false
},

把 Toast 狀態:

js
toastData: null,
toastTimer: null

替換為:

js
toastData: null,
toastTimer: null,
toastGapTimer: null,
toastQueue: [],
toastShowing: false

七、修改 els

刪除:

js
endPlayer1Button: document.getElementById("endPlayer1Button"),
endPlayer2Button: document.getElementById("endPlayer2Button"),

在結果視窗元素之後加入:

js
flightConfirmBackdrop:
  document.getElementById("flightConfirmBackdrop"),
flightConfirmEyebrow:
  document.getElementById("flightConfirmEyebrow"),
flightConfirmTitle:
  document.getElementById("flightConfirmTitle"),
flightConfirmSummary:
  document.getElementById("flightConfirmSummary"),
flightConfirmCancel:
  document.getElementById("flightConfirmCancel"),
flightConfirmAccept:
  document.getElementById("flightConfirmAccept")

注意前一個屬性末尾要有逗號。例如:

js
resultSecondary: document.getElementById("resultSecondary"),

flightConfirmBackdrop:
  document.getElementById("flightConfirmBackdrop"),

八、修改 applyLanguage()

刪除:

js
els.endPlayer1Button.textContent = translate("endOwnGame");
els.endPlayer2Button.textContent = translate("endOwnGame");

applyLanguage() 的末尾、結果視窗更新邏輯之後加入:

js
if (!els.flightConfirmBackdrop.hidden) {
  renderFlightConfirmDialog();
}

九、替換階段判定函式

1. 替換 getRequiredPiece()getActivePiece()

將原本兩個函式完整替換為:

js
function isMinoFlightPhase(player) {
  return Boolean(
    player &&
    state.minoFlightMode[player] &&
    !state.playerEnded[player]
  );
}

function getRequiredPiece(player) {
  if (
    !player ||
    !state.inventory[player] ||
    state.playerEnded[player] ||
    isMinoFlightPhase(player)
  ) {
    return null;
  }

  for (const type of PIECE_ORDER) {
    if (state.inventory[player][type] > 0) {
      return type;
    }
  }

  return null;
}

function getActivePiece() {
  if (
    !state.gameStarted ||
    state.roundEnded ||
    state.gameOver ||
    !state.currentPlayer ||
    isMinoFlightPhase(state.currentPlayer)
  ) {
    return null;
  }

  const type = getRequiredPiece(state.currentPlayer);

  if (!type) {
    return null;
  }

  return {
    player: state.currentPlayer,
    type,
    orientation:
      state.orientationMemory[state.currentPlayer][type] ||
      (type === "mino" ? "single" : "tl")
  };
}

2. 在 hasAnyLegalPlacement() 後加入

js
function getLegalMinoDestinations(pieceId) {
  const piece = findPiece(pieceId);
  const destinations = new Map();

  if (
    !piece ||
    piece.type !== "mino" ||
    piece.cells.length !== 1
  ) {
    return destinations;
  }

  const start = piece.cells[0];

  Object.keys(DIRECTIONS).forEach((directionName) => {
    const plan = planMinoMove(pieceId, directionName);

    if (!plan.ok) {
      return;
    }

    const row = start.row + plan.dr;
    const col = start.col + plan.dc;

    destinations.set(cellKey(row, col), {
      row,
      col,
      directionName
    });
  });

  return destinations;
}

function withTemporaryMinoMove(piece, destination, callback) {
  const originalCells = piece.cells.map((cell) => ({ ...cell }));

  piece.cells = [{
    row: destination.row,
    col: destination.col
  }];

  rebuildBoard();

  try {
    return callback();
  } finally {
    piece.cells = originalCells;
    rebuildBoard();
  }
}

function canContinueVinoPhase(player) {
  if (state.inventory[player].vino <= 0) {
    return false;
  }

  if (hasAnyLegalPlacement(player, "vino")) {
    return true;
  }

  const minos = state.pieces.filter((piece) => (
    piece.owner === player &&
    piece.type === "mino" &&
    piece.cells.length === 1
  ));

  for (const mino of minos) {
    const destinations = getLegalMinoDestinations(mino.id);

    for (const destination of destinations.values()) {
      const canPlaceAfterMove = withTemporaryMinoMove(
        mino,
        destination,
        () => hasAnyLegalPlacement(player, "vino")
      );

      if (canPlaceAfterMove) {
        return true;
      }
    }
  }

  return false;
}

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

      state.inventory[player].fino = 0;

      showToast(
        "phaseFinoToMino",
        { player: playerToken(player) },
        1000
      );

      continue;
    }

    if (required === "mino") {
      if (hasAnyLegalPlacement(player, "mino")) {
        return;
      }

      state.inventory[player].mino = 0;

      showToast(
        "phaseMinoToVino",
        { player: playerToken(player) },
        1000
      );

      continue;
    }

    if (required === "vino") {
      if (canContinueVinoPhase(player)) {
        return;
      }

      enterMinoFlightPhase(player);
      return;
    }

    // 維諾已全部用完時,也必須透過飛子結束。
    enterMinoFlightPhase(player);
    return;
  }
}

十、修改棋子選中及預覽

1. 修改 renderPieces()

renderPieces() 中搜尋:

js
const selected =
  activePlayer &&
  requiredPiece === type;

const locked =
  count > 0 &&
  requiredPiece !== null &&
  requiredPiece !== type;

替換為:

js
const inFlightPhase = isMinoFlightPhase(player);

const vinoBlockedByMinoSelection =
  activePlayer &&
  type === "vino" &&
  state.selectedMinoId !== null;

const selected =
  activePlayer &&
  !inFlightPhase &&
  requiredPiece === type &&
  !vinoBlockedByMinoSelection;

const locked =
  count > 0 &&
  (
    inFlightPhase ||
    (
      requiredPiece !== null &&
      requiredPiece !== type
    )
  );

這會使進入飛行階段後,維諾的 selected 樣式消失;在維諾階段選中米諾後,維諾的選中樣式也暫時消失。

2. 修改 renderPreview()

搜尋:

js
if (!active || !state.hoverCell) {
  return;
}

替換為:

js
if (
  !active ||
  !state.hoverCell ||
  state.selectedMinoId !== null ||
  isMinoFlightPhase(state.currentPlayer)
) {
  return;
}

十一、替換 renderMoveArrows()

將原本的 renderMoveArrows() 完整替換為:

js
function appendMinoMoveMarker(row, col, owner) {
  const marker = document.createElementNS(SVG_NS, "circle");

  marker.setAttribute(
    "cx",
    (col + 0.5) * CELL_SIZE
  );

  marker.setAttribute(
    "cy",
    (row + 0.5) * CELL_SIZE
  );

  marker.setAttribute("r", 6);
  marker.setAttribute(
    "class",
    `mino-move-marker owner-${owner}`
  );

  els.moveLayer.appendChild(marker);
}

function renderMoveArrows() {
  els.moveLayer.replaceChildren();

  const pendingMove = state.pendingMinoMove;

  if (
    pendingMove &&
    !state.roundEnded &&
    !state.gameOver
  ) {
    appendMinoMoveMarker(
      pendingMove.from.row,
      pendingMove.from.col,
      pendingMove.player
    );

    appendMinoMoveMarker(
      pendingMove.to.row,
      pendingMove.to.col,
      pendingMove.player
    );
  }

  if (
    !state.selectedMinoId ||
    state.roundEnded ||
    state.gameOver
  ) {
    return;
  }

  const piece = findPiece(state.selectedMinoId);

  if (
    !piece ||
    piece.type !== "mino" ||
    piece.cells.length !== 1
  ) {
    state.selectedMinoId = null;
    return;
  }

  const cell = piece.cells[0];

  const ring = document.createElementNS(SVG_NS, "rect");
  ring.setAttribute("x", cell.col * CELL_SIZE + 5);
  ring.setAttribute("y", cell.row * CELL_SIZE + 5);
  ring.setAttribute("width", CELL_SIZE - 10);
  ring.setAttribute("height", CELL_SIZE - 10);
  ring.setAttribute("rx", 10);
  ring.setAttribute("class", "selected-mino-ring");

  els.moveLayer.appendChild(ring);

  // 飛行階段只顯示選中框,不顯示任何移動箭頭。
  if (
    isMinoFlightPhase(state.currentPlayer) ||
    state.movedThisTurn
  ) {
    return;
  }

  Object.keys(DIRECTIONS).forEach((directionName) => {
    const plan = planMinoMove(piece.id, directionName);

    if (plan.ok) {
      els.moveLayer.appendChild(
        createMoveArrow(
          directionName,
          cell.row,
          cell.col
        )
      );
    }
  });
}

十二、替換狀態文字渲染

renderStatus() 完整替換為:

js
function renderStatus() {
  if (!state.gameStarted) {
    return;
  }

  if (state.gameOver) {
    els.roundText.textContent = translate("matchCompleteStatus");
  } else if (state.roundEnded) {
    els.roundText.textContent =
      translate("roundCompleteStatus", {
        round: state.roundNumber
      });
  } else {
    els.roundText.textContent = translate("roundOf", {
      round: state.roundNumber
    });
  }

  if (state.roundEnded || state.gameOver) {
    els.turnText.hidden = true;
    els.viewResultButton.hidden = false;
    els.viewResultButton.textContent = state.gameOver
      ? translate("viewFinalResult")
      : translate("viewRoundResult");

    return;
  }

  const active = getActivePiece();

  els.viewResultButton.hidden = true;
  els.turnText.hidden = false;

  if (isMinoFlightPhase(state.currentPlayer)) {
    els.turnText.textContent = translate("turnMinoFlight", {
      player: playerToken(state.currentPlayer)
    });
  } else if (
    active &&
    active.type === "vino" &&
    state.selectedMinoId !== null
  ) {
    els.turnText.textContent = translate("turnMinoSelected", {
      player: playerToken(active.player)
    });
  } else if (active && active.type === "vino") {
    els.turnText.textContent = state.movedThisTurn
      ? translate("turnVinoMoved", {
          player: playerToken(active.player)
        })
      : translate("turnVinoReady", {
          player: playerToken(active.player)
        });
  } else if (active) {
    els.turnText.textContent = translate("turnAuto", {
      player: playerToken(active.player),
      piece: pieceToken(active.type)
    });
  } else {
    els.turnText.textContent = "";
  }

  els.turnText.classList.remove("owner-1", "owner-2");
  els.turnText.classList.add(`owner-${state.currentPlayer}`);
}

renderBoard() 中搜尋:

js
const active = getActivePiece();

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

替換為:

js
if (isMinoFlightPhase(state.currentPlayer)) {
  status = translate("turnMinoFlight", {
    player: playerToken(state.currentPlayer)
  });
} else {
  const active = getActivePiece();

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

十三、刪除結束按鈕渲染程式

完整刪除:

js
function renderEndButtons() {
  ...
}

並從 renderAll() 中刪除:

js
renderEndButtons();

修改後的 renderAll() 應為:

js
function renderAll() {
  renderScores();
  renderPieces();
  renderBoard();
  renderStatus();
  updateToolbar();

  if (state.gameStarted) {
    scheduleBoardFit();
  }
}

十四、修改回合切換

1. 刪除立即判負函式

完整刪除:

js
function completeRoundByNoLegalMove(loser, requiredType) {
  ...
}

2. 替換 resolveNextTurn()

js
function resolveNextTurn(playerWhoActed) {
  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;

  preparePlayerPhase(state.currentPlayer);
}

十五、修改 placePiece()

placePiece() 完整替換為:

js
function placePiece(validation, active) {
  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.hoverCell = null;
  state.selectedMinoId = null;
  state.pendingMinoMove = null;
  state.movedThisTurn = false;

  // 放完全部維諾後不能直接結束;
  // 下次輪到該玩家時會自動進入米諾飛行階段。
  resolveNextTurn(active.player);

  pushHistorySnapshot();
  renderAll();

  if (state.roundEnded) {
    window.setTimeout(() => {
      if (state.roundEnded && !state.gameOver) {
        showRoundModal();
      }
    }, 120);
  }
}

原本以下自動結束邏輯不得保留:

js
if (
  active.type === "vino" &&
  state.inventory[active.player].vino === 0
) {
  state.playerEnded[active.player] = true;
}

十六、替換米諾操作及棋盤點擊邏輯

從原本的:

js
function selectMino(pieceId) {

開始,一直到原本 handleBoardClick() 結束為止,全部替換成以下程式:

js
function refreshMinoSelectionUI() {
  renderPieces();
  renderPreview();
  renderMoveArrows();
  renderStatus();
}

function selectMino(pieceId) {
  const piece = findPiece(pieceId);

  if (
    !piece ||
    piece.owner !== state.currentPlayer ||
    piece.type !== "mino"
  ) {
    return;
  }

  if (state.movedThisTurn) {
    showToast("movedAlready");
    return;
  }

  state.hoverCell = null;

  if (state.selectedMinoId === pieceId) {
    state.selectedMinoId = null;
    refreshMinoSelectionUI();
    return;
  }

  // 已選取一個米諾時點擊另一個本方米諾,直接切換。
  state.selectedMinoId = pieceId;
  refreshMinoSelectionUI();

  if (!isMinoFlightPhase(state.currentPlayer)) {
    const hasDirection =
      getLegalMinoDestinations(pieceId).size > 0;

    if (!hasDirection) {
      showToast("noMinoDirection");
    }
  }
}

function performNormalMinoMove(pieceId, destination) {
  const piece = findPiece(pieceId);

  if (
    !piece ||
    piece.owner !== state.currentPlayer ||
    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
  };

  piece.cells = [{ ...to }];

  rebuildBoard();
  recalculateScores();

  state.pendingMinoMove = {
    player: state.currentPlayer,
    pieceId,
    from,
    to
  };

  state.movedThisTurn = true;
  state.selectedMinoId = null;
  state.hoverCell = null;

  pushHistorySnapshot();
  renderAll();
}

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.pendingMinoMove = null;
  state.movedThisTurn = false;
  state.selectedMinoId = null;
  state.hoverCell = null;

  pushHistorySnapshot();
  renderAll();
}

function executeMinoMove(directionName) {
  const active = getActivePiece();

  if (
    !active ||
    active.type !== "vino" ||
    !state.selectedMinoId ||
    state.movedThisTurn ||
    isMinoFlightPhase(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
  });
}

function renderFlightConfirmDialog() {
  if (!state.pendingFlight) {
    return;
  }

  els.flightConfirmEyebrow.textContent =
    translate("flightConfirmEyebrow");

  els.flightConfirmTitle.textContent =
    translate("flightConfirmTitle");

  els.flightConfirmSummary.textContent =
    translate("flightConfirmSummary", {
      player: playerToken(state.pendingFlight.player)
    });

  els.flightConfirmCancel.textContent =
    translate("cancelFlight");

  els.flightConfirmAccept.textContent =
    translate("confirmFlight");
}

function showFlightConfirmDialog() {
  renderFlightConfirmDialog();
  els.flightConfirmBackdrop.hidden = false;

  requestAnimationFrame(() => {
    els.flightConfirmCancel.focus();
  });
}

function hideFlightConfirmDialog() {
  els.flightConfirmBackdrop.hidden = true;
}

function finishPlayerByFlight(player) {
  hideFlightConfirmDialog();

  state.pendingFlight = null;
  state.playerEnded[player] = true;
  state.selectedMinoId = null;
  state.pendingMinoMove = null;
  state.movedThisTurn = false;
  state.hoverCell = null;

  resolveNextTurn(player);
  pushHistorySnapshot();
  renderAll();

  if (state.roundEnded) {
    window.setTimeout(() => {
      if (state.roundEnded && !state.gameOver) {
        showRoundModal();
      }
    }, 120);
  }
}

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 from = { ...piece.cells[0] };
  const to = {
    row: destination.row,
    col: destination.col
  };

  const wasAutomaticFlightPhase =
    isMinoFlightPhase(player);

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

function cancelPendingFlight() {
  const pendingFlight = state.pendingFlight;

  if (!pendingFlight) {
    hideFlightConfirmDialog();
    return;
  }

  const piece = findPiece(pendingFlight.pieceId);

  if (piece && piece.type === "mino") {
    piece.cells = [{ ...pendingFlight.from }];
  }

  rebuildBoard();
  recalculateScores();

  state.pendingFlight = null;
  state.movedThisTurn = false;
  state.pendingMinoMove = null;
  state.hoverCell = null;

  /*
   * 取消後保持該米諾為選取狀態,
   * 玩家可重新選擇正常移動或另一個飛子位置。
   */
  state.selectedMinoId =
    piece && piece.owner === state.currentPlayer
      ? piece.id
      : null;

  hideFlightConfirmDialog();
  renderAll();
}

function confirmPendingFlight() {
  const pendingFlight = state.pendingFlight;

  if (!pendingFlight) {
    hideFlightConfirmDialog();
    return;
  }

  const player = pendingFlight.player;

  state.pendingFlight = null;
  finishPlayerByFlight(player);
}

function handleMinoFlightBoardClick(cell, occupied) {
  const player = state.currentPlayer;

  if (occupied) {
    if (
      occupied.owner === player &&
      occupied.type === "mino"
    ) {
      // 同一米諾可取消,另一米諾可切換選取。
      selectMino(occupied.pieceId);
      return;
    }

    showToast("flightWrongPiece", {}, 1000);
    return;
  }

  if (!state.selectedMinoId) {
    showToast("flightSelectMinoFirst", {}, 1000);
    return;
  }

  const legalDestinations =
    getLegalMinoDestinations(state.selectedMinoId);

  if (legalDestinations.has(cellKey(cell.row, cell.col))) {
    showToast("flightReachableCell", {}, 1000);
    return;
  }

  attemptMinoFlight(state.selectedMinoId, cell);
}

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 (
    active.type === "vino" &&
    occupied &&
    occupied.owner === active.player &&
    occupied.type === "mino" &&
    state.pendingMinoMove &&
    occupied.pieceId === state.pendingMinoMove.pieceId
  ) {
    revertPendingMinoMove();
    return;
  }

  /*
   * 維諾階段點擊本方米諾:
   * 未選取時選取;已選取另一個時切換。
   */
  if (
    active.type === "vino" &&
    occupied &&
    occupied.owner === active.player &&
    occupied.type === "mino"
  ) {
    selectMino(occupied.pieceId);
    return;
  }

  /*
   * 米諾已選取時,所有空位點擊都解釋為米諾落點,
   * 不再解釋為維諾放置。
   */
  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 {
      attemptMinoFlight(
        state.selectedMinoId,
        cell
      );
    }

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

十七、修改滑鼠預覽判定

handleBoardPointerMove() 完整替換為:

js
function handleBoardPointerMove(event) {
  const active = getActivePiece();

  if (
    !state.gameStarted ||
    state.roundEnded ||
    state.gameOver ||
    !active ||
    state.selectedMinoId !== null ||
    isMinoFlightPhase(state.currentPlayer)
  ) {
    return;
  }

  const cell = getBoardCellFromPointer(event);

  if (!cell) {
    if (state.hoverCell) {
      state.hoverCell = null;
      renderPreview();
    }

    return;
  }

  if (
    !state.hoverCell ||
    state.hoverCell.row !== cell.row ||
    state.hoverCell.col !== cell.col
  ) {
    state.hoverCell = cell;
    renderPreview();
  }
}

十八、刪除舊結束函式

完整刪除:

js
function endPlayerGame(player) {
  ...
}

十九、讓回退/前進保存新狀態

1. 在 captureSnapshot() 前加入

js
function clonePendingMinoMove(move) {
  if (!move) {
    return null;
  }

  return {
    player: move.player,
    pieceId: move.pieceId,
    from: { ...move.from },
    to: { ...move.to }
  };
}

2. 修改 captureSnapshot()

在返回物件的 playerEnded 之後加入:

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

pendingMinoMove:
  clonePendingMinoMove(state.pendingMinoMove),

修改後相關部分應為:

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

minoFlightMode: {
  1: state.minoFlightMode[1],
  2: state.minoFlightMode[2]
},

pendingMinoMove:
  clonePendingMinoMove(state.pendingMinoMove),

movedThisTurn: state.movedThisTurn,
roundEnded: state.roundEnded,
endInfo: state.endInfo ? { ...state.endInfo } : null

3. 修改 restoreSnapshot()

在恢復 playerEnded 之後加入:

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

state.pendingMinoMove =
  clonePendingMinoMove(snapshot.pendingMinoMove);

state.pendingFlight = null;
hideFlightConfirmDialog();

不要在函式末尾再次把 pendingMinoMove 清空。

保留:

js
state.selectedMinoId = null;
state.hoverCell = null;

二十、修改 startRound()

startRound() 中,搜尋:

js
state.hoverCell = null;
state.selectedMinoId = null;
state.movedThisTurn = false;

替換為:

js
state.hoverCell = null;
state.selectedMinoId = null;
state.movedThisTurn = false;
state.pendingMinoMove = null;
state.pendingFlight = null;

state.minoFlightMode = {
  1: false,
  2: false
};

hideFlightConfirmDialog();

設定 state.currentPlayer 後,把:

js
initializeHistory();

替換為:

js
preparePlayerPhase(state.currentPlayer);
initializeHistory();

二十一、修改本局結果文字邏輯

showRoundModal() 中,刪除:

js
if (result.endInfo.reason === "noLegalFino") {
  ...
} else if (
  result.endInfo.reason === "noLegalRequired"
) {
  ...
} else if ...

整段替換為:

js
if (result.endInfo.winner === 0) {
  els.resultSummary.textContent =
    translate("finishedTieSummary");
} else {
  els.resultSummary.textContent =
    translate("finishedWinSummary", {
      winner: playerToken(result.endInfo.winner)
    });
}

因為修改後,無法放法諾、米諾或維諾都不再直接判負。


二十二、把 Toast 改成支援 1 秒及排隊顯示

保留原本的 updateToastText(),把 showToast()clearToast() 替換為:

js
function drainToastQueue() {
  if (
    state.toastShowing ||
    state.toastQueue.length === 0
  ) {
    return;
  }

  const item = state.toastQueue.shift();

  state.toastShowing = true;
  state.toastData = item;

  updateToastText();

  els.toast.classList.remove("show");
  void els.toast.offsetWidth;
  els.toast.classList.add("show");

  state.toastTimer = window.setTimeout(() => {
    els.toast.classList.remove("show");

    state.toastData = null;
    state.toastShowing = false;
    state.toastTimer = null;

    state.toastGapTimer = window.setTimeout(() => {
      state.toastGapTimer = null;
      drainToastQueue();
    }, 180);
  }, item.duration);
}

function showToast(
  key,
  variables = {},
  duration = 2400
) {
  state.toastQueue.push({
    key,
    variables,
    duration
  });

  drainToastQueue();
}

function clearToast() {
  window.clearTimeout(state.toastTimer);
  window.clearTimeout(state.toastGapTimer);

  state.toastData = null;
  state.toastTimer = null;
  state.toastGapTimer = null;
  state.toastQueue = [];
  state.toastShowing = false;

  els.toast.classList.remove("show");
}

這樣階段切換及飛行階段錯誤可以使用:

js
showToast("某個key", {}, 1000);

而且如果法諾與米諾階段連續被略過,兩條一秒提示不會互相覆蓋。


二十三、修改事件註冊

1. 刪除結束按鈕事件

在:

js
els.gameScreen.addEventListener("click", (event) => {

內刪除:

js
const endButton =
  event.target.closest("[data-end-player]");

if (endButton) {
  endPlayerGame(Number(endButton.dataset.endPlayer));
}

保留後應為:

js
els.gameScreen.addEventListener("click", (event) => {
  const frame = event.target.closest(".piece-frame");

  if (frame) {
    handlePieceFrameClick(event, frame);
  }
});

2. 加入飛子確認事件

在其他事件註冊附近加入:

js
els.flightConfirmCancel.addEventListener(
  "click",
  cancelPendingFlight
);

els.flightConfirmAccept.addEventListener(
  "click",
  confirmPendingFlight
);

不要讓點擊確認視窗背景直接確認或取消,避免誤操作。

3. 修改 Escape 邏輯

document.addEventListener("keydown", ...) 中,將 Escape 判斷的最前面加入:

js
if (
  event.key === "Escape" &&
  !els.flightConfirmBackdrop.hidden
) {
  cancelPendingFlight();
  return;
}

完整前半段應類似:

js
document.addEventListener("keydown", (event) => {
  if (
    event.key === "Escape" &&
    !els.flightConfirmBackdrop.hidden
  ) {
    cancelPendingFlight();
    return;
  }

  if (event.key === "Escape") {
    if (!els.resultBackdrop.hidden) {
      hideResultModal();
      return;
    }

    if (!els.rulesBackdrop.hidden) {
      hideRules();
      return;
    }
  }

  if (!els.flightConfirmBackdrop.hidden) {
    return;
  }

  // 原有 Ctrl/Cmd + Z、Y 邏輯繼續放在這裡
});

二十四、返回標題時清理飛子視窗

returnToTitle() 開頭加入:

js
hideFlightConfirmDialog();
state.pendingFlight = null;
state.pendingMinoMove = null;

修改後開頭為:

js
function returnToTitle() {
  hideResultModal();
  hideRules();
  hideFlightConfirmDialog();
  clearToast();

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

  // 其餘保持不變
}

最後需要確認的一個規則邊界

新規則存在一個原規則沒有定義的情況:

如果某玩家在米諾放置階段一個米諾都沒有成功放下,之後進入米諾飛行階段時,棋盤上沒有可供飛走的米諾。

上面的程式嚴格遵守「必須飛走棋盤上的一個米諾才能結束」,因此這種情況下該玩家將沒有可選棋子。正式版本最好額外指定一條規則,例如:

  • 沒有已落下米諾時自動結束;或
  • 至少必須保證放下一個米諾;或
  • 允許把尚未落下的米諾直接飛入一個空位並結束。

除這個未定義邊界外,上述替換已涵蓋法諾/米諾自動跳階、維諾可行性模擬、米諾正常移動、飛子確認、移動退回、紅藍標記、飛行階段錯誤提示,以及刪除舊結束按鈕的完整流程。