QuanAI只读快照

共享会话

🔧 修復與改進下載功能

分享于 2026年8月23日 08:16
提问者

修復和改進 一,開局時的第一手棋的旋轉問題

當開局的第一手棋旋轉時,所旋轉的應是棋子本身,而不是棋盤(etanidrop)。

更改所有受此影響的代碼。

二,改進下載功能

不要加入「」這兩層。當etanidrop在移動鏡頭時,平移則直接使用「animateTransform attributeName="transform" attributeType="XML" type="translate"」,同時平移和縮放則把「animateTransform attributeName="transform" attributeType="XML" type="translate"」和「animateTransform attributeName="transform" attributeType="XML" type="scale" additive="sum"」聯合起來使用。

在「下載窗口」中,如果是電腦端,download-preview-shell不需要横向鋪滿,只需要加上padding,同時將窗口寬度改為600px。如果是手機端,download-preview-shell也不要縱向鋪滿,同樣只需加上padding,允許窗口外上下存在空隙。

在生成動畫svg時,把「棋子數量」改為9~0十個數字全部生成並縱向排列,間隔仍是60,則如果在某一回合同一個棋用掉了兩個,位移會是120。

把「分數」,「棋子數量」的text的縱坐標都往下移一些。

當動畫結束時,應加上「分數」和「棋子數量」回到初始狀態的動畫「」」。

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

trigolden_game2662.htmltext/plain · 355.9 KB预览下载
QuanAI

我會先定位首手旋轉、etanidrop 鏡頭動畫、SVG 下載生成與下載視窗樣式的實作,再依現有資料流提出可直接替換且不改動遊戲規則的程式碼。

以下修改只調整顯示座標、下載 SVG 動畫與下載視窗排版,不修改棋子的精確代數頂點、貼邊算法、合法性判定、計分、AI、棋譜格式、回退/前進及回合流程。

核心做法是:

  • boardTransform.angle 繼續保存首手角度,以相容棋譜、歷史狀態和導入功能。
  • 但角度不再寫入 etanidrop
  • 首手角度改為加入每一枚棋子的顯示座標,從而由各個棋子的 <use transform="... rotate(...)"> 承擔旋轉。
  • 下載動畫不再產生 btzoombtworld 兩層。
  • 鏡頭動畫全部直接作用於 #etanidrop

一、修復開局第一手棋旋轉問題

1. 修改 exactToScreen()

搜尋:

