功能:首頁日期預設當天、記分板語音改為報比分與發球區

摘要:
- 首頁「指定日期」每次進入都預設為當天
- 記分板語音得分播報改為「比分(發球方先)+ 發球者左右發球區」,賽末點獲勝時整段改播「贏得比賽」

根本原因:
- 日期原本從 localStorage 讀上次選的值,重開會停在舊日期
- 語音原本只唸「誰得分、誰發球」,現場較不直覺,球敘想聽到即時比分與發球位置

影響:
- src/App.tsx:targetDate 改為直接用當天、移除日期的 localStorage 記憶;recordPoint 算出發球方比分與發球區,重整 voiceAnnouncement 欄位
- src/pages/ScoreboardPage.tsx:語音組字改為「X比Y」「OO左/右邊發球」,獲勝時改播「贏得比賽」;語音設定「播報誰得分」更名「播報比分」

修法:
- 發球方分數先報;上方(左側)隊伍發球區做左右鏡像以對齊畫面,下方隊伍不鏡像

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:33:22 +08:00
parent 3677162747
commit 091e654bdb
2 changed files with 473 additions and 451 deletions
+24 -14
View File
@@ -45,7 +45,6 @@ const STORAGE_KEYS = {
areaA: 'badminton-scoreboard::area-a', areaA: 'badminton-scoreboard::area-a',
areaB: 'badminton-scoreboard::area-b', areaB: 'badminton-scoreboard::area-b',
history: 'badminton-scoreboard::history', history: 'badminton-scoreboard::history',
targetDate: 'badminton-scoreboard::target-date',
} as const } as const
const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧'] const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧']
@@ -86,9 +85,11 @@ type VictoryAnnouncement = {
type VoiceAnnouncement = { type VoiceAnnouncement = {
key: number key: number
scorerName: string servingScore: number
serverChanged: boolean opponentScore: number
serverName: string serverName: string
serverCourt: 'left' | 'right'
winnerTeamName: string | null
} }
const STREAK_TITLES: Record<number, string> = { const STREAK_TITLES: Record<number, string> = {
@@ -108,9 +109,7 @@ function App() {
const navigate = useNavigate() const navigate = useNavigate()
const isScoreboardRoute = location.pathname === '/scoreboard' const isScoreboardRoute = location.pathname === '/scoreboard'
const [targetDate, setTargetDate] = useState(() => const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
loadStoredText(STORAGE_KEYS.targetDate, formatDateInputValue()),
)
const [areaAInput, setAreaAInput] = useState(() => const [areaAInput, setAreaAInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')), loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')),
) )
@@ -155,10 +154,6 @@ function App() {
const liveRoomId = liveRoomSession?.roomId ?? null const liveRoomId = liveRoomSession?.roomId ?? null
const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null) const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null)
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
}, [targetDate])
useEffect(() => { useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
}, [areaAInput]) }, [areaAInput])
@@ -771,14 +766,31 @@ function App() {
: scoreState.rightRightCourtPlayer, : scoreState.rightRightCourtPlayer,
} }
const reachedTarget = hasWonGame(nextScoreState)
const winnerTeamName = reachedTarget
? side === 'left'
? getTeamDisplayName(leftTeam)
: getTeamDisplayName(rightTeam)
: null
// 得分方接著發球,報分以發球方分數為先;左側隊伍的發球區需鏡像對應畫面。
const servingScore = side === 'left' ? nextScoreState.scoreLeft : nextScoreState.scoreRight
const opponentScore = side === 'left' ? nextScoreState.scoreRight : nextScoreState.scoreLeft
const serverCourt =
side === 'left'
? getMirroredCourt(getServiceCourt(servingScore))
: getServiceCourt(servingScore)
setScoreHistory((current) => [...current, { pointLog, scoreState }]) setScoreHistory((current) => [...current, { pointLog, scoreState }])
setPointLog(nextPointLog) setPointLog(nextPointLog)
setScoreState(nextScoreState) setScoreState(nextScoreState)
setVoiceAnnouncement({ setVoiceAnnouncement({
key: Date.now(), key: Date.now(),
scorerName: side === 'left' ? leftTeam.playerA : rightTeam.playerA, servingScore,
serverChanged: side === scoreState.serving, opponentScore,
serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side), serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side),
serverCourt,
winnerTeamName,
}) })
if (streakTitle) { if (streakTitle) {
@@ -790,8 +802,6 @@ function App() {
}) })
} }
const reachedTarget = hasWonGame(nextScoreState)
if (reachedTarget) { if (reachedTarget) {
setVictoryAnnouncement({ setVictoryAnnouncement({
key: Date.now() + 1, key: Date.now() + 1,
+18 -6
View File
@@ -60,9 +60,11 @@ type ScoreboardPageProps = {
} | null } | null
voiceAnnouncement: { voiceAnnouncement: {
key: number key: number
scorerName: string servingScore: number
serverChanged: boolean opponentScore: number
serverName: string serverName: string
serverCourt: 'left' | 'right'
winnerTeamName: string | null
} | null } | null
targetDate: string targetDate: string
onApplyMatchup: ( onApplyMatchup: (
@@ -245,17 +247,27 @@ export function ScoreboardPage({
return return
} }
if (voiceAnnouncement.winnerTeamName) {
const winnerSpeech = voiceAnnouncement.winnerTeamName
.split('/')
.map((name) => getSpeechName(name.trim()))
.filter(Boolean)
.join('、')
speakAnnouncement(`${winnerSpeech}贏得比賽`, voiceSettings.rate)
return
}
const parts: string[] = [] const parts: string[] = []
if (voiceSettings.announceScore) { if (voiceSettings.announceScore) {
parts.push(`${getSpeechName(voiceAnnouncement.scorerName)}得分`) parts.push(`${voiceAnnouncement.servingScore}${voiceAnnouncement.opponentScore}`)
} }
if (voiceSettings.announceServer && voiceAnnouncement.serverName) { if (voiceSettings.announceServer && voiceAnnouncement.serverName) {
parts.push( parts.push(
`${getSpeechName(voiceAnnouncement.serverName)}${ `${getSpeechName(voiceAnnouncement.serverName)}${
voiceAnnouncement.serverChanged ? '換邊發球' : '發球' voiceAnnouncement.serverCourt === 'left' ? '左邊' : '右邊'
}`, }發球`,
) )
} }
@@ -983,7 +995,7 @@ function VoiceSettingsModal({
<h3></h3> <h3></h3>
<label className="voice-setting-row"> <label className="voice-setting-row">
<span></span> <span></span>
<input <input
checked={settings.announceScore} checked={settings.announceScore}
type="checkbox" type="checkbox"