QuanAI只读快照

共享会话

增加字體,改進下載功能

分享于 2026年8月22日 06:30
提问者

增加字體,改進下載功能 以下是一個數字字體: @font-face { font-family: 'Techfont'; src: url('data:font/woff2;charset=utf-8;base64,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') format('woff2'); font-weight: normal; font-style: normal; font-display: swap; } 將其保存為js中的一個變量,使其能夠復用。

玩家控制區中的棋子數量以及分數,都要用這個字體。

之前點擊工具欄的「下載」按鈕,會直接下載當前的棋盤svg,現在改為彈出一個「下載窗口」。

窗口右上角有叉形關閉按鈕,其內部有標題,svg預覽區,下方選項區,以及下載按鈕。所有的元素都要顯示在當前視圖中,不能有縱向滾動條,可縮放svg預覽區以使其佔滿屏幕。

標題為「下載svg局面和動畫」。

預覽區將展示所要下載的靜態或動畫svg,其內容為svg棋盤的clone,其長寬將保持1:1。在其內容中去掉所有的「 class="board-target"」和「 class="placed-piece"」。

選項為「數字、動畫」,二者最多選一個,也可以兩個都不選。初始為都不選。將其做成美觀的勾選項。

下載按鈕將居中,其下方將有小字標注文件大小(byte)。當點擊時將下載文件,下載內容將跟預覽區的內容相同,文件名為bracethetrigolden_yyyymmdd_hhiiss.svg,如果是數字則為bracethetrigolden_number_yyyymmdd_hhiiss.svg,如果是動畫則為bracethetrigolden_animate_yyyymmdd_hhiiss.svg。

如果選擇數字,則在clone的最開頭加上style再把字體寫進去,然後按棋子出現順序把數字標注在棋子上方(在.etdrop內),從1開始。注意一位數和兩位數的坐標差異。

數字模板為「棋子次序」。

按clone中每個use棋子的次序添加對應的數字,在代碼中寫明數字的横坐標和縱坐標相對於棋子中心坐標的偏移量,並分為一位數和兩位數,使我在實測中可以調整。

如果選擇動畫,則按以下步驟進行:

  • 數字字體同樣加入clone中的style。

  • 在clone內部的內部的最下方加入「」。

  • 在clone內的最後面加入「」,也就是在整個svg上方高60的地方為「狀態欄」。

  • 在狀態欄中間加入當前的計分標記,縮小至60*60的大小。即把g#svg-dinger或g#svg-xin複製後加至狀態欄中,translate到上面的中間,scale為0.5。

  • 為.etdrop增加屬性opacity=0,同時在clone內部最下面添加動畫「」。

  • 在clone內部最下面添加動畫「」。

  • 為etanidrop內的每一個use棋子內添加動畫「」。

  • 在遊戲中,第1回合和第28回合只下一個棋子,其它回合都是兩個棋子。則按順序為每個回合的一個或兩個棋子添加動畫「」,n指的是第幾回合。其中一回合有兩個棋子的話,則第二個棋子的動畫不需要加id。注意有時不一定滿28回合,比如只下到一半,或因仲裁而分出勝負。

  • 在clone內的最後面添加鏡頭移動動畫「」,每個回合的坐標指的是把視圖中心移至棋子坐標,如果有兩個棋子則是視圖中心移至「這兩個棋子坐標連線的中心」。注意因為最上方有高60被狀態欄佔用,所以視圖尺寸是480*420。

  • 將所有的「頂鑫環」path都加進etanidrop內最下面,並加上opacity=0,然後在每個頂鑫環path內加上「」,n指的是觸發這個頂鑫環的回合。

  • 在狀態欄中,計分標記的右邊和左邊分別是玩家一和玩家二的分數,預留能顯示三位整數。在玩家一分數的右邊從左到右依次是第一、第二、第三個棋子,在玩家二分數的左邊從右到左依次是第一、第二、第三個棋子,棋子的數量位於所對應的棋子中心。分數和棋子數量套用前面嵌入的數字字體。雙方棋子的翻轉情況是相反的,跟遊戲一開始時在控制區中的棋子狀態一樣。

  • 玩家分數將放在中,字體大小為36。每一次的分數變化都要往裡面加一個text,並且縱坐標遞減60。則當到了加分的回合,將在裡面加一個下移的動畫「」,其中n指的是加分的回合,m指的是第幾次加分。

  • 棋子數量將放在,n指0到5,對應defs中的tile0到tile5,字體大小為24。每一次的棋子數量變化都要往裡面加一個text,並且縱坐標遞增60。則當到了這個棋子數量變化的回合,將在裡面加一個上移的動畫「」,其中n指的是使用該棋子的回合,m指的是第幾次變化。

  • 當所有回合都結束時,設最後一個回合的動畫id為bt(),則添加移動鏡頭的動畫到clone內的最後面,把鏡頭移到全景,使所有棋子都能顯示在視圖中,移動鏡頭的動畫為「<animateTransformattributeName="transform"attributeType="XML"type="translate"id="btfull"href="etanidrop"values="當前坐標;全景坐標"dur="2s"fill="freeze"begin="bt(),則添加移動鏡頭的動畫到clone內的最後面,把鏡頭移到全景,使所有棋子都能顯示在視圖中,移動鏡頭的動畫為「<animateTransform attributeName="transform" attributeType="XML" type="translate" id="btfull" href="etanidrop" values="當前坐標;全景坐標" dur="2s" fill="freeze" begin="bt().end+2s"/>」。鏡頭移到全景後,上下左右邊界跟棋子的距離為12。注意上邊界已有60是狀態欄。

  • 最後把整個clone漸變消失,並把鏡頭移到初始狀態。往clone內的最後面添加以下動畫:「」。

回答要求:不要影響當前正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。

trigolden_game2661.htmltext/plain · 312.1 KB预览下载
QuanAI

下面按 4 個位置修改即可。這些代碼只會讀取當前棋局並操作棋盤的 clone,不改動 piecesscoresgameHistorycommittedSearchState 或正式落子流程。

