QuanAI只读快照

共享会话

更改「下載」按鈕的功能

分享于 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%寬的也要調整到相同寬度。

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

ifif243.htmltext/plain · 110 KB预览下载
QuanAI

以下修改均針對附件 ifif243.html。修改完成後:

  • 點擊工具列「下載」不再直接下載,而是開啟「下載 SVG」視窗。
  • 視窗包含標題、SVG 預覽、置中的「導出動畫」選項、下載按鈕及檔案大小。
  • 預覽區會隨視窗壓縮,但 SVG 始終保持 1:11:1
  • 靜態檔名為 fireandice_static_yyyymmdd_hhiiss.svg
  • 動畫檔名為 fireandice_animate_yyyymmdd_hhiiss.svg
  • 動畫會根據目前 historyIndex 之前的實際棋譜重播。
  • 導出的 SVG 不再包含 xmlns:xlink、metadata、互動層及無關屬性。
  • 所有動畫位移座標最多兩位小數,並自動去除小數末尾的零。
  • 下載按鈕與開始頁面的「開始遊戲」按鈕同寬。
  • 設置視窗的「重新開局」按鈕也調整為相同最大寬度。

一、移除棋盤上的 xmlns:xlink

找到:

html
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"

替換為:

html
xmlns="http://www.w3.org/2000/svg"

目前程式全部使用:

html
<use href="#...">

並沒有使用 xlink:href,因此不需要 xmlns:xlink


二、修改設置視窗按鈕寬度

找到原有 CSS:

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;
}

替換為:

css
.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 前面,加入:

css
.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;
  }
}

這個視窗使用固定的四列結構:

  1. 標題列
  2. 可壓縮的預覽列
  3. 動畫選項
  4. 下載按鈕及檔案大小

當手機高度不足時,只會壓縮第二列預覽區,其他元素仍會留在視圖內。


四、加入下載 SVG 視窗 HTML

找到 recordModal 的結尾:

html
    </div>

    <script id="mcts-worker-source" type="javascript/worker">

在兩者之間加入:

html
    <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 中找到:

js
downloadPosition: '下載',
settings: '設置',

替換為:

js
downloadPosition: '下載',
downloadSVGTitle: '下載 SVG',
downloadSVGButton: '下載 SVG',
downloadFileSize: '檔案大小',
exportAnimation: '導出動畫',
settings: '設置',

2. 英文 I18N

在英文 I18N.en 中找到:

js
downloadPosition: 'Download',
settings: 'Settings',

替換為:

js
downloadPosition: 'Download',
downloadSVGTitle: 'Download SVG',
downloadSVGButton: 'Download SVG',
downloadFileSize: 'File size',
exportAnimation: 'Export animation',
settings: 'Settings',

六、加入 XLink 命名空間常量

找到:

js
const SVG_NS = 'http://www.w3.org/2000/svg';

替換為:

js
const SVG_NS = 'http://www.w3.org/2000/svg';
const XLINK_NS = 'http://www.w3.org/1999/xlink';

這個常量只用來辨認並移除舊的 xlink:href 屬性,不會寫入導出的 SVG。


七、加入下載預覽狀態

找到:

js
let animatedHistoryIndex = -999;

在其後加入:

js
let downloadSVGSource = '';
let downloadSVGByteSize = 0;

八、替換原有下載函式

保留原來的 downloadBlob()

js
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);
}

刪除原來整個:

js
function downloadPosition() {
  ...
}

然後在 downloadBlob() 後面加入以下完整代碼:

js
      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。
  • 最後依照目前棋盤上的位置,將最終 SVG 中每個 <use> 對應回其 track。
  • 因此同一枚棋子經歷多次移動時,所有位移動畫仍會加在同一個 <use> 上。
  • 如果目前使用過回退,只會導出 historyIndex 之前的步驟,不會包含已回退掉的未來步驟。

動畫時間關係為:

text
fistart
  → fi0 初始火棋出現
  → fi1 第一回合移動及新棋出現
  → fi2 第二回合移動及新棋出現
  → ...
  → fi(m) 最後一回合
  → 勝利線及勝方背景
  → fiend
  → fistart 重新開始

十、修改語言切換後的檔案大小文字

applyTranslations() 函式末尾找到:

js
updateCustomTimeLabels();

替換為:

js
updateCustomTimeLabels();

if (downloadSVGSource) {
  updateDownloadFileSize();
}

這樣在下載視窗開啟期間切換語言時,「檔案大小」也會更新語言。


十一、替換下載按鈕事件

bindEvents() 中找到:

js
$('#downloadButton').addEventListener('click', downloadPosition);

替換為:

js
$('#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 關閉順序

在鍵盤事件中找到:

js
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;
}

替換為:

js
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() 中找到:

js
if ('ResizeObserver' in window) {
  new ResizeObserver(fitBoard).observe($('#boardWrap'));
} else {
  window.addEventListener('resize', fitBoard);
}

window.visualViewport?.addEventListener('resize', fitBoard);

替換為:

js
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();
});

這樣手機地址列收合、橫豎屏切換、軟鍵盤或視口高度變化時,下載預覽都會重新計算正方形大小。


十四、最終應刪除的舊代碼

確認以下舊函式已完整刪除,不能與新函式同時存在:

js
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);
}

舊的事件綁定也必須刪除:

js
$('#downloadButton').addEventListener('click', downloadPosition);

十五、導出的 SVG 清理結果

靜態 SVG

靜態檔案會移除:

text
hitLayer
targetLayer
metadata
animate
animateTransform
set
pointer-events
class
role
aria-*
data-*
tabindex
focusable
事件屬性 on*
xmlns:xlink
xlink:href

靜態檔案仍保留:

  • 當前棋盤。
  • 當前棋子。
  • 當前島嶼控制顏色。
  • 當前回合 marker。
  • 如果已分勝負,保留最後的勝利線和勝方背景。
  • 所有真正影響 SVG 顯示的 fillstrokefilteropacitytransform 等屬性。

動畫 SVG

動畫檔案在上述清理之外,還會移除:

text
markerLayer
triple-marker
flame-marker
所有 ID 名稱含 marker 的元素
SVG 原生 marker 元素

並重新加入:

xml
<animate id="fistart" ... />
<animate id="fi0" ... />
<animateTransform id="fi1" ... />
<animateTransform id="fi2" ... />
...
<animate id="fivline" ... />
<animate id="fiend" ... />

座標會輸出為這類格式:

xml
from="240 285.63"
to="217.27 272.5"

不會輸出:

xml
from="240.00 285.6250"
to="217.270 272.50"

最終檔名示例:

text
fireandice_static_20260912_192716.svg

或:

text
fireandice_animate_20260912_192716.svg