QuanAI只读快照

共享会话

增加導入導出棋譜功能

分享于 2026年9月1日 01:11
提问者

增加導入導出棋譜功能 當點擊「導出棋譜按鈕」時,將彈出一個「導出導入棋譜」窗口。其內容是「導出textarea,複製按鈕,導入textarea,確認按鈕」。每組按鈕都放到相應的textarea的右邊且與其等高。當打開窗口時將為當前局面生成新的棋譜至「導出textarra」,並清空「導入textarea」。當按下「確認」按鈕,將把導入textarea中的棋譜導入到當前局面,包括undo和redo。

棋譜格式為「xyn」或「xyxyxyn(先移動一個米諾再落下一個維諾)或「xyxy0」(飛子),x是横坐標(從左到右從0到9),y是縱坐標(從上二下從0到9)。

n是棋子類型: 法諾朝左上是1 法諾朝右上是2 法諾朝右下是3 法諾朝左下是4 米諾是5 維諾朝左上是6 維諾朝右上是7 維諾朝右下是8 維諾朝左下是9 飛子是0

朝向指的是V形尖角的朝向,坐標也指的是V形尖角的坐標。

每手棋用空格隔開,在前面是雙方輪流行動,但是當有一步出現飛子0之後,則餘下的行動將只是另一方,直到出現第二個飛子0,則本局結束。

本遊戲會有可能比兩局,則第二局的棋譜將直接加在第一局後面。

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

vino2252.htmltext/plain · 147.7 KB预览下载
QuanAI

下面按你上傳的 vino2252.html 現有結構修改。完成後會有:

  • 工具列的「導出棋譜」按鈕不再顯示「尚未實現」。
  • 點擊後打開「導出導入棋譜」窗口。
  • 導出與導入各有一個 textarea,按鈕位於右側並與 textarea 等高。
  • 每次打開窗口重新生成當前棋譜,並清空導入區。
  • 支援:
    • xyn
    • xyxyxyn:移動米諾後放置維諾
    • xyxy0:飛子
  • 支援兩局棋譜直接相連。
  • 導入時重新建立當前局的 history,因此導入後可以 undo/redo。
  • 第一局完成後進入第二局時,會保留第一局棋譜供導出。

注意:你定義的棋譜格式沒有表示「米諾已移動、但維諾尚未落下」這種半手狀態。因此如果在這個狀態打開窗口,導出內容只會包含此前已完成的手數,並顯示提示。完成維諾落子後便會輸出完整的 xyxyxyn


一、增加窗口 CSS

在 CSS 中找到:

css
      /* ---------- Toast ---------- */

在它前面加入:

css
      /* ---------- Game record dialog ---------- */

      #recordBackdrop {
        z-index: 175;
      }

      .record-dialog {
        width: min(100%, 780px);
      }

      .record-fields {
        display: grid;
        gap: 20px;
        margin-top: 22px;
      }

      .record-field {
        display: grid;
        gap: 8px;
      }

      .record-label {
        color: var(--muted);
        font-size: 0.78rem;
        font-weight: 800;
        letter-spacing: 0.08em;
        text-transform: uppercase;
      }

      .record-row {
        display: grid;
        grid-template-columns: minmax(0, 1fr) clamp(78px, 14vw, 108px);
        align-items: stretch;
        gap: 10px;
        min-width: 0;
      }

      .record-textarea {
        width: 100%;
        min-width: 0;
        min-height: 150px;
        padding: 13px 15px;
        resize: vertical;
        border: 1px solid rgba(255, 255, 255, 0.14);
        border-radius: 13px;
        color: var(--text);
        background: rgba(3, 8, 14, 0.48);
        font:
          0.88rem/1.65 ui-monospace,
          SFMono-Regular,
          Menlo,
          Monaco,
          Consolas,
          'Liberation Mono',
          monospace;
        outline: none;
      }

      .record-textarea:focus {
        border-color: rgba(242, 167, 189, 0.75);
        box-shadow: 0 0 0 3px rgba(186, 90, 121, 0.14);
      }

      .record-textarea[readonly] {
        color: #f7e7d9;
        background: rgba(3, 8, 14, 0.35);
      }

      .record-side-button {
        width: 100%;
        height: 100%;
        min-height: 150px;
        padding: 12px;
      }

      @media (max-width: 480px) {
        .record-row {
          grid-template-columns: minmax(0, 1fr) 76px;
          gap: 7px;
        }

        .record-textarea,
        .record-side-button {
          min-height: 132px;
        }

        .record-side-button {
          padding: 8px;
          font-size: 0.78rem;
        }
      }