js
function exactToScreen(p) {

把目前整個 exactToScreen() 替換為以下程式碼,並在它前面加入 rotateOpeningDisplayPoint()

js
/*
 * 首手角度只套用到棋子及其局部座標。
 * 不再透過旋轉 etanidrop 來旋轉整個棋盤。
 */
function rotateOpeningDisplayPoint(point, angleDegrees) {
  const angle = angleDegrees || 0;

  if (angle === 0 || angle === 360) {
    return point;
  }

  const radians = (angle * 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 exactToScreen(p) {
  const pt = exactToScreenRaw(p);

  if (pieces.length === 0 && tempPieces.length === 0) {
    return pt;
  }

  /*
   * 先抵消第一手棋的精確幾何偏移及翻轉,
   * 維持原有精確代數座標和合法性判定流程。
   */
  const nx = pt.x - globalOffset.tx;
  const ny = pt.y - globalOffset.ty;

  const ix = (globalOffset.d * nx - globalOffset.c * ny) / globalOffset.det;
  const iy = (-globalOffset.b * nx + globalOffset.a * ny) / globalOffset.det;

  const localPoint = {
    x: globalOffset.sx * ix,
    y: iy
  };

  /*
   * 最後把首手角度加入棋子的顯示座標。
   * getPieceTransform() 因而會把角度寫入每枚棋子的 transform,
   * 而不是寫入 etanidrop。
   */
  return rotateOpeningDisplayPoint(localPoint, boardTransform.angle || 0);
}

這裡不要直接修改 vertices。首手允許任意 11360360 度,但目前的精確代數座標只適合遊戲幾何規則;如果直接旋轉精確頂點,會影響貼邊、碰撞、計分和 AI。以上做法只改變顯示轉換,不改變遊戲幾何。


2. 修改 updateTransform()

搜尋目前的:

js
function updateTransform() {
  etanidrop.setAttribute(
    'transform',
    `translate(${trimNum(boardTransform.tx)},${trimNum(boardTransform.ty)}) scale(${trimNum(boardTransform.scale)}) rotate(${boardTransform.angle || 0})`
  );
}

整個替換為:

js
function updateTransform() {
  /*
   * etanidrop 只負責鏡頭平移及縮放。
   * 首手角度已由 exactToScreen() 加入每枚棋子的 transform。
   */
  etanidrop.setAttribute(
    'transform',
    `translate(${trimNum(boardTransform.tx)},${trimNum(boardTransform.ty)}) scale(${trimNum(boardTransform.scale)})`
  );
}

修改後,正式棋盤中的 etanidrop 不會再有:

xml
rotate(...)

3. 修改首手拖曳旋轉時的重新繪製

boardSvg.addEventListener('pointermove', ...) 中搜尋:

js
boardTransform.angle = newAngle;
updateTransform();
// 三、同步更新首手棋子暫存的旋轉角度,避免棋譜永遠記錄成 360
if (tempPieces[0]) tempPieces[0].boardAngle = newAngle;

替換為:

js
boardTransform.angle = newAngle;

/*
 * 角度現在位於棋子的 transform 中,因此必須重新計算棋子顯示轉換,
 * 不能只更新 etanidrop。
 */
if (tempPieces[0]) {
  tempPieces[0].boardAngle = newAngle;
}

renderBoard();

因為首回合只有一枚棋子,拖動時重新繪製不會影響目前遊戲效能。


4. 修改 AI 第一手棋

placeAIFirstMove() 中搜尋:

js
boardTransform.angle = angle;
updateTransform();

替換為:

js
/*
 * 只保存首手角度。
 * renderBoard() 會把這個角度寫入棋子本身的 transform。
 */
boardTransform.angle = angle;

後面的:

js
updateUI();
renderBoard();

保持不變。


5. 建議更新變數註釋,但不要改名

目前:

js
let boardTransform = { tx: 240, ty: 240, scale: 1, angle: 0 };

可改成:

js
/*
 * tx、ty、scale 是 etanidrop 的鏡頭變換;
 * angle 是首手棋子的顯示角度,不再直接套用到 etanidrop。
 * 保留原欄位名稱,以相容棋譜、undo/redo 和歷史狀態。
 */
let boardTransform = { tx: 240, ty: 240, scale: 1, angle: 0 };

不要把 angle 改名,否則還要修改棋譜導入、歷史快照和存檔格式,沒有必要。


二、修改下載視窗排版

1. 替換 #download-dialog.download-dialog-box

搜尋目前的:

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

替換為:

css
#download-dialog {
  padding: 16px;
  box-sizing: border-box;
  overflow: auto;
}

.download-dialog-box {
  width: min(600px, calc(100vw - 32px));
  height: auto;
  max-width: 600px;

  /* vh 是備援,dvh 用於手機動態視窗高度 */
  max-height: calc(100vh - 32px);
  max-height: calc(100dvh - 32px);

  padding: 14px 18px 12px;
  box-sizing: border-box;
  display: grid;

  /*
   * 五個正常網格項目:
   * 標題、預覽、選項、下載按鈕、大小。
   * 不再讓預覽列使用 minmax(0, 1fr) 鋪滿剩餘高度。
   */
  grid-template-rows: repeat(5, auto);
  gap: 9px;
  overflow: auto;
}

這會把電腦端下載窗口寬度改為 600px,同時不再強制鋪滿整個視窗高度。


2. 替換 .download-preview-shell

搜尋完整的:

css
.download-preview-shell {
  ...
}

替換為:

css
.download-preview-shell {
  /*
   * 不橫向或縱向鋪滿 grid。
   * shell 只包住 SVG 預覽及 padding。
   */
  width: fit-content;
  height: fit-content;
  max-width: 100%;
  min-width: 0;
  min-height: 0;
  justify-self: center;
  align-self: center;
  flex: none;

  padding: 14px;
  box-sizing: border-box;

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

3. 替換手機端 @media (max-width: 600px)

把目前整個下載相關的:

css
@media (max-width: 600px) {
  ...
}

替換為:

css
@media (max-width: 600px) {
  #download-dialog {
    /*
     * 上下保留 16px,左右保留 8px。
     * 對話框內容較小時,窗口外可以自然存在上下空隙。
     */
    padding: 16px 8px;
  }

  .download-dialog-box {
    width: calc(100vw - 16px);
    height: auto;
    max-width: 600px;

    max-height: calc(100vh - 32px);
    max-height: calc(100dvh - 32px);

    padding: 10px 8px 8px;
    gap: 6px;
    overflow: auto;
  }

  .download-preview-shell {
    width: fit-content;
    height: fit-content;
    justify-self: center;
    align-self: center;
    padding: 10px;
  }

  .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) 可以保留。


4. 替換 fitDownloadPreview()

因為 download-preview-shell 現在是 fit-content,不能再根據 shell 當前寬高反推預覽尺寸,否則會出現循環尺寸問題。

搜尋整個:

js
function fitDownloadPreview() {

替換為:

js
function fitDownloadPreview() {
  const dialog = document.getElementById('download-dialog');
  const box = document.querySelector('#download-dialog .download-dialog-box');
  const shell = document.querySelector('#download-dialog .download-preview-shell');
  const preview = document.getElementById('download-preview');

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

  const toPixels = value => {
    const result = parseFloat(value);
    return Number.isFinite(result) ? result : 0;
  };

  /*
   * 先清除舊尺寸,讓 shell 回到只有 padding 和 border 的自然大小,
   * 再計算目前窗口能容納的最大正方形。
   */
  preview.style.width = '0px';
  preview.style.height = '0px';

  const dialogStyle = getComputedStyle(dialog);
  const boxStyle = getComputedStyle(box);
  const shellStyle = getComputedStyle(shell);

  const dialogVerticalPadding =
    toPixels(dialogStyle.paddingTop) +
    toPixels(dialogStyle.paddingBottom);

  const boxHorizontalChrome =
    toPixels(boxStyle.paddingLeft) +
    toPixels(boxStyle.paddingRight) +
    toPixels(boxStyle.borderLeftWidth) +
    toPixels(boxStyle.borderRightWidth);

  const boxVerticalChrome =
    toPixels(boxStyle.paddingTop) +
    toPixels(boxStyle.paddingBottom) +
    toPixels(boxStyle.borderTopWidth) +
    toPixels(boxStyle.borderBottomWidth);

  const shellHorizontalChrome =
    toPixels(shellStyle.paddingLeft) +
    toPixels(shellStyle.paddingRight) +
    toPixels(shellStyle.borderLeftWidth) +
    toPixels(shellStyle.borderRightWidth);

  const shellVerticalChrome =
    toPixels(shellStyle.paddingTop) +
    toPixels(shellStyle.paddingBottom) +
    toPixels(shellStyle.borderTopWidth) +
    toPixels(shellStyle.borderBottomWidth);

  /*
   * 關閉按鈕是 absolute,不佔 grid row,因此不加入高度。
   */
  const flowChildren = Array.from(box.children).filter(child => {
    return !child.classList.contains('download-close-btn');
  });

  const fixedRowsHeight = flowChildren
    .filter(child => child !== shell)
    .reduce((sum, child) => {
      return sum + child.getBoundingClientRect().height;
    }, 0);

  const rowGap = toPixels(boxStyle.rowGap || boxStyle.gap);
  const totalGapHeight = Math.max(0, flowChildren.length - 1) * rowGap;

  const boxOuterWidth = box.getBoundingClientRect().width;

  const maxByWidth =
    boxOuterWidth -
    boxHorizontalChrome -
    shellHorizontalChrome;

  /*
   * 對話框外保留由 #download-dialog padding 指定的上下空隙。
   */
  const availableBoxHeight =
    window.innerHeight -
    dialogVerticalPadding;

  const maxByHeight =
    availableBoxHeight -
    boxVerticalChrome -
    fixedRowsHeight -
    totalGapHeight -
    shellVerticalChrome;

  const side = Math.max(
    0,
    Math.floor(
      Math.min(
        480,
        maxByWidth,
        maxByHeight
      )
    )
  );

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

三、生成固定的 9~0 棋子數量

1. 加入文字基準縱座標

在:

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

後面加入:

js
/*
 * 原本是 y=30,統一向下移 4。
 * 分數和棋子數量都使用這個基準。
 */
const DOWNLOAD_STATUS_TEXT_Y = 34;

2. 修改 buildDownloadAnimationTimeline() 的初始化

搜尋:

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

替換為:

js
/*
 * 每一種棋子的數量固定生成 N_PIECES~0。
 * 目前 N_PIECES=9,因此會生成 9、8、7、6、5、4、3、2、1、0
 * 共十個數字。
 */
countHistory: Array.from({ length: 6 }, () =>
  Array.from(
    { length: N_PIECES + 1 },
    (_, index) => N_PIECES - index
  )
),

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

3. 修改同回合使用棋子的事件位移

在同一函式中搜尋:

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

整段替換為:

js
usedThisTurn.forEach((usedCount, tileIndex) => {
  remainingCounts[tileIndex] = Math.max(
    0,
    remainingCounts[tileIndex] - usedCount
  );

  /*
   * m 是從 9 開始累計已使用的棋子數,而不是事件次數。
   *
   * 使用一枚:m 增加 1,位移 -60。
   * 同回合使用兩枚同類棋:m 增加 2,位移直接增加到 -120。
   */
  timeline.countEvents[tileIndex].push({
    turn: turnNumberForAnimation,
    m: N_PIECES - remainingCounts[tileIndex]
  });
});

修改後不要再向 countHistory 動態 push()


四、向下移動分數和棋子數量,並加入循環復位動畫

搜尋整個:

js
function appendDownloadRollingTextGroup({

替換為:

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

  history.forEach((value, index) => {
    /*
     * 分數向上排列,棋子數量向下排列。
     * 基準由原來的 30 改為 34,兩者都向下移 4。
     */
    const y =
      direction === 'score'
        ? DOWNLOAD_STATUS_TEXT_Y - index * 60
        : DOWNLOAD_STATUS_TEXT_Y + 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);
  });

  /*
   * 每次 bt0 開始時,把分數和棋子數量滾動列復位。
   * 這也會在 btend.end 觸發下一輪 bt0 時執行。
   */
  group.appendChild(
    createDownloadSvgElement('animateTransform', {
      attributeName: 'transform',
      attributeType: 'XML',
      type: 'translate',
      to: '0,0',
      dur: '1s',
      fill: 'freeze',
      begin: 'bt0.begin'
    })
  );

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

生成結果中,六個棋子數量群組和兩個分數群組都會包含:

xml
<animateTransform
  attributeName="transform"
  attributeType="XML"
  type="translate"
  to="0,0"
  dur="1s"
  fill="freeze"
  begin="bt0.begin"
/>

五、刪除下載 SVG 的 btzoombtworld

1. 修改下載鏡頭座標函式

刪除整個:

js
function rotateDownloadPoint(point, angleDegrees) {
  ...
}

因為首手角度現在已經由 exactToScreen() 寫入棋子的顯示座標,不應在下載鏡頭中再旋轉一次。

然後搜尋:

js
function getDownloadCameraTranslation(center, scale, angle) {

替換為:

js
function getDownloadCameraTranslation(center, scale) {
  /*
   * center 已包含首手棋子的顯示角度,
   * 此處只計算鏡頭平移,不再額外旋轉。
   */
  return {
    x: 240 - center.x * scale,
    y: 270 - center.y * scale
  };
}

2. 替換 getDownloadFullView()

搜尋整個:

js
function getDownloadFullView(pieceRecords, angle) {

替換為:

js
function getDownloadFullView(pieceRecords) {
  const points = [];

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

    piece.vertices.forEach(vertex => {
      /*
       * exactToScreen() 已包含首手顯示角度,
       * 不能再呼叫 rotateDownloadPoint(),否則會重複旋轉。
       */
      points.push(exactToScreen(vertex));
    });
  });

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

六、修改 addDownloadAnimation()

以下都在 addDownloadAnimation(svg) 內。

1. 刪除 btzoombtworld 建立程式碼

搜尋從:

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

一直到:

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

把這整段替換為:

js
const initialScale = Math.max(
  0.0001,
  boardTransform.scale || 1
);

/*
 * 棋子和環路都直接放在 etanidrop。
 * 不再建立:
 *
 * <g id="btzoom" ...>
 * <g id="btworld" ...>
 */
drop.replaceChildren(...pieceUses);

/*
 * etanidrop 的靜態變換只保留鏡頭平移和縮放。
 * 首手角度已包含在每枚棋子的 use transform 中。
 */
drop.setAttribute(
  'transform',
  `translate(${trimNum(boardTransform.tx)},${trimNum(boardTransform.ty)}) ` +
    `scale(${trimNum(initialScale)})`
);

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

必須完全刪除原來的:

js
const zoomGroup = ...
const worldGroup = ...
zoomGroup.appendChild(worldGroup);
drop.replaceChildren(zoomGroup);

2. 頂鑫環直接加入 etanidrop

搜尋:

js
worldGroup.appendChild(ringPath);

替換為:

js
drop.appendChild(ringPath);

3. 修改每回合鏡頭動畫

搜尋:

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

替換為:

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

然後把目前建立 cameraValues 後的這個平移動畫:

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

替換為:

js
const cameraDuration = `${turns.length * 2}s`;
const cameraBegin = 'bt0.end';

/*
 * 平移直接作用於 etanidrop。
 */
svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'translate',
    href: '#etanidrop',
    values: cameraValues.join(';'),
    dur: cameraDuration,
    fill: 'freeze',
    begin: cameraBegin
  })
);

/*
 * translate 動畫會取代 etanidrop 原本的 transform,
 * 因此用 additive="sum" 的 scale 保留目前縮放比例。
 *
 * 這不是額外的 SVG 群組,只是同一個 etanidrop 上的
 * SMIL transform 動畫組合。
 */
svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'scale',
    additive: 'sum',
    href: '#etanidrop',
    values: `${trimNum(initialScale)};${trimNum(initialScale)}`,
    dur: cameraDuration,
    fill: 'freeze',
    begin: cameraBegin
  })
);

