QuanAI只读快照

共享会话

修復和改進

分享于 2026年8月23日 10:11
提问者

修復和改進 在下載功能的「數字」中,所有的數字都應往下移,直至數字的中心移到棋子的坐標。

在下載功能的「動畫」中,在下完最後一子之後,應由當前的位移和縮放狀態,拉至使所有棋子都能顯示在視圖中的狀態。但現在是突然跳到某個地方,而且最後也沒有拉到顯示所有棋子的位移和縮放狀態。請修復這個問題。

另在「動畫」中,增加元素「白點圓」,在etanidrop內的最後加兩個circle,它們的位置將由「animateTransform attributeName="transform" attributeType="XML" type="translate" values="..." keyTimes="..." calcMode="discrete"」來控制,每個棋子落下的begin+1s,就把這兩個圓移至棋子的坐標。注意calcMode="discrete"是在時間結束之時到達。如果當前回合只下一個棋子,則這兩個白點圓移到的坐標相同。兩個白點圓的初始坐標是777,777,即視圖外很遠的地方,當bt0.begin時就把它移走。

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

trigolden_game2663.htmltext/plain · 358.2 KB预览下载
QuanAI

以下修改只作用於下載時建立的 SVG clone,不修改 piecestempPiecesscoresactionCheck()renderBoard() 或正式棋盤的遊戲流程。


一、修正「數字」位置:數字中心對準棋子座標

1. 刪除原本的數字偏移常數

搜尋:

js
const DOWNLOAD_NUMBER_OFFSET = Object.freeze({
  oneDigit: Object.freeze({
    x: -7,
    y: -25
  }),
  twoDigits: Object.freeze({
    x: -13,
    y: -24
  })
});

替換為:

js
/*
 * 動畫白點的視圖外初始座標。
 * 白點本身使用 cx=0、cy=0,實際位置完全由 translate 控制。
 */
const DOWNLOAD_WHITE_DOT_OUTSIDE = Object.freeze({
  x: 777,
  y: 777
});

2. 完整替換 addDownloadPieceNumbers()

搜尋整個:

