代码: 全选
這幾個改進都非常切中要害!特別是把 `Date.now()` 的依賴去掉並將 ID 改為落子次序,不僅讓系統邏輯更加乾淨,還為「棋譜導出導入」功能打下了完美的基礎。使用 Undo/Redo Stack 的做法也遠比反覆 `JSON.parse` 效能更好且不會產生深拷貝帶來的副作用。
以下是為你精確編寫的代碼替換與修改方案。請依照步驟將原本的代碼替換掉:
### 一、 修改多國語言字典 (i18n)
在 `<script>` 頂部的 `const i18n = { ... }` 裡,加入以下四組新翻譯(用於「遊戲資訊」關閉按鈕與導出/導入面板):
```javascript
'ui-info-close': { zh: '關閉', en: 'Close' },
'ui-io-title': { zh: '棋譜導出 / 導入', en: 'Export / Import Notation' },
'ui-io-copy': { zh: '複製', en: 'Copy' },
'ui-io-confirm': { zh: '確認', en: 'Confirm' }
```
### 二、 更新 CSS 樣式 (關閉按鈕、恢復按鈕放大)
在 `<style>` 標籤中,找到 `.close-icon` 的部分,並增加寬高設定(因為從文字改為 SVG),同時把 `#btn-restore` 稍微放大:
```css
.close-icon {
position: absolute;
top: 10px;
right: 15px;
cursor: pointer;
color: #aaa;
width: 24px; /* 新增 */
height: 24px; /* 新增 */
}
.close-icon:hover {
color: white;
}
/* 放大恢復視窗按鈕 */
#btn-restore {
transform: scale(1.15);
}
```
### 三、 替換 HTML 結構 (SVG 圖標、視窗按鈕、導入導出面板)
**1. 替換所有 `.close-icon` 與導航箭頭 (◄ / ►)**
找到 `#rules-modal`、`#arb-result-dialog`、`#info-dialog` 裡面的 `<span class="close-icon"...>✖</span>`,並把 `#arb-nav-prev` 和 `#arb-nav-next` 的文字清空,統一換成 SVG:
```html
<svg class="close-icon" onclick="document.getElementById('rules-modal').style.display = 'none'" viewBox="0 0 24 24" style="z-index: 2100"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
<svg class="close-icon" onclick="document.getElementById('arb-result-dialog').style.display = 'none'" viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
<svg class="close-icon" onclick="document.getElementById('info-dialog').style.display = 'none'" viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
<button class="menu-btn" style="background: #555" onclick="document.getElementById('info-dialog').style.display = 'none'" id="ui-info-close">關閉</button>
<div id="arb-nav-prev" class="arb-nav-btn" onclick="arbNav(-1)">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z"/></svg>
</div>
<div id="arb-nav-next" class="arb-nav-btn" onclick="arbNav(1)">
<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>
</div>
```
**2. 替換工具列 `#toolbar` 中的 仲裁、恢復按鈕,並增加 導出/導入 按鈕**
在 `<div id="toolbar">` 中,找到仲裁和恢復的 `<svg>`,替換為新的,並在「下載」按鈕前插入導出按鈕:
```html
<svg class="tool-btn" id="btn-arbitrate" viewBox="0 0 24 24" onclick="requestArbitration()">
<path d="M5.2,12.7L2.4,15.5c-0.8,0.8-0.8,2,0,2.8l2.8,2.8c0.8,0.8,2,0.8,2.8,0l2.8-2.8L5.2,12.7z M20.9,4.3l-2.1-2.1c-0.6-0.6-1.5-0.6-2.1,0L13,5.8l4.2,4.2l3.7-3.7C21.5,5.8,21.5,4.9,20.9,4.3z M15.8,11.4l-4.2-4.2L6.6,12.2l4.2,4.2L15.8,11.4z"/>
</svg>
<svg class="tool-btn" id="btn-restore" viewBox="0 0 24 24" onclick="restoreWindow()">
<path d="M3 3h18v18H3V3zm2 2v14h14V5H5z"/>
<path id="restore-num" d="M11 15h2V9l-2 1v1l1-.5V15z"/>
</svg>
<svg class="tool-btn" id="btn-export-import" viewBox="0 0 24 24" onclick="openIO()">
<path d="M16 3l-4 4h3v7h2V7h3l-4-4zm-5 14H8v-7H6v7H3l4 4 4-4z"/>
</svg>
```
**3. 在 `<body>` 結尾前 (任意 dialog 旁邊) 加入導出導入面板**
```html
<div id="io-dialog" class="custom-dialog">
<div class="dialog-box" style="width: 420px; max-width: 90vw;">
<svg class="close-icon" onclick="document.getElementById('io-dialog').style.display = 'none'" viewBox="0 0 24 24"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
<h2 id="ui-io-title">棋譜導出 / 導入</h2>
<div style="display: flex; gap: 10px; margin-bottom: 15px; height: 75px;">
<textarea id="io-export-text" style="flex: 1; resize: none; border-radius: 5px; padding: 5px;" readonly></textarea>
<button class="menu-btn" style="margin: 0; height: 100%;" onclick="copyExport()" id="ui-io-copy">複製</button>
</div>
<div style="display: flex; gap: 10px; height: 75px;">
<textarea id="io-import-text" style="flex: 1; resize: none; border-radius: 5px; padding: 5px;"></textarea>
<button class="menu-btn" style="margin: 0; height: 100%; background: #43b581;" onclick="confirmImport()" id="ui-io-confirm">確認</button>
</div>
</div>
</div>
```
### 四、 JS 邏輯修改 (核心區塊)
**1. 新增全域變數** (加在 `let SCORING_MODE = 0;` 附近)
```javascript
let completedGamesNotation = ""; // 儲存第一局的完整棋譜
let arbEnded = false; // 記錄是否被仲裁終結
```
**2. 替換 `updateUI()` 中關於 `#btn-restore` 數字更新的邏輯**
在 `updateUI()` 中增加一行控制:
```javascript
let numPath = document.getElementById('restore-num');
if (numPath) {
numPath.setAttribute('d', gameNumber === 1 ? 'M11 15h2V9l-2 1v1l1-.5V15z' : 'M10 15h4v-1.5h-2.5v-.5l2.5-1.5V9h-4v1.5h2.5v.5L10 13v2z');
}
```
**3. 替換 `selectTile` 與 `commitGhost` 裡的 ID 生成邏輯**
在 `selectTile` 裡的第一手棋建立:
```javascript
tempPieces = [
{
id: pieces.length + tempPieces.length + 1, // 改為次序號
owner: currentPlayer,
type: SHAPE_MAP[tileId],
svgId: tileId,
isFlipped: isFlipped[tileId],
vertices: vertices,
parentId: null,
level: 0,
boardAngle: boardTransform.angle === 0 ? 360 : (boardTransform.angle || 360) // 記錄初始旋轉供導出
}
];
```
在 `commitGhost` 裡:
```javascript
function commitGhost(index) {
let gp = ghosts[index];
gp.id = pieces.length + tempPieces.length + 1; // 改為次序號
gp.targetId = targetOpponentPieceId;
// ... (保持原有代碼)
```
**4. 徹底重構 `saveState`、`undo`、`redo` 以及修正順序**
找到原來的這三個函數並完全替換,同時移除了所有的 `JSON.parse` 依賴,並加入自動彈出對應視窗的邏輯:
```javascript
function saveState() {
gameHistory.length = historyIndex + 1;
gameHistory.push({
newPieces: [...lastTurnPieces], // 淺拷貝陣列,只存參照
scores: { ...scores },
totalScores: { ...totalScores },
piecesCount: { ...piecesCount },
turnNumber: turnNumber,
currentPlayer: currentPlayer,
gameNumber: gameNumber,
startingPlayer: startingPlayer,
scoredVictims: new Set(scoredVictims),
boardAngle: boardTransform.angle,
arbEnded: arbEnded,
dialogMode: currentDialogMode // 記錄該狀態時的視窗情境
});
historyIndex++;
updateToolbar();
}
function undo() {
if (historyIndex > 0) {
let currentState = gameHistory[historyIndex];
historyIndex--;
let prevState = gameHistory[historyIndex];
// 從 pieces 中 pop 出對應數量的棋子
let removeCount = currentState.newPieces ? currentState.newPieces.length : 0;
for (let i = 0; i < removeCount; i++) pieces.pop();
scores = { ...prevState.scores };
totalScores = { ...prevState.totalScores };
piecesCount = { ...prevState.piecesCount };
turnNumber = prevState.turnNumber;
currentPlayer = prevState.currentPlayer;
gameNumber = prevState.gameNumber;
startingPlayer = prevState.startingPlayer;
boardTransform.angle = prevState.boardAngle || 0;
applyRotation();
scoredVictims = new Set(prevState.scoredVictims);
lastTurnPieces = prevState.newPieces ? [...prevState.newPieces] : [];
tempPieces = [];
ghosts = [];
selectedTile = null;
targetOpponentPieceId = null;
arbEnded = prevState.arbEnded;
currentDialogMode = prevState.dialogMode;
// 隱藏所有彈窗,再根據狀態決定開啟哪一個
document.querySelectorAll('.custom-dialog').forEach(d => d.style.display = 'none');
if (currentDialogMode === 'end-game') {
checkEndGame();
} else if (currentDialogMode === 'arbitration') {
let ways = arbValidMoves.length;
let msgEl = document.getElementById('ui-res-msg');
let waysEl = document.getElementById('ui-res-ways');
if (ways > 0) {
msgEl.innerText = i18n['arb-fail'][currentLang];
waysEl.innerText = i18n['arb-ways'][currentLang].replace('{n}', ways);
document.getElementById('arb-nav-prev').style.display = ways > 1 ? 'block' : 'none';
document.getElementById('arb-nav-next').style.display = ways > 1 ? 'block' : 'none';
applyArbMove(arbCurrentIndex);
} else {
msgEl.innerText = i18n['arb-success'][currentLang];
waysEl.innerText = i18n['arb-ways'][currentLang].replace('{n}', 0);
document.getElementById('arb-nav-prev').style.display = 'none';
document.getElementById('arb-nav-next').style.display = 'none';
}
document.getElementById('arb-result-dialog').style.display = 'flex';
}
updateTransform();
updateUI();
renderBoard();
updateToolbar();
}
}
function redo() {
if (historyIndex < gameHistory.length - 1) {
historyIndex++;
let nextState = gameHistory[historyIndex];
// 將重做的棋子 push 回 pieces
let piecesToAdd = nextState.newPieces ? [...nextState.newPieces] : [];
for (let p of piecesToAdd) pieces.push(p);
scores = { ...nextState.scores };
totalScores = { ...nextState.totalScores };
piecesCount = { ...nextState.piecesCount };
turnNumber = nextState.turnNumber;
currentPlayer = nextState.currentPlayer;
gameNumber = nextState.gameNumber;
startingPlayer = nextState.startingPlayer;
boardTransform.angle = nextState.boardAngle || 0;
applyRotation();
scoredVictims = new Set(nextState.scoredVictims);
lastTurnPieces = [...piecesToAdd];
tempPieces = [];
ghosts = [];
selectedTile = null;
targetOpponentPieceId = null;
arbEnded = nextState.arbEnded;
currentDialogMode = nextState.dialogMode;
document.querySelectorAll('.custom-dialog').forEach(d => d.style.display = 'none');
if (currentDialogMode === 'end-game') {
checkEndGame();
} else if (currentDialogMode === 'arbitration') {
let ways = arbValidMoves.length;
if (ways > 0) applyArbMove(arbCurrentIndex);
document.getElementById('arb-result-dialog').style.display = 'flex';
}
updateTransform();
updateUI();
renderBoard();
updateToolbar();
}
}
```
**5. 調整 `actionCheck` 和 `executeArbitration` 確保狀態正確存取**
在 `actionCheck()` 最末端,將原來的:
```javascript
lastTurnPieces = JSON.parse(JSON.stringify(tempPieces));
pieces = allNewP;
// ... (省略)
saveState();
checkEndGame();
```
更改為:
```javascript
pieces.push(...tempPieces);
lastTurnPieces = [...tempPieces];
tempPieces = [];
selectedTile = null;
targetOpponentPieceId = null;
ghosts = [];
if (allRings.length > 0) {
setTimeout(() => { allRings = []; renderBoard(); }, 1200);
}
turnNumber++;
currentPlayer = currentPlayer === 1 ? 2 : 1;
applyRotation();
updateUI();
renderBoard();
// 必須先判斷結局,再保存狀態(這樣才能將正確的 dialogMode 存入)
checkEndGame();
saveState();
```
在 `executeArbitration()` 裡,為了保證 Undo 可以正常返回沒彈窗的狀態,修改如下:
```javascript
function executeArbitration() {
document.getElementById('arb-confirm-dialog').style.display = 'none';
actionCross(false);
arbValidMoves = getAllValidMoves(currentPlayer);
let ways = arbValidMoves.length;
arbEnded = true;
currentDialogMode = 'arbitration';
saveState(); // 先保存為終局狀態,之後再顯示預覽棋子
let resDialog = document.getElementById('arb-result-dialog');
let msgEl = document.getElementById('ui-res-msg');
let waysEl = document.getElementById('ui-res-ways');
if (ways > 0) {
arbCurrentIndex = 0;
applyArbMove(arbCurrentIndex);
// ...(保持原有代碼)
```
**6. 更新 `restartFromArb`、`startGame` 與 `nextGame**`
由於 Arbitration 重來或者兩局轉換的資料清空,將它們加入清理的屬性中。
```javascript
function restartFromArb() {
// ... 原代碼 ...
arbEnded = false;
completedGamesNotation = "";
// ...
}
function startGame() {
// ... 原代碼 ...
arbEnded = false;
completedGamesNotation = "";
// ...
}
function nextGame() {
document.getElementById('end-game-dialog').style.display = 'none';
if (gameNumber === 1) {
completedGamesNotation = getGameNotation(pieces); // 進入第二局前存儲第一局棋譜
totalScores[1] = scores[1];
// ... 原代碼
```
**7. 加入棋譜導出/導入核心邏輯 (放在 JS 檔案的末尾即可)**
```javascript
function getSvgIdForPlayer(type, isFlipped, owner) {
if (owner === 1) {
if (type === 'B') return 'tile0';
if (type === 'A') return 'tile1';
if (type === 'C') return 'tile2';
} else {
if (type === 'B') return 'tile3';
if (type === 'A') return 'tile4';
if (type === 'C') return 'tile5';
}
}
function getGameNotation(pts) {
if (!pts || pts.length === 0) return "";
let res = [];
for (let i = 0; i < pts.length; i++) {
let p = pts[i];
let t;
if (p.type === 'B' && !p.isFlipped) t = 1;
else if (p.type === 'B' && p.isFlipped) t = 2;
else if (p.type === 'A' && !p.isFlipped) t = 3;
else if (p.type === 'A' && p.isFlipped) t = 4;
else if (p.type === 'C' && !p.isFlipped) t = 5;
else if (p.type === 'C' && p.isFlipped) t = 6;
if (i === 0) {
res.push(t + "" + (p.boardAngle || 360));
} else {
res.push(t + "" + (p.myEdge + 1) + "" + p.parentId + "" + (p.targetEdge + 1));
}
}
return res.join(" ");
}
function openIO() {
let cur = getGameNotation(pieces.concat(tempPieces));
let full = completedGamesNotation ? completedGamesNotation + (cur ? " " + cur : "") : cur;
if (arbEnded) full += " 0";
document.getElementById('io-export-text').value = full;
document.getElementById('io-dialog').style.display = 'flex';
}
function copyExport() {
let el = document.getElementById('io-export-text');
el.select();
document.execCommand('copy');
showMessage(currentLang === 'zh' ? '已複製' : 'Copied', 1500, true);
}
function confirmImport() {
let txt = document.getElementById('io-import-text').value.trim();
if (!txt) return;
document.getElementById('io-dialog').style.display = 'none';
document.getElementById('start-screen').style.display = 'none';
// 將遊戲狀態完整初始化
completedGamesNotation = "";
arbEnded = false;
gameNumber = 1;
totalScores = { 1: 0, 2: 0 };
startingPlayer = 1;
resetGame(false);
saveState();
let tokens = txt.split(/\s+/);
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (token === "0") {
arbEnded = true;
executeArbitration();
break;
}
let isGameStart = (pieces.length === 0 && tempPieces.length === 0);
let t = parseInt(token[0]);
let shapeType = (t === 1 || t === 2) ? 'B' : (t === 3 || t === 4) ? 'A' : 'C';
let isFlipped = (t % 2 === 0);
let svgId = getSvgIdForPlayer(shapeType, isFlipped, currentPlayer);
if (isGameStart) {
let ang = parseInt(token.substring(1));
boardTransform.angle = (ang === 360 ? 0 : ang);
let shape = SHAPES[SHAPE_MAP[svgId]];
let { vertices } = buildShapeVertices(ZERO, 0, shape, isFlipped);
let p = {
id: pieces.length + tempPieces.length + 1,
owner: currentPlayer,
type: shapeType,
svgId: svgId,
isFlipped: isFlipped,
vertices: vertices,
parentId: null,
level: 0,
boardAngle: ang
};
tempPieces.push(p);
piecesCount[svgId]--;
actionCheck(); // 自動落子驗算
} else {
let me = parseInt(token[1]) - 1;
let te = parseInt(token[token.length - 1]) - 1;
let pid = parseInt(token.substring(2, token.length - 1));
let tPiece = pieces.find(x => x.id === pid);
let res = attachByEdge(shapeType, isFlipped, me, tPiece.vertices[te], tPiece.vertices[(te + 1) % 4], !!tPiece.isFlipped);
let p = {
id: pieces.length + tempPieces.length + 1,
vertices: res.vertices,
type: shapeType,
owner: currentPlayer,
svgId: svgId,
isFlipped: isFlipped,
targetId: tPiece.id,
myEdge: me,
targetEdge: te,
parentId: tPiece.id,
level: tPiece.level + 1
};
tempPieces.push(p);
piecesCount[svgId]--;
let req = (turnNumber === 1 || pieces.length === N_PIECES * 6 - 1) ? 1 : 2;
if (tempPieces.length === req) {
actionCheck();
}
}
// 若導入過程中自動觸發了局數替換
if (pieces.length === N_PIECES * 6 && gameNumber === 1 && i < tokens.length - 1) {
nextGame();
}
}
}
```