4. 修改全景平移和縮放

搜尋:

js
const fullView = getDownloadFullView(actualPieceRecords, initialAngle);

替換為:

js
const fullView = getDownloadFullView(actualPieceRecords);

然後把目前的 btfull 平移動畫和 #btzoom 縮放動畫整段替換為:

js
const fullViewBegin = `bt${lastTurnNumber}.end+2s`;

/*
 * 先建立平移動畫。
 */
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: fullViewBegin
  })
);

/*
 * camera 階段的 additive scale 已經提供 initialScale。
 * 此處應加入「比例」,不能再次直接加入 fullView.scale,
 * 否則多個 additive scale 會相乘而造成過度縮放。
 */
const fullScaleRatio =
  fullView.scale / initialScale;

svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'scale',
    additive: 'sum',
    href: '#etanidrop',
    values: `1;${trimNum(fullScaleRatio, 6)}`,
    dur: '2s',
    fill: 'freeze',
    begin: fullViewBegin
  })
);

這會生成同一目標上的聯合動畫:

xml
<animateTransform
  attributeName="transform"
  attributeType="XML"
  type="translate"
  ...
/>

<animateTransform
  attributeName="transform"
  attributeType="XML"
  type="scale"
  additive="sum"
  ...
/>

5. 修改動畫結束後的鏡頭復位

