共享会话
修復和改進
分享于 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時就把它移走。
回答要求:不要影響當前正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。
QuanAI以下修改只作用於下載時建立的 SVG clone,不修改 pieces、tempPieces、scores、actionCheck()、renderBoard() 或正式棋盤的遊戲流程。
一、修正「數字」位置:數字中心對準棋子座標
1. 刪除原本的數字偏移常數
搜尋:
const DOWNLOAD_NUMBER_OFFSET = Object.freeze({
oneDigit: Object.freeze({
x: -7,
y: -25
}),
twoDigits: Object.freeze({
x: -13,
y: -24
})
});替換為:
/*
* 動畫白點的視圖外初始座標。
* 白點本身使用 cx=0、cy=0,實際位置完全由 translate 控制。
*/
const DOWNLOAD_WHITE_DOT_OUTSIDE = Object.freeze({
x: 777,
y: 777
});2. 完整替換 addDownloadPieceNumbers()
搜尋整個:
function addDownloadPieceNumbers(svg) {將此函數完整替換為:
/*
* 數字模式。
* 所有 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 疊加。 - 因此會出現突然跳位、縮放比例相乘錯誤,最終也不一定能顯示全部棋子。
修正方式是:
btcamera:只控制平移
└─ etanidrop:只控制縮放
└─ 棋子、環路、白點如此畫面座標固定為:
screenX = cameraTx + localX * scale;
screenY = cameraTy + localY * scale;與 getDownloadFullView() 的計算完全一致。
三、加入鏡頭分層及兩個白點圓的輔助函數
在 getDownloadFullView() 函數結束後、appendDownloadStatusPiece() 之前,加入以下兩個函數:
/*
* 將下載動畫的平移和縮放拆成兩層:
*
* 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) 中搜尋:
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');替換為:
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() 的以下區段中:
if (turns.length === 0) {在這個分支的 return; 前加入:
/*
* 空局面的完整循環:
* bt0 2 秒 + 停留 4 秒 + btend 2 秒 = 8 秒。
*/
appendDownloadWhiteDotCircles(drop, turns, 8);修改後該分支尾部應為:
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;六、在所有環路後加入兩個白點圓
在這段環路程式碼之後:
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);
});立即加入:
/*
* 完整循環時間:
*
* 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() 中,搜尋從:
/*
* 鏡頭順序:開始,一直到函數末尾原本的復位縮放動畫結束。
也就是刪除原本使用:
additive: 'sum'以及:
const fullScaleRatio = fullView.scale / initialScale;
const resetScaleRatio = initialScale / fullView.scale;的整段程式,替換為以下內容:
/*
* 鏡頭順序:
*
* 初始座標;
* 第 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'
})
);修正後的動畫行為
- 下載動畫從使用者目前的
boardTransform.tx、boardTransform.ty、boardTransform.scale開始。 - 每回合第一秒平移至該回合棋子的中心。
- 每個
btN.begin+1s:- 第一個白點跳到第一子座標。
- 第二個白點跳到第二子座標。
- 若只有一子,兩個白點重合。
- 最後一子完成後停留兩秒。
- 從當前鏡頭位置及當前縮放比例,平滑拉至
getDownloadFullView()算出的完整局面。 - 最終平移、縮放值不再被
additive="sum"重複相乘,因此所有棋子都會落入狀態欄下方的可視範圍。 - 淡出後在不可見期間復位。
- 下一次
bt0.begin時,兩個白點立即回到777,777。