Published on
· 4 min read

[Side Project] VolleyScore 開發筆記:技術選型與功能實作

這個專案做什麼

VolleyScore 是一個排球計分用的 Web App,主要功能是讓多台裝置加入同一個房間,即時共用比分。
實際場景:一台平板放場邊當顯示器,一支手機拿在手上計分,分數即時同步。

網址:volleyscore.ctheworldx.com


Tech Stack

項目選用原因
UI 框架React 19 + Vite熟悉,Vite 開發速度快
即時同步Firebase Realtime Database不用自架 WebSocket,免費額度夠用
PWAvite-plugin-pwa一套設定自動產生 Service Worker + manifest
樣式Vanilla CSS不需要 UI 框架,用 Design Token 管全域變數
部署Vercel自動部署 + 自訂網域,幾乎零設定

架構設計

SPA,用 React State 做頁面切換

這個 App 沒有用 React Router,改用 mode state 控制要渲染哪個畫面:

// App.jsx
const [mode, setMode] = useState('landing') // 'landing' | 'scoreboard'
const [entry, setEntry] = useState(null) // 'create' | 'join' | 'offline'

頁面很少(Landing + ScoreBoard),routing 帶來的複雜度不值得,用 state 夠用。


useRoom:集中管理房間狀態與 Firebase 同步

所有與房間相關的邏輯都封裝在 useRoom hook,包含:

  • 建立房間(產生 4 位房間碼、Firebase 初始化)
  • 加入房間(驗證房間碼、監聽 onValue
  • 計分、重設、隊伍設定、局數記錄

元件層只呼叫 hook 提供的方法,不直接碰 Firebase。


Firebase 資料結構

rooms/
  {roomCode}/
    score:   { host, guest }
    teams:   { hostName, guestName, hostColor, guestColor, showTeamNames }
    records: [ { host, guest }, ... ]   // 最多 5 筆
    updatedAt: timestamp

設計原則是增量更新,而不是每次寫入覆蓋整個 room object,避免多裝置同時操作時互蓋資料。


離線模式的資料存放

單機模式不使用 Firebase,改用 sessionStorage

nowScore  → { host, guest }
records   → [ { host, guest }, ... ]

功能實作細節

長按重設(useLongPress)

比賽中若想要重設比分,使用者直覺上會連續點擊 − 按鈕,這樣的連點行為本身就是一個常見場景。如果把「歸零」這個高風險操作也綁在同一個按鈕的單次點擊上,幾乎必然誤觸。

因此改為長按(600ms)觸發確認視窗,把歸零和一般扣分在操作層面明確分開。

// hooks/useLongPress.js
const useLongPress = (callback, delay = 600) => {
  const timerRef = useRef(null)
  const onStart = () => {
    timerRef.current = setTimeout(callback, delay)
  }
  const onEnd = () => clearTimeout(timerRef.current)
  return { onPointerDown: onStart, onPointerUp: onEnd, onPointerLeave: onEnd }
}

同一個 hook 同時支援短按(−1 分)和長按(觸發重設),在 按鈕上組合使用。


拖曳對調隊伍位置

拖曳分數卡可對調兩隊的顯示位置,使用 Pointer Events API 實作,閾值設在畫面尺寸的 25%:

  • 橫式螢幕:左右超過 25% 寬度觸發換位
  • 直式螢幕:上下超過 25% 高度觸發換位

拖曳過程卡片跟著指尖移動,鬆開後動畫歸位,超過閾值則直接換位。


計分 / 顯示模式切換

遠端模式下,裝置右上角有一個切換開關,可以在以下兩種狀態間切換:

  • 計分模式:分數卡可點擊 +1,下方顯示 + / − 工具列
  • 顯示模式:比分大字顯示,不可點擊,適合放在場邊當大螢幕

這個設計讓同一台裝置可以根據需求靈活切換角色。


QR Code 配對

建立房間後自動產生 QR Code,掃描後開啟 App 並帶入房間號,減少輸入步驟。
使用 qrcode 套件產生,直接 render 到 <canvas>


PWA 設定

使用 vite-plugin-pwa,主要配置:

  • 導覽請求NetworkFirst,逾時 3 秒 fallback 快取,確保每次開啟都優先取得最新版本
  • 靜態資源(JS、CSS):precache,由 vite-plugin-pwa 自動處理版本更新
  • Google FontsCacheFirst,快取一年,減少重複請求
  • Firebase 資料 / Vercel AnalyticsNetworkOnly,即時資料不快取
  • Manifest:設定 display: standalone,安裝後全螢幕、無瀏覽器工具列
  • Orientationlandscape 為主,確保計分板橫式顯示優先

Design Token 色彩系統

整個 App 用 CSS 自訂屬性統一管色彩,不散落在各元件:

:root {
  --bg: #485696; /* 背景藍 */
  --bg-dark: #2e3a6e; /* Header 深藍 */
  --text: #e7e7e7; /* 主文字 */
  --accent: #f9c784; /* 主要 CTA(暖黃) */
  --energy: #f24c00; /* 危險操作、得分強調(深橙) */
}

隊伍預設色:主隊深橙(#f24c00)、客隊藍(#244ecd),兩色與背景對比度夠,視覺區隔清楚。


值得記錄的決策

為什麼用 sessionStorage 而不是 localStorage?
單機模式的資料只有當次比賽有意義,關掉分頁就清除是正確行為,不需要持久化。

Firebase 額度限制:用 Google Apps Script 做自動清理
Firebase Realtime Database 免費方案有儲存量上限。房間資料在比賽結束後通常就沒用了,但沒有自動清除機制,長期累積會撐爆額度。

做法是串接 Google Apps Script,設定每週自動執行一次,透過 Firebase REST API 撈出所有房間,刪除 updatedAt 超過 7 天的紀錄:

// Google Apps Script
function cleanOldRooms() {
  const BASE_URL = 'https://<project>.firebaseio.com'
  const SECRET = PropertiesService.getScriptProperties().getProperty('FIREBASE_SECRET')
  const now = Date.now()
  const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000

  const res = UrlFetchApp.fetch(`${BASE_URL}/rooms.json?auth=${SECRET}`)
  const rooms = JSON.parse(res.getContentText())
  if (!rooms) return

  Object.entries(rooms).forEach(([code, room]) => {
    if (now - room.updatedAt > SEVEN_DAYS) {
      UrlFetchApp.fetch(`${BASE_URL}/rooms/${code}.json?auth=${SECRET}`, {
        method: 'delete',
      })
    }
  })
}

觸發器設定為每週一次,完全免費,不需要另外起 server。