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

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
+64 -8
View File
@@ -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<number, string> = {
3: '大殺特殺',
4: '暴走',
@@ -129,6 +139,7 @@ function App() {
})
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(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 })
}
+36 -5
View File
@@ -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 {
+42 -54
View File
@@ -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<number | null>(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}
>
<span className="team-number">{getPlayerNumber(teamSlot, assignment.slot)}</span>
<span className="team-number">{getPlayerNumber(teamSlot, assignment.court)}</span>
<strong>{assignment.name}</strong>
</div>
))}
@@ -731,23 +721,25 @@ function ScoreboardTeamPanel({
const serveBar = (
<button
className={
currentServer && !canArrangeMatch
hasInitialServing && !canSetServing
? 'serve-lane serve-lane-locked'
: showServingPrompt
? 'serve-lane serve-lane-prompt'
: 'serve-lane'
}
disabled={!canArrangeMatch || !team}
disabled={!canSetServing || !team}
type="button"
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>
{currentServer ? (
<small>
{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
{currentReceiver ? ` / 接發:${currentReceiver}` : ''}
</small>
) : hasInitialServing ? (
<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') {
return slot === 'playerA' ? 1 : 2
return court === 'left' ? 1 : 2
}
return slot === 'playerA' ? 4 : 3
return court === 'right' ? 3 : 4
}
function sanitizeTargetScore(value: string) {
@@ -1196,10 +1188,6 @@ function loadVoiceSettings(): VoiceSettings {
}
}
function getAnnouncementName(team: GroupTeam | null) {
return getSpeechName(team?.playerA ?? '本隊')
}
function getSpeechName(name: string) {
return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
}
+1
View File
@@ -42,6 +42,7 @@ export type ScoreState = {
gamesRight: number
currentGame: number
targetScore: number
initialServing: ScoreSide | null
serving: ScoreSide | null
leftRightCourtPlayer: PlayerSlot
rightRightCourtPlayer: PlayerSlot