
HexaZen - EP07:分享編碼與 URL 壓縮
我希望分享連結即可讓他人聽到你設計的自然音效,但是我不想登入也不想有後端,這代表場景資料要塞進 URL 裡了。◝( •ω• )◟
JSON 太長了,Base64 編碼 JSON 也不夠精簡,所以這裡採用位元打包(Bit-packing)格式。੭ ˙ᗜ˙ )੭
編碼格式設計
每個積木需要什麼資訊?
| 欄位 | 說明 | 範圍 | 位元數 |
|---|---|---|---|
| type | 積木種類(如 "g1"、"b7") | 2 個 ASCII 字元 | 16 bits |
| q | 六角格 q 座標 | [-3, 4] | 3 bits |
| r | 六角格 r 座標 | [-3, 4] | 3 bits |
| rotation | 旋轉步數 | [0, 7] | 3 bits |
每個積木只需要 25 bits!
完整格式
[version: 4 bits][block₁: 25 bits][block₂: 25 bits]...最前面 4 bits 是版本號,方便未來擴展。目前是 v2。
BitWriter 與 BitReader
以 MSB-first 方式讀寫任意寬度的位元。
content\aquarium\hexazen\domains\share\codec\index.ts
class BitWriter {
private buffer: number[] = []
private currentByte = 0
private bitPosition = 0
/** 寫入 width 個位元(MSB-first) */
write(value: number, width: number): void {
for (let i = width - 1; i >= 0; i--) {
this.currentByte = (this.currentByte << 1) | ((value >> i) & 1)
this.bitPosition++
if (this.bitPosition === 8) {
this.buffer.push(this.currentByte)
this.currentByte = 0
this.bitPosition = 0
}
}
}
/** 結束寫入,將剩餘位元補 0 後輸出 */
finish(): Uint8Array {
if (this.bitPosition > 0) {
this.buffer.push(this.currentByte << (8 - this.bitPosition))
}
return new Uint8Array(this.buffer)
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
BitReader 則是反向操作,從 Uint8Array 中逐位元讀取。
編碼:場景 → URL
export function encodeBlocks(blocks: Iterable<SharedBlock>): string {
const blockArray = [...blocks]
if (blockArray.length === 0)
return ''
const writer = new BitWriter()
// 版本號
writer.write(FORMAT_VERSION, BITS_VERSION)
for (const block of blockArray) {
// type: 2 個 ASCII 字元,各 8 bits
writer.write(block.type.charCodeAt(0), BITS_CHAR)
writer.write(block.type.charCodeAt(1), BITS_CHAR)
// 座標加偏移量,將 [-3, 3] 映射到 [0, 6]
const encodedQ = block.hex.q + COORDINATE_OFFSET
const encodedR = block.hex.r + COORDINATE_OFFSET
writer.write(encodedQ, BITS_COORDINATE)
writer.write(encodedR, BITS_COORDINATE)
// 旋轉
writer.write(block.rotation, BITS_ROTATION)
}
return uint8ArrayToBase64url(writer.finish())
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
座標使用固定偏移量,就可以避免資料出現負數,例如:q + 3 把 [-3, 3] 轉成 [0, 6],剛好可以用 3 bits 表示。
最後透過 Base64url 編碼確保 URL 安全:
function uint8ArrayToBase64url(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}2
3
4
5
6
7
8
9
10
標準 Base64 的 +、/、= 在 URL 中有特殊含義,替換成 -、_ 並去掉 padding。
解碼:URL → 場景
function decodeBinaryBlocks(encoded: string): SharedBlock[] {
const bytes = base64urlToUint8Array(encoded)
const reader = new BitReader(bytes)
const version = reader.read(BITS_VERSION)
if (version !== FORMAT_VERSION) {
throw new TypeError(`不支援的版本號: ${version}`)
}
const totalBits = bytes.length * 8
const dataBits = totalBits - BITS_VERSION
const blockCount = Math.floor(dataBits / BITS_PER_BLOCK)
const blocks: SharedBlock[] = []
for (let i = 0; i < blockCount; i++) {
const char1 = String.fromCharCode(reader.read(BITS_CHAR))
const char2 = String.fromCharCode(reader.read(BITS_CHAR))
const blockType = `${char1}${char2}` as BlockType
if (!validBlockTypes.has(blockType)) {
throw new TypeError(`無效的 BlockType: "${blockType}"`)
}
const q = reader.read(BITS_COORDINATE) - COORDINATE_OFFSET
const r = reader.read(BITS_COORDINATE) - COORDINATE_OFFSET
const rotation = reader.read(BITS_ROTATION)
blocks.push({
type: blockType,
hex: Hex.fromAxial(q, r),
rotation,
})
}
return blocks
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
根據 totalBits 和 BITS_PER_BLOCK 可以推算出有幾個積木,不需要額外儲存數量。
分享與還原流程
分享:編碼 → 複製連結
async function handleShare() {
const sharedBlocks = Array.from(placedBlockMap.values()).map((block) => {
const rotationStep = Math.round(block.rootNode.rotation.y / (Math.PI / 3))
const rotation = ((rotationStep % 6) + 6) % 6
return {
type: block.type,
hex: block.hex,
rotation,
}
})
const encoded = encodeBlocks(sharedBlocks)
const url = new URL(window.parent.location.href)
url.searchParams.set('view', encoded)
await navigator.clipboard.writeText(url.toString())
toast.add({
title: t('linkCopied'),
description: t('shareLinkDescription'),
})
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
旋轉角度從弧度轉回步數(六角形嘛,轉 6 次就是一圈了,所以步數為 0~5),確保 ((step % 6) + 6) % 6 處理負數旋轉。
還原:載入分享連結
async function restoreSharedView() {
if (!sharedViewEncodedData)
return
isRestoringView.value = true
try {
const sharedBlocks = decodeBlocks(sharedViewEncodedData)
const tasks = sharedBlocks.map(({ type, hex, rotation }) => {
return mainSceneRef.value!.spawnBlock(type, hex).then((block) => {
block.rootNode.rotation.y = rotation * (Math.PI / 3)
})
})
await Promise.all(tasks)
}
catch (error) {
toast.add({
title: t('restoreFailed'),
description: t('linkInvalid'),
color: 'error',
})
}
finally {
// 至少顯示 3 秒 loading 畫面
await promiseTimeout(Math.max(3000, new Date().getTime() - startedAt))
isRestoringView.value = false
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
promiseTimeout 確保 loading 畫面至少顯示 3 秒,避免一閃而過造成使用者困惑。
還原後的檢視模式會自動開啟攝影機自動旋轉,讓使用者可以悠閒地欣賞音景。(´,,•ω•,,)
效率比較
假設放了 10 個積木:
| 格式 | 大小 |
|---|---|
| JSON | ~500 bytes |
| Bit-packing + Base64url | ~43 bytes |
壓縮率超過 90%!URL 短而美觀。(/≧▽≦)/
總結 🐟
以上程式碼可以在此取得
- Bit-packing 將每個積木壓縮至 25 bits
- Base64url 確保 URL 安全
BitWriter/BitReader實現任意位元寬度的讀寫- 版本號機制方便未來擴展格式
- 分享連結包含完整場景資訊,不需後端
下一章讓我們來看 UI 設計與國際化的實作吧。♪( ◜ω◝و(و
TIP
因為篇幅問題,某些部分簡單帶過。
若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