共享会话
增加導入導出棋譜功能
分享于 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,則本局結束。
本遊戲會有可能比兩局,則第二局的棋譜將直接加在第一局後面。
明確給出所要修改的地方和所要替換的代碼。
QuanAI下面按你上傳的 vino2252.html 現有結構修改。完成後會有:
- 工具列的「導出棋譜」按鈕不再顯示「尚未實現」。
- 點擊後打開「導出導入棋譜」窗口。
- 導出與導入各有一個
textarea,按鈕位於右側並與 textarea 等高。 - 每次打開窗口重新生成當前棋譜,並清空導入區。
- 支援:
xynxyxyxyn:移動米諾後放置維諾xyxy0:飛子
- 支援兩局棋譜直接相連。
- 導入時重新建立當前局的 history,因此導入後可以 undo/redo。
- 第一局完成後進入第二局時,會保留第一局棋譜供導出。
注意:你定義的棋譜格式沒有表示「米諾已移動、但維諾尚未落下」這種半手狀態。因此如果在這個狀態打開窗口,導出內容只會包含此前已完成的手數,並顯示提示。完成維諾落子後便會輸出完整的
xyxyxyn。
一、增加窗口 CSS
在 CSS 中找到:
/* ---------- Toast ---------- */在它前面加入:
/* ---------- 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;
}
}二、替換工具列的導出按鈕
找到原本這段:
<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>整段替換成:
<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
找到:
<!-- Mino flight confirmation dialog -->在它前面加入:
<!-- 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 中找到:
toolAutoRotate: 'Auto rotate',在其後加入:
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 中找到:
toolAutoRotate: '自動旋轉',在其後加入:
recordEyebrow: '文本棋譜',
recordTitle: '導出導入棋譜',
recordSummary: '每手棋以空格分隔。確認導入後將取代目前的對局。',
recordExportLabel: '導出棋譜',
recordImportLabel: '導入棋譜',
recordCopy: '複製',
recordConfirm: '確認',
recordCopied: '棋譜已複製。',
recordCopyFailed: '無法複製棋譜。',
recordImported: '棋譜已成功導入。',
recordImportFailed: '導入失敗:{message}',
recordEmpty: '請先輸入棋譜。',
recordPendingMove: '目前的米諾移動尚未完成;落下維諾後才能導出完整局面。',五、增加棋譜狀態
在 const state = { 中找到:
history: [],
historyIndex: 0,替換成:
history: [],
historyIndex: 0,
// 當前局已完成的棋譜手數。
recordMoves: [],
// 進入第二局後保存第一局棋譜。
completedRoundRecords: {
1: [],
2: []
},
// 導入時避免彈出自動結果窗口。
importingRecord: false,六、增加窗口 DOM 引用
在 const els = { 中找到:
undoButton: document.getElementById('undoButton'),
redoButton: document.getElementById('redoButton'),
gameRulesButton: document.getElementById('gameRulesButton'),替換成:
undoButton: document.getElementById('undoButton'),
redoButton: document.getElementById('redoButton'),
gameRulesButton: document.getElementById('gameRulesButton'),
recordButton: document.getElementById('recordButton'),然後找到:
flightConfirmBackdrop: document.getElementById('flightConfirmBackdrop'),在它前面加入:
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) 中找到:
els.rulesEyebrow.textContent = translate('rulesEyebrow');
els.rulesModalTitle.textContent = translate('rulesTitle');
els.rulesContent.innerHTML = I18N[state.lang].rulesHtml;在其後加入:
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');再找到:
[els.rulesClose, els.resultClose].forEach(button => {替換成:
[els.rulesClose, els.resultClose, els.recordClose].forEach(button => {八、增加棋子編號和棋譜生成函數
在以下函數後面:
function otherPlayer(player) {
return player === 1 ? 2 : 1;
}加入:
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() 返回物件內找到:
movedThisTurn: state.movedThisTurn,
roundEnded: state.roundEnded,
endInfo: state.endInfo ? { ...state.endInfo } : null替換成:
movedThisTurn: state.movedThisTurn,
roundEnded: state.roundEnded,
endInfo: state.endInfo ? { ...state.endInfo } : null,
// undo/redo 時同步恢復棋譜。
recordMoves: [...state.recordMoves]9.2 修改 restoreSnapshot(snapshot)
找到:
state.endInfo = snapshot.endInfo ? { ...snapshot.endInfo } : null;
state.selectedMinoId = null;替換成:
state.endInfo = snapshot.endInfo ? { ...snapshot.endInfo } : null;
state.recordMoves = Array.isArray(snapshot.recordMoves)
? [...snapshot.recordMoves]
: [];
state.selectedMinoId = null;這樣 undo/redo 不只恢復棋盤,也會同步恢復導出棋譜。
十、修改新局初始化
找到完整的:
function startRound(roundNumber) {將整個函數替換成:
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();
}十一、落子時寫入棋譜
找到完整的:
function placePiece(validation, active) {將整個函數替換成:
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
找到完整的:
function finishPlayerByFlight(player) {將整個函數替換成:
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() 末尾找到:
finishPlayerByFlight(player);替換成:
finishPlayerByFlight(player, {
player,
pieceId,
from,
to
});12.3 替換 confirmPendingFlight
找到完整的:
function confirmPendingFlight() {
const pendingFlight = state.pendingFlight;
if (!pendingFlight) {
hideFlightConfirmDialog();
return;
}
const player = pendingFlight.player;
state.pendingFlight = null;
finishPlayerByFlight(player);
}替換成:
function confirmPendingFlight() {
const pendingFlight = state.pendingFlight;
if (!pendingFlight) {
hideFlightConfirmDialog();
return;
}
finishPlayerByFlight(pendingFlight.player, clonePendingMinoMove(pendingFlight));
}pendingFlight 與 pendingMinoMove 的資料結構都是:
{
player,
pieceId,
from,
to
}所以可以直接使用現有的 clonePendingMinoMove()。
十三、增加導出、複製和導入功能
在:
/* ---------- Events ---------- */前面加入以下完整程式碼:
/* ---------- 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() 最後這段:
window.setTimeout(() => {
if (state.gameOver) {
showFinalModal();
}
}, 120);替換成:
if (!state.importingRecord) {
window.setTimeout(() => {
if (state.gameOver) {
showFinalModal();
}
}, 120);
}十五、增加窗口事件
在事件區域找到:
els.startButton.addEventListener('click', startGame);
els.rulesButton.addEventListener('click', showRules);
els.gameRulesButton.addEventListener('click', showRules);在其後加入:
els.recordButton.addEventListener('click', showRecordDialog);
els.recordClose.addEventListener('click', hideRecordDialog);
els.recordCopyButton.addEventListener('click', copyExportRecord);
els.recordConfirmButton.addEventListener('click', confirmRecordImport);再找到:
els.resultBackdrop.addEventListener('click', event => {
if (event.target === els.resultBackdrop) {
hideResultModal();
}
});在其後加入:
els.recordBackdrop.addEventListener('click', event => {
if (event.target === els.recordBackdrop) {
hideRecordDialog();
}
});十六、讓 Escape 可以關閉棋譜窗口
在 document.addEventListener('keydown', event => { 裡找到:
if (event.key === 'Escape') {
if (!els.resultBackdrop.hidden) {
hideResultModal();
return;
}
if (!els.rulesBackdrop.hidden) {
hideRules();
return;
}
}替換成:
if (event.key === 'Escape') {
if (!els.recordBackdrop.hidden) {
hideRecordDialog();
return;
}
if (!els.resultBackdrop.hidden) {
hideResultModal();
return;
}
if (!els.rulesBackdrop.hidden) {
hideRules();
return;
}
}十七、返回標題頁時關閉棋譜窗口
在 returnToTitle() 開頭找到:
hideResultModal();
hideRules();
hideFlightConfirmDialog();替換成:
hideResultModal();
hideRules();
hideRecordDialog();
hideFlightConfirmDialog();棋譜輸出示例
普通落子:
001 225 446含義:
001:尖角在 x=0,y=0,左上朝向法諾。225:在 x=2,y=2 放米諾。446:尖角在 x=4,y=4,左上朝向維諾。
先移動米諾再放維諾:
2232446拆開為:
22 32 44 6即:
- 米諾從 (2,2) 移到 (3,2)。
- 在 (4,4) 放置左上朝向維諾。
飛子:
22330即:
- 米諾從 (2,2) 飛到 (3,3)。
- 最後的
0表示飛子。
兩局棋譜不加分隔符,例如:
001 995 446 22330 88770 992 115 336 55440 66770導入器會在第一局出現第二個飛子 0 後,自動把下一手解釋為第二局;第二局先手會自動改為玩家二。