Skip to content

hexazen-cover

HexaZen - EP08:UI 設計與國際化

3D 場景和音效引擎都搞定了,最後要讓整個應用的 UI 既好看又好用。

HexaZen 跑在 VitePress 的 whyframe iframe 裡,所以 vue-i18n 無法正常運作。

沒關係,那就自己做一個。ᕕ( ゚ ∀。)ᕗ

倒角邊框元件

HexaZen 的 UI 風格不用圓角,而是用「倒角(chamfer)」——像寶石一樣的多邊形切割。

content\aquarium\hexazen\components\chamfer-border-card.vue

vue
<template>
  <div class="chamfer-4 p-1" :style="frameStyle">
    <div class="bg-white chamfer-3.5 p-3" :class="props.contentClass">
      <slot />
    </div>
  </div>
</template>

<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed } from 'vue'

interface Props {
  contentClass?: string;
  borderSize?: string;
  borderColor?: string;
}
const props = withDefaults(defineProps<Props>(), {
  contentClass: '',
  borderSize: '4px',
  borderColor: '#e5e7eb',
})

const frameStyle = computed<CSSProperties>(() => ({
  padding: props.borderSize,
  backgroundColor: props.borderColor,
}))
</script>

外層一個帶色的 chamfer 框,內層一個白色的 chamfer 框,用 padding 差異製造邊框效果。

倒角按鈕

按鈕也用 clip-path 做出八邊形倒角:

typescript
const style = computed<CSSProperties>(() => {
  const size = height.value / 2
  const size2 = (size / 3) * 2

  return {
    paddingInline: `${size * 1.1}px`,
    clipPath: `polygon(
      ${size2}px 0,
      calc(100% - ${size2}px) 0,
      100% ${size}px,
      100% calc(100% - ${size}px),
      calc(100% - ${size2}px) 100%,
      ${size2}px 100%,
      0 calc(100% - ${size}px),
      0 ${size}px
    )`,
  }
})

useElementSize 動態取得按鈕高度,讓倒角比例自動適配不同大小的按鈕。( •̀ ω •́ )✧

聲景混音器

每個音效都可以獨立調整音量,透過 Modal 呈現:

vue
<template>
  <div class="space-y-4 p-2">
    <div
      v-for="item in playerList"
      :key="item.id"
      class="flex items-center gap-3"
    >
      <u-icon
        :name="soundscapeIconMap[item.type] ?? 'i-mingcute:music-2-fill'"
        class="text-xl text-gray-500 shrink-0"
      />

      <span class="text-sm text-gray-600 flex-1 capitalize">
        {{ t(item.player.title as any) }}
      </span>

      <u-slider
        :model-value="volumeMap[item.id] ?? 1"
        :min="0"
        :max="2"
        :step="0.01"
        class="flex-1"
        @update:model-value="(value) => handleVolumeChange(item.id, value ?? 1)"
      />
    </div>
  </div>
</template>

每個音效類型都有專屬 icon:

typescript
const soundscapeIconMap: Record<SoundscapeType, string> = {
  rustle: 'i-mingcute:leaf-fill',
  insect: 'i-mingcute:bug-fill',
  bird: 'i-material-symbols:raven',
  frog: 'i-fa7-solid:frog',
  beast: 'i-mingcute:cat-fill',
  river: 'i-material-symbols:water',
  building: 'i-solar:buildings-2-bold',
  ocean: 'i-lucide-lab:waves-birds',
  alpine: 'i-mynaui:mountain-snow-solid',
  rain: 'i-material-symbols:rainy',
  campfire: 'i-mingcute:campfire-fill',
}

音量範圍 0~2,也就是最大可以放大到原始音量的 2 倍。

只顯示目前正在播放(未被靜音)的音效,避免混亂。ԅ(´∀` ԅ)

公告欄 Modal

首次開啟會顯示歡迎頁面,包含 Intro 和 Quick Start 兩個分頁。

Quick Start 使用輪播展示操作步驟,搭配影片和圖片:

typescript
const quickStartItems = computed(() => {
  const descriptionList = t('start')
  const imgList = t('startImg')

  return descriptionList.map((text, index) => ({
    img: imgList[index] || '',
    description: text,
  }))
})

有趣的是文字內容和圖片路徑都放在 i18n 翻譯檔中,可以根據語言顯示不同的教學內容。

輕量 i18n:不用 vue-i18n 的方案

因為 whyframe 環境的限制,只好自己做了一個輕量版的 i18n composable。

content\aquarium\hexazen\composables\use-simple-i18n.ts

typescript
export function useSimpleI18n<
  Messages extends Record<DefaultLang, MessageSchema>,
  DefaultLang extends 'zh-hant' | 'en',
  Data = Messages[DefaultLang],
>(
  messages: Messages & StrictCheck<Messages>,
  options?: {
    defaultLocale?: DefaultLang;
  },
) {
  const currentLocale = ref(
    options?.defaultLocale ?? Object.keys(messages)[0],
  ) as Ref<DefaultLang>

  const languages = usePreferredLanguages()

  watch(
    languages,
    ([lang]) => {
      currentLocale.value = (
        lang?.includes('zh') ? 'zh-hant' : 'en'
      ) as DefaultLang
    },
    { immediate: true },
  )

  function t<Key extends keyof Data>(
    key: Key,
    params?: Record<string, string | number>,
  ): Data[Key] extends string[] ? string[] : string {
    const value = getMessage(key) as any

    if (Array.isArray(value))
      return value

    if (typeof value === 'string' && params) {
      let text = value
      Object.entries(params).forEach(([k, v]) => {
        text = text.replace(new RegExp(`{${k}}`, 'g'), String(v))
      })
      return text
    }

    return value
  }

  return { locale: currentLocale, setLocale, t }
}

型別安全的嚴格檢查

最精華的部分是 TypeScript 型別層面的檢查:

typescript
type StrictCheck<Messages> = {
  [L in keyof Messages]: {
    [K in KeyUniverse<Messages>]: CommonType<Messages, K>;
  };
}

StrictCheck 確保所有語言都必須有完全相同的 key 集合

如果中文多了一個 key 但英文沒有,TypeScript 編譯時就會報錯。不用等到運行時才發現遺漏翻譯。╰(*´︶`*)╯

特色

  • 自動偵測瀏覽器語言(usePreferredLanguages
  • 支援字串插值({name} 參數替換)
  • 支援字串陣列(用於教學步驟等清單)
  • 編譯時期型別檢查,缺漏翻譯直接報錯
  • 不到 100 行程式碼,零外部依賴

總結 🐟

以上程式碼可以在此取得

  • 倒角邊框風格統一整個應用的視覺語言
  • 聲景混音器提供精細的個別音量控制
  • 公告欄兼具歡迎頁和操作教學
  • 自製輕量 i18n,編譯時期保證翻譯完整性
  • 自動偵測瀏覽器語言,零設定

下一章是最後一篇,讓我們來聊聊 WebGPU 與效能優化。♪( ◜ω◝و(و

TIP

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

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