搜尋目前的 btreset 平移和 href="#btzoom" 縮放動畫,把兩段一起替換為:

js
/*
 * 淡出完成後,在不可見期間恢復初始鏡頭。
 */
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'
  })
);

/*
 * full 階段已疊加 fullView.scale / initialScale;
 * 復位時再疊加 initialScale / fullView.scale,
 * 兩個比例相消,最終回到 initialScale。
 */
const resetScaleRatio =
  initialScale / fullView.scale;

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

必須刪除所有:

js
href: '#btzoom'

七、修改完成後應不存在的程式碼

在 HTML 內全文搜尋,以下內容都應為零處:

text
id: 'btzoom'
id: 'btworld'
href: '#btzoom'
worldGroup
zoomGroup
rotateDownloadPoint

生成的動畫 SVG 中也不應再出現:

xml
<g id="btzoom" ...>
<g id="btworld" ...>

etanidrop 應直接包含棋子和環路,例如:

xml
<g
  class="etdrop"
  id="etanidrop"
  transform="translate(240,240) scale(1)"
>
  <use href="#tile0" transform="... rotate(137)"/>
  <use href="#tile3" transform="... rotate(...)"/>
  <path d="..." />
</g>

鏡頭動畫則直接指向:

xml
href="#etanidrop"

