共享会话
增加字體,改進下載功能
分享于 2026年8月22日 06:30增加字體,改進下載功能 以下是一個數字字體: @font-face { font-family: 'Techfont'; src: url('data:font/woff2;charset=utf-8;base64,d09GMgABAAAAAAZYABAAAAAADKgAAAX8AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP0ZGVE0cGh4GVgCCSggCCYJzEQgKiRiHPAseAAE2AiQDKgQgBYUXBz4MBBuPClGUUc51AL464ImIPfSGSJBwilV8gGZdqqaFHS8VDyOhv8JO3IpGiaQRkszy8LhW9X6SrgVAkuvwqMb23LFDBwgWABWQUP3zc+0homdviO/Mkv471BZ3Ik61QbXQqB8fjUSjNEIlMmWX1729H6l78faCKpPuHbHKpItbSK1VpQNUAajC/2QvF4DP9wx4e7lKfkAislcwHvF4Z8v2cpcSf0JSNv6NI28kRt0M+613BV0SFHmHBoWt1ZWuLh5XNRJtsUtGIXe3rJm93ADn+pUqsswPhYBfB58/h29u8QT8/K1mKTSgK/oDSQjdhIKESM/F7Rqku9P7uO15fvxhdrSTNLLn1HDp5lX2LbBBlhJt6XyN1X70jPluHPovXauPTGg2UagUXcKItpITdbr1288faVH5YzuRkLxNE2Sp0qg6rALxHyCQtN/LTN0i6rjz4FPErYdejpaXrR32qaN84jgriqmIJOKicZKr01RRTGb8TXkqumke594/neZQGClsOiuMdGOY99MyLoPDIwLCoRlI7TkYGe17DzJ05SHJz55zHGdlRQVruGgEFbuNUZThKIj2H/iMxtQNpHn87uneKa3XDqEr13IShkFC3y5b6NvmZTgOaa7YQh48i4zzph2s1WQxdETSHJhmpSQcajb/iUwztLbUEGEM9xgkejtVGEZX2kWonjyOTHu9U6ypL53kTzEVHwsnWSpdrKjT1A2khbgqor43CyBaJdAdDAX6gLiL+BRQQAJkIYT0eXmWSunZ3y1hZ9vG9P1ySUjQP9bf1dKjnO7W6Hpd83WlfqNLl/a3otH2Zudj9eedOze/JO1X9eu/8Hvz0Yh7Hmt/ZBA8XNf2WKIpSfUr9cuduzYfDV0XQ3GXOVC/0aUb0FI/fAQ/oBlBSUDN+mRgtd2a9fB9rzsRoxxuDGkXUuC1RFtAaLN7LrS/lls6n5XsI6r6YXAgtz0KmB8Quu9ynTf/w9APfiFRFxHa15pQwCkoQ91nBDccab5OrN9vq85ldT5wzarrmq+rHw9DFA3OBIuTos4XDXudqT2ssbfzTXNkzkLCPNdlDlxDbf1z1v2vfPyXPHruUSvQ4ougiGww2Su0kAkKGfjRKAd8NUdHmVswZwz8Rp1REPfoM7veY3xuMl7TRdarnwxJcZj3Hz2SICl/wf/z66GP/ZH85eh7BS2E8/7mz/T6KAdqUsVipgsaqMQuEXEGDntaAdAZiY2gUU4ovDXSN8NpXoptirnUsrmL+n2c0bCTzYeOUahkigjQglrBqowDm8Iy03npqY/0LGY+53auJeGdZDq1coJQfP154oF4XrxekZKezOinkYsbGSYzoZEO7BVzRaIUDi3gRuKJXqG5H3Tu4ESvph5PJGcx3oDa+mAyO+mpUsS4lI1Ui8EHAosKaZUqGrqz52dmU0ExFdSD0LqL0kn4Z3N2WOboSPMrjLKjFNrZiR+7r5Cs5oL64NT2ClDIJkWUkynZrUxYtS/Jkj6WjMPuOg3ZjYwJ7mkjkVhETiZwb809ygxQAJJSRBFEKKTCjAwtrL+ueGA+3J7osfSvY5ei9UiIXLou/4SOSkveSould9EYoMGbr0N6y9YOgm8FwH/QoVv3fa53tJ/obq4mjkY6r0iI4Z6+vQSXKP8Oh44yllR+YNnImGNFxzjEGrrFjbQavZ92Ovs9WZROuARCFnp6ZUlXH7FspV9W9AwPaxgcXm3Foze0k1SP1khJq8iKCAnLQwt8FiEtNQ2LFZZft2OfB/HabZrWXvOv2da4iYhDx3hxcYH/tc6KNjvGfHg51CFV4HVKsPOzNt/iVgLNHJScSvumx76/tu12Ej/wIR1OPsv7+NyK3rYa55UAewX4hAWlJOXBxOzYldkPsF1KVoKMAZa5GARYr4vffzsrJABZIRaXVGsYsUyroKX26cAU7LKWbCiAVvQH25zGyJrY3pyF0xL2C/hzImL0oVGuYEEEg6K2rlFqNAi0cjP9vF1cqADnI5JgXG67PR7is5FkNbppa8wcb6buvZy31sewEaPGjJswSUL6MlMAAA==') 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().end+2s"/>」。鏡頭移到全景後,上下左右邊界跟棋子的距離為12。注意上邊界已有60是狀態欄。
-
最後把整個clone漸變消失,並把鏡頭移到初始狀態。往clone內的最後面添加以下動畫:「」。
回答要求:不要影響當前正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。
QuanAI下面按 4 個位置修改即可。這些代碼只會讀取當前棋局並操作棋盤的 clone,不改動 pieces、scores、gameHistory、committedSearchState 或正式落子流程。
另外,動畫實作中做了兩個必要修正:
- SVG SMIL 的
href必須寫成href="#etanidrop"。 - 你給出的代碼中
id="btfull"出現兩次,會造成引用歧義;重置鏡頭動畫改用id="btreset"。 - 為避免同一個
transform上的平移、縮放動畫互相覆蓋,下載動畫的結構改成:#etanidrop:鏡頭平移#btzoom:鏡頭縮放#btworld:保留棋盤原本旋轉
顯示效果和指定時序不變,但在 Chrome、Firefox 中更穩定。
一、增加 CSS
在原本 <style> 的末尾、</style> 之前加入:
/* 數字字體套用至雙方分數及棋子數量 */
.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
在:
<div id="ai-settings-dialog" class="custom-dialog">對應的整個對話框結束後,也就是 panel-p2 之前,加入:
<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> 開頭、原本這一行之前:
// 多國語言辭典加入下面代碼。字體只保存一次,正式頁面和下載 SVG 會共用同一個變量:
/*
* 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()
找到原本的整個函數:
function downloadSVG() {
const svg = document.getElementById('etanitrigolden').cloneNode(true);
...
}把它完整替換成下面這一整段:
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();
}
});修改後的行為
-
玩家一、玩家二控制區的:
- 分數
.score-num - 棋子數量
.tile-count
都會使用Techfont。
- 分數
-
點擊原工具欄下載按鈕,不再直接下載,而是打開下載窗口。
-
每次打開窗口:
- 「數字」未選。
- 「動畫」未選。
- 預覽普通 SVG 局面。
- 顯示實際 UTF-8 SVG Blob 大小。
-
選項可以:
- 兩個都不選。
- 只選數字。
- 只選動畫。
- 不能同時選中。
-
普通文件名:
bracethetrigolden_yyyymmdd_hhiiss.svg- 數字文件名:
bracethetrigolden_number_yyyymmdd_hhiiss.svg- 動畫文件名:
bracethetrigolden_animate_yyyymmdd_hhiiss.svg-
數字模式會在 SVG 第一個子節點加入字體,並在
.etdrop末尾加入編號。 -
動畫模式會:
- 重建當前局全部已發生的分數變化。
- 重建全部歷史頂鑫環,而不只是
allRings中暫時保存的最後一組。 - 支援未滿 28 回合、半局、暫時落子及仲裁預覽。
- 全景視圖在狀態欄下方區域至少保留 12 的棋子邊距。
- 播放完畢後淡出、重置鏡頭並循環播放。
- 不調用正式落子、存檔、回退、AI 或仲裁函數。