另外,動畫實作中做了兩個必要修正:

  • SVG SMIL 的 href 必須寫成 href="#etanidrop"
  • 你給出的代碼中 id="btfull" 出現兩次,會造成引用歧義;重置鏡頭動畫改用 id="btreset"
  • 為避免同一個 transform 上的平移、縮放動畫互相覆蓋,下載動畫的結構改成:
    • #etanidrop:鏡頭平移
    • #btzoom:鏡頭縮放
    • #btworld:保留棋盤原本旋轉
      顯示效果和指定時序不變,但在 Chrome、Firefox 中更穩定。

一、增加 CSS

在原本 <style> 的末尾、</style> 之前加入:

css
      /* 數字字體套用至雙方分數及棋子數量 */
      .score-num,
      .tile-count {
        font-family: 'Techfont', sans-serif;
        font-variant-numeric: tabular-nums;
      }

      /* SVG 下載窗口 */
      #download-dialog {
        padding: 8px;
        box-sizing: border-box;
        overflow: hidden;
      }

      .download-dialog-box {
        width: min(960px, calc(100vw - 16px));
        height: calc(100vh - 16px);
        max-width: none;
        max-height: none;
        padding: 14px 18px 12px;
        box-sizing: border-box;
        display: grid;
        grid-template-rows: auto minmax(0, 1fr) auto auto auto;
        gap: 9px;
        overflow: hidden;
      }

      .download-dialog-title {
        margin: 0;
        padding: 0 44px;
        color: #7289da;
        font-size: 1.42em;
        line-height: 34px;
        text-align: center;
        white-space: nowrap;
      }

      .download-close-btn {
        appearance: none;
        position: absolute;
        top: 9px;
        right: 9px;
        z-index: 20;
        width: 36px;
        height: 36px;
        margin: 0;
        padding: 6px;
        border: none;
        outline: none;
        border-radius: 50%;
        background: transparent;
        color: #aaa;
        cursor: pointer;
        transition:
          color 0.2s,
          background 0.2s;
      }

      .download-close-btn:hover,
      .download-close-btn:focus-visible {
        color: #fff;
        background: rgba(255, 255, 255, 0.08);
      }

      .download-close-btn svg {
        display: block;
        width: 24px;
        height: 24px;
      }

      .download-preview-shell {
        min-width: 0;
        min-height: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        overflow: hidden;
        border: 1px solid rgba(114, 137, 218, 0.5);
        border-radius: 10px;
        background:
          linear-gradient(45deg, #e6e6e6 25%, transparent 25%),
          linear-gradient(-45deg, #e6e6e6 25%, transparent 25%),
          linear-gradient(45deg, transparent 75%, #e6e6e6 75%),
          linear-gradient(-45deg, transparent 75%, #e6e6e6 75%),
          #f5f5f5;
        background-position:
          0 0,
          0 10px,
          10px -10px,
          -10px 0;
        background-size: 20px 20px;
        box-shadow: inset 0 0 16px rgba(0, 0, 0, 0.18);
      }

      #download-preview {
        flex: none;
        display: block;
        overflow: hidden;
        background: white;
        box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
      }

      #download-preview object {
        display: block;
        width: 100%;
        height: 100%;
        margin: 0;
        padding: 0;
        border: none;
        overflow: hidden;
        background: #fff;
      }

      .download-option-row {
        min-height: 44px;
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 18px;
      }

      .download-option {
        position: relative;
        min-width: 112px;
        height: 40px;
        padding: 0 18px 0 13px;
        box-sizing: border-box;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        gap: 10px;
        border: 1px solid rgba(114, 137, 218, 0.52);
        border-radius: 20px;
        background: rgba(255, 255, 255, 0.055);
        color: #ddd;
        cursor: pointer;
        transition:
          border-color 0.2s,
          background 0.2s,
          color 0.2s,
          box-shadow 0.2s;
      }

      .download-option:hover {
        color: #fff;
        border-color: #7289da;
        background: rgba(114, 137, 218, 0.13);
      }

      .download-option input {
        position: absolute;
        width: 1px;
        height: 1px;
        opacity: 0;
        pointer-events: none;
      }

      .download-checkmark {
        width: 21px;
        height: 21px;
        box-sizing: border-box;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        flex: none;
        border: 2px solid #7f8790;
        border-radius: 6px;
        background: #23272a;
        transition:
          border-color 0.2s,
          background 0.2s,
          box-shadow 0.2s;
      }

      .download-checkmark svg {
        width: 15px;
        height: 15px;
        color: #fff;
        opacity: 0;
        transform: scale(0.65);
        transition:
          opacity 0.16s,
          transform 0.16s;
      }

      .download-option input:checked + .download-checkmark {
        border-color: #7289da;
        background: #7289da;
        box-shadow: 0 0 10px rgba(114, 137, 218, 0.75);
      }

      .download-option input:checked + .download-checkmark svg {
        opacity: 1;
        transform: scale(1);
      }

      .download-option:has(input:checked) {
        color: #fff;
        border-color: #7289da;
        background: rgba(114, 137, 218, 0.18);
      }

      .download-confirm-btn {
        justify-self: center;
        min-width: 150px;
        min-height: 42px;
        margin: 0;
        padding: 8px 26px;
        border: none;
        border-radius: 21px;
        background: #43b581;
        color: #fff;
        font-size: 1.05em;
        font-weight: bold;
        cursor: pointer;
        box-shadow: 0 4px 10px rgba(0, 0, 0, 0.32);
        transition:
          background 0.2s,
          transform 0.12s,
          box-shadow 0.2s;
      }

      .download-confirm-btn:hover,
      .download-confirm-btn:focus-visible {
        background: #3ca374;
        box-shadow: 0 5px 14px rgba(0, 0, 0, 0.4);
      }

      .download-confirm-btn:active {
        transform: scale(0.96);
      }

      #download-size {
        min-height: 17px;
        color: #aaa;
        font-family: 'Techfont', sans-serif;
        font-size: 12px;
        line-height: 17px;
        text-align: center;
      }

      @media (max-width: 600px) {
        .download-dialog-box {
          padding: 10px 8px 8px;
          gap: 6px;
        }

        .download-dialog-title {
          padding: 0 38px;
          font-size: 1.08em;
          line-height: 32px;
        }

        .download-option-row {
          gap: 9px;
        }

        .download-option {
          min-width: 102px;
          height: 36px;
          padding: 0 13px 0 10px;
        }

        .download-confirm-btn {
          min-height: 38px;
          padding-top: 6px;
          padding-bottom: 6px;
        }
      }

      @media (max-height: 560px) {
        .download-dialog-box {
          padding-top: 7px;
          padding-bottom: 6px;
          gap: 4px;
        }

        .download-dialog-title {
          line-height: 28px;
        }

        .download-option-row {
          min-height: 34px;
        }

        .download-option {
          height: 32px;
        }

        .download-confirm-btn {
          min-height: 34px;
          padding-top: 5px;
          padding-bottom: 5px;
        }
      }

二、增加下載窗口 HTML

在:

html
    <div id="ai-settings-dialog" class="custom-dialog">

對應的整個對話框結束後,也就是 panel-p2 之前,加入:

html
    <div
      id="download-dialog"
      class="custom-dialog"
      role="dialog"
      aria-modal="true"
      aria-labelledby="download-dialog-title"
    >
      <div class="dialog-box download-dialog-box">
        <button
          type="button"
          class="download-close-btn"
          onclick="closeDownloadDialog()"
          aria-label="關閉下載窗口"
        >
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <path
              fill="currentColor"
              d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
            />
          </svg>
        </button>

        <h2 id="download-dialog-title" class="download-dialog-title">下載svg局面和動畫</h2>

        <div class="download-preview-shell">
          <div id="download-preview" aria-label="SVG 預覽區"></div>
        </div>

        <div class="download-option-row" role="group" aria-label="下載選項">
          <label class="download-option">
            <input
              type="checkbox"
              id="download-option-number"
              onchange="handleDownloadOptionChange('number')"
            />
            <span class="download-checkmark" aria-hidden="true">
              <svg viewBox="0 0 24 24">
                <path
                  fill="currentColor"
                  d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
                />
              </svg>
            </span>
            <span>數字</span>
          </label>

          <label class="download-option">
            <input
              type="checkbox"
              id="download-option-animate"
              onchange="handleDownloadOptionChange('animate')"
            />
            <span class="download-checkmark" aria-hidden="true">
              <svg viewBox="0 0 24 24">
                <path
                  fill="currentColor"
                  d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
                />
              </svg>
            </span>
            <span>動畫</span>
          </label>
        </div>

        <button type="button" class="download-confirm-btn" onclick="confirmSVGDownload()">下載</button>

        <div id="download-size">0 byte</div>
      </div>
    </div>

三、保存並安裝 Techfont

<script> 開頭、原本這一行之前:

js
      // 多國語言辭典

加入下面代碼。字體只保存一次,正式頁面和下載 SVG 會共用同一個變量:

js
      /*
       * Techfont 數字字體。
       * 正式控制區和下載 SVG 共用這一個變量,避免重複保存 base64。
       */
      const TECHFONT_CSS = String.raw`@font-face {
    font-family: 'Techfont';
    src: url('data:font/woff2;charset=utf-8;base64,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') format('woff2');
    font-weight: normal;
    font-style: normal;
    font-display: swap;
}`;

      /*
       * 將上面的變量安裝到當前頁面。
       * 下載 SVG 時則會直接把同一變量加入 clone 的第一個 style。
       */
      if (!document.getElementById('techfont-runtime-style')) {
        const techfontRuntimeStyle = document.createElement('style');
        techfontRuntimeStyle.id = 'techfont-runtime-style';
        techfontRuntimeStyle.textContent = TECHFONT_CSS;
        document.head.appendChild(techfontRuntimeStyle);
      }

四、替換原有 downloadSVG()

找到原本的整個函數:

js
      function downloadSVG() {
        const svg = document.getElementById('etanitrigolden').cloneNode(true);
        ...
      }

把它完整替換成下面這一整段:

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

      /*
       * 棋子編號相對於棋子中心的偏移量。
       *
       * 一位數:
       *   x = 棋子中心 x - 7
       *   y = 棋子中心 y - 25
       *
       * 兩位數:
       *   x = 棋子中心 x - 13
       *   y = 棋子中心 y - 24
       *
       * 實測時只需要調整下面四個數值。
       */
      const DOWNLOAD_NUMBER_OFFSET = Object.freeze({
        oneDigit: Object.freeze({
          x: -7,
          y: -25
        }),
        twoDigits: Object.freeze({
          x: -13,
          y: -24
        })
      });

      /*
       * 狀態欄配置。
       *
       * 左至右:
       * 玩家二第三子、第二子、第一子、玩家二分數、
       * 計分標記、
       * 玩家一分數、第一子、第二子、第三子。
       */
      const DOWNLOAD_STATUS_TILE_X = Object.freeze({
        0: 350,
        1: 402,
        2: 454,
        3: 130,
        4: 78,
        5: 26
      });

      const DOWNLOAD_STATUS_SCORE_X = Object.freeze({
        1: 300,
        2: 180
      });

      let downloadSvgSource = '';
      let downloadSvgBlob = null;
      let downloadPreviewUrl = '';

      function createDownloadSvgElement(tagName, attributes = {}) {
        const element = document.createElementNS(DOWNLOAD_SVG_NS, tagName);

        Object.entries(attributes).forEach(([name, value]) => {
          if (value !== undefined && value !== null) {
            element.setAttribute(name, String(value));
          }
        });

        return element;
      }

      function getDownloadMode() {
        const numberOption = document.getElementById('download-option-number');
        const animateOption = document.getElementById('download-option-animate');

        if (numberOption && numberOption.checked) {
          return 'number';
        }

        if (animateOption && animateOption.checked) {
          return 'animate';
        }

        return 'plain';
      }

      /*
       * 兩個選項最多只能選一個。
       * 因為使用 checkbox,所以再次點擊已選項目仍可取消,從而回到兩個都不選。
       */
      function handleDownloadOptionChange(changedMode) {
        const numberOption = document.getElementById('download-option-number');
        const animateOption = document.getElementById('download-option-animate');

        if (changedMode === 'number' && numberOption.checked) {
          animateOption.checked = false;
        }

        if (changedMode === 'animate' && animateOption.checked) {
          numberOption.checked = false;
        }

        refreshDownloadPreview();
      }

      /*
       * 克隆正式棋盤。
       * 只從 class 中移除 board-target 和 placed-piece,不修改正式棋盤。
       */
      function createDownloadBoardClone() {
        const clone = document.getElementById('etanitrigolden').cloneNode(true);

        clone.setAttribute('width', '480');
        clone.setAttribute('height', '480');
        clone.setAttribute('viewBox', '0 0 480 480');
        clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');

        clone.querySelectorAll('[class]').forEach(element => {
          element.classList.remove('board-target', 'placed-piece');

          const remainingClass = element.getAttribute('class');

          if (!remainingClass || !remainingClass.trim()) {
            element.removeAttribute('class');
          }
        });

        return clone;
      }

      /*
       * 將字體 style 放在 SVG clone 的第一個子節點,位於 defs 之前。
       */
      function prependDownloadFontStyle(svg) {
        const style = createDownloadSvgElement('style', {
          type: 'text/css',
          'data-bt-font': 'Techfont'
        });

        style.textContent = TECHFONT_CSS;
        svg.insertBefore(style, svg.firstChild);
      }

      function getDirectDownloadUses(group) {
        return Array.from(group.children).filter(element => element.localName === 'use');
      }

      /*
       * 取得 use 對應的本地棋盤中心。
       *
       * 正式棋子、暫時棋子和 ghost 的產生順序,與 renderBoard()
       * 加入 use 的順序一致。
       */
      function getDownloadPieceCenter(piece, useElement) {
        if (piece && piece.svgId && piece.vertices) {
          const transform = getPieceTransform(piece.svgId, piece.vertices);

          return {
            x: transform.tx,
            y: transform.ty
          };
        }

        /*
         * 沒有對應棋局資料時,從 use 的第一個 translate() 讀取。
         */
        const transformText = useElement ? useElement.getAttribute('transform') || '' : '';

        const match = transformText.match(
          /translate\(\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*[, ]\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*\)/i
        );

        return match
          ? {
              x: parseFloat(match[1]),
              y: parseFloat(match[2])
            }
          : {
              x: 0,
              y: 0
            };
      }

      /*
       * 數字模式。
       * 所有 text 最後統一加到 .etdrop 末尾,確保數字顯示在棋子上方。
       */
      function addDownloadPieceNumbers(svg) {
        prependDownloadFontStyle(svg);

        const drop = svg.querySelector('.etdrop');

        if (!drop) {
          return;
        }

        const uses = getDirectDownloadUses(drop);

        /*
         * renderBoard() 的 use 順序:
         * pieces -> tempPieces -> ghosts
         */
        const modelPieces = pieces.concat(tempPieces, ghosts);

        uses.forEach((useElement, index) => {
          const number = index + 1;
          const piece = modelPieces[index] || null;
          const center = getDownloadPieceCenter(piece, useElement);

          const offset =
            number < 10 ? DOWNLOAD_NUMBER_OFFSET.oneDigit : DOWNLOAD_NUMBER_OFFSET.twoDigits;

          const text = createDownloadSvgElement('text', {
            x: trimNum(center.x + offset.x),
            y: trimNum(center.y + offset.y),
            stroke: 'none',
            fill: '#333',
            'font-size': '24',
            'font-family': 'Techfont'
          });

          text.textContent = String(number);
          drop.appendChild(text);
        });
      }

      /*
       * 第一回合一子,之後每回合兩子。
       * 如果當前只進行到半局或仲裁局面,最後一組可以只有一子。
       */
      function groupDownloadAnimationTurns(entries) {
        if (entries.length === 0) {
          return [];
        }

        const turns = [[entries[0]]];

        for (let index = 1; index < entries.length; index += 2) {
          turns.push(entries.slice(index, index + 2));
        }

        return turns;
      }

      function cloneDownloadReplayPiece(piece) {
        return {
          ...piece,
          vertices: piece.vertices,
          satBounds: piece.satBounds
        };
      }

      /*
       * 用當前棋子順序在本地重放計分。
       *
       * 不調用 actionCheck(),也不修改正式 scores、scoredVictims、
       * piecesCount 或 committedSearchState。
       */
      function buildDownloadAnimationTimeline(turns) {
        const timeline = {
          scoreHistory: {
            1: [0],
            2: [0]
          },

          scoreEvents: {
            1: [],
            2: []
          },

          countHistory: Array.from({ length: 6 }, () => [N_PIECES]),
          countEvents: Array.from({ length: 6 }, () => []),

          rings: []
        };

        const remainingCounts = Array(6).fill(N_PIECES);

        const replayState = {
          boardPieces: [],
          scores: {
            1: 0,
            2: 0
          },
          scoredVictims: new Set(),
          scoringMode: SCORING_MODE,
          incremental: null
        };

        turns.forEach((turn, turnIndex) => {
          const turnNumberForAnimation = turnIndex + 1;
          const usedThisTurn = new Map();

          turn.forEach(entry => {
            const tileId = entry.piece && entry.piece.svgId;

            if (!tileId || !/^tile[0-5]$/.test(tileId)) {
              return;
            }

            const tileIndex = parseInt(tileId.substring(4), 10);

            usedThisTurn.set(tileIndex, (usedThisTurn.get(tileIndex) || 0) + 1);
          });

          /*
           * 同一回合使用兩個同類棋子時,合併為一次狀態欄滾動,
           * 直接由舊數量切換到減二後的數量,避免同時執行兩個
           * animateTransform 互相覆蓋。
           */
          usedThisTurn.forEach((usedCount, tileIndex) => {
            remainingCounts[tileIndex] -= usedCount;

            timeline.countHistory[tileIndex].push(remainingCounts[tileIndex]);

            timeline.countEvents[tileIndex].push({
              turn: turnNumberForAnimation,
              m: timeline.countHistory[tileIndex].length - 1
            });
          });

          /*
           * 只有已正式確認的棋子才產生分數變化。
           * tempPieces 仍會出現在動畫及棋子數量中,但不提前結算分數。
           */
          const allCommitted =
            turn.length > 0 && turn.every(entry => entry.committed && entry.piece);

          const replayPieces = turn
            .filter(entry => entry.piece)
            .map(entry => cloneDownloadReplayPiece(entry.piece));

          if (allCommitted && replayPieces.length > 0) {
            try {
              const adjudication = adjudicateSearchTurn(replayState, replayPieces);

              [1, 2].forEach(player => {
                const gain = adjudication.gain[player] || 0;

                if (gain !== 0) {
                  replayState.scores[player] += gain;

                  timeline.scoreHistory[player].push(replayState.scores[player]);

                  timeline.scoreEvents[player].push({
                    turn: turnNumberForAnimation,
                    m: timeline.scoreHistory[player].length - 1
                  });
                }
              });

              adjudication.newScoredKeys.forEach(key => {
                replayState.scoredVictims.add(key);
              });

              adjudication.rings.forEach(ring => {
                timeline.rings.push({
                  turn: turnNumberForAnimation,
                  ring
                });
              });
            } catch (error) {
              /*
               * 不完整或舊版棋譜資料不應中斷下載;
               * 這種情況只跳過該回合的計分動畫。
               */
            }
          }

          replayState.boardPieces.push(...replayPieces);
        });

        return timeline;
      }

      function rotateDownloadPoint(point, angleDegrees) {
        const radians = (angleDegrees * Math.PI) / 180;
        const cos = Math.cos(radians);
        const sin = Math.sin(radians);

        return {
          x: point.x * cos - point.y * sin,
          y: point.x * sin + point.y * cos
        };
      }

      function formatDownloadPoint(point) {
        return `${trimNum(point.x)},${trimNum(point.y)}`;
      }

      function getDownloadTurnCenter(turn) {
        const validEntries = turn.filter(entry => entry.center);

        if (validEntries.length === 0) {
          return {
            x: 0,
            y: 0
          };
        }

        const total = validEntries.reduce(
          (sum, entry) => ({
            x: sum.x + entry.center.x,
            y: sum.y + entry.center.y
          }),
          {
            x: 0,
            y: 0
          }
        );

        return {
          x: total.x / validEntries.length,
          y: total.y / validEntries.length
        };
      }

      /*
       * 上方 60 是狀態欄,因此棋盤可視區是:
       * x = 0..480
       * y = 60..480
       *
       * 可視區中心為 (240, 270)。
       */
      function getDownloadCameraTranslation(center, scale, angle) {
        const rotatedCenter = rotateDownloadPoint(center, angle);

        return {
          x: 240 - rotatedCenter.x * scale,
          y: 270 - rotatedCenter.y * scale
        };
      }

      /*
       * 計算所有棋子的全景邊界。
       * 上下左右至少保留 12,狀態欄下方的內容區為 480 × 420。
       */
      function getDownloadFullView(pieceRecords, angle) {
        const points = [];

        pieceRecords.forEach(piece => {
          if (!piece || !piece.vertices) {
            return;
          }

          piece.vertices.forEach(vertex => {
            points.push(rotateDownloadPoint(exactToScreen(vertex), angle));
          });
        });

        if (points.length === 0) {
          return {
            tx: 240,
            ty: 270,
            scale: 1
          };
        }

        let minX = Infinity;
        let minY = Infinity;
        let maxX = -Infinity;
        let maxY = -Infinity;

        points.forEach(point => {
          minX = Math.min(minX, point.x);
          minY = Math.min(minY, point.y);
          maxX = Math.max(maxX, point.x);
          maxY = Math.max(maxY, point.y);
        });

        const width = Math.max(1, maxX - minX);
        const height = Math.max(1, maxY - minY);

        /*
         * 左右各 12:480 - 24 = 456
         * 狀態欄下方高度 420,上下各 12:420 - 24 = 396
         */
        const fullScale = Math.min(456 / width, 396 / height);

        const centerX = (minX + maxX) / 2;
        const centerY = (minY + maxY) / 2;

        return {
          tx: 240 - centerX * fullScale,
          ty: 270 - centerY * fullScale,
          scale: fullScale
        };
      }

      function appendDownloadStatusPiece(status, tileIndex) {
        const pieceCenter = getOperationPieceCenter('tile' + tileIndex);

        /*
         * 與遊戲開始時的控制區一致:
         * 玩家一 tile0..2:flipX = -1
         * 玩家二 tile3..5:flipX = 1
         */
        const flipX = tileIndex < 3 ? -1 : 1;
        const centerX = DOWNLOAD_STATUS_TILE_X[tileIndex];

        const use = createDownloadSvgElement('use', {
          href: '#tile' + tileIndex,
          transform:
            `translate(${centerX},30) ` +
            `scale(0.28) ` +
            `scale(${flipX},1) ` +
            `translate(${trimNum(-pieceCenter.x)},${trimNum(-pieceCenter.y)})`
        });

        status.appendChild(use);
      }

      function appendDownloadRollingTextGroup({
        status,
        id,
        x,
        fontSize,
        history,
        events,
        direction
      }) {
        const group = createDownloadSvgElement('g', {
          id
        });

        history.forEach((value, index) => {
          const y =
            direction === 'score'
              ? 30 - index * 60
              : 30 + index * 60;

          const text = createDownloadSvgElement('text', {
            x,
            y,
            stroke: 'none',
            fill: '#333',
            'font-size': fontSize,
            'font-family': 'Techfont',
            'text-anchor': 'middle',
            'dominant-baseline': 'middle'
          });

          text.textContent = String(value);
          group.appendChild(text);
        });

        events.forEach(event => {
          const moveY =
            direction === 'score'
              ? 60 * event.m
              : -60 * event.m;

          const animation = createDownloadSvgElement('animateTransform', {
            attributeName: 'transform',
            attributeType: 'XML',
            type: 'translate',
            to: `0,${moveY}`,
            dur: '1s',
            fill: 'freeze',
            begin: `bt${event.turn}.begin+1s`
          });

          group.appendChild(animation);
        });

        status.appendChild(group);
      }

      function appendDownloadStatus(svg, defs, timeline) {
        const clipPath = createDownloadSvgElement('clipPath', {
          id: 'btclip'
        });

        clipPath.appendChild(
          createDownloadSvgElement('path', {
            d: 'M0,0h480v60h-480z'
          })
        );

        /*
         * clipPath 加到原有 defs 的最下方。
         */
        defs.appendChild(clipPath);

        const status = createDownloadSvgElement('g', {
          id: 'btstatus',
          'clip-path': 'url(#btclip)',
          opacity: '0'
        });

        status.appendChild(
          createDownloadSvgElement('path', {
            d: 'M0,0h480v60h-480z',
            fill: '#faf8d7',
            stroke: 'none'
          })
        );

        const scoringMarkerId = SCORING_MODE === 0 ? 'svg-dinger' : 'svg-xin';
        const scoringMarker = document.getElementById(scoringMarkerId);

        if (scoringMarker) {
          const scoringMarkerClone = scoringMarker.cloneNode(true);

          scoringMarkerClone.setAttribute(
            'transform',
            'translate(210,0) scale(0.5)'
          );

          status.appendChild(scoringMarkerClone);
        }

        /*
         * 先加入六個棋子的圖案,再加入數量文字,使數量壓在棋子中心。
         */
        [0, 1, 2, 3, 4, 5].forEach(tileIndex => {
          appendDownloadStatusPiece(status, tileIndex);
        });

        appendDownloadRollingTextGroup({
          status,
          id: 'btp1score',
          x: DOWNLOAD_STATUS_SCORE_X[1],
          fontSize: 36,
          history: timeline.scoreHistory[1],
          events: timeline.scoreEvents[1],
          direction: 'score'
        });

        appendDownloadRollingTextGroup({
          status,
          id: 'btp2score',
          x: DOWNLOAD_STATUS_SCORE_X[2],
          fontSize: 36,
          history: timeline.scoreHistory[2],
          events: timeline.scoreEvents[2],
          direction: 'score'
        });

        [0, 1, 2, 3, 4, 5].forEach(tileIndex => {
          appendDownloadRollingTextGroup({
            status,
            id: 'btpiece' + tileIndex,
            x: DOWNLOAD_STATUS_TILE_X[tileIndex],
            fontSize: 24,
            history: timeline.countHistory[tileIndex],
            events: timeline.countEvents[tileIndex],
            direction: 'count'
          });
        });

        svg.appendChild(status);

        return status;
      }

      function createDownloadRingPath(ringData) {
        const pathPoints = (ringData.path || [])
          .filter(Boolean)
          .map(piece => {
            const transform = getPieceTransform(piece.svgId, piece.vertices);

            return {
              x: transform.tx,
              y: transform.ty
            };
          });

        if (pathPoints.length < 2) {
          return null;
        }

        const d =
          pathPoints
            .map((point, index) => {
              return (
                (index === 0 ? 'M ' : 'L ') +
                trimNum(point.x) +
                ' ' +
                trimNum(point.y)
              );
            })
            .join(' ') + ' Z';

        return createDownloadSvgElement('path', {
          d,
          fill: 'none',
          stroke: ringData.scorer === 1 ? '#8b0000' : '#006400',
          'stroke-width': '3',
          'stroke-linejoin': 'round',
          'stroke-linecap': 'round',
          opacity: '0',
          'pointer-events': 'none'
        });
      }

      function addDownloadAnimation(svg) {
        prependDownloadFontStyle(svg);

        const defs = svg.querySelector('defs');
        const drop = svg.querySelector('.etdrop');

        if (!defs || !drop) {
          return;
        }

        /*
         * 動畫只播放正式棋子和當前暫時落下的棋子。
         * ghost 只是操作候選,不應被當作已經出現的回合。
         */
        const actualPieceRecords = pieces.concat(tempPieces);
        const directUses = getDirectDownloadUses(drop);

        /*
         * renderBoard() 先加入 actualPieceRecords,再加入 ghosts,
         * 所以超出 actualPieceRecords 的 use 都是 ghost。
         */
        directUses.slice(actualPieceRecords.length).forEach(use => {
          use.remove();
        });

        /*
         * 動畫模式會自行重建所有歷史頂鑫環。
         * 因此移除 clone 中只代表當前 UI 狀態的圓點和暫時頂鑫環。
         */
        Array.from(drop.children).forEach(child => {
          if (child.localName !== 'use') {
            child.remove();
          }
        });

        const pieceUses = getDirectDownloadUses(drop);

        const entries = pieceUses.map((use, index) => {
          const piece = actualPieceRecords[index];

          return {
            use,
            piece,
            committed: index < pieces.length,
            center: getDownloadPieceCenter(piece, use)
          };
        });

        const turns = groupDownloadAnimationTurns(entries);
        const timeline = buildDownloadAnimationTimeline(turns);

        const initialScale = Math.max(0.0001, boardTransform.scale || 1);
        const initialAngle = boardTransform.angle || 0;

        /*
         * 將原本:
         * translate(...) scale(...) rotate(...)
         *
         * 拆分成三層,防止 SMIL translate 和 scale 互相覆蓋。
         */
        const zoomGroup = createDownloadSvgElement('g', {
          id: 'btzoom',
          transform: `scale(${trimNum(initialScale)})`
        });

        const worldGroup = createDownloadSvgElement('g', {
          id: 'btworld',
          transform: `rotate(${trimNum(initialAngle)})`
        });

        pieceUses.forEach(use => {
          worldGroup.appendChild(use);
        });

        zoomGroup.appendChild(worldGroup);
        drop.replaceChildren(zoomGroup);

        drop.setAttribute(
          'transform',
          `translate(${trimNum(boardTransform.tx)},${trimNum(boardTransform.ty)})`
        );

        drop.setAttribute('opacity', '0');

        /*
         * bt0:整個棋盤淡入。
         * btend 結束後重新播放。
         */
        svg.appendChild(
          createDownloadSvgElement('animate', {
            id: 'bt0',
            href: '#etanidrop',
            begin: '0s;btend.end',
            attributeName: 'opacity',
            values: '0;0;1',
            fill: 'freeze',
            dur: '2s'
          })
        );

        const status = appendDownloadStatus(svg, defs, timeline);

        status.appendChild(
          createDownloadSvgElement('animate', {
            href: '#btstatus',
            begin: 'bt0.begin',
            attributeName: 'opacity',
            values: '0;0;1',
            fill: 'freeze',
            dur: '2s'
          })
        );

        /*
         * 所有棋子在 bt0 開始時先恢復到 opacity=0。
         */
        entries.forEach(entry => {
          entry.use.appendChild(
            createDownloadSvgElement('animate', {
              begin: 'bt0.begin',
              attributeName: 'opacity',
              values: '1;0',
              fill: 'freeze',
              dur: '1s'
            })
          );
        });

        /*
         * 每回合棋子依次出現。
         * 同一回合第二個棋子的動畫不設 id。
         */
        turns.forEach((turn, turnIndex) => {
          const animationTurn = turnIndex + 1;

          const begin =
            animationTurn === 1
              ? 'bt0.end'
              : `bt${animationTurn - 1}.end`;

          turn.forEach((entry, pieceIndex) => {
            const attributes = {
              begin,
              attributeName: 'opacity',
              values: '0;1;1',
              fill: 'freeze',
              dur: '2s'
            };

            if (pieceIndex === 0) {
              attributes.id = 'bt' + animationTurn;
            }

            entry.use.appendChild(
              createDownloadSvgElement('animate', attributes)
            );
          });
        });

        /*
         * 重建所有歷史頂鑫環,並在對應回合中段顯示。
         */
        timeline.rings.forEach(item => {
          const ringPath = createDownloadRingPath(item.ring);

          if (!ringPath) {
            return;
          }

          ringPath.appendChild(
            createDownloadSvgElement('set', {
              begin: `bt${item.turn}.begin+1s`,
              attributeName: 'opacity',
              to: '1',
              dur: '0.777s'
            })
          );

          worldGroup.appendChild(ringPath);
        });

        /*
         * 沒有棋子時仍顯示一個可循環的空局面預覽。
         */
        if (turns.length === 0) {
          svg.appendChild(
            createDownloadSvgElement('animate', {
              id: 'btend',
              href: '#etanidrop',
              begin: 'bt0.end+4s',
              attributeName: 'opacity',
              values: '1;0',
              fill: 'freeze',
              dur: '2s'
            })
          );

          svg.appendChild(
            createDownloadSvgElement('animate', {
              href: '#btstatus',
              begin: 'btend.begin',
              attributeName: 'opacity',
              values: '1;0',
              fill: 'freeze',
              dur: '2s'
            })
          );

          return;
        }

        /*
         * 鏡頭順序:
         * 初始座標;
         * 回合1座標;回合1座標;
         * 回合2座標;回合2座標……
         */
        const initialCamera = {
          x: boardTransform.tx,
          y: boardTransform.ty
        };

        const turnCameras = turns.map(turn => {
          return getDownloadCameraTranslation(
            getDownloadTurnCenter(turn),
            initialScale,
            initialAngle
          );
        });

        const cameraValues = [formatDownloadPoint(initialCamera)];

        turnCameras.forEach(camera => {
          const formatted = formatDownloadPoint(camera);

          cameraValues.push(formatted, formatted);
        });

        svg.appendChild(
          createDownloadSvgElement('animateTransform', {
            attributeName: 'transform',
            attributeType: 'XML',
            type: 'translate',
            href: '#etanidrop',
            values: cameraValues.join(';'),
            dur: `${turns.length * 2}s`,
            fill: 'freeze',
            begin: 'bt0.end'
          })
        );

        const fullView = getDownloadFullView(
          actualPieceRecords,
          initialAngle
        );

        const lastTurnNumber = turns.length;
        const currentCamera =
          turnCameras[turnCameras.length - 1] || initialCamera;

        /*
         * 最後一回合後停兩秒,再移至全景。
         */
        svg.appendChild(
          createDownloadSvgElement('animateTransform', {
            attributeName: 'transform',
            attributeType: 'XML',
            type: 'translate',
            id: 'btfull',
            href: '#etanidrop',
            values:
              `${formatDownloadPoint(currentCamera)};` +
              `${trimNum(fullView.tx)},${trimNum(fullView.ty)}`,
            dur: '2s',
            fill: 'freeze',
            begin: `bt${lastTurnNumber}.end+2s`
          })
        );

        svg.appendChild(
          createDownloadSvgElement('animateTransform', {
            attributeName: 'transform',
            attributeType: 'XML',
            type: 'scale',
            href: '#btzoom',
            values:
              `${trimNum(initialScale)};${trimNum(fullView.scale)}`,
            dur: '2s',
            fill: 'freeze',
            begin: `bt${lastTurnNumber}.end+2s`
          })
        );

        /*
         * 全景停留四秒後淡出。
         */
        svg.appendChild(
          createDownloadSvgElement('animate', {
            id: 'btend',
            href: '#etanidrop',
            begin: 'btfull.end+4s',
            attributeName: 'opacity',
            values: '1;0',
            fill: 'freeze',
            dur: '2s'
          })
        );

        svg.appendChild(
          createDownloadSvgElement('animate', {
            href: '#btstatus',
            begin: 'btend.begin',
            attributeName: 'opacity',
            values: '1;0',
            fill: 'freeze',
            dur: '2s'
          })
        );

        /*
         * 淡出後在不可見期間恢復初始鏡頭。
         * 重置動畫使用 btreset,避免與 btfull 重複 ID。
         */
        svg.appendChild(
          createDownloadSvgElement('animateTransform', {
            attributeName: 'transform',
            attributeType: 'XML',
            type: 'translate',
            id: 'btreset',
            href: '#etanidrop',
            values:
              `${trimNum(fullView.tx)},${trimNum(fullView.ty)};` +
              `${formatDownloadPoint(initialCamera)}`,
            dur: '1s',
            fill: 'freeze',
            begin: 'btend.end'
          })
        );

        svg.appendChild(
          createDownloadSvgElement('animateTransform', {
            attributeName: 'transform',
            attributeType: 'XML',
            type: 'scale',
            href: '#btzoom',
            values:
              `${trimNum(fullView.scale)};${trimNum(initialScale)}`,
            dur: '1s',
            fill: 'freeze',
            begin: 'btend.end'
          })
        );
      }

      function buildDownloadSvg() {
        const mode = getDownloadMode();
        const svg = createDownloadBoardClone();

        if (mode === 'number') {
          addDownloadPieceNumbers(svg);
        } else if (mode === 'animate') {
          addDownloadAnimation(svg);
        }

        return svg;
      }

      function serializeDownloadSvg(svg) {
        const serializer = new XMLSerializer();
        let source = serializer.serializeToString(svg);

        if (!/^<svg\b[^>]*\bxmlns=/.test(source)) {
          source = source.replace(
            /^<svg/,
            '<svg xmlns="http://www.w3.org/2000/svg"'
          );
        }

        return '<?xml version="1.0" standalone="no"?>\r\n' + source;
      }

      /*
       * 根據預覽區的實際可用寬高,建立最大的 1:1 正方形。
       */
      function fitDownloadPreview() {
        const shell = document.querySelector(
          '#download-dialog .download-preview-shell'
        );

        const preview = document.getElementById('download-preview');

        if (!shell || !preview) {
          return;
        }

        const side = Math.max(
          0,
          Math.floor(
            Math.min(
              Math.max(0, shell.clientWidth - 14),
              Math.max(0, shell.clientHeight - 14)
            )
          )
        );

        preview.style.width = side + 'px';
        preview.style.height = side + 'px';
      }

      function releaseDownloadPreviewUrl() {
        if (downloadPreviewUrl) {
          URL.revokeObjectURL(downloadPreviewUrl);
          downloadPreviewUrl = '';
        }
      }

      /*
       * 預覽和下載共用同一份 Blob,所以內容及 byte 大小完全一致。
       * object 使用獨立 SVG 文件環境,避免 clone 中的 tile0、etanidrop
       * 等 ID 與正式棋盤的同名 ID 發生衝突。
       */
      function refreshDownloadPreview() {
        const preview = document.getElementById('download-preview');
        const sizeElement = document.getElementById('download-size');

        if (!preview || !sizeElement) {
          return;
        }

        preview.replaceChildren();
        releaseDownloadPreviewUrl();

        const svg = buildDownloadSvg();

        downloadSvgSource = serializeDownloadSvg(svg);

        downloadSvgBlob = new Blob([downloadSvgSource], {
          type: 'image/svg+xml;charset=utf-8'
        });

        sizeElement.textContent = `${downloadSvgBlob.size} byte`;

        downloadPreviewUrl = URL.createObjectURL(downloadSvgBlob);

        const object = document.createElement('object');

        object.type = 'image/svg+xml';
        object.data = downloadPreviewUrl;
        object.setAttribute('aria-label', '下載 SVG 預覽');

        preview.appendChild(object);

        requestAnimationFrame(fitDownloadPreview);
      }

      function openDownloadDialog() {
        const dialog = document.getElementById('download-dialog');
        const numberOption = document.getElementById(
          'download-option-number'
        );
        const animateOption = document.getElementById(
          'download-option-animate'
        );

        /*
         * 每次打開都回復成兩個選項均未選。
         */
        numberOption.checked = false;
        animateOption.checked = false;

        dialog.style.display = 'flex';

        refreshDownloadPreview();

        requestAnimationFrame(() => {
          fitDownloadPreview();
          requestAnimationFrame(fitDownloadPreview);
        });
      }

      function closeDownloadDialog() {
        const dialog = document.getElementById('download-dialog');
        const preview = document.getElementById('download-preview');

        dialog.style.display = 'none';

        if (preview) {
          preview.replaceChildren();
          preview.style.width = '';
          preview.style.height = '';
        }

        releaseDownloadPreviewUrl();

        downloadSvgSource = '';
        downloadSvgBlob = null;
      }

      function getDownloadTimestamp() {
        const now = new Date();
        const pad = value => String(value).padStart(2, '0');

        return (
          String(now.getFullYear()) +
          pad(now.getMonth() + 1) +
          pad(now.getDate()) +
          '_' +
          pad(now.getHours()) +
          pad(now.getMinutes()) +
          pad(now.getSeconds())
        );
      }

      function getDownloadFilename() {
        const mode = getDownloadMode();
        const timestamp = getDownloadTimestamp();

        if (mode === 'number') {
          return `bracethetrigolden_number_${timestamp}.svg`;
        }

        if (mode === 'animate') {
          return `bracethetrigolden_animate_${timestamp}.svg`;
        }

        return `bracethetrigolden_${timestamp}.svg`;
      }

      function confirmSVGDownload() {
        /*
         * 正常情況下直接使用預覽中的同一份 Blob。
         * 如果瀏覽器清除了預覽資料,再重新建立一次。
         */
        if (!downloadSvgBlob || !downloadPreviewUrl) {
          refreshDownloadPreview();
        }

        if (!downloadSvgBlob || !downloadPreviewUrl) {
          return;
        }

        const anchor = document.createElement('a');

        anchor.href = downloadPreviewUrl;
        anchor.download = getDownloadFilename();
        anchor.style.display = 'none';

        document.body.appendChild(anchor);
        anchor.click();
        anchor.remove();
      }

      /*
       * 保留工具欄原本的 onclick="downloadSVG()",
       * 但現在只負責打開下載窗口。
       */
      function downloadSVG() {
        openDownloadDialog();
      }

      window.addEventListener('resize', () => {
        const dialog = document.getElementById('download-dialog');

        if (dialog && dialog.style.display === 'flex') {
          fitDownloadPreview();
        }
      });

      document.addEventListener('keydown', event => {
        if (event.key !== 'Escape') {
          return;
        }

        const dialog = document.getElementById('download-dialog');

        if (dialog && dialog.style.display === 'flex') {
          closeDownloadDialog();
        }
      });

修改後的行為

  1. 玩家一、玩家二控制區的:

    • 分數 .score-num
    • 棋子數量 .tile-count
      都會使用 Techfont
  2. 點擊原工具欄下載按鈕,不再直接下載,而是打開下載窗口。

  3. 每次打開窗口:

    • 「數字」未選。
    • 「動畫」未選。
    • 預覽普通 SVG 局面。
    • 顯示實際 UTF-8 SVG Blob 大小。
  4. 選項可以:

    • 兩個都不選。
    • 只選數字。
    • 只選動畫。
    • 不能同時選中。
  5. 普通文件名:

text
bracethetrigolden_yyyymmdd_hhiiss.svg
  1. 數字文件名:
text
bracethetrigolden_number_yyyymmdd_hhiiss.svg
  1. 動畫文件名:
text
bracethetrigolden_animate_yyyymmdd_hhiiss.svg
  1. 數字模式會在 SVG 第一個子節點加入字體,並在 .etdrop 末尾加入編號。

  2. 動畫模式會:

    • 重建當前局全部已發生的分數變化。
    • 重建全部歷史頂鑫環,而不只是 allRings 中暫時保存的最後一組。
    • 支援未滿 28 回合、半局、暫時落子及仲裁預覽。
    • 全景視圖在狀態欄下方區域至少保留 12 的棋子邊距。
    • 播放完畢後淡出、重置鏡頭並循環播放。
    • 不調用正式落子、存檔、回退、AI 或仲裁函數。