js
function addDownloadPieceNumbers(svg) {

將此函數完整替換為:

js
/*
 * 數字模式。
 * 所有 text 最後統一加到 .etdrop 末尾,確保數字顯示在棋子上方。
 *
 * x/y 直接使用棋子座標,並使用水平、垂直置中屬性,
 * 使數字的中心與棋子的座標完全重合。
 */
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 text = createDownloadSvgElement('text', {
      x: trimNum(center.x),
      y: trimNum(center.y),
      stroke: 'none',
      fill: '#333',
      'font-size': '24',
      'font-family': 'Techfont',
      'text-anchor': 'middle',
      'dominant-baseline': 'central'
    });

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

這樣不論一位數、兩位數,數字都不再依賴手動的 -25/-24 偏移,而是直接以數字中心對準棋子座標。


二、增加動畫鏡頭分層,修正最後全景跳動及縮放錯誤

原本的問題是:

  • etanidrop 同時承擔平移和縮放。
  • 多個 animateTransform 使用 additive="sum" 疊加縮放。
  • 最後全景動畫開始時,新的 transform 會和前面已經 freeze 的 transform 疊加。
  • 因此會出現突然跳位、縮放比例相乘錯誤,最終也不一定能顯示全部棋子。

修正方式是:

text
btcamera:只控制平移
└─ etanidrop:只控制縮放
   └─ 棋子、環路、白點

如此畫面座標固定為:

js
screenX = cameraTx + localX * scale;
screenY = cameraTy + localY * scale;

getDownloadFullView() 的計算完全一致。


三、加入鏡頭分層及兩個白點圓的輔助函數

getDownloadFullView() 函數結束後、appendDownloadStatusPiece() 之前,加入以下兩個函數:

js
/*
 * 將下載動畫的平移和縮放拆成兩層:
 *
 * btcamera:只負責 translate
 * etanidrop:只負責 scale
 *
 * 這樣最後的全景動畫不需要 additive="sum",
 * 也不會和之前 freeze 的縮放動畫重複相乘。
 */
function installDownloadCameraLayers(drop, initialCamera, initialScale) {
  const parent = drop.parentNode;

  if (!parent) {
    return null;
  }

  const cameraLayer = createDownloadSvgElement('g', {
    id: 'btcamera',
    transform: `translate(${trimNum(initialCamera.x)},${trimNum(initialCamera.y)})`
  });

  parent.insertBefore(cameraLayer, drop);
  cameraLayer.appendChild(drop);

  drop.setAttribute('transform', `scale(${trimNum(initialScale, 6)})`);

  return cameraLayer;
}

/*
 * 在 etanidrop 最後加入兩個白點圓。
 *
 * 每一回合:
 * - 第一個圓移到第一子的座標。
 * - 第二個圓移到第二子的座標。
 * - 如果該回合只有一子,兩個圓使用相同座標。
 *
 * 白點位置完全由單一 animateTransform 的 values/keyTimes 控制。
 * calcMode="discrete" 會在指定 keyTime 到達時切換到新座標。
 *
 * bt0.begin 時,第一個 value 會立即將兩個圓移回 777,777。
 */
function appendDownloadWhiteDotCircles(drop, turns, cycleDurationSeconds) {
  if (!drop || !Number.isFinite(cycleDurationSeconds) || cycleDurationSeconds <= 0) {
    return;
  }

  const outsidePoint = {
    x: DOWNLOAD_WHITE_DOT_OUTSIDE.x,
    y: DOWNLOAD_WHITE_DOT_OUTSIDE.y
  };

  const valuesByCircle = [
    [formatDownloadPoint(outsidePoint)],
    [formatDownloadPoint(outsidePoint)]
  ];

  const keyTimes = ['0'];

  turns.forEach((turn, turnIndex) => {
    const firstCenter =
      turn[0] && turn[0].center
        ? turn[0].center
        : outsidePoint;

    /*
     * 當前回合只有一個棋子時,
     * 第二個白點與第一個白點使用相同座標。
     */
    const secondCenter =
      turn[1] && turn[1].center
        ? turn[1].center
        : firstCenter;

    valuesByCircle[0].push(formatDownloadPoint(firstCenter));
    valuesByCircle[1].push(formatDownloadPoint(secondCenter));

    /*
     * bt0 為 2 秒。
     * 第 n 回合的 bt(n).begin 時間為 2n 秒,
     * 因此 begin+1s 是 2n+1 秒。
     */
    const absoluteTime = 2 * (turnIndex + 1) + 1;
    const normalizedTime = absoluteTime / cycleDurationSeconds;

    keyTimes.push(trimNum(normalizedTime, 9));
  });

  /*
   * 補上 keyTimes=1 所需的最後一個值。
   * 最後一段保持在最後一回合座標,直到下一次 bt0.begin。
   */
  valuesByCircle.forEach(values => {
    values.push(values[values.length - 1]);
  });

  const animationKeyTimes = keyTimes.concat('1').join(';');

  valuesByCircle.forEach((values, circleIndex) => {
    const circle = createDownloadSvgElement('circle', {
      id: `btwhite${circleIndex + 1}`,
      cx: '0',
      cy: '0',
      r: '3',
      fill: '#ffffff',
      stroke: 'none',
      transform: `translate(${DOWNLOAD_WHITE_DOT_OUTSIDE.x},${DOWNLOAD_WHITE_DOT_OUTSIDE.y})`,
      'pointer-events': 'none'
    });

    circle.appendChild(
      createDownloadSvgElement('animateTransform', {
        attributeName: 'transform',
        attributeType: 'XML',
        type: 'translate',
        values: values.join(';'),
        keyTimes: animationKeyTimes,
        calcMode: 'discrete',
        begin: 'bt0.begin',
        dur: `${trimNum(cycleDurationSeconds, 6)}s`,
        fill: 'freeze',
        restart: 'always'
      })
    );

    /*
     * 兩個 circle 在所有棋子和環路之後 append,
     * 因而是 etanidrop 內最後的兩個元素。
     */
    drop.appendChild(circle);
  });
}

四、修改 addDownloadAnimation() 的初始鏡頭結構

addDownloadAnimation(svg) 中搜尋:

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

drop.replaceChildren(...pieceUses);

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

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

替換為:

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

const initialCamera = {
  x: boardTransform.tx,
  y: boardTransform.ty
};

drop.replaceChildren(...pieceUses);

/*
 * 下載 SVG clone 專用:
 * btcamera 控制平移,etanidrop 控制縮放。
 * 不會修改正式棋盤 DOM。
 */
installDownloadCameraLayers(drop, initialCamera, initialScale);

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

五、空局面動畫也加入兩個白點圓

addDownloadAnimation() 的以下區段中:

js
if (turns.length === 0) {

在這個分支的 return; 前加入:

js
/*
 * 空局面的完整循環:
 * bt0 2 秒 + 停留 4 秒 + btend 2 秒 = 8 秒。
 */
appendDownloadWhiteDotCircles(drop, turns, 8);

修改後該分支尾部應為:

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

/*
 * 空局面的完整循環:
 * bt0 2 秒 + 停留 4 秒 + btend 2 秒 = 8 秒。
 */
appendDownloadWhiteDotCircles(drop, turns, 8);

return;

六、在所有環路後加入兩個白點圓

在這段環路程式碼之後:

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

  drop.appendChild(ringPath);
});

