共享会话
更改「下載」按鈕的功能
分享于 2026年9月12日 11:37更改「下載」按鈕的功能: 當點擊「下載」按鈕,將彈出一個「下載svg」窗口,這其右上角是一個關閉打叉svg圖標按鈕。
窗口內有標題(下載SVG),預覽區,選項區,下載按鈕。右上角是打叉關閉svg按鈕。
整個窗口的元素都要顯示在視圖內,不能超出屏幕。適配手機端。
預覽區將是當前棋盤svg的clone,保持高寬比為1:1。當窗口為了所有元素都顯示在視圖內,可擠壓預覽區,但要保證預覽的clone高寬比。
選項只有「導出動畫」的勾選項,水平居中,當勾選時,將按後面的步驟處理clone。樣式要美觀現代。
點擊下載按鈕將把預覽區中的svg下載下來,文件名為:靜態「fireandice_static_yyyymmdd_hhiiss.svg」,動畫「fireandice_animate_yyyymmdd_hhiiss.svg」。其下方用小字標明當前下載文件的大小(byte)。
導出動畫步驟:
一,去掉不需要的元素
去掉所有的marker相關的元素和圖層,如markerLayer,triple-marker,flame-marker等。
把metadata去掉。xmlns:xlink也要去掉,順帶檢查代碼,不應該生成xmlns:xlink。
把所有的pointer-events="none"去掉。把所有跟svg顯示無關的屬性去掉。
在g#pieceLayer中,去掉所有use棋子內的「」。
二,動畫流程
在g#pieceLayer內部的最下方增加「」。
為g#pieceLayer內的所有use棋子增加「移到第一次出現時的位置」的動畫「」,並增加消失的動畫「」。
第一個出現的火棋子要添加的動畫是「」和「」。
之後按行動順序,為每回合對應的棋子添加動畫。
從火方第一回合開始,所要添加的動畫是「」,n指的是第幾回合。同時也為這次行動所產生的棋子添加出現動畫「」。
如果本局有勝負結果,則為勝利層(victoryLayer)添加開始隱藏動畫「」,以及結束時顯示動畫「」,m指的是最後一回合。把boardBackground的 fill改為url("#boardNormal"),即「」,再加上某一方贏了set贏的那一方的fill,即「」。
最後在g#pieceLayer內部的最下方增加結束動畫「」。
把所有位移的坐標值都保留最多兩位小數。最後都不要有零,如果是兩個零,則連同小數點一起去掉。
下載按鈕不要100%寬,應跟開始頁面的「開始遊戲」按鈕寬度相同。如果其它窗口有按鈕是100%寬的也要調整到相同寬度。
回答要求:明確給出所要修改的地方和所要替換的代碼。
QuanAI以下修改均針對附件 ifif243.html。修改完成後:
- 點擊工具列「下載」不再直接下載,而是開啟「下載 SVG」視窗。
- 視窗包含標題、SVG 預覽、置中的「導出動畫」選項、下載按鈕及檔案大小。
- 預覽區會隨視窗壓縮,但 SVG 始終保持 1:1。
- 靜態檔名為
fireandice_static_yyyymmdd_hhiiss.svg。 - 動畫檔名為
fireandice_animate_yyyymmdd_hhiiss.svg。 - 動畫會根據目前
historyIndex之前的實際棋譜重播。 - 導出的 SVG 不再包含
xmlns:xlink、metadata、互動層及無關屬性。 - 所有動畫位移座標最多兩位小數,並自動去除小數末尾的零。
- 下載按鈕與開始頁面的「開始遊戲」按鈕同寬。
- 設置視窗的「重新開局」按鈕也調整為相同最大寬度。
一、移除棋盤上的 xmlns:xlink
找到:
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"替換為:
xmlns="http://www.w3.org/2000/svg"目前程式全部使用:
<use href="#...">並沒有使用 xlink:href,因此不需要 xmlns:xlink。
二、修改設置視窗按鈕寬度
找到原有 CSS:
.settings-actions {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-top: 19px;
}
.settings-actions button {
min-height: 45px;
border-radius: 12px;
cursor: pointer;
font-weight: 900;
}替換為:
.settings-actions {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 19px;
}
.settings-actions button {
width: min(320px, 100%);
min-height: 45px;
border-radius: 12px;
cursor: pointer;
font-weight: 900;
}這會使「重新開局」按鈕與「開始遊戲」按鈕使用相同的最大寬度 320px,不再填滿整個視窗。
三、加入下載 SVG 視窗樣式
在 CSS 中,建議放在 .record-action-button 樣式後面、#gameBoard .hit-slot 前面,加入:
.download-card {
width: min(620px, 100%);
height: min(760px, 100%);
max-height: 100%;
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
gap: clamp(10px, 2vh, 16px);
padding: clamp(14px, 3vw, 22px);
border: 1px solid rgba(255, 255, 255, 0.68);
border-radius: 24px;
background:
radial-gradient(circle at 10% 0%, rgba(113, 218, 250, 0.15), transparent 38%),
radial-gradient(circle at 100% 100%, rgba(239, 91, 55, 0.12), transparent 38%),
#f7fbfe;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.42);
}
.download-card .settings-header {
min-width: 0;
margin-bottom: 0;
}
.download-card .settings-header h2 {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.download-preview-shell {
min-width: 0;
min-height: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(7px, 1.5vw, 12px);
border: 1px solid rgba(34, 79, 105, 0.16);
border-radius: 18px;
background:
linear-gradient(45deg, rgba(31, 83, 115, 0.035) 25%, transparent 25%),
linear-gradient(-45deg, rgba(31, 83, 115, 0.035) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(31, 83, 115, 0.035) 75%),
linear-gradient(-45deg, transparent 75%, rgba(31, 83, 115, 0.035) 75%),
#eaf1f5;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-size: 16px 16px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 0 24px rgba(31, 79, 106, 0.07);
}
.download-preview {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.download-preview > svg {
display: block;
flex: 0 0 auto;
width: var(--download-preview-size, 100%);
height: var(--download-preview-size, 100%);
max-width: 100%;
max-height: 100%;
aspect-ratio: 1 / 1;
}
.download-option-row {
display: flex;
justify-content: center;
align-items: center;
}
.download-animation-option {
min-height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 9px 18px;
border: 1px solid rgba(31, 107, 145, 0.18);
border-radius: 999px;
color: #253b52;
background: rgba(255, 255, 255, 0.78);
box-shadow:
0 7px 18px rgba(24, 69, 96, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 900;
cursor: pointer;
user-select: none;
}
.download-animation-option:hover {
border-color: rgba(31, 126, 169, 0.34);
background: #fff;
}
.download-animation-option input {
width: 22px;
height: 22px;
margin: 0;
accent-color: #1e789f;
cursor: pointer;
}
.download-actions {
min-width: 0;
text-align: center;
}
.download-action-button {
width: min(320px, 100%);
min-height: 52px;
}
.download-file-size {
display: block;
min-height: 17px;
margin-top: 7px;
color: #697789;
text-align: center;
font-size: 11px;
line-height: 1.45;
font-variant-numeric: tabular-nums;
}
@media (max-width: 520px) {
.download-card {
gap: 9px;
padding: 12px;
border-radius: 19px;
}
.download-card .settings-header h2 {
font-size: 20px;
}
.download-card .close-button {
width: 36px;
height: 36px;
border-radius: 11px;
}
.download-preview-shell {
padding: 6px;
border-radius: 14px;
}
.download-animation-option {
min-height: 42px;
padding: 7px 15px;
font-size: 13px;
}
.download-action-button {
min-height: 48px;
border-radius: 14px;
font-size: 15px;
}
}
@media (max-height: 520px) {
.download-card {
gap: 7px;
padding: 10px 12px;
}
.download-card .settings-header h2 {
font-size: 19px;
}
.download-animation-option {
min-height: 38px;
padding: 5px 14px;
}
.download-action-button {
min-height: 42px;
}
.download-file-size {
margin-top: 3px;
}
}這個視窗使用固定的四列結構:
- 標題列
- 可壓縮的預覽列
- 動畫選項
- 下載按鈕及檔案大小
當手機高度不足時,只會壓縮第二列預覽區,其他元素仍會留在視圖內。
四、加入下載 SVG 視窗 HTML
找到 recordModal 的結尾:
</div>
<script id="mcts-worker-source" type="javascript/worker">在兩者之間加入:
<div id="downloadModal" class="modal-backdrop" hidden>
<section
class="download-card"
role="dialog"
aria-modal="true"
aria-labelledby="downloadSVGTitle"
>
<header class="settings-header">
<h2 id="downloadSVGTitle" data-i18n="downloadSVGTitle">下載 SVG</h2>
<button
id="closeDownloadButton"
type="button"
class="close-button"
data-i18n-title="close"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<use href="#icon-close"></use>
</svg>
</button>
</header>
<div class="download-preview-shell">
<div
id="downloadPreview"
class="download-preview"
role="img"
aria-label="SVG 預覽"
></div>
</div>
<div class="download-option-row">
<label class="download-animation-option">
<input id="exportAnimationCheckbox" type="checkbox" />
<span data-i18n="exportAnimation">導出動畫</span>
</label>
</div>
<div class="download-actions">
<button
id="confirmDownloadButton"
type="button"
class="start-button download-action-button"
data-i18n="downloadSVGButton"
>
下載 SVG
</button>
<small id="downloadFileSize" class="download-file-size">
檔案大小:0 byte
</small>
</div>
</section>
</div>下載按鈕直接重用 .start-button,因此寬度與開始頁面的「開始遊戲」按鈕完全相同。
五、加入中英文文字
1. 中文 I18N
在中文 I18N.zh 中找到:
downloadPosition: '下載',
settings: '設置',替換為:
downloadPosition: '下載',
downloadSVGTitle: '下載 SVG',
downloadSVGButton: '下載 SVG',
downloadFileSize: '檔案大小',
exportAnimation: '導出動畫',
settings: '設置',2. 英文 I18N
在英文 I18N.en 中找到:
downloadPosition: 'Download',
settings: 'Settings',替換為:
downloadPosition: 'Download',
downloadSVGTitle: 'Download SVG',
downloadSVGButton: 'Download SVG',
downloadFileSize: 'File size',
exportAnimation: 'Export animation',
settings: 'Settings',六、加入 XLink 命名空間常量
找到:
const SVG_NS = 'http://www.w3.org/2000/svg';替換為:
const SVG_NS = 'http://www.w3.org/2000/svg';
const XLINK_NS = 'http://www.w3.org/1999/xlink';這個常量只用來辨認並移除舊的 xlink:href 屬性,不會寫入導出的 SVG。
七、加入下載預覽狀態
找到:
let animatedHistoryIndex = -999;在其後加入:
let downloadSVGSource = '';
let downloadSVGByteSize = 0;八、替換原有下載函式
保留原來的 downloadBlob():
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}刪除原來整個:
function downloadPosition() {
...
}然後在 downloadBlob() 後面加入以下完整代碼:
function formatSVGCoordinate(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return '0';
}
const rounded = Math.round((number + Number.EPSILON) * 100) / 100;
return String(Object.is(rounded, -0) ? 0 : rounded);
}
function cellTranslateValue(index) {
const [x, y] = cellCoordinates(index);
return `${formatSVGCoordinate(x)} ${formatSVGCoordinate(y)}`;
}
function appendExportAnimation(parent, name, attributes) {
const animation = createSVGElement(name, attributes);
parent.appendChild(animation);
return animation;
}
function removeXLinkAttributes(root) {
[root, ...root.querySelectorAll('*')].forEach(element => {
Array.from(element.attributes).forEach(attribute => {
if (attribute.namespaceURI !== XLINK_NS) return;
/*
* 如果遇到舊的 xlink:href,先轉成現代的 href,
* 然後再移除 xlink 屬性。
*/
if (attribute.localName === 'href' && !element.hasAttribute('href')) {
element.setAttribute('href', attribute.value);
}
element.removeAttributeNS(XLINK_NS, attribute.localName);
});
});
root.removeAttribute('xmlns:xlink');
}
function removeNonDisplaySVGAttributes(root) {
const removableNames = new Set([
'class',
'role',
'tabindex',
'focusable',
'pointer-events'
]);
[root, ...root.querySelectorAll('*')].forEach(element => {
Array.from(element.attributes).forEach(attribute => {
const name = attribute.name.toLowerCase();
if (
removableNames.has(name) ||
name === 'xmlns:xlink' ||
name.startsWith('aria-') ||
name.startsWith('data-') ||
name.startsWith('on')
) {
element.removeAttribute(attribute.name);
}
});
});
root.removeAttribute('id');
root.removeAttribute('xmlns:xlink');
root.setAttribute('xmlns', SVG_NS);
}
function normalizeTranslateCoordinates(root) {
const numberPattern = '[-+]?(?:\\d*\\.\\d+|\\d+\\.?\\d*)(?:[eE][-+]?\\d+)?';
const translatePattern = new RegExp(
`translate\\(\\s*(${numberPattern})(?:[\\s,]+(${numberPattern}))?\\s*\\)`,
'g'
);
root.querySelectorAll('[transform]').forEach(element => {
const transform = element.getAttribute('transform');
const normalized = transform.replace(
translatePattern,
(match, x, y) => {
const normalizedX = formatSVGCoordinate(x);
if (y === undefined) {
return `translate(${normalizedX})`;
}
return `translate(${normalizedX} ${formatSVGCoordinate(y)})`;
}
);
element.setAttribute('transform', normalized);
});
root
.querySelectorAll('animateTransform[type="translate"]')
.forEach(animation => {
['from', 'to', 'by'].forEach(attributeName => {
if (!animation.hasAttribute(attributeName)) return;
const parts = animation
.getAttribute(attributeName)
.trim()
.split(/[\s,]+/)
.filter(Boolean)
.map(formatSVGCoordinate);
animation.setAttribute(attributeName, parts.join(' '));
});
if (animation.hasAttribute('values')) {
const values = animation
.getAttribute('values')
.split(';')
.map(value =>
value
.trim()
.split(/[\s,]+/)
.filter(Boolean)
.map(formatSVGCoordinate)
.join(' ')
)
.join(';');
animation.setAttribute('values', values);
}
});
}
function prepareBaseDownloadSVG() {
const clone = $('#gameBoard').cloneNode(true);
/*
* 命中層和可移動目標層屬於操作介面,
* 不應出現在靜態或動畫下載檔案內。
*/
['hitLayer', 'targetLayer'].forEach(id => {
clone.querySelector(`#${id}`)?.remove();
});
/*
* 靜態檔案不能保留目前介面的淡入、目標閃爍或勝利閃爍動畫。
* 動畫檔案稍後會重新加入完整棋譜動畫。
*/
clone
.querySelectorAll('animate, animateTransform, set')
.forEach(animation => animation.remove());
clone
.querySelectorAll('metadata')
.forEach(metadata => metadata.remove());
clone.setAttribute('width', '480');
clone.setAttribute('height', '480');
clone.setAttribute('viewBox', '0 0 480 480');
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
clone.setAttribute('xmlns', SVG_NS);
removeXLinkAttributes(clone);
removeNonDisplaySVGAttributes(clone);
return clone;
}
function removeAnimationMarkerElements(clone) {
/*
* 移除 markerLayer、triple-marker、flame-marker,
* 以及日後可能新增但 ID 中含 marker 的相關元素。
*/
Array.from(clone.querySelectorAll('[id]')).forEach(element => {
const id = element.getAttribute('id') || '';
if (id.toLowerCase().includes('marker')) {
element.remove();
}
});
/*
* 同時移除 SVG 原生的 <marker> 定義,避免遺漏。
*/
clone
.querySelectorAll('marker')
.forEach(marker => marker.remove());
}
function buildAnimationPieceTracks() {
const entries = history.slice(0, historyIndex + 1);
const initialState = entries[0]?.state || createInitialState();
const positionToTrack = new Map();
let trackSerial = 0;
/*
* 初始棋子。現有規則下只有中心位置的一枚火棋,
* 但這裡仍以通用方式掃描整個初始棋盤。
*/
for (let index = 0; index < 49; index++) {
const player = initialState.board[index];
if (!player) continue;
positionToTrack.set(index, {
serial: trackSerial++,
player,
firstPosition: index,
bornAt: 0,
moves: []
});
}
let turnNumber = 0;
for (let historyPosition = 1; historyPosition < entries.length; historyPosition++) {
const move = entries[historyPosition]?.move;
if (!move) continue;
turnNumber++;
const movedTrack = positionToTrack.get(move.from);
if (!movedTrack) {
throw new Error(`無法建立動畫:第 ${turnNumber} 回合找不到移動棋子。`);
}
movedTrack.moves.push({
turn: turnNumber,
from: move.from,
to: move.to
});
positionToTrack.delete(move.from);
positionToTrack.set(move.to, movedTrack);
/*
* 原位置生成對方棋子。
*/
positionToTrack.set(move.from, {
serial: trackSerial++,
player: -move.player,
firstPosition: move.from,
bornAt: turnNumber,
moves: []
});
}
return {
positionToTrack,
lastTurn: turnNumber
};
}
function addPieceReplayAnimations(clone) {
const pieceLayer = clone.querySelector('#pieceLayer');
if (!pieceLayer) return 0;
const { positionToTrack, lastTurn } = buildAnimationPieceTracks();
const pieceUses = Array.from(pieceLayer.children).filter(
element => element.localName === 'use'
);
let useCursor = 0;
let initialAnimationAdded = false;
/*
* renderPieces() 按棋盤索引由小到大建立 use,
* 因此用同樣順序將最後局面的 use 對應到棋子生命軌跡。
*/
for (let index = 0; index < 49; index++) {
if (!state.board[index]) continue;
const use = pieceUses[useCursor++];
const track = positionToTrack.get(index);
if (!use || !track) {
throw new Error(`無法建立動畫:位置 ${index} 的棋子資料不完整。`);
}
use.setAttribute('opacity', '1');
/*
* 每輪開始時:
* 1. 將最終局面的棋子全部移回它第一次出現的位置。
* 2. 在一秒內隱藏。
*/
appendExportAnimation(use, 'animateTransform', {
attributeName: 'transform',
type: 'translate',
to: cellTranslateValue(track.firstPosition),
begin: 'fistart.begin',
dur: '1s',
fill: 'freeze'
});
appendExportAnimation(use, 'animate', {
attributeName: 'opacity',
values: '1;0',
begin: 'fistart.begin',
dur: '1s',
fill: 'freeze'
});
if (track.bornAt === 0) {
/*
* 初始火棋出現。
*/
appendExportAnimation(use, 'animate', {
id: 'fi0',
attributeName: 'opacity',
values: '0;1',
begin: 'fistart.begin+1s',
dur: '1s',
fill: 'freeze'
});
appendExportAnimation(use, 'animateTransform', {
attributeName: 'transform',
type: 'scale',
from: '0',
to: '1',
begin: 'fi0.begin',
dur: '1s',
fill: 'freeze',
additive: 'sum'
});
initialAnimationAdded = true;
} else {
/*
* 每回合在原位置生成的對方棋子。
*/
appendExportAnimation(use, 'animate', {
attributeName: 'opacity',
values: '0;1',
begin: `fi${track.bornAt}.begin`,
dur: '1s',
fill: 'freeze'
});
appendExportAnimation(use, 'animateTransform', {
attributeName: 'transform',
type: 'scale',
from: '0',
to: '1',
begin: `fi${track.bornAt}.begin`,
dur: '1s',
fill: 'freeze',
additive: 'sum'
});
}
/*
* 為這枚棋子加入它在整局中經歷的所有移動。
* 每一回合只會有一枚棋子移動,因此 fi1、fi2……
* 在整個 SVG 中都是唯一 ID。
*/
track.moves.forEach(move => {
appendExportAnimation(use, 'animateTransform', {
id: `fi${move.turn}`,
attributeName: 'transform',
type: 'translate',
from: cellTranslateValue(move.from),
to: cellTranslateValue(move.to),
begin: `fi${move.turn - 1}.end+1s`,
dur: '1s',
fill: 'freeze'
});
});
}
if (!initialAnimationAdded) {
throw new Error('無法建立動畫:找不到初始火棋。');
}
/*
* 放在 pieceLayer 所有 use 的下方。
*/
appendExportAnimation(pieceLayer, 'animate', {
id: 'fistart',
attributeName: 'opacity',
values: '0;0;1',
begin: '0;fiend.end',
dur: '2s',
fill: 'freeze'
});
return lastTurn;
}
function addVictoryReplayAnimations(clone, lastTurn) {
if (state.winner !== 1 && state.winner !== -1) {
return;
}
const victoryLayer = clone.querySelector('#victoryLayer');
const boardBackground = clone.querySelector('#boardBackground');
if (victoryLayer) {
appendExportAnimation(victoryLayer, 'set', {
attributeName: 'opacity',
to: '0',
begin: 'fistart.begin'
});
appendExportAnimation(victoryLayer, 'animate', {
id: 'fivline',
attributeName: 'opacity',
values: '0;1;0.7;1;0.7;1;0.7;1;0',
begin: `fi${lastTurn}.end+0.777s`,
dur: '4s',
fill: 'freeze'
});
}
if (boardBackground) {
appendExportAnimation(boardBackground, 'set', {
attributeName: 'fill',
to: 'url(#boardNormal)',
begin: 'fistart.begin'
});
appendExportAnimation(boardBackground, 'set', {
attributeName: 'fill',
to: state.winner === 1
? 'url(#boardFireWin)'
: 'url(#boardIceWin)',
begin: 'fivline.begin'
});
}
}
function addReplayEndAnimation(clone, lastTurn) {
const pieceLayer = clone.querySelector('#pieceLayer');
if (!pieceLayer) return;
/*
* fiend 必須位於 pieceLayer 最下方。
* 它結束時會再次觸發 fistart,形成循環。
*/
appendExportAnimation(pieceLayer, 'animate', {
id: 'fiend',
attributeName: 'opacity',
values: '1;0',
begin: `fi${lastTurn}.end+5s`,
dur: '1s',
fill: 'freeze'
});
}
function buildDownloadSVG(animated) {
const clone = prepareBaseDownloadSVG();
if (animated) {
removeAnimationMarkerElements(clone);
const lastTurn = addPieceReplayAnimations(clone);
addVictoryReplayAnimations(clone, lastTurn);
addReplayEndAnimation(clone, lastTurn);
}
/*
* 包含靜態 translate 及動畫 from/to 的座標,
* 一律限制為最多兩位小數並移除末尾的零。
*/
normalizeTranslateCoordinates(clone);
/*
* 再檢查一次,確保新加入的動畫不帶入無關屬性,
* 且最終 SVG 不包含任何 xlink。
*/
removeXLinkAttributes(clone);
removeNonDisplaySVGAttributes(clone);
let serialized = new XMLSerializer().serializeToString(clone);
/*
* 防禦性清理。正常情況下前面的 DOM 清理已經不會產生
* xmlns:xlink,這裡避免舊瀏覽器序列化時重新補入。
*/
serialized = serialized.replace(
/\sxmlns:xlink=(["'])http:\/\/www\.w3\.org\/1999\/xlink\1/g,
''
);
const source =
`<?xml version="1.0" encoding="UTF-8"?>\n${serialized}`;
return {
svg: clone,
source
};
}
function updateDownloadFileSize() {
const element = $('#downloadFileSize');
if (!element) return;
const label = I18N[language].downloadFileSize;
const locale = language === 'zh' ? 'zh-Hant' : 'en-US';
element.textContent =
`${label}:${downloadSVGByteSize.toLocaleString(locale)} byte`;
}
function fitDownloadPreview() {
const preview = $('#downloadPreview');
if (!preview || $('#downloadModal').hidden) return;
const style = getComputedStyle(preview);
const availableWidth =
preview.clientWidth -
parseFloat(style.paddingLeft || 0) -
parseFloat(style.paddingRight || 0);
const availableHeight =
preview.clientHeight -
parseFloat(style.paddingTop || 0) -
parseFloat(style.paddingBottom || 0);
const size = Math.floor(
Math.max(0, Math.min(availableWidth, availableHeight))
);
if (size > 0) {
preview.style.setProperty(
'--download-preview-size',
`${size}px`
);
}
}
function refreshDownloadPreview() {
const animated = $('#exportAnimationCheckbox').checked;
try {
const result = buildDownloadSVG(animated);
downloadSVGSource = result.source;
downloadSVGByteSize = new Blob(
[downloadSVGSource],
{ type: 'image/svg+xml;charset=utf-8' }
).size;
$('#downloadPreview').replaceChildren(result.svg);
updateDownloadFileSize();
requestAnimationFrame(fitDownloadPreview);
} catch (error) {
console.error(error);
downloadSVGSource = '';
downloadSVGByteSize = 0;
$('#downloadPreview').replaceChildren();
updateDownloadFileSize();
}
}
function openDownloadModal() {
const modal = $('#downloadModal');
$('#exportAnimationCheckbox').checked = false;
modal.hidden = false;
refreshDownloadPreview();
$('#closeDownloadButton').focus();
}
function closeDownloadModal() {
$('#downloadModal').hidden = true;
/*
* 移除預覽 SVG,停止預覽中的循環動畫,
* 同時避免關閉後仍保留重複的 SVG ID。
*/
$('#downloadPreview').replaceChildren();
downloadSVGSource = '';
downloadSVGByteSize = 0;
$('#downloadButton').focus();
}
function padDownloadDatePart(value) {
return String(value).padStart(2, '0');
}
function makeDownloadTimestamp(date = new Date()) {
return (
`${date.getFullYear()}` +
`${padDownloadDatePart(date.getMonth() + 1)}` +
`${padDownloadDatePart(date.getDate())}_` +
`${padDownloadDatePart(date.getHours())}` +
`${padDownloadDatePart(date.getMinutes())}` +
`${padDownloadDatePart(date.getSeconds())}`
);
}
function confirmDownloadSVG() {
if (!downloadSVGSource) {
refreshDownloadPreview();
}
if (!downloadSVGSource) return;
const animated = $('#exportAnimationCheckbox').checked;
const mode = animated ? 'animate' : 'static';
const filename =
`fireandice_${mode}_${makeDownloadTimestamp()}.svg`;
const blob = new Blob(
[downloadSVGSource],
{ type: 'image/svg+xml;charset=utf-8' }
);
downloadBlob(blob, filename);
showNotice(I18N[language].positionSaved);
}九、動畫輸出的棋子結構說明
上述代碼不只是按照最終位置匹配棋子,而是會為每枚棋子建立完整生命軌跡:
- 初始中心火棋建立一個 track。
- 每回合:
- 原棋子的 track 從
from移至to。 from位置建立一個新的對方棋子 track。
- 原棋子的 track 從
- 最後依照目前棋盤上的位置,將最終 SVG 中每個
<use>對應回其 track。 - 因此同一枚棋子經歷多次移動時,所有位移動畫仍會加在同一個
<use>上。 - 如果目前使用過回退,只會導出
historyIndex之前的步驟,不會包含已回退掉的未來步驟。
動畫時間關係為:
fistart
→ fi0 初始火棋出現
→ fi1 第一回合移動及新棋出現
→ fi2 第二回合移動及新棋出現
→ ...
→ fi(m) 最後一回合
→ 勝利線及勝方背景
→ fiend
→ fistart 重新開始十、修改語言切換後的檔案大小文字
在 applyTranslations() 函式末尾找到:
updateCustomTimeLabels();替換為:
updateCustomTimeLabels();
if (downloadSVGSource) {
updateDownloadFileSize();
}這樣在下載視窗開啟期間切換語言時,「檔案大小」也會更新語言。
十一、替換下載按鈕事件
在 bindEvents() 中找到:
$('#downloadButton').addEventListener('click', downloadPosition);替換為:
$('#downloadButton').addEventListener('click', openDownloadModal);
$('#closeDownloadButton').addEventListener('click', closeDownloadModal);
$('#exportAnimationCheckbox').addEventListener(
'change',
refreshDownloadPreview
);
$('#confirmDownloadButton').addEventListener(
'click',
confirmDownloadSVG
);
$('#downloadModal').addEventListener('click', event => {
if (event.target === $('#downloadModal')) {
closeDownloadModal();
}
});十二、修改 Escape 關閉順序
在鍵盤事件中找到:
if (event.key === 'Escape') {
if (!$('#settingsModal').hidden) {
closeSettings();
} else if (!$('#rulesModal').hidden) {
closeRules();
} else if (!$('#recordModal').hidden) {
closeRecordModal();
} else if (selectedCell >= 0) {
selectedCell = -1;
renderBoard();
renderStatus();
}
return;
}替換為:
if (event.key === 'Escape') {
if (!$('#downloadModal').hidden) {
closeDownloadModal();
} else if (!$('#settingsModal').hidden) {
closeSettings();
} else if (!$('#rulesModal').hidden) {
closeRules();
} else if (!$('#recordModal').hidden) {
closeRecordModal();
} else if (selectedCell >= 0) {
selectedCell = -1;
renderBoard();
renderStatus();
}
return;
}十三、加入下載預覽區 ResizeObserver
在 bindEvents() 中找到:
if ('ResizeObserver' in window) {
new ResizeObserver(fitBoard).observe($('#boardWrap'));
} else {
window.addEventListener('resize', fitBoard);
}
window.visualViewport?.addEventListener('resize', fitBoard);替換為:
if ('ResizeObserver' in window) {
const boardResizeObserver = new ResizeObserver(fitBoard);
boardResizeObserver.observe($('#boardWrap'));
const downloadResizeObserver =
new ResizeObserver(fitDownloadPreview);
downloadResizeObserver.observe($('#downloadPreview'));
} else {
window.addEventListener('resize', () => {
fitBoard();
fitDownloadPreview();
});
}
window.visualViewport?.addEventListener('resize', () => {
fitBoard();
fitDownloadPreview();
});這樣手機地址列收合、橫豎屏切換、軟鍵盤或視口高度變化時,下載預覽都會重新計算正方形大小。
十四、最終應刪除的舊代碼
確認以下舊函式已完整刪除,不能與新函式同時存在:
function downloadPosition() {
const original = $('#gameBoard');
const clone = original.cloneNode(true);
['hitLayer', 'targetLayer'].forEach(id => {
clone.querySelector(`#${id}`)?.remove();
});
clone.setAttribute('width', '480');
clone.setAttribute('height', '480');
clone.setAttribute('xmlns', SVG_NS);
clone.removeAttribute('aria-label');
clone.removeAttribute('role');
const metadata = document.createElementNS(SVG_NS, 'metadata');
metadata.textContent = JSON.stringify({
game: 'Fire and Ice',
ply: state.ply,
turn: state.turn,
winner: state.winner,
board: Array.from(state.board),
islandControl: Array.from(state.claims)
});
clone.insertBefore(metadata, clone.firstChild);
const source =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
new XMLSerializer().serializeToString(clone);
downloadBlob(
new Blob([source], {
type: 'image/svg+xml;charset=utf-8'
}),
`fire-and-ice-position-${state.ply}.svg`
);
showNotice(I18N[language].positionSaved);
}舊的事件綁定也必須刪除:
$('#downloadButton').addEventListener('click', downloadPosition);十五、導出的 SVG 清理結果
靜態 SVG
靜態檔案會移除:
hitLayer
targetLayer
metadata
animate
animateTransform
set
pointer-events
class
role
aria-*
data-*
tabindex
focusable
事件屬性 on*
xmlns:xlink
xlink:href靜態檔案仍保留:
- 當前棋盤。
- 當前棋子。
- 當前島嶼控制顏色。
- 當前回合 marker。
- 如果已分勝負,保留最後的勝利線和勝方背景。
- 所有真正影響 SVG 顯示的
fill、stroke、filter、opacity、transform等屬性。
動畫 SVG
動畫檔案在上述清理之外,還會移除:
markerLayer
triple-marker
flame-marker
所有 ID 名稱含 marker 的元素
SVG 原生 marker 元素並重新加入:
<animate id="fistart" ... />
<animate id="fi0" ... />
<animateTransform id="fi1" ... />
<animateTransform id="fi2" ... />
...
<animate id="fivline" ... />
<animate id="fiend" ... />座標會輸出為這類格式:
from="240 285.63"
to="217.27 272.5"不會輸出:
from="240.00 285.6250"
to="217.270 272.50"最終檔名示例:
fireandice_static_20260912_192716.svg或:
fireandice_animate_20260912_192716.svg