調整發球鏡像規則並更新說明文件
This commit is contained in:
+64
-8
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export type ScoreState = {
|
||||
gamesRight: number
|
||||
currentGame: number
|
||||
targetScore: number
|
||||
initialServing: ScoreSide | null
|
||||
serving: ScoreSide | null
|
||||
leftRightCourtPlayer: PlayerSlot
|
||||
rightRightCourtPlayer: PlayerSlot
|
||||
|
||||
Reference in New Issue
Block a user