立即加入:

js
/*
 * 完整循環時間:
 *
 * bt0:2 秒
 * 所有回合:turns.length * 2 秒
 * 最後一回合後等待:2 秒
 * 拉至全景:2 秒
 * 全景停留:4 秒
 * 淡出:2 秒
 *
 * 合計 turns.length * 2 + 12 秒。
 */
const downloadAnimationCycleDuration = turns.length * 2 + 12;

appendDownloadWhiteDotCircles(
  drop,
  turns,
  downloadAnimationCycleDuration
);

這個位置很重要:環路已經加入完畢,接著才加入白點,所以兩個白點圓確實是 etanidrop 內最後兩個元素。


七、完整替換原本的鏡頭動畫區段

addDownloadAnimation() 中,搜尋從:

js
/*
 * 鏡頭順序:

開始,一直到函數末尾原本的復位縮放動畫結束。

也就是刪除原本使用:

js
additive: 'sum'

以及:

js
const fullScaleRatio = fullView.scale / initialScale;
const resetScaleRatio = initialScale / fullView.scale;

的整段程式,替換為以下內容:

js
/*
 * 鏡頭順序:
 *
 * 初始座標;
 * 第 1 回合的第 1 秒拉到該回合中心;
 * 第 2 秒保持;
 * 後續回合同樣處理。
 *
 * 平移只作用於 #btcamera。
 * 縮放只作用於 #etanidrop。
 */
const turnCameras = turns.map(turn => {
  return getDownloadCameraTranslation(
    getDownloadTurnCenter(turn),
    initialScale
  );
});

const cameraValues = [formatDownloadPoint(initialCamera)];

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

  /*
   * 第一個值用於一秒內拉到新位置;
   * 第二個相同值用於下一秒保持位置。
   */
  cameraValues.push(formatted, formatted);
});

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

/*
 * 回合鏡頭平移。
 */
svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    id: 'btcamera-pan',
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'translate',
    href: '#btcamera',
    values: cameraValues.join(';'),
    dur: cameraDuration,
    fill: 'freeze',
    begin: cameraBegin
  })
);

