diff --git a/README.md b/README.md index e6e1e25..40ddc24 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,56 @@ # 羽毛球記分板 -這是一個使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板,支援手機操作、PWA 安裝、即時觀戰房間、歷史戰績與 Docker / NAS 部署。 +這是一個使用 `Vite + React + TypeScript + Node.js` 製作的羽毛球記分板專案,提供手機優先的記分介面、歷史戰績、房間觀戰、語音播報、PWA 安裝,以及 Docker / NAS 部署方式。 -## 功能總覽 +## 功能特色 -- 選隊伍 +- 選隊伍頁面 - 可依指定日期從資料庫讀取分組資料。 - - 若指定日期沒有資料,可手動輸入 A、B 區名單建立分組。 - - 點選分組後可直接進入記分板。 + - 若當天沒有資料,可手動輸入 A、B 區名單建立配對。 + - 點進記分板時會直接帶入該組對戰。 - 記分板 - - 隊伍名稱只顯示在最上方與最下方。 - - 可在設定隊伍面板中逐一選人,也可快速選擇預設隊伍。 - - 先選到的 `1、2` 為一隊,`3、4` 為一隊。 - - 可設定獲勝分數,預設為 `21` 分。 - - 必須先選先攻,才能開始記分。 - - 點擊隊伍分數直接加分,不提供加一減一按鈕。 - - 第一分記下後,`設定隊伍` 會切換成 `上一步`。 - - 可交換上下兩隊位置,也可交換同隊左右站位。 - - `比賽結算` 需要長按 `1 秒` 才會觸發。 - - 比分 `0:0` 時不可結算。 - - 全站文字預設不可選取,避免手機誤觸反白。 - - 只要已設定先攻並開始比賽,就不能切換到其他分頁,需先完成結算。 + - 兩隊隊員可自由交換上下、左右位置。 + - 畫面編號固定為左上 `1`、右上 `2`、右下 `3`、左下 `4`。 + - 先攻只能在開局設定一次,之後不會跟著發球權改變。 + - 點擊分數直接加分,沒有加一減一按鈕。 + - 第一分開始後,`設定隊伍` 會改成 `上一步`。 + - `比賽結算` 需要長按 `1` 秒才會觸發,避免誤觸。 + - 達標分數後有獲勝動畫與結算流程。 +- 羽球規則 + - 預設 `21` 分制,可在設定隊伍時調整目標分數。 + - 支援 Deuce:`20:20` 後需領先 `2` 分才獲勝。 + - `29:29` 時第 `30` 分直接獲勝。 + - 發球方依羽球規則處理,`0` 分在右發球區。 + - 畫面以下方隊伍為我方、上方隊伍為對方。 + - 上方隊伍採鏡像顯示,所以我方 `0:0` 在右邊發球時,對方會在左邊接發。 - 語音播報 - - 可設定是否播報得分者。 - - 可設定是否播報發球者。 - - 語速最高可調到 `10x`。 - - `RURU` 以大小寫不敏感方式播報成「嚕嚕」。 -- 動畫與提示 - - 未選先攻時,`先攻` 文字會有提示動畫。 - - 選定先攻後會顯示打勾。 - - 支援連勝稱號動畫與獲勝動畫。 + - 只在按下加分當下播報,不會因復原或其他操作重複報分。 + - 可選擇是否播報得分與發球者。 + - 同隊連續得分才會播報 `換邊發球`。 + - 支援調整語速,最高可到 `10x`。 + - `RURU` 會做大小寫無關判斷並以指定發音播報。 - 歷史戰績 - - 可將比賽結果上傳到資料庫 `history`。 - - 歷史列表直接從 DB 顯示。 - - 可查看逐球得分紀錄。 - - 每筆紀錄可刪除,刪除前會確認一次。 -- 即時房間 / 觀戰 - - 帶入隊伍進入記分板後會自動建立房間。 - - 記分板會顯示房號。 - - 房間列表不顯示比分,只顯示房號、隊伍、目標分數與更新時間。 - - 房間列表可手動重新取得,按一次後有 `5 秒` 冷卻。 - - 手動重新取得時,會順便清理沒有主控在線的無主房間。 - - 觀戰者只能看,不能操作。 - - 觀戰同步使用 `SSE + 輪詢備援`。 - - 房主重整、離開記分板或換隊伍時,未完成房間會自動清掉。 - - 達標獲勝時,觀戰者會收到獲勝通知。 - - 手動按 `比賽結算` 完成後,觀戰者也會收到獲勝結果,再返回房間列表。 + - 可從資料庫讀取歷史列表。 + - 點開單筆可查看得分過程。 + - 每筆資料可刪除,刪除前會顯示確認提示。 +- 房間觀戰 + - 記分板帶入隊伍後會自動建立房間。 + - 房間列表可查看目前直播中的比賽。 + - 觀戰者只能看,不能操作記分。 + - 分數、房間狀態、比賽結算會即時同步給觀戰者。 + - 房間失效、重整、重選隊伍後也會通知觀戰者。 + - 房間列表有 `重新取得列表`,並帶有 `5` 秒冷卻。 - PWA - - 可加入手機主畫面,像 App 一樣開啟。 - - 支援主畫面 icon 與版本更新提示。 - - 文件頁面改為網路優先,降低 iPad / iPhone PWA 卡舊版快取的機率。 + - 可安裝到 iPhone / iPad / Android 主畫面。 + - 支援 Web App 模式啟動。 + - 新版本部署後會提示重新整理或重新安裝。 -## 開發 +## 本機開發 ### Port -- Client:`3501` -- Server:`8788` +- Client: `3501` +- Server: `8788` ### 安裝 @@ -75,7 +69,7 @@ npm run dev - 前端:`http://localhost:3501` - API:`http://localhost:8788` -### 建置與檢查 +### 檢查 ```bash npm run lint @@ -84,7 +78,7 @@ npm run build ## 環境變數 -請在專案根目錄建立 `.env`: +請先建立 `.env`: ```env DB_HOST=127.0.0.1 @@ -99,10 +93,10 @@ PORT=8788 ## Docker / NAS 部署 -正式部署時: +對外服務配置: -- App 內部服務:`8788` -- 對外 HTTPS 入口:`3501` +- 容器內 Node / API:`8788` +- 對外 HTTPS 網址:`3501` 部署指令: @@ -110,29 +104,29 @@ PORT=8788 sudo docker compose up -d --build ``` -部署完成後對外入口為: +部署完成後可用: ```text https://你的網域或 NAS IP:3501 ``` -每次執行 `sudo docker compose up -d --build`,容器都會刷新版本號,已安裝的 PWA 會在偵測到新版本後跳出更新提示。 +每次執行 `sudo docker compose up -d --build` 都會重新建置前後端與 PWA 靜態資產。 -## SSL 憑證目錄 +## SSL 憑證 -Docker Compose 會直接掛載: +Docker Compose 會掛載以下目錄: ```text /volume1/homes/JianMiau/www/certificate/ ``` -預設使用的檔案: +需包含: - `RSA-cert.pem` - `RSA-chain.pem` - `RSA-privkey.pem` -之後只要更新這個資料夾內的憑證檔即可,不需要重建 image。 +之後只要更新這個目錄內的憑證檔案,再重新部署容器即可套用新 SSL。 ## 資料表格式 @@ -144,17 +138,17 @@ Docker Compose 會直接掛載: - `score` - `winScore` - `type` - - `0`:雙打 - - `1`:單打 + - `0`: 雙打 + - `1`: 單打 - `players` - - 依 `1 ~ 4` 編號排序的玩家陣列 + - 依照 `1 ~ 4` 固定編號順序儲存玩家名單。 - `team` - - `1、2` 為一隊 - - `3、4` 為一隊 + - `1` 跟 `2` 一隊 + - `3` 跟 `4` 一隊 - `scoreList` - - 格式:`[round, 發球者編號, 連勝次數, 得分隊伍(0 或 1)]` + - 格式:`[round, 發球者編號, 連勝數, 得分隊伍(0 或 1)]` -## PWA 圖示 +## PWA Icon 目前使用: @@ -163,9 +157,9 @@ Docker Compose 會直接掛載: - `public/pwa-192.png` - `public/pwa-512.png` -## Git 中文設定 +## Git 中文顯示 -建議設定 git 使用 UTF-8: +若要讓 git log / commit 顯示中文,建議設定: ```bash git config i18n.commitEncoding utf-8 diff --git a/src/App.tsx b/src/App.tsx index 4dc6324..27c0d8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,8 @@ import { convertDateToKey, convertDbRecordToGroups, formatDateInputValue, + getMirroredCourt, + getServiceCourt, getServingPlayer, getTeamDisplayName, getWinnerName, @@ -56,6 +58,7 @@ const initialScoreState: ScoreState = { gamesRight: 0, currentGame: 1, targetScore: 21, + initialServing: null, serving: null, leftRightCourtPlayer: 'playerA', rightRightCourtPlayer: 'playerA', @@ -81,6 +84,13 @@ type VictoryAnnouncement = { title: string } +type VoiceAnnouncement = { + key: number + scorerName: string + serverChanged: boolean + serverName: string +} + const STREAK_TITLES: Record = { 3: '大殺特殺', 4: '暴走', @@ -129,6 +139,7 @@ function App() { }) const [streakAnnouncement, setStreakAnnouncement] = useState(null) const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) + const [voiceAnnouncement, setVoiceAnnouncement] = useState(null) const [pwaUpdateReady, setPwaUpdateReady] = useState(false) const [liveRoomSession, setLiveRoomSession] = useState(null) const [navigationLockMessage, setNavigationLockMessage] = useState('') @@ -288,6 +299,7 @@ function App() { setPointLog([]) setStreakAnnouncement(null) setVictoryAnnouncement(null) + setVoiceAnnouncement(null) setSettlement({ error: '', open: false, @@ -441,9 +453,9 @@ function App() { } const winnerTeamName = - scoreState.scoreLeft >= scoreState.targetScore + hasWonGame(scoreState) && scoreState.scoreLeft > scoreState.scoreRight ? getTeamDisplayName(leftTeam) - : scoreState.scoreRight >= scoreState.targetScore + : hasWonGame(scoreState) && scoreState.scoreRight > scoreState.scoreLeft ? getTeamDisplayName(rightTeam) : null const nextStatus = winnerTeamName ? 'finished' : 'live' @@ -668,6 +680,12 @@ function App() { : current.serving === 'right' ? 'left' : null, + initialServing: + current.initialServing === 'left' + ? 'right' + : current.initialServing === 'right' + ? 'left' + : null, leftRightCourtPlayer: current.rightRightCourtPlayer, rightRightCourtPlayer: current.leftRightCourtPlayer, })) @@ -692,12 +710,13 @@ function App() { } const setServing = (side: ScoreSide) => { - if (scoreHistory.length > 0) { + if (scoreHistory.length > 0 || scoreState.initialServing !== null) { return } setScoreState((current) => ({ ...current, + initialServing: side, serving: side, })) } @@ -747,6 +766,12 @@ function App() { setScoreHistory((current) => [...current, { pointLog, scoreState }]) setPointLog(nextPointLog) setScoreState(nextScoreState) + setVoiceAnnouncement({ + key: Date.now(), + scorerName: side === 'left' ? leftTeam.playerA : rightTeam.playerA, + serverChanged: side === scoreState.serving, + serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side), + }) if (streakTitle) { setStreakAnnouncement({ @@ -757,9 +782,7 @@ function App() { }) } - const reachedTarget = - nextScoreState.scoreLeft >= nextScoreState.targetScore || - nextScoreState.scoreRight >= nextScoreState.targetScore + const reachedTarget = hasWonGame(nextScoreState) if (reachedTarget) { setVictoryAnnouncement({ @@ -988,6 +1011,7 @@ function App() { selectedGroup={selectedGroup} streakAnnouncement={streakAnnouncement} victoryAnnouncement={victoryAnnouncement} + voiceAnnouncement={voiceAnnouncement} targetDate={targetDate} onApplyMatchup={applyMatchup} onCloseFinishDialog={closeSettlementDialog} @@ -1081,7 +1105,7 @@ function getServerHistoryIndex( return null } - return server.slot === 'playerA' ? 0 : 1 + return getMirroredCourt(getServiceCourt(state.scoreLeft)) === 'left' ? 0 : 1 } if (state.serving === 'right') { @@ -1091,12 +1115,44 @@ function getServerHistoryIndex( return null } - return server.slot === 'playerB' ? 2 : 3 + return getServiceCourt(state.scoreRight) === 'right' ? 2 : 3 } return null } +function getNextServerName( + state: ScoreState, + leftTeam: GroupTeam, + rightTeam: GroupTeam, + side: ScoreSide, +) { + if (side === 'left') { + return getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)?.name ?? '' + } + + return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? '' +} + +function hasWonGame(state: ScoreState) { + const leadingScore = Math.max(state.scoreLeft, state.scoreRight) + const trailingScore = Math.min(state.scoreLeft, state.scoreRight) + + if (leadingScore < state.targetScore) { + return false + } + + if (leadingScore >= 30) { + return true + } + + if (trailingScore >= state.targetScore - 1) { + return leadingScore - trailingScore >= 2 + } + + return true +} + function formatPlayedAt(timestamp: number) { return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false }) } diff --git a/src/lib/match.ts b/src/lib/match.ts index 929cc15..5a66e4e 100644 --- a/src/lib/match.ts +++ b/src/lib/match.ts @@ -91,17 +91,28 @@ export function getServiceCourt(score: number): CourtSide { return score % 2 === 0 ? 'right' : 'left' } -export function getCourtAssignments(team: GroupTeam, rightCourtPlayer: PlayerSlot) { +export function getMirroredCourt(court: CourtSide): CourtSide { + return court === 'right' ? 'left' : 'right' +} + +export function getCourtAssignments( + team: GroupTeam, + rightCourtPlayer: PlayerSlot, + mirrored = false, +) { + const rightScreenCourt = mirrored ? getMirroredCourt('right') : 'right' + const leftScreenCourt = mirrored ? getMirroredCourt('left') : 'left' + return [ { slot: 'playerA' as const, name: team.playerA, - court: (rightCourtPlayer === 'playerA' ? 'right' : 'left') as CourtSide, + court: (rightCourtPlayer === 'playerA' ? rightScreenCourt : leftScreenCourt) as CourtSide, }, { slot: 'playerB' as const, name: team.playerB, - court: (rightCourtPlayer === 'playerB' ? 'right' : 'left') as CourtSide, + court: (rightCourtPlayer === 'playerB' ? rightScreenCourt : leftScreenCourt) as CourtSide, }, ] } @@ -120,7 +131,17 @@ export function getServingPlayer( rightCourtPlayer: PlayerSlot, score: number, ) { - return getPlayerOnCourt(team, rightCourtPlayer, getServiceCourt(score)) + const serverSlot = getServiceCourt(score) === 'right' + ? rightCourtPlayer + : rightCourtPlayer === 'playerA' + ? 'playerB' + : 'playerA' + + return { + slot: serverSlot, + name: team[serverSlot], + court: getServiceCourt(score), + } } export function getReceivingPlayer( @@ -128,7 +149,17 @@ export function getReceivingPlayer( rightCourtPlayer: PlayerSlot, servingScore: number, ) { - return getPlayerOnCourt(team, rightCourtPlayer, getServiceCourt(servingScore)) + const receiverSlot = getServiceCourt(servingScore) === 'right' + ? rightCourtPlayer + : rightCourtPlayer === 'playerA' + ? 'playerB' + : 'playerA' + + return { + slot: receiverSlot, + name: team[receiverSlot], + court: getServiceCourt(servingScore), + } } export function swapCourtPositions(rightCourtPlayer: PlayerSlot): PlayerSlot { diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index ab4299a..05975a4 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { getCourtAssignments, + getMirroredCourt, getReceivingPlayer, getServiceCourt, getServingPlayer, @@ -57,6 +58,12 @@ type ScoreboardPageProps = { teamName: string title: string } | null + voiceAnnouncement: { + key: number + scorerName: string + serverChanged: boolean + serverName: string + } | null targetDate: string onApplyMatchup: ( leftTeam: GroupTeam, @@ -88,6 +95,7 @@ export function ScoreboardPage({ selectedGroup, streakAnnouncement, victoryAnnouncement, + voiceAnnouncement, targetDate, onApplyMatchup, onCloseFinishDialog, @@ -117,8 +125,6 @@ export function ScoreboardPage({ const finishHoldTimerRef = useRef(null) const finishHoldStartRef = useRef(0) const finishTriggeredRef = useRef(false) - const lastAnnouncedPointRef = useRef(0) - const previousScoresRef = useRef({ left: 0, right: 0 }) useEffect(() => { const timer = window.setInterval(() => { @@ -191,12 +197,12 @@ export function ScoreboardPage({ const leftAssignments = useMemo( () => - leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [], + leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer, true) : [], [leftTeam, scoreState.leftRightCourtPlayer], ) const rightAssignments = useMemo( () => - rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [], + rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer, false) : [], [rightTeam, scoreState.rightRightCourtPlayer], ) @@ -235,57 +241,29 @@ export function ScoreboardPage({ : null useEffect(() => { - const totalPoints = scoreState.scoreLeft + scoreState.scoreRight - - if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) { - lastAnnouncedPointRef.current = totalPoints - previousScoresRef.current = { - left: scoreState.scoreLeft, - right: scoreState.scoreRight, - } + if (!voiceAnnouncement) { return } - if (lastAnnouncedPointRef.current === totalPoints) { - return - } - - lastAnnouncedPointRef.current = totalPoints - - const scorerSide = - scoreState.scoreLeft > previousScoresRef.current.left - ? 'left' - : scoreState.scoreRight > previousScoresRef.current.right - ? 'right' - : null - - previousScoresRef.current = { - left: scoreState.scoreLeft, - right: scoreState.scoreRight, - } - const parts: string[] = [] - if (voiceSettings.announceScore && scorerSide) { - parts.push( - `${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`, - ) + if (voiceSettings.announceScore) { + parts.push(`${getSpeechName(voiceAnnouncement.scorerName)}得分`) } - if (voiceSettings.announceServer) { - parts.push(`${getSpeechName(currentServer.name)}發球`) + if (voiceSettings.announceServer && voiceAnnouncement.serverName) { + parts.push( + `${getSpeechName(voiceAnnouncement.serverName)}${ + voiceAnnouncement.serverChanged ? '換邊發球' : '發球' + }`, + ) } if (parts.length > 0) { speakAnnouncement(parts.join(','), voiceSettings.rate) } }, [ - currentServer?.name, - leftTeam, - rightTeam, - scoreState.scoreLeft, - scoreState.scoreRight, - scoreState.serving, + voiceAnnouncement, voiceSettings.announceScore, voiceSettings.announceServer, voiceSettings.rate, @@ -485,14 +463,20 @@ export function ScoreboardPage({ assignments={leftAssignments} canArrangeMatch={canArrangeMatch} canScore={canScore} + canSetServing={canArrangeMatch && scoreState.initialServing === null} currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null} currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null} + hasInitialServing={scoreState.initialServing === 'left'} onRecordPoint={() => onRecordPoint('left')} onSetServing={() => onSetServing('left')} onSwapPlayers={() => onSwapTeamPlayers('left')} onSwapTeams={onSwapMatchup} score={scoreState.scoreLeft} - serviceCourt={scoreState.serving === 'left' ? servingCourt : null} + serviceCourt={ + scoreState.serving === 'left' && servingCourt + ? getMirroredCourt(servingCourt) + : null + } showServingPrompt={scoreState.serving === null} team={leftTeam} teamSlot="top" @@ -512,8 +496,10 @@ export function ScoreboardPage({ assignments={rightAssignments} canArrangeMatch={canArrangeMatch} canScore={canScore} + canSetServing={canArrangeMatch && scoreState.initialServing === null} currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null} currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null} + hasInitialServing={scoreState.initialServing === 'right'} onRecordPoint={() => onRecordPoint('right')} onSetServing={() => onSetServing('right')} onSwapPlayers={() => onSwapTeamPlayers('right')} @@ -650,8 +636,10 @@ type ScoreboardTeamPanelProps = { assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }> canArrangeMatch: boolean canScore: boolean + canSetServing: boolean currentReceiver: string | null currentServer: string | null + hasInitialServing: boolean onRecordPoint: () => void onSetServing: () => void onSwapPlayers: () => void @@ -667,8 +655,10 @@ function ScoreboardTeamPanel({ assignments, canArrangeMatch, canScore, + canSetServing, currentReceiver, currentServer, + hasInitialServing, onRecordPoint, onSetServing, onSwapPlayers, @@ -699,7 +689,7 @@ function ScoreboardTeamPanel({ } key={assignment.slot} > - {getPlayerNumber(teamSlot, assignment.slot)} + {getPlayerNumber(teamSlot, assignment.court)} {assignment.name} ))} @@ -731,23 +721,25 @@ function ScoreboardTeamPanel({ const serveBar = (