二、替換工具列的導出按鈕

找到原本這段:

html
              <button type="button" class="tool-button" data-label-key="toolExport" data-placeholder-tool>
                <svg viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M7 3h7l4 4v14H7z"></path>
                  <path d="M14 3v5h5"></path>
                  <path d="M10 12h5M10 16h5"></path>
                </svg>
                <span class="sr-only"></span>
              </button>

整段替換成:

html
              <button id="recordButton" type="button" class="tool-button" data-label-key="toolExport">
                <svg viewBox="0 0 24 24" aria-hidden="true">
                  <path d="M7 3h7l4 4v14H7z"></path>
                  <path d="M14 3v5h5"></path>
                  <path d="M10 12h5M10 16h5"></path>
                </svg>
                <span class="sr-only"></span>
              </button>

主要改動是:

  • 加入 id="recordButton"
  • 刪除 data-placeholder-tool

否則它仍會被原有的「尚未實現」事件攔截。


三、加入「導出導入棋譜」窗口 HTML

找到:

html
    <!-- Mino flight confirmation dialog -->

在它前面加入:

html
    <!-- Export/import game record dialog -->
    <div id="recordBackdrop" class="modal-backdrop" hidden>
      <section
        class="dialog record-dialog"
        role="dialog"
        aria-modal="true"
        aria-labelledby="recordTitle"
      >
        <div class="lang-switch compact modal-lang" role="group">
          <button type="button" class="lang-option" data-set-lang="en">EN</button>
          <button type="button" class="lang-option" data-set-lang="zh">中文</button>
        </div>

        <button id="recordClose" type="button" class="modal-close">
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <path d="M6 6 18 18M18 6 6 18"></path>
          </svg>
          <span class="sr-only"></span>
        </button>

        <p id="recordEyebrow" class="modal-eyebrow"></p>
        <h2 id="recordTitle"></h2>
        <p id="recordSummary" class="modal-summary"></p>

        <div class="record-fields">
          <div class="record-field">
            <label id="recordExportLabel" class="record-label" for="recordExportTextarea"></label>

            <div class="record-row">
              <textarea
                id="recordExportTextarea"
                class="record-textarea"
                readonly
                spellcheck="false"
              ></textarea>

              <button
                id="recordCopyButton"
                type="button"
                class="secondary-button record-side-button"
              ></button>
            </div>
          </div>

          <div class="record-field">
            <label id="recordImportLabel" class="record-label" for="recordImportTextarea"></label>

            <div class="record-row">
              <textarea
                id="recordImportTextarea"
                class="record-textarea"
                spellcheck="false"
                autocomplete="off"
              ></textarea>

              <button
                id="recordConfirmButton"
                type="button"
                class="primary-button record-side-button"
              ></button>
            </div>
          </div>
        </div>
      </section>
    </div>

四、增加中英文文字

4.1 英文

I18N.en 中找到:

js
          toolAutoRotate: 'Auto rotate',

在其後加入:

js
          recordEyebrow: 'Game record',
          recordTitle: 'Export / Import Record',
          recordSummary:
            'Each completed move is separated by a space. Importing replaces the current match.',
          recordExportLabel: 'Export record',
          recordImportLabel: 'Import record',
          recordCopy: 'Copy',
          recordConfirm: 'Confirm',
          recordCopied: 'The game record has been copied.',
          recordCopyFailed: 'Unable to copy the game record.',
          recordImported: 'The game record has been imported.',
          recordImportFailed: 'Import failed: {message}',
          recordEmpty: 'Please enter a game record.',
          recordPendingMove:
            'The current Mino move is not yet complete. Place the Vino before exporting the complete position.',

4.2 中文

I18N.zh 中找到:

js
          toolAutoRotate: '自動旋轉',

在其後加入:

js
          recordEyebrow: '文本棋譜',
          recordTitle: '導出導入棋譜',
          recordSummary: '每手棋以空格分隔。確認導入後將取代目前的對局。',
          recordExportLabel: '導出棋譜',
          recordImportLabel: '導入棋譜',
          recordCopy: '複製',
          recordConfirm: '確認',
          recordCopied: '棋譜已複製。',
          recordCopyFailed: '無法複製棋譜。',
          recordImported: '棋譜已成功導入。',
          recordImportFailed: '導入失敗:{message}',
          recordEmpty: '請先輸入棋譜。',
          recordPendingMove: '目前的米諾移動尚未完成;落下維諾後才能導出完整局面。',