/*
 * 回合播放期間保持當前下載時的縮放比例。
 *
 * 這裡不再使用 additive="sum",
 * 因此 initialScale 不會與自己重複相乘。
 */
svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    id: 'btcamera-scale',
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'scale',
    href: '#etanidrop',
    values:
      `${trimNum(initialScale, 6)};` +
      `${trimNum(initialScale, 6)}`,
    dur: cameraDuration,
    fill: 'freeze',
    begin: cameraBegin
  })
);

const fullView = getDownloadFullView(actualPieceRecords);

const lastTurnNumber = turns.length;

/*
 * 最後一回合結束時,#btcamera 的實際位置就是最後一個
 * turnCamera,縮放則仍是 initialScale。
 */
const currentCamera =
  turnCameras[turnCameras.length - 1] || initialCamera;

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

/*
 * 從最後一回合當前平移位置,平滑移動到全景位置。
 *
 * 因為 #btcamera 只包含 translate,
 * 新動畫的第一個值與前一動畫最後值完全相同,
 * 開始時不會突然跳位。
 */
svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'translate',
    id: 'btfull',
    href: '#btcamera',
    values:
      `${formatDownloadPoint(currentCamera)};` +
      `${trimNum(fullView.tx)},${trimNum(fullView.ty)}`,
    dur: '2s',
    fill: 'freeze',
    begin: fullViewBegin
  })
);

/*
 * 從當前縮放比例直接平滑拉到全景縮放比例。
 *
 * 不再使用 fullView.scale / initialScale 的 additive 比例,
 * 因而最終 scale 就是 getDownloadFullView() 算出的
 * fullView.scale,能正確顯示全部棋子。
 */
svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'scale',
    href: '#etanidrop',
    values:
      `${trimNum(initialScale, 6)};` +
      `${trimNum(fullView.scale, 6)}`,
    dur: '2s',
    fill: 'freeze',
    begin: fullViewBegin
  })
);

/*
 * 全景停留四秒後淡出。
 */
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'
  })
);

/*
 * 淡出完成後,在不可見期間恢復初始平移位置。
 *
 * bt0 也會在 btend.end 同時重新開始;
 * bt0 的前一秒維持 opacity=0,因此復位過程不會被看見。
 */
svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'translate',
    id: 'btreset',
    href: '#btcamera',
    values:
      `${trimNum(fullView.tx)},${trimNum(fullView.ty)};` +
      `${formatDownloadPoint(initialCamera)}`,
    dur: '1s',
    fill: 'freeze',
    begin: 'btend.end'
  })
);

/*
 * 在不可見期間由 fullView.scale 直接恢復 initialScale。
 * 同樣不使用 additive,不會累積上一輪的縮放比例。
 */
svg.appendChild(
  createDownloadSvgElement('animateTransform', {
    attributeName: 'transform',
    attributeType: 'XML',
    type: 'scale',
    href: '#etanidrop',
    values:
      `${trimNum(fullView.scale, 6)};` +
      `${trimNum(initialScale, 6)}`,
    dur: '1s',
    fill: 'freeze',
    begin: 'btend.end'
  })
);

修正後的動畫行為

  1. 下載動畫從使用者目前的 boardTransform.txboardTransform.tyboardTransform.scale 開始。
  2. 每回合第一秒平移至該回合棋子的中心。
  3. 每個 btN.begin+1s
    • 第一個白點跳到第一子座標。
    • 第二個白點跳到第二子座標。
    • 若只有一子,兩個白點重合。
  4. 最後一子完成後停留兩秒。
  5. 從當前鏡頭位置及當前縮放比例,平滑拉至 getDownloadFullView() 算出的完整局面。
  6. 最終平移、縮放值不再被 additive="sum" 重複相乘,因此所有棋子都會落入狀態欄下方的可視範圍。
  7. 淡出後在不可見期間復位。
  8. 下一次 bt0.begin 時,兩個白點立即回到 777,777