共享会话
🔧 修復與改進下載功能
分享于 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的縱坐標都往下移一些。
當動畫結束時,應加上「分數」和「棋子數量」回到初始狀態的動畫「」」。
回答要求:不要影響當前正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。
QuanAI我會先定位首手旋轉、etanidrop 鏡頭動畫、SVG 下載生成與下載視窗樣式的實作,再依現有資料流提出可直接替換且不改動遊戲規則的程式碼。
以下修改只調整顯示座標、下載 SVG 動畫與下載視窗排版,不修改棋子的精確代數頂點、貼邊算法、合法性判定、計分、AI、棋譜格式、回退/前進及回合流程。
核心做法是:
boardTransform.angle繼續保存首手角度,以相容棋譜、歷史狀態和導入功能。- 但角度不再寫入
etanidrop。 - 首手角度改為加入每一枚棋子的顯示座標,從而由各個棋子的
<use transform="... rotate(...)">承擔旋轉。 - 下載動畫不再產生
btzoom、btworld兩層。 - 鏡頭動畫全部直接作用於
#etanidrop。
一、修復開局第一手棋旋轉問題
1. 修改 exactToScreen()
搜尋:
function exactToScreen(p) {把目前整個 exactToScreen() 替換為以下程式碼,並在它前面加入 rotateOpeningDisplayPoint():
/*
* 首手角度只套用到棋子及其局部座標。
* 不再透過旋轉 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。首手允許任意 1~360 度,但目前的精確代數座標只適合遊戲幾何規則;如果直接旋轉精確頂點,會影響貼邊、碰撞、計分和 AI。以上做法只改變顯示轉換,不改變遊戲幾何。
2. 修改 updateTransform()
搜尋目前的:
function updateTransform() {
etanidrop.setAttribute(
'transform',
`translate(${trimNum(boardTransform.tx)},${trimNum(boardTransform.ty)}) scale(${trimNum(boardTransform.scale)}) rotate(${boardTransform.angle || 0})`
);
}整個替換為:
function updateTransform() {
/*
* etanidrop 只負責鏡頭平移及縮放。
* 首手角度已由 exactToScreen() 加入每枚棋子的 transform。
*/
etanidrop.setAttribute(
'transform',
`translate(${trimNum(boardTransform.tx)},${trimNum(boardTransform.ty)}) scale(${trimNum(boardTransform.scale)})`
);
}修改後,正式棋盤中的 etanidrop 不會再有:
rotate(...)3. 修改首手拖曳旋轉時的重新繪製
在 boardSvg.addEventListener('pointermove', ...) 中搜尋:
boardTransform.angle = newAngle;
updateTransform();
// 三、同步更新首手棋子暫存的旋轉角度,避免棋譜永遠記錄成 360
if (tempPieces[0]) tempPieces[0].boardAngle = newAngle;替換為:
boardTransform.angle = newAngle;
/*
* 角度現在位於棋子的 transform 中,因此必須重新計算棋子顯示轉換,
* 不能只更新 etanidrop。
*/
if (tempPieces[0]) {
tempPieces[0].boardAngle = newAngle;
}
renderBoard();因為首回合只有一枚棋子,拖動時重新繪製不會影響目前遊戲效能。
4. 修改 AI 第一手棋
在 placeAIFirstMove() 中搜尋:
boardTransform.angle = angle;
updateTransform();替換為:
/*
* 只保存首手角度。
* renderBoard() 會把這個角度寫入棋子本身的 transform。
*/
boardTransform.angle = angle;後面的:
updateUI();
renderBoard();保持不變。
5. 建議更新變數註釋,但不要改名
目前:
let boardTransform = { tx: 240, ty: 240, scale: 1, angle: 0 };可改成:
/*
* 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
搜尋目前的:
#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 {
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
搜尋完整的:
.download-preview-shell {
...
}替換為:
.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)
把目前整個下載相關的:
@media (max-width: 600px) {
...
}替換為:
@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 當前寬高反推預覽尺寸,否則會出現循環尺寸問題。
搜尋整個:
function fitDownloadPreview() {替換為:
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. 加入文字基準縱座標
在:
const DOWNLOAD_STATUS_SCORE_X = Object.freeze({
1: 300,
2: 180
});後面加入:
/*
* 原本是 y=30,統一向下移 4。
* 分數和棋子數量都使用這個基準。
*/
const DOWNLOAD_STATUS_TEXT_Y = 34;2. 修改 buildDownloadAnimationTimeline() 的初始化
搜尋:
countHistory: Array.from({ length: 6 }, () => [N_PIECES]),
countEvents: Array.from({ length: 6 }, () => []),替換為:
/*
* 每一種棋子的數量固定生成 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. 修改同回合使用棋子的事件位移
在同一函式中搜尋:
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
});
});整段替換為:
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()。
四、向下移動分數和棋子數量,並加入循環復位動畫
搜尋整個:
function appendDownloadRollingTextGroup({替換為:
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);
}生成結果中,六個棋子數量群組和兩個分數群組都會包含:
<animateTransform
attributeName="transform"
attributeType="XML"
type="translate"
to="0,0"
dur="1s"
fill="freeze"
begin="bt0.begin"
/>五、刪除下載 SVG 的 btzoom、btworld
1. 修改下載鏡頭座標函式
刪除整個:
function rotateDownloadPoint(point, angleDegrees) {
...
}因為首手角度現在已經由 exactToScreen() 寫入棋子的顯示座標,不應在下載鏡頭中再旋轉一次。
然後搜尋:
function getDownloadCameraTranslation(center, scale, angle) {替換為:
function getDownloadCameraTranslation(center, scale) {
/*
* center 已包含首手棋子的顯示角度,
* 此處只計算鏡頭平移,不再額外旋轉。
*/
return {
x: 240 - center.x * scale,
y: 270 - center.y * scale
};
}2. 替換 getDownloadFullView()
搜尋整個:
function getDownloadFullView(pieceRecords, angle) {替換為:
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. 刪除 btzoom、btworld 建立程式碼
搜尋從:
const initialScale = Math.max(0.0001, boardTransform.scale || 1);
const initialAngle = boardTransform.angle || 0;一直到:
drop.setAttribute('opacity', '0');把這整段替換為:
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');必須完全刪除原來的:
const zoomGroup = ...
const worldGroup = ...
zoomGroup.appendChild(worldGroup);
drop.replaceChildren(zoomGroup);2. 頂鑫環直接加入 etanidrop
搜尋:
worldGroup.appendChild(ringPath);替換為:
drop.appendChild(ringPath);3. 修改每回合鏡頭動畫
搜尋:
const turnCameras = turns.map(turn => {
return getDownloadCameraTranslation(getDownloadTurnCenter(turn), initialScale, initialAngle);
});替換為:
const turnCameras = turns.map(turn => {
return getDownloadCameraTranslation(
getDownloadTurnCenter(turn),
initialScale
);
});然後把目前建立 cameraValues 後的這個平移動畫:
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 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. 修改全景平移和縮放
搜尋:
const fullView = getDownloadFullView(actualPieceRecords, initialAngle);替換為:
const fullView = getDownloadFullView(actualPieceRecords);然後把目前的 btfull 平移動畫和 #btzoom 縮放動畫整段替換為:
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
})
);這會生成同一目標上的聯合動畫:
<animateTransform
attributeName="transform"
attributeType="XML"
type="translate"
...
/>
<animateTransform
attributeName="transform"
attributeType="XML"
type="scale"
additive="sum"
...
/>5. 修改動畫結束後的鏡頭復位
搜尋目前的 btreset 平移和 href="#btzoom" 縮放動畫,把兩段一起替換為:
/*
* 淡出完成後,在不可見期間恢復初始鏡頭。
*/
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'
})
);必須刪除所有:
href: '#btzoom'七、修改完成後應不存在的程式碼
在 HTML 內全文搜尋,以下內容都應為零處:
id: 'btzoom'
id: 'btworld'
href: '#btzoom'
worldGroup
zoomGroup
rotateDownloadPoint生成的動畫 SVG 中也不應再出現:
<g id="btzoom" ...>
<g id="btworld" ...>etanidrop 應直接包含棋子和環路,例如:
<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>鏡頭動畫則直接指向:
href="#etanidrop"八、不受影響的遊戲流程
以上修改刻意保留以下部分不變:
buildShapeVertices()的首手精確頂點仍以ZERO, 0建立。validateOpeningPiece()不需要修改。attachByEdge()不需要修改。isValidGhost()、SAT、頂鑫計分不需要修改。boardAngle棋譜格式不需要修改。confirmImport()仍能導入舊棋譜。saveState()、undo()、redo()仍保存和恢復原有boardAngle。- AI 第一手仍可隨機選擇 1~360 度。
- 普通 SVG 和數字 SVG 會跟動畫 SVG 使用相同的棋子顯示角度。
- 自動旋轉雙方控制區的
applyRotation()與body.rotated-view不受影響。
建議最後測試以下情形:
- 人類首手拖動旋轉時,檢查
etanidrop的transform沒有rotate()。 - 首手棋子的
<use transform>角度會隨拖動改變。 - 首回合確認後,後續棋子仍能正確貼合。
- AI 第一手、棋譜導入、undo/redo 均能恢復相同角度。
- 動畫 SVG 不包含
btzoom、btworld。 - 同回合使用兩個相同棋子時,數量位移為
120。 - 動畫重新循環時,兩方分數及六種棋子數量都先回到初始位置。
- 電腦端下載窗口為
600px,預覽 shell 只包住預覽和 padding。 - 手機端窗口上下保留空隙,預覽 shell 不再縱向鋪滿。