Skip to content

hexazen-cover

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

typescript
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)
  }
}

BitReader 則是反向操作,從 Uint8Array 中逐位元讀取。

編碼:場景 → URL

typescript
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())
}

座標使用固定偏移量,就可以避免資料出現負數,例如:q + 3 把 [-3, 3] 轉成 [0, 6],剛好可以用 3 bits 表示。

最後透過 Base64url 編碼確保 URL 安全:

typescript
function uint8ArrayToBase64url(bytes: Uint8Array): string {
  let binary = ''
  for (const byte of bytes) {
    binary += String.fromCharCode(byte)
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '')
}

標準 Base64 的 +/= 在 URL 中有特殊含義,替換成 -_ 並去掉 padding。

解碼:URL → 場景

typescript
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
}

根據 totalBitsBITS_PER_BLOCK 可以推算出有幾個積木,不需要額外儲存數量。

分享與還原流程

分享:編碼 → 複製連結

typescript
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'),
  })
}

旋轉角度從弧度轉回步數(六角形嘛,轉 6 次就是一圈了,所以步數為 0~5),確保 ((step % 6) + 6) % 6 處理負數旋轉。

還原:載入分享連結

typescript
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
  }
}

promiseTimeout 確保 loading 畫面至少顯示 3 秒,避免一閃而過造成使用者困惑。

還原後的檢視模式會自動開啟攝影機自動旋轉,讓使用者可以悠閒地欣賞音景。(´,,•ω•,,)

效率比較

假設放了 10 個積木:

格式大小
JSON~500 bytes
Bit-packing + Base64url~43 bytes

壓縮率超過 90%!URL 短而美觀。(/≧▽≦)/

總結 🐟

以上程式碼可以在此取得

  • Bit-packing 將每個積木壓縮至 25 bits
  • Base64url 確保 URL 安全
  • BitWriter / BitReader 實現任意位元寬度的讀寫
  • 版本號機制方便未來擴展格式
  • 分享連結包含完整場景資訊,不需後端

下一章讓我們來看 UI 設計與國際化的實作吧。♪( ◜ω◝و(و

TIP

因為篇幅問題,某些部分簡單帶過。

若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