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