調整發球鏡像規則並更新說明文件

This commit is contained in:
2026-04-28 08:52:47 +08:00
parent edab74f125
commit f3e51ea83d
5 changed files with 203 additions and 133 deletions

126
README.md
View File

@@ -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`
- `12` 一隊 - `1``2` 一隊
- `34` 一隊 - `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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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