八、不受影響的遊戲流程

以上修改刻意保留以下部分不變:

  • buildShapeVertices() 的首手精確頂點仍以 ZERO, 0 建立。
  • validateOpeningPiece() 不需要修改。
  • attachByEdge() 不需要修改。
  • isValidGhost()、SAT、頂鑫計分不需要修改。
  • boardAngle 棋譜格式不需要修改。
  • confirmImport() 仍能導入舊棋譜。
  • saveState()undo()redo() 仍保存和恢復原有 boardAngle
  • AI 第一手仍可隨機選擇 11360360 度。
  • 普通 SVG 和數字 SVG 會跟動畫 SVG 使用相同的棋子顯示角度。
  • 自動旋轉雙方控制區的 applyRotation()body.rotated-view 不受影響。

建議最後測試以下情形:

  1. 人類首手拖動旋轉時,檢查 etanidroptransform 沒有 rotate()
  2. 首手棋子的 <use transform> 角度會隨拖動改變。
  3. 首回合確認後,後續棋子仍能正確貼合。
  4. AI 第一手、棋譜導入、undo/redo 均能恢復相同角度。
  5. 動畫 SVG 不包含 btzoombtworld
  6. 同回合使用兩個相同棋子時,數量位移為 120
  7. 動畫重新循環時,兩方分數及六種棋子數量都先回到初始位置。
  8. 電腦端下載窗口為 600px,預覽 shell 只包住預覽和 padding。
  9. 手機端窗口上下保留空隙,預覽 shell 不再縱向鋪滿。