共享会话
更改「下載」按鈕的功能
分享于 2026年9月12日 01:06更改「下載」按鈕的功能: 當點擊「下載」按鈕,將彈出一個「下載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指的是最後一回合。
最後在g#pieceLayer內部的最下方增加結束動畫「」。
回答要求:明確給出所要修改的地方和所要替換的代碼。
QuanAI下面按順序修改即可;MCTS Worker 及棋局邏輯不需要改動。
1. 刪除棋盤上的 xmlns:xlink
找到 #gameBoard:
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。
2. 增加下載窗口樣式
在現有 CSS 的:
@media (prefers-reduced-motion: reduce) {之前插入:
.download-card {
width: min(620px, 100%);
height: min(760px, calc(100vh - 28px));
height: min(760px, calc(100dvh - 28px));
max-height: 100%;
min-height: 0;
overflow: hidden;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto auto;
gap: 10px;
padding: clamp(12px, 2.8vmin, 22px);
}
.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;
border: 1px solid #cbd9e2;
border-radius: 16px;
padding: 8px;
background:
linear-gradient(45deg, #edf2f5 25%, transparent 25%) 0 0 / 18px 18px,
linear-gradient(-45deg, #edf2f5 25%, transparent 25%) 0 9px / 18px 18px,
linear-gradient(45deg, transparent 75%, #edf2f5 75%) 9px -9px / 18px 18px,
linear-gradient(-45deg, transparent 75%, #edf2f5 75%) -9px 0 / 18px 18px,
#fff;
}
.download-preview-frame {
flex: 0 0 auto;
width: 1px;
height: 1px;
overflow: hidden;
border-radius: 13px;
box-shadow: 0 8px 24px rgba(20, 43, 61, 0.2);
background: #fff;
}
.download-preview-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.download-option {
min-width: 0;
min-height: 44px;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border: 1px solid #d6e1e8;
border-radius: 12px;
color: #293b51;
background: #f1f7fa;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.download-option input {
flex: 0 0 auto;
width: 22px;
height: 22px;
margin: 0;
accent-color: #1e789f;
cursor: pointer;
}
.download-svg-button {
width: 100%;
min-height: 48px;
border-radius: 13px;
color: #fff;
background: linear-gradient(100deg, #1e8ebe, #194e79 48%, #c52e20 52%, #ed5337);
box-shadow: 0 9px 22px rgba(23, 67, 99, 0.25);
font-size: 15px;
font-weight: 950;
cursor: pointer;
}
.download-svg-button:hover {
filter: brightness(1.08);
}
.download-svg-button:active {
transform: translateY(1px);
}
.download-file-size {
min-width: 0;
min-height: 15px;
display: block;
overflow: hidden;
color: #687789;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
line-height: 1.35;
}
@media (max-width: 520px), (max-height: 620px) {
.download-card {
gap: 7px;
padding: 11px;
}
.download-card .settings-header h2 {
font-size: 19px;
}
.download-card .close-button {
width: 34px;
height: 34px;
border-radius: 10px;
}
.download-preview-shell {
padding: 5px;
border-radius: 12px;
}
.download-preview-frame {
border-radius: 9px;
}
.download-option {
min-height: 38px;
padding: 6px 9px;
font-size: 13px;
}
.download-option input {
width: 20px;
height: 20px;
}
.download-svg-button {
min-height: 42px;
}
}下載卡片使用 Grid,中間預覽區是唯一可被壓縮的部分,因此標題、選項、按鈕和文件大小不會超出視圖。
3. 增加「下載SVG」窗口 HTML
在 #recordModal 結束標籤之後、mcts-worker-source 之前插入:
<div id="downloadModal" class="modal-backdrop" hidden>
<section
class="settings-card 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 id="downloadPreviewShell" class="download-preview-shell">
<div id="downloadPreviewFrame" class="download-preview-frame">
<img
id="downloadPreviewImage"
class="download-preview-image"
alt="SVG 預覽"
/>
</div>
</div>
<label class="download-option">
<input id="exportAnimationCheckbox" type="checkbox" />
<span data-i18n="exportAnimation">導出動畫</span>
</label>
<button
id="confirmDownloadButton"
type="button"
class="download-svg-button"
data-i18n="downloadSvgAction"
>
下載
</button>
<small id="downloadFileSize" class="download-file-size" aria-live="polite">
當前下載文件大小:0 byte
</small>
</section>
</div>預覽使用由棋盤 clone 序列化後產生的 Blob URL,不會把第二個帶有重複 ID 的 SVG 直接插入頁面。
4. 增加翻譯文字
中文
在 I18N.zh 的:
positionSaved: '局面 SVG 已下載',後面增加:
downloadSvgTitle: '下載SVG',
exportAnimation: '導出動畫',
downloadSvgAction: '下載',
downloadFileSize: '當前下載文件大小',
downloadPreviewAlt: 'SVG 預覽',英文
在 I18N.en 的:
positionSaved: 'Position SVG downloaded',後面增加:
downloadSvgTitle: 'Download SVG',
exportAnimation: 'Export animation',
downloadSvgAction: 'Download',
downloadFileSize: 'Current download size',
downloadPreviewAlt: 'SVG preview',5. 增加 SVG namespace 常量
找到:
const SVG_NS = 'http://www.w3.org/2000/svg';替換為:
const SVG_NS = 'http://www.w3.org/2000/svg';
const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
const XLINK_NS = 'http://www.w3.org/1999/xlink';6. 增加下載預覽狀態變量
在:
let aiProgress = { elapsed: 0, iterations: 0 };後面增加:
let downloadPreviewURL = null;
let downloadPreviewSource = '';
let downloadPreviewBlob = null;
let downloadPreviewSize = 0;
let downloadPreviewRevision = '';
let downloadPreviewAnimated = false;7. 為棋子增加臨時位置資料
找到 renderPieces() 中建立棋子的部分:
const use = createSVGElement('use', {
href: player === 1 ? '#fire-piece' : '#ice-piece',
transform: `translate(${x} ${y})`,
opacity: '1'
});替換為:
const use = createSVGElement('use', {
href: player === 1 ? '#fire-piece' : '#ice-piece',
transform: `translate(${x} ${y})`,
opacity: '1',
'data-cell': index
});data-cell 只用於將最終 SVG 棋子對應到棋譜中的棋子。導出時會自動移除,不會保留在下載文件內。
8. 替換原來的 downloadPosition()
保留現有的 downloadBlob() 不變。
把整個舊函式:
function downloadPosition() {
...
}替換成以下代碼:
function appendExportAnimation(parent, elementName, attributes) {
const animation = document.createElementNS(SVG_NS, elementName);
for (const [name, value] of Object.entries(attributes)) {
animation.setAttribute(name, String(value));
}
parent.appendChild(animation);
return animation;
}
function removeSVGAttribute(element, attribute) {
if (attribute.namespaceURI) {
element.removeAttributeNS(attribute.namespaceURI, attribute.localName);
} else {
element.removeAttribute(attribute.name);
}
}
function removeExistingSVGAnimations(svg) {
const animationNames = new Set([
'animate',
'animatetransform',
'animatemotion',
'set'
]);
Array.from(svg.querySelectorAll('*')).forEach(element => {
const localName = String(element.localName || '').toLowerCase();
if (animationNames.has(localName)) {
element.remove();
}
});
}
function removeMarkerRelatedElements(svg) {
const elements = Array.from(svg.querySelectorAll('*'));
elements.forEach(element => {
const localName = String(element.localName || '').toLowerCase();
const id = String(element.getAttribute('id') || '').toLowerCase();
const classNames = String(element.getAttribute('class') || '')
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
const markerRelated =
localName === 'marker' ||
id.includes('marker') ||
classNames.some(className => className.includes('marker'));
if (markerRelated) {
element.remove();
}
});
Array.from(svg.querySelectorAll('*')).forEach(element => {
['marker-start', 'marker-mid', 'marker-end'].forEach(attributeName => {
element.removeAttribute(attributeName);
});
});
}
function stripNonVisualSVGAttributes(svg) {
const elements = [svg, ...Array.from(svg.querySelectorAll('*'))];
elements.forEach(element => {
Array.from(element.attributes).forEach(attribute => {
const name = attribute.name.toLowerCase();
/*
* 如果日後代碼中出現 xlink:href,先轉換成標準 href,
* 然後刪除所有 xlink namespace 和 xlink 屬性。
*/
if (
attribute.namespaceURI === XLINK_NS ||
name.startsWith('xlink:')
) {
if (
attribute.localName === 'href' &&
!element.hasAttribute('href')
) {
element.setAttribute('href', attribute.value);
}
removeSVGAttribute(element, attribute);
return;
}
if (
name === 'xmlns:xlink' ||
(attribute.namespaceURI === XMLNS_NS &&
attribute.localName === 'xlink')
) {
removeSVGAttribute(element, attribute);
return;
}
const shouldRemove =
name === 'pointer-events' ||
name === 'role' ||
name === 'tabindex' ||
name === 'focusable' ||
name === 'draggable' ||
name === 'contenteditable' ||
name === 'class' ||
name.startsWith('aria-') ||
name.startsWith('data-') ||
name.startsWith('on');
if (shouldRemove) {
removeSVGAttribute(element, attribute);
}
});
});
svg.removeAttribute('xmlns:xlink');
svg.removeAttributeNS(XMLNS_NS, 'xlink');
}
function createBaseDownloadSVG() {
const clone = $('#gameBoard').cloneNode(true);
/*
* hitLayer 沒有顯示內容,targetLayer 是操作提示,
* 二者均不屬於下載局面。
*/
['hitLayer', 'targetLayer'].forEach(id => {
clone.querySelector(`#${id}`)?.remove();
});
/*
* 不輸出 metadata,並刪除目前棋盤中為畫面更新而產生的
* opacity、勝利線循環等動畫。
*/
clone.querySelectorAll('metadata').forEach(metadata => {
metadata.remove();
});
removeExistingSVGAnimations(clone);
/*
* renderPieces() 剛執行時可能仍保留 opacity=".12",
* 導出時直接還原為完整可見狀態。
*/
const pieceLayer = clone.querySelector('#pieceLayer');
if (pieceLayer) {
Array.from(pieceLayer.children).forEach(element => {
if (String(element.localName).toLowerCase() === 'use') {
element.removeAttribute('opacity');
}
});
}
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);
return clone;
}
/*
* 按棋譜重建每枚棋子的身份。
*
* 一枚棋子移走後,原位置會產生一枚對方棋子,因此每一回合:
* 1. 原棋子從 from 移到 to。
* 2. 在 from 建立一枚新的對方棋子。
*
* 最後可將每個棋子身份對應到最終棋盤上的 use 元素。
*/
function traceDownloadAnimationPieces() {
const initialEntry = history[0];
if (!initialEntry?.state?.board) {
throw new Error('Missing initial game state.');
}
const cells = new Array(49).fill(null);
const entities = [];
const initialEntities = [];
const movedByTurn = new Map();
const createdByTurn = new Map();
let nextEntityId = 0;
for (let cell = 0; cell < 49; cell++) {
const player = Number(initialEntry.state.board[cell]);
if (!player) continue;
const entity = {
id: nextEntityId++,
player,
firstCell: cell,
createdAt: 0,
finalCell: cell,
moves: []
};
cells[cell] = entity;
entities.push(entity);
initialEntities.push(entity);
}
for (let turnNumber = 1; turnNumber <= historyIndex; turnNumber++) {
const move = history[turnNumber]?.move;
if (
!move ||
!Number.isInteger(move.from) ||
!Number.isInteger(move.to)
) {
throw new Error(`Missing move at turn ${turnNumber}.`);
}
const movingEntity = cells[move.from];
if (!movingEntity) {
throw new Error(`Missing moving piece at turn ${turnNumber}.`);
}
if (cells[move.to]) {
throw new Error(`Destination is occupied at turn ${turnNumber}.`);
}
const player =
move.player === 1 || move.player === -1
? move.player
: movingEntity.player;
if (movingEntity.player !== player) {
throw new Error(`Invalid moving player at turn ${turnNumber}.`);
}
movingEntity.moves.push({
turnNumber,
from: move.from,
to: move.to
});
cells[move.to] = movingEntity;
movedByTurn.set(turnNumber, movingEntity);
const createdEntity = {
id: nextEntityId++,
player: -player,
firstCell: move.from,
createdAt: turnNumber,
finalCell: move.from,
moves: []
};
cells[move.from] = createdEntity;
entities.push(createdEntity);
createdByTurn.set(turnNumber, createdEntity);
}
cells.forEach((entity, cell) => {
if (entity) {
entity.finalCell = cell;
}
const actualPlayer = entity ? entity.player : 0;
const expectedPlayer = Number(state.board[cell]);
if (actualPlayer !== expectedPlayer) {
throw new Error(`Animation piece mismatch at cell ${cell}.`);
}
});
const initialFire = initialEntities.find(entity => entity.player === 1);
if (!initialFire) {
throw new Error('Missing initial fire piece.');
}
return {
cells,
entities,
initialEntities,
initialFire,
movedByTurn,
createdByTurn
};
}
function addDownloadAnimation(svg) {
removeMarkerRelatedElements(svg);
const pieceLayer = svg.querySelector('#pieceLayer');
if (!pieceLayer) {
throw new Error('Missing pieceLayer.');
}
const trace = traceDownloadAnimationPieces();
const useByCell = new Map();
const useByEntity = new Map();
Array.from(pieceLayer.children).forEach(element => {
if (String(element.localName).toLowerCase() !== 'use') return;
const cell = Number(element.getAttribute('data-cell'));
if (Number.isInteger(cell)) {
useByCell.set(cell, element);
}
});
trace.cells.forEach((entity, finalCell) => {
if (!entity) return;
const use = useByCell.get(finalCell);
if (!use) {
throw new Error(`Missing SVG piece at cell ${finalCell}.`);
}
const [finalX, finalY] = cellCoordinates(finalCell);
use.setAttribute(
'transform',
`translate(${finalX} ${finalY})`
);
useByEntity.set(entity, use);
});
/*
* 所有棋子先回到第一次出現的位置,並縮小至 0。
*/
trace.entities.forEach(entity => {
const use = useByEntity.get(entity);
if (!use) {
throw new Error(`Missing SVG entity ${entity.id}.`);
}
const [firstX, firstY] = cellCoordinates(entity.firstCell);
appendExportAnimation(use, 'animateTransform', {
attributeName: 'transform',
type: 'translate',
to: `${firstX} ${firstY}`,
begin: 'fistart.begin',
dur: '1s',
fill: 'freeze'
});
appendExportAnimation(use, 'animateTransform', {
attributeName: 'transform',
type: 'scale',
to: '0',
begin: 'fistart.begin',
dur: '1s',
fill: 'freeze',
additive: 'sum'
});
});
/*
* 第一枚火棋:fi0。
*/
const initialFireUse = useByEntity.get(trace.initialFire);
appendExportAnimation(initialFireUse, 'animateTransform', {
id: 'fi0',
attributeName: 'transform',
type: 'scale',
to: '1',
begin: 'fistart.end',
dur: '1s',
fill: 'freeze',
additive: 'sum'
});
/*
* 正常遊戲只有一枚初始火棋。
* 如果日後初始盤面增加其他棋子,讓它們與 fi0 同時出現。
*/
trace.initialEntities.forEach(entity => {
if (entity === trace.initialFire) return;
appendExportAnimation(
useByEntity.get(entity),
'animateTransform',
{
attributeName: 'transform',
type: 'scale',
to: '1',
begin: 'fi0.begin',
dur: '1s',
fill: 'freeze',
additive: 'sum'
}
);
});
/*
* fi1、fi2……依照 history 的行動順序產生。
*/
for (let turnNumber = 1; turnNumber <= historyIndex; turnNumber++) {
const movingEntity = trace.movedByTurn.get(turnNumber);
const createdEntity = trace.createdByTurn.get(turnNumber);
if (!movingEntity || !createdEntity) {
throw new Error(`Incomplete animation turn ${turnNumber}.`);
}
const move = movingEntity.moves.find(
item => item.turnNumber === turnNumber
);
if (!move) {
throw new Error(`Missing animation move ${turnNumber}.`);
}
const movingUse = useByEntity.get(movingEntity);
const createdUse = useByEntity.get(createdEntity);
const [fromX, fromY] = cellCoordinates(move.from);
const [toX, toY] = cellCoordinates(move.to);
appendExportAnimation(movingUse, 'animateTransform', {
id: `fi${turnNumber}`,
attributeName: 'transform',
type: 'translate',
from: `${fromX} ${fromY}`,
to: `${toX} ${toY}`,
begin: `fi${turnNumber - 1}.end+1s`,
dur: '1s',
fill: 'freeze'
});
appendExportAnimation(createdUse, 'animateTransform', {
attributeName: 'transform',
type: 'scale',
to: '1',
begin: `fi${turnNumber}.begin`,
dur: '1s',
fill: 'freeze',
additive: 'sum'
});
}
/*
* pieceLayer 最下方的開始動畫。
*/
appendExportAnimation(pieceLayer, 'animate', {
id: 'fistart',
attributeName: 'opacity',
values: '0;0;1',
begin: '0;fiend.end',
dur: '2s',
fill: 'freeze'
});
const lastAnimationId =
historyIndex > 0 ? `fi${historyIndex}` : 'fi0';
/*
* 有火/冰勝者時處理 victoryLayer。
* 和局不會生成勝利線。
*/
if (state.winner === 1 || state.winner === -1) {
const victoryLayer = svg.querySelector('#victoryLayer');
if (victoryLayer) {
appendExportAnimation(victoryLayer, 'animate', {
attributeName: 'opacity',
values: '1;0',
begin: 'fistart.begin',
dur: '1s',
fill: 'freeze'
});
appendExportAnimation(victoryLayer, 'animate', {
id: 'fivline',
attributeName: 'opacity',
values: '0;1;0.7;1;0.7;1;0.7;1;0',
begin: `${lastAnimationId}.end`,
dur: '5s',
fill: 'freeze'
});
}
}
/*
* pieceLayer 最下方的結束動畫。
* 動畫完成後由 fistart 的 fiend.end 再次開始。
*/
appendExportAnimation(pieceLayer, 'animate', {
id: 'fiend',
attributeName: 'opacity',
values: '1;0',
begin: `${lastAnimationId}.end+5s`,
dur: '1s',
fill: 'freeze'
});
}
function createDownloadSVGSource(animated) {
const clone = createBaseDownloadSVG();
if (animated) {
addDownloadAnimation(clone);
}
/*
* 動畫已建立完成後才清理 data-cell、ARIA、class、
* pointer-events 和 xlink 等非顯示屬性。
*/
stripNonVisualSVGAttributes(clone);
clone.removeAttribute('id');
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);
clone.removeAttribute('xmlns:xlink');
clone.removeAttributeNS(XMLNS_NS, 'xlink');
const serialized = new XMLSerializer().serializeToString(clone);
/*
* 這裡只作檢查,不以字符串猜測或補寫 namespace。
*/
if (
serialized.includes('xmlns:xlink=') ||
serialized.includes('xlink:href=')
) {
throw new Error('Unexpected xlink namespace in exported SVG.');
}
return (
'<?xml version="1.0" encoding="UTF-8"?>\n' +
serialized
);
}
function currentDownloadRevision() {
return [
historyIndex,
state.ply,
state.turn,
state.winner,
selectedCell,
buildRecordNotation(),
Array.from(state.board).join(','),
Array.from(state.claims).join(',')
].join('|');
}
function revokeDownloadPreviewURL() {
if (!downloadPreviewURL) return;
URL.revokeObjectURL(downloadPreviewURL);
downloadPreviewURL = null;
}
function updateDownloadFileSize() {
const output = $('#downloadFileSize');
if (!output) return;
const dictionary = I18N[language] || I18N.zh;
const formattedSize = Number(downloadPreviewSize).toLocaleString(
language === 'zh' ? 'zh-Hant' : 'en-US'
);
output.textContent =
`${dictionary.downloadFileSize}:${formattedSize} byte`;
}
function fitDownloadPreview() {
const modal = $('#downloadModal');
if (!modal || modal.hidden) return;
const shell = $('#downloadPreviewShell');
const frame = $('#downloadPreviewFrame');
if (!shell || !frame) return;
const size = Math.max(
1,
Math.floor(
Math.min(shell.clientWidth, shell.clientHeight)
)
);
frame.style.width = `${size}px`;
frame.style.height = `${size}px`;
}
function refreshDownloadPreview() {
const animated = $('#exportAnimationCheckbox').checked;
const source = createDownloadSVGSource(animated);
const blob = new Blob([source], {
type: 'image/svg+xml;charset=utf-8'
});
revokeDownloadPreviewURL();
downloadPreviewSource = source;
downloadPreviewBlob = blob;
downloadPreviewSize = blob.size;
downloadPreviewRevision = currentDownloadRevision();
downloadPreviewAnimated = animated;
downloadPreviewURL = URL.createObjectURL(blob);
const image = $('#downloadPreviewImage');
image.alt = I18N[language].downloadPreviewAlt;
image.onload = () => {
fitDownloadPreview();
};
image.src = downloadPreviewURL;
updateDownloadFileSize();
requestAnimationFrame(fitDownloadPreview);
}
function openDownloadModal() {
const modal = $('#downloadModal');
const checkbox = $('#exportAnimationCheckbox');
checkbox.checked = false;
modal.hidden = false;
refreshDownloadPreview();
requestAnimationFrame(() => {
fitDownloadPreview();
$('#closeDownloadButton').focus();
});
}
function closeDownloadModal() {
$('#downloadModal').hidden = true;
revokeDownloadPreviewURL();
$('#downloadPreviewImage').removeAttribute('src');
$('#downloadButton').focus();
}
function formatDownloadTimestamp(date = new Date()) {
const pad = value => String(value).padStart(2, '0');
return (
String(date.getFullYear()) +
pad(date.getMonth() + 1) +
pad(date.getDate()) +
'_' +
pad(date.getHours()) +
pad(date.getMinutes()) +
pad(date.getSeconds())
);
}
function downloadPreviewSVG() {
const animated = $('#exportAnimationCheckbox').checked;
const revision = currentDownloadRevision();
/*
* 如果開窗後棋局被 AI 更新,下載前重新建立一次,
* 確保下載內容與最新預覽一致。
*/
if (
!downloadPreviewBlob ||
downloadPreviewRevision !== revision ||
downloadPreviewAnimated !== animated
) {
refreshDownloadPreview();
}
const timestamp = formatDownloadTimestamp();
const filename = animated
? `fireandice_animate_${timestamp}.svg`
: `fireandice_static_${timestamp}.svg`;
downloadBlob(downloadPreviewBlob, filename);
showNotice(I18N[language].positionSaved);
}這段代碼會:
- 靜態預覽移除舊的 SMIL 畫面更新動畫。
- 動畫預覽額外移除:
markerLayertriple-markerflame-marker- 所有名稱包含
marker的 ID/class - SVG
<marker> marker-start、marker-mid、marker-end
- 移除所有
metadata。 - 移除所有
pointer-events屬性。 - 移除
data-*、ARIA、role、class、事件屬性等非顯示資料。 - 保證不輸出
xmlns:xlink和xlink:href。 - 根據實際
history重建棋子的產生及移動順序。 - 動畫在
fiend.end後重新循環。
9. 更新 applyTranslations()
在 applyTranslations() 中找到:
document.title = language === 'zh' ? '冰火島|Fire and Ice' : 'Fire and Ice';
updateCustomTimeLabels();替換為:
document.title =
language === 'zh'
? '冰火島|Fire and Ice'
: 'Fire and Ice';
const downloadPreviewImage = $('#downloadPreviewImage');
if (downloadPreviewImage) {
downloadPreviewImage.alt = dictionary.downloadPreviewAlt;
}
updateDownloadFileSize();
updateCustomTimeLabels();10. 替換下載按鈕事件
在 bindEvents() 中找到:
$('#downloadButton').addEventListener('click', downloadPosition);替換為:
$('#downloadButton').addEventListener('click', openDownloadModal);
$('#closeDownloadButton').addEventListener(
'click',
closeDownloadModal
);
$('#exportAnimationCheckbox').addEventListener(
'change',
refreshDownloadPreview
);
$('#confirmDownloadButton').addEventListener(
'click',
downloadPreviewSVG
);
$('#downloadModal').addEventListener('click', event => {
if (event.target === $('#downloadModal')) {
closeDownloadModal();
}
});11. 讓 Esc 可以關閉下載窗口
在鍵盤事件中找到:
if (event.key === 'Escape') {
if (!$('#settingsModal').hidden) {
closeSettings();
} else if (!$('#rulesModal').hidden) {
closeRules();
} else if (!$('#recordModal').hidden) {
closeRecordModal();
} else if (selectedCell >= 0) {替換為:
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) {後面的代碼保持不變。
12. 更新窗口尺寸監聽
找到:
if ('ResizeObserver' in window) {
new ResizeObserver(fitBoard).observe($('#boardWrap'));
} else {
window.addEventListener('resize', fitBoard);
}
window.visualViewport?.addEventListener('resize', fitBoard);替換為:
if ('ResizeObserver' in window) {
new ResizeObserver(fitBoard).observe($('#boardWrap'));
new ResizeObserver(fitDownloadPreview).observe(
$('#downloadPreviewShell')
);
} else {
window.addEventListener('resize', () => {
fitBoard();
fitDownloadPreview();
});
}
window.visualViewport?.addEventListener('resize', () => {
fitBoard();
fitDownloadPreview();
});13. 頁面退出時釋放預覽 URL
找到:
window.addEventListener('beforeunload', () => {
cancelAI();
if (aiWorkerURL) URL.revokeObjectURL(aiWorkerURL);
});替換為:
window.addEventListener('beforeunload', () => {
cancelAI();
revokeDownloadPreviewURL();
if (aiWorkerURL) {
URL.revokeObjectURL(aiWorkerURL);
}
});完成後:
- 點擊工具欄「下載」只會打開「下載SVG」窗口。
- 初始顯示靜態 SVG 預覽。
- 勾選「導出動畫」後立即重新生成動畫 SVG 預覽。
- 預覽始終保持 1:1。
- 文件大小是實際 Blob 的 byte 數。
- 靜態文件名:
fireandice_static_yyyymmdd_hhiiss.svg- 動畫文件名:
fireandice_animate_yyyymmdd_hhiiss.svg