QuanAI只读快照

共享会话

更改「下載」按鈕的功能

分享于 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內部的最下方增加結束動畫「」。

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

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

下面按順序修改即可;MCTS Worker 及棋局邏輯不需要改動。

1. 刪除棋盤上的 xmlns:xlink

找到 #gameBoard

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

替換為:

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

目前所有 <use> 都使用標準的 href,不需要 xlink:href


2. 增加下載窗口樣式

在現有 CSS 的:

css
@media (prefers-reduced-motion: reduce) {

之前插入:

css
      .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 之前插入:

html
    <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 的:

js
positionSaved: '局面 SVG 已下載',

後面增加:

js
          downloadSvgTitle: '下載SVG',
          exportAnimation: '導出動畫',
          downloadSvgAction: '下載',
          downloadFileSize: '當前下載文件大小',
          downloadPreviewAlt: 'SVG 預覽',

英文

I18N.en 的:

js
positionSaved: 'Position SVG downloaded',

後面增加:

js
          downloadSvgTitle: 'Download SVG',
          exportAnimation: 'Export animation',
          downloadSvgAction: 'Download',
          downloadFileSize: 'Current download size',
          downloadPreviewAlt: 'SVG preview',

5. 增加 SVG namespace 常量

找到:

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

替換為:

js
      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. 增加下載預覽狀態變量

在:

js
      let aiProgress = { elapsed: 0, iterations: 0 };

後面增加:

js
      let downloadPreviewURL = null;
      let downloadPreviewSource = '';
      let downloadPreviewBlob = null;
      let downloadPreviewSize = 0;
      let downloadPreviewRevision = '';
      let downloadPreviewAnimated = false;

7. 為棋子增加臨時位置資料

找到 renderPieces() 中建立棋子的部分:

js
          const use = createSVGElement('use', {
            href: player === 1 ? '#fire-piece' : '#ice-piece',
            transform: `translate(${x} ${y})`,
            opacity: '1'
          });

替換為:

js
          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() 不變。

把整個舊函式:

js
      function downloadPosition() {
        ...
      }

替換成以下代碼:

js
      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 畫面更新動畫。
  • 動畫預覽額外移除:
    • markerLayer
    • triple-marker
    • flame-marker
    • 所有名稱包含 marker 的 ID/class
    • SVG <marker>
    • marker-startmarker-midmarker-end
  • 移除所有 metadata
  • 移除所有 pointer-events 屬性。
  • 移除 data-*、ARIA、role、class、事件屬性等非顯示資料。
  • 保證不輸出 xmlns:xlinkxlink:href
  • 根據實際 history 重建棋子的產生及移動順序。
  • 動畫在 fiend.end 後重新循環。

9. 更新 applyTranslations()

applyTranslations() 中找到:

js
        document.title = language === 'zh' ? '冰火島|Fire and Ice' : 'Fire and Ice';

        updateCustomTimeLabels();

替換為:

js
        document.title =
          language === 'zh'
            ? '冰火島|Fire and Ice'
            : 'Fire and Ice';

        const downloadPreviewImage = $('#downloadPreviewImage');

        if (downloadPreviewImage) {
          downloadPreviewImage.alt = dictionary.downloadPreviewAlt;
        }

        updateDownloadFileSize();
        updateCustomTimeLabels();

10. 替換下載按鈕事件

bindEvents() 中找到:

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

替換為:

js
        $('#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 可以關閉下載窗口

在鍵盤事件中找到:

js
          if (event.key === 'Escape') {
            if (!$('#settingsModal').hidden) {
              closeSettings();
            } else if (!$('#rulesModal').hidden) {
              closeRules();
            } else if (!$('#recordModal').hidden) {
              closeRecordModal();
            } else if (selectedCell >= 0) {

替換為:

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) {

後面的代碼保持不變。


12. 更新窗口尺寸監聽

找到:

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) {
          new ResizeObserver(fitBoard).observe($('#boardWrap'));
          new ResizeObserver(fitDownloadPreview).observe(
            $('#downloadPreviewShell')
          );
        } else {
          window.addEventListener('resize', () => {
            fitBoard();
            fitDownloadPreview();
          });
        }

        window.visualViewport?.addEventListener('resize', () => {
          fitBoard();
          fitDownloadPreview();
        });

13. 頁面退出時釋放預覽 URL

找到:

js
        window.addEventListener('beforeunload', () => {
          cancelAI();
          if (aiWorkerURL) URL.revokeObjectURL(aiWorkerURL);
        });

替換為:

js
        window.addEventListener('beforeunload', () => {
          cancelAI();
          revokeDownloadPreviewURL();

          if (aiWorkerURL) {
            URL.revokeObjectURL(aiWorkerURL);
          }
        });

完成後:

  • 點擊工具欄「下載」只會打開「下載SVG」窗口。
  • 初始顯示靜態 SVG 預覽。
  • 勾選「導出動畫」後立即重新生成動畫 SVG 預覽。
  • 預覽始終保持 1:11:1
  • 文件大小是實際 Blob 的 byte 數。
  • 靜態文件名:
text
fireandice_static_yyyymmdd_hhiiss.svg
  • 動畫文件名:
text
fireandice_animate_yyyymmdd_hhiiss.svg