五、增加棋譜狀態

const state = { 中找到:

js
        history: [],
        historyIndex: 0,

替換成:

js
        history: [],
        historyIndex: 0,

        // 當前局已完成的棋譜手數。
        recordMoves: [],

        // 進入第二局後保存第一局棋譜。
        completedRoundRecords: {
          1: [],
          2: []
        },

        // 導入時避免彈出自動結果窗口。
        importingRecord: false,

六、增加窗口 DOM 引用

const els = { 中找到:

js
        undoButton: document.getElementById('undoButton'),
        redoButton: document.getElementById('redoButton'),
        gameRulesButton: document.getElementById('gameRulesButton'),

替換成:

js
        undoButton: document.getElementById('undoButton'),
        redoButton: document.getElementById('redoButton'),
        gameRulesButton: document.getElementById('gameRulesButton'),
        recordButton: document.getElementById('recordButton'),

然後找到:

js
        flightConfirmBackdrop: document.getElementById('flightConfirmBackdrop'),

在它前面加入:

js
        recordBackdrop: document.getElementById('recordBackdrop'),
        recordClose: document.getElementById('recordClose'),
        recordEyebrow: document.getElementById('recordEyebrow'),
        recordTitle: document.getElementById('recordTitle'),
        recordSummary: document.getElementById('recordSummary'),
        recordExportLabel: document.getElementById('recordExportLabel'),
        recordImportLabel: document.getElementById('recordImportLabel'),
        recordExportTextarea: document.getElementById('recordExportTextarea'),
        recordImportTextarea: document.getElementById('recordImportTextarea'),
        recordCopyButton: document.getElementById('recordCopyButton'),
        recordConfirmButton: document.getElementById('recordConfirmButton'),

七、讓窗口文字跟隨語言切換

applyLanguage(language) 中找到:

js
        els.rulesEyebrow.textContent = translate('rulesEyebrow');
        els.rulesModalTitle.textContent = translate('rulesTitle');
        els.rulesContent.innerHTML = I18N[state.lang].rulesHtml;

在其後加入:

js
        els.recordEyebrow.textContent = translate('recordEyebrow');
        els.recordTitle.textContent = translate('recordTitle');
        els.recordSummary.textContent = translate('recordSummary');
        els.recordExportLabel.textContent = translate('recordExportLabel');
        els.recordImportLabel.textContent = translate('recordImportLabel');
        els.recordCopyButton.textContent = translate('recordCopy');
        els.recordConfirmButton.textContent = translate('recordConfirm');

再找到:

js
        [els.rulesClose, els.resultClose].forEach(button => {

替換成:

js
        [els.rulesClose, els.resultClose, els.recordClose].forEach(button => {

八、增加棋子編號和棋譜生成函數

在以下函數後面:

js
      function otherPlayer(player) {
        return player === 1 ? 2 : 1;
      }

加入:

js
      const RECORD_NUMBER_TO_PIECE = Object.freeze({
        1: { type: 'fino', orientation: 'tl' },
        2: { type: 'fino', orientation: 'tr' },
        3: { type: 'fino', orientation: 'br' },
        4: { type: 'fino', orientation: 'bl' },
        5: { type: 'mino', orientation: 'single' },
        6: { type: 'vino', orientation: 'tl' },
        7: { type: 'vino', orientation: 'tr' },
        8: { type: 'vino', orientation: 'br' },
        9: { type: 'vino', orientation: 'bl' }
      });

      const RECORD_PIECE_TO_NUMBER = Object.freeze({
        fino: Object.freeze({
          tl: 1,
          tr: 2,
          br: 3,
          bl: 4
        }),

        mino: Object.freeze({
          single: 5
        }),

        vino: Object.freeze({
          tl: 6,
          tr: 7,
          br: 8,
          bl: 9
        })
      });

      function getRecordPieceNumber(type, orientation) {
        const typeMap = RECORD_PIECE_TO_NUMBER[type];

        if (!typeMap) {
          return null;
        }

        return typeMap[orientation] ?? null;
      }

      function makePlacementRecordToken(type, orientation, row, col) {
        const number = getRecordPieceNumber(type, orientation);

        if (number === null) {
          throw new Error(`Unknown piece orientation: ${type}/${orientation}`);
        }

        // 棋譜座標順序是 x、y;程式內部則是 row、col。
        return `${col}${row}${number}`;
      }

      function makeMoveAndVinoRecordToken(move, orientation, anchorRow, anchorCol) {
        const number = getRecordPieceNumber('vino', orientation);

        if (number === null) {
          throw new Error(`Unknown Vino orientation: ${orientation}`);
        }

        return (
          `${move.from.col}${move.from.row}` +
          `${move.to.col}${move.to.row}` +
          `${anchorCol}${anchorRow}${number}`
        );
      }

      function makeFlightRecordToken(flight) {
        return (
          `${flight.from.col}${flight.from.row}` +
          `${flight.to.col}${flight.to.row}0`
        );
      }

      function buildExportRecord() {
        const tokens = [];

        if (state.roundNumber >= 2 && state.completedRoundRecords[1]) {
          tokens.push(...state.completedRoundRecords[1]);
        }

        tokens.push(...state.recordMoves);

        return tokens.join(' ');
      }

九、讓 history 保存棋譜狀態

9.1 修改 captureSnapshot()

captureSnapshot() 返回物件內找到:

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

替換成:

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

          // undo/redo 時同步恢復棋譜。
          recordMoves: [...state.recordMoves]

9.2 修改 restoreSnapshot(snapshot)

找到:

js
        state.endInfo = snapshot.endInfo ? { ...snapshot.endInfo } : null;

        state.selectedMinoId = null;

替換成:

js
        state.endInfo = snapshot.endInfo ? { ...snapshot.endInfo } : null;

        state.recordMoves = Array.isArray(snapshot.recordMoves)
          ? [...snapshot.recordMoves]
          : [];

        state.selectedMinoId = null;

這樣 undo/redo 不只恢復棋盤,也會同步恢復導出棋譜。


十、修改新局初始化

找到完整的:

js
      function startRound(roundNumber) {

將整個函數替換成:

js
      function startRound(roundNumber) {
        hideResultModal();
        clearToast();

        /*
         * 開始第二局前保存第一局棋譜。
         * 第二局導出時會把第一局和第二局直接連在一起。
         */
        if (roundNumber === 2 && state.roundNumber === 1) {
          state.completedRoundRecords[1] = [...state.recordMoves];
        }

        /*
         * 開始第一局代表開始一場新比賽,
         * 清除上一場比賽保存的棋譜。
         */
        if (roundNumber === 1) {
          state.completedRoundRecords = {
            1: [],
            2: []
          };
        }

        state.roundNumber = roundNumber;
        state.gameOver = false;
        state.roundEnded = false;
        state.endInfo = null;

        state.board = createEmptyBoard();
        state.pieces = [];
        state.nextPieceId = 1;
        state.inventory = createInventory();
        state.phaseSkipped = createPhaseSkipped();

        state.scoreStats = {
          1: emptyScoreStats(),
          2: emptyScoreStats()
        };

        state.orientationMemory = createOrientationMemory();
        state.orientationDrag = null;
        state.hoverCell = null;
        state.selectedMinoId = null;
        state.movedThisTurn = false;
        state.pendingMinoMove = null;
        state.pendingFlight = null;

        state.recordMoves = [];

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

        hideFlightConfirmDialog();

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

        state.currentPlayer = roundNumber === 1 ? 1 : 2;

        preparePlayerPhase(state.currentPlayer);
        initializeHistory();
        renderAll();
        scheduleBoardFit();
      }

十一、落子時寫入棋譜

找到完整的:

js
      function placePiece(validation, active) {

將整個函數替換成:

js
      function placePiece(validation, active) {
        /*
         * 在清除 pendingMinoMove 前先保存。
         * 如果本手先移動米諾再放維諾,需要合併成 xyxyxyn。
         */
        const pendingMoveForRecord =
          active.type === 'vino' &&
          state.pendingMinoMove &&
          state.pendingMinoMove.player === active.player
            ? clonePendingMinoMove(state.pendingMinoMove)
            : null;

        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;

        const anchor = validation.cells[0];

        if (pendingMoveForRecord) {
          state.recordMoves.push(
            makeMoveAndVinoRecordToken(
              pendingMoveForRecord,
              active.orientation,
              anchor.row,
              anchor.col
            )
          );
        } else {
          state.recordMoves.push(
            makePlacementRecordToken(
              active.type,
              active.orientation,
              anchor.row,
              anchor.col
            )
          );
        }

        rebuildBoard();
        recalculateScores();

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

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

        pushHistorySnapshot();
        renderAll();

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

十二、飛子時寫入棋譜

12.1 替換 finishPlayerByFlight

找到完整的:

js
      function finishPlayerByFlight(player) {

將整個函數替換成:

js
      function finishPlayerByFlight(player, completedFlight = null) {
        const flightForRecord = completedFlight || state.pendingFlight;

        hideFlightConfirmDialog();

        state.pendingFlight = null;

        if (flightForRecord) {
          state.recordMoves.push(makeFlightRecordToken(flightForRecord));
        }

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

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

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

12.2 修改 attemptMinoFlight

attemptMinoFlight() 末尾找到:

js
        finishPlayerByFlight(player);

替換成:

js
        finishPlayerByFlight(player, {
          player,
          pieceId,
          from,
          to
        });

12.3 替換 confirmPendingFlight

找到完整的:

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

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

        const player = pendingFlight.player;

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

替換成:

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

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

        finishPlayerByFlight(pendingFlight.player, clonePendingMinoMove(pendingFlight));
      }

pendingFlightpendingMinoMove 的資料結構都是:

js
{
  player,
  pieceId,
  from,
  to
}

所以可以直接使用現有的 clonePendingMinoMove()


十三、增加導出、複製和導入功能

在:

js
      /* ---------- Events ---------- */

前面加入以下完整程式碼:

js
      /* ---------- Export/import game record ---------- */

      function showRecordDialog() {
        els.recordExportTextarea.value = buildExportRecord();
        els.recordImportTextarea.value = '';

        els.recordBackdrop.hidden = false;

        if (state.pendingMinoMove) {
          showToast('recordPendingMove', {}, 3000);
        }

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

      function hideRecordDialog() {
        els.recordBackdrop.hidden = true;
      }

      async function copyExportRecord() {
        const text = els.recordExportTextarea.value;

        try {
          if (navigator.clipboard && window.isSecureContext) {
            await navigator.clipboard.writeText(text);
          } else {
            els.recordExportTextarea.focus();
            els.recordExportTextarea.select();

            const copied = document.execCommand('copy');

            els.recordExportTextarea.setSelectionRange(0, 0);

            if (!copied) {
              throw new Error('Copy command failed.');
            }
          }

          showToast('recordCopied', {}, 1300);
        } catch (error) {
          showToast('recordCopyFailed', {}, 1800);
        }
      }

      function makeRecordImportError(zhMessage, enMessage) {
        return new Error(state.lang === 'zh' ? zhMessage : enMessage);
      }

      function clonePlainData(value) {
        if (value === undefined) {
          return undefined;
        }

        return JSON.parse(JSON.stringify(value));
      }

      /*
       * 導入失敗時恢復原來整場比賽,
       * 避免錯誤棋譜只導入一半便破壞目前局面。
       */
      const IMPORT_BACKUP_KEYS = [
        'gameStarted',
        'gameOver',
        'roundNumber',
        'currentPlayer',
        'pieces',
        'nextPieceId',
        'inventory',
        'phaseSkipped',
        'scoreStats',
        'orientationMemory',
        'selectedMinoId',
        'movedThisTurn',
        'pendingMinoMove',
        'pendingFlight',
        'minoFlightMode',
        'playerEnded',
        'roundEnded',
        'endInfo',
        'roundResults',
        'matchForfeit',
        'history',
        'historyIndex',
        'resultMode',
        'recordMoves',
        'completedRoundRecords'
      ];

      function captureImportBackup() {
        const backup = {};

        IMPORT_BACKUP_KEYS.forEach(key => {
          backup[key] = clonePlainData(state[key]);
        });

        return backup;
      }

      function restoreImportBackup(backup) {
        IMPORT_BACKUP_KEYS.forEach(key => {
          state[key] = clonePlainData(backup[key]);
        });

        state.importingRecord = false;
        state.orientationDrag = null;
        state.hoverCell = null;

        hideFlightConfirmDialog();

        els.startScreen.hidden = state.gameStarted;
        els.gameScreen.hidden = !state.gameStarted;

        rebuildBoard();
        recalculateScores();
        renderAll();
      }

      function getImportedMinoAt(row, col, player) {
        const occupied = state.board[row]?.[col];

        if (
          !occupied ||
          occupied.owner !== player ||
          occupied.type !== 'mino'
        ) {
          return null;
        }

        const piece = findPiece(occupied.pieceId);

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

        return piece;
      }

      /*
       * 棋譜不另外記錄主動跳過階段的操作。
       * 因此當下一手棋子類型比目前階段更後時,
       * 根據盤面上是否已有至少一個當前階段棋子來恢復跳過狀態。
       */
      function prepareImportedPieceType(player, targetType) {
        for (let attempt = 0; attempt < 3; attempt += 1) {
          const requiredType = getRequiredPiece(player);

          if (requiredType === targetType) {
            return;
          }

          if (
            requiredType === 'fino' &&
            (targetType === 'mino' || targetType === 'vino') &&
            piecesPlacedCount(player, 'fino') >= 1
          ) {
            state.phaseSkipped[player].fino = true;
            continue;
          }

          if (
            requiredType === 'mino' &&
            targetType === 'vino' &&
            piecesPlacedCount(player, 'mino') >= 1
          ) {
            state.phaseSkipped[player].mino = true;
            continue;
          }

          throw makeRecordImportError(
            `目前輪到玩家${player}放置${pieceName(requiredType || targetType)},不能導入${pieceName(targetType)}。`,
            `Player ${player} cannot place ${pieceName(targetType)} during the current phase.`
          );
        }

        throw makeRecordImportError(
          '無法判斷棋子的行棋階段。',
          'Unable to determine the piece phase.'
        );
      }

      function addImportedPiece(player, type, orientation, row, col, recordToken) {
        prepareImportedPieceType(player, type);

        const validation = validatePlacement(
          player,
          type,
          orientation,
          row,
          col
        );

        if (!validation.ok) {
          const reason = {
            bounds: state.lang === 'zh' ? '棋子超出棋盤。' : 'The piece extends beyond the board.',
            overlap: state.lang === 'zh' ? '棋子與其他棋子重疊。' : 'The piece overlaps another piece.',
            contact:
              state.lang === 'zh'
                ? '法諾或米諾與本方棋子接觸。'
                : 'The Fino or Mino touches another friendly piece.'
          }[validation.reason];

          throw new Error(reason || (state.lang === 'zh' ? '落子不合法。' : 'Illegal placement.'));
        }

        const piece = {
          id: state.nextPieceId,
          owner: player,
          type,
          orientation,
          cells: validation.cells.map(cell => ({ ...cell }))
        };

        state.nextPieceId += 1;
        state.pieces.push(piece);
        state.inventory[player][type] -= 1;

        state.recordMoves.push(recordToken);

        rebuildBoard();
        recalculateScores();

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

        resolveNextTurn(player);
        pushHistorySnapshot();
      }

      function importSimplePlacement(token) {
        const x = Number(token[0]);
        const y = Number(token[1]);
        const number = Number(token[2]);
        const definition = RECORD_NUMBER_TO_PIECE[number];

        if (!definition) {
          throw makeRecordImportError(
            `未知的棋子類型 ${number}。`,
            `Unknown piece number ${number}.`
          );
        }

        const player = state.currentPlayer;

        if (!player || state.roundEnded || state.gameOver) {
          throw makeRecordImportError(
            '目前沒有可以行動的玩家。',
            'There is no active player.'
          );
        }

        addImportedPiece(
          player,
          definition.type,
          definition.orientation,
          y,
          x,
          token
        );
      }

      function importMoveAndVino(token) {
        const fromX = Number(token[0]);
        const fromY = Number(token[1]);
        const toX = Number(token[2]);
        const toY = Number(token[3]);
        const vinoX = Number(token[4]);
        const vinoY = Number(token[5]);
        const number = Number(token[6]);

        const definition = RECORD_NUMBER_TO_PIECE[number];

        if (!definition || definition.type !== 'vino') {
          throw makeRecordImportError(
            '移動米諾後必須落下一個維諾,最後一位只能是 6、7、8 或 9。',
            'A Mino move must be followed by a Vino numbered 6, 7, 8 or 9.'
          );
        }

        const player = state.currentPlayer;

        if (!player || state.roundEnded || state.gameOver) {
          throw makeRecordImportError(
            '目前沒有可以行動的玩家。',
            'There is no active player.'
          );
        }

        prepareImportedPieceType(player, 'vino');

        const mino = getImportedMinoAt(fromY, fromX, player);

        if (!mino) {
          throw makeRecordImportError(
            `座標 ${fromX}${fromY} 沒有玩家${player}的米諾。`,
            `There is no Mino belonging to player ${player} at ${fromX}${fromY}.`
          );
        }

        const destinations = getLegalMinoDestinations(mino.id);
        const destination = destinations.get(cellKey(toY, toX));

        if (!destination) {
          throw makeRecordImportError(
            `米諾不能從 ${fromX}${fromY} 正常移動到 ${toX}${toY}。`,
            `The Mino cannot move normally from ${fromX}${fromY} to ${toX}${toY}.`
          );
        }

        const from = { row: fromY, col: fromX };
        const to = { row: toY, col: toX };

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

        rebuildBoard();
        recalculateScores();

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

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

        /*
         * 現有遊戲在正常移動米諾後會保存一次 history,
         * 所以導入也保存這個中間狀態。
         */
        pushHistorySnapshot();

        addImportedPiece(
          player,
          'vino',
          definition.orientation,
          vinoY,
          vinoX,
          token
        );
      }

      function importFlight(token) {
        const fromX = Number(token[0]);
        const fromY = Number(token[1]);
        const toX = Number(token[2]);
        const toY = Number(token[3]);
        const lastNumber = Number(token[4]);

        if (lastNumber !== 0) {
          throw makeRecordImportError(
            '五位棋譜的最後一位必須是飛子 0。',
            'A five-digit record token must end in flight number 0.'
          );
        }

        const player = state.currentPlayer;

        if (!player || state.roundEnded || state.gameOver) {
          throw makeRecordImportError(
            '目前沒有可以執行飛子的玩家。',
            'There is no active player who can perform a flight.'
          );
        }

        const automaticFlightPhase = isMinoFlightPhase(player);
        const requiredType = getRequiredPiece(player);

        if (!automaticFlightPhase && requiredType !== 'vino') {
          throw makeRecordImportError(
            '只有維諾階段或飛子階段可以執行飛子。',
            'A flight is only allowed during the Vino or flight phase.'
          );
        }

        if (!automaticFlightPhase && piecesPlacedCount(player, 'vino') === 0) {
          throw makeRecordImportError(
            '玩家尚未放置維諾,不能執行飛子。',
            'The player has not placed a Vino and cannot perform a flight.'
          );
        }

        const mino = getImportedMinoAt(fromY, fromX, player);

        if (!mino) {
          throw makeRecordImportError(
            `座標 ${fromX}${fromY} 沒有玩家${player}的米諾。`,
            `There is no Mino belonging to player ${player} at ${fromX}${fromY}.`
          );
        }

        if (state.board[toY]?.[toX]) {
          throw makeRecordImportError(
            `飛子落點 ${toX}${toY} 已被佔用。`,
            `Flight destination ${toX}${toY} is occupied.`
          );
        }

        const legalDestinations = getLegalMinoDestinations(mino.id);

        if (legalDestinations.has(cellKey(toY, toX))) {
          throw makeRecordImportError(
            `位置 ${toX}${toY} 可以正常移動到達,不能作為飛子落點。`,
            `Cell ${toX}${toY} is reachable by a normal move and cannot be a flight destination.`
          );
        }

        const from = { row: fromY, col: fromX };
        const to = { row: toY, col: toX };

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

        rebuildBoard();
        recalculateScores();

        state.recordMoves.push(token);

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

        resolveNextTurn(player);
        pushHistorySnapshot();
      }

      function applyImportedRecordToken(token) {
        if (/^\d{3}$/.test(token)) {
          importSimplePlacement(token);
          return;
        }

        if (/^\d{5}$/.test(token)) {
          importFlight(token);
          return;
        }

        if (/^\d{7}$/.test(token)) {
          importMoveAndVino(token);
          return;
        }

        throw makeRecordImportError(
          `「${token}」格式錯誤;每手只能是 3、5 或 7 位數字。`,
          `"${token}" is invalid. Each token must contain 3, 5 or 7 digits.`
        );
      }

      function importGameRecord(recordText) {
        const trimmed = recordText.trim();

        if (!trimmed) {
          throw new Error(translate('recordEmpty'));
        }

        const tokens = trimmed.split(/\s+/);

        tokens.forEach(token => {
          if (!/^\d+$/.test(token)) {
            throw makeRecordImportError(
              `「${token}」包含非數字字符。`,
              `"${token}" contains non-numeric characters.`
            );
          }

          if (![3, 5, 7].includes(token.length)) {
            throw makeRecordImportError(
              `「${token}」的長度錯誤。`,
              `"${token}" has an invalid length.`
            );
          }
        });

        const backup = captureImportBackup();

        state.importingRecord = true;

        try {
          hideResultModal();
          hideFlightConfirmDialog();
          clearToast();

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

          els.startScreen.hidden = true;
          els.gameScreen.hidden = false;

          startRound(1);

          for (let index = 0; index < tokens.length; index += 1) {
            /*
             * 第一局已有兩個飛子而且後面還有棋譜,
             * 則餘下內容自動解釋為第二局。
             */
            if (state.roundEnded) {
              if (state.roundNumber >= 2) {
                throw makeRecordImportError(
                  `第 ${index + 1} 手出現在第二局已結束之後。`,
                  `Move ${index + 1} appears after round two has ended.`
                );
              }

              commitPendingRoundResult();
              state.completedRoundRecords[1] = [...state.recordMoves];
              startRound(2);
            }

            if (state.gameOver) {
              throw makeRecordImportError(
                `第 ${index + 1} 手出現在比賽已結束之後。`,
                `Move ${index + 1} appears after the match has ended.`
              );
            }

            try {
              applyImportedRecordToken(tokens[index]);
            } catch (error) {
              throw makeRecordImportError(
                `第 ${index + 1} 手「${tokens[index]}」:${error.message}`,
                `Move ${index + 1}, "${tokens[index]}": ${error.message}`
              );
            }
          }

          state.importingRecord = false;
          clearToast();

          rebuildBoard();
          recalculateScores();
          renderAll();
          scheduleBoardFit();
        } catch (error) {
          restoreImportBackup(backup);
          throw error;
        }
      }

      function confirmRecordImport() {
        try {
          importGameRecord(els.recordImportTextarea.value);
          hideRecordDialog();
          showToast('recordImported', {}, 1600);
        } catch (error) {
          showToast(
            'recordImportFailed',
            {
              message: error.message
            },
            4200
          );

          els.recordImportTextarea.focus();
        }
      }

十四、避免導入期間自動彈出判負窗口

找到 forfeitMatch() 最後這段:

js
        window.setTimeout(() => {
          if (state.gameOver) {
            showFinalModal();
          }
        }, 120);

替換成:

js
        if (!state.importingRecord) {
          window.setTimeout(() => {
            if (state.gameOver) {
              showFinalModal();
            }
          }, 120);
        }

十五、增加窗口事件

在事件區域找到:

js
      els.startButton.addEventListener('click', startGame);
      els.rulesButton.addEventListener('click', showRules);
      els.gameRulesButton.addEventListener('click', showRules);

在其後加入:

js
      els.recordButton.addEventListener('click', showRecordDialog);
      els.recordClose.addEventListener('click', hideRecordDialog);
      els.recordCopyButton.addEventListener('click', copyExportRecord);
      els.recordConfirmButton.addEventListener('click', confirmRecordImport);

再找到:

js
      els.resultBackdrop.addEventListener('click', event => {
        if (event.target === els.resultBackdrop) {
          hideResultModal();
        }
      });

在其後加入:

js
      els.recordBackdrop.addEventListener('click', event => {
        if (event.target === els.recordBackdrop) {
          hideRecordDialog();
        }
      });

十六、讓 Escape 可以關閉棋譜窗口

document.addEventListener('keydown', event => { 裡找到:

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

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

替換成:

js
        if (event.key === 'Escape') {
          if (!els.recordBackdrop.hidden) {
            hideRecordDialog();
            return;
          }

          if (!els.resultBackdrop.hidden) {
            hideResultModal();
            return;
          }

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

十七、返回標題頁時關閉棋譜窗口

returnToTitle() 開頭找到:

js
        hideResultModal();
        hideRules();
        hideFlightConfirmDialog();

替換成:

js
        hideResultModal();
        hideRules();
        hideRecordDialog();
        hideFlightConfirmDialog();

棋譜輸出示例

普通落子:

text
001 225 446

含義:

  • 001:尖角在 x=0,y=0x=0,y=0,左上朝向法諾。
  • 225:在 x=2,y=2x=2,y=2 放米諾。
  • 446:尖角在 x=4,y=4x=4,y=4,左上朝向維諾。

先移動米諾再放維諾:

text
2232446

拆開為:

text
22 32 44 6

即:

  • 米諾從 (2,2)(2,2) 移到 (3,2)(3,2)
  • (4,4)(4,4) 放置左上朝向維諾。

飛子:

text
22330

即:

  • 米諾從 (2,2)(2,2) 飛到 (3,3)(3,3)
  • 最後的 0 表示飛子。

兩局棋譜不加分隔符,例如:

text
001 995 446 22330 88770 992 115 336 55440 66770

導入器會在第一局出現第二個飛子 0 後,自動把下一手解釋為第二局;第二局先手會自動改為玩家二。