diff --git a/README.md b/README.md index 000eea4..4be9627 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,14 @@ - 選定先攻後,該方的先攻方框會直接顯示打勾 - 第一分後 `設定隊伍` 會改成 `上一步` - 支援上下交換兩隊位置、左右交換隊內站位 + - 三連勝以上會顯示連勝稱號動畫 + - `3` 連勝:`大殺特殺` + - `4` 連勝:`暴走` + - `5` 連勝:`無人能擋` + - `6` 連勝:`主宰比賽` + - `7` 連勝:`像神一般的` + - `8` 連勝:`成為傳說` + - 達到目標分數獲勝時,會跳出獲勝動畫特效 - 歷史戰績頁 - 直接從資料庫 `history` 表讀取列表 - 點擊任一筆可開啟得分紀錄彈窗 diff --git a/src/App.css b/src/App.css index 8b00cac..aa9faab 100644 --- a/src/App.css +++ b/src/App.css @@ -436,6 +436,139 @@ align-items: start; } +.streak-banner { + position: fixed; + left: 50%; + top: 18%; + z-index: 85; + display: grid; + justify-items: center; + gap: 6px; + min-width: min(88vw, 420px); + padding: 18px 28px; + border-radius: 28px; + color: #fff8e8; + background: + radial-gradient(circle at top, rgba(255, 219, 112, 0.38), transparent 46%), + linear-gradient(135deg, rgba(143, 25, 26, 0.96), rgba(248, 128, 45, 0.92)); + box-shadow: + 0 24px 48px rgba(8, 47, 73, 0.32), + inset 0 0 0 1px rgba(255, 238, 194, 0.24); + transform: translate(-50%, 0); + pointer-events: none; + animation: streak-banner-burst 1.8s ease forwards; +} + +.streak-banner-count { + font-size: 0.96rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(255, 244, 214, 0.86); +} + +.streak-banner strong { + font-size: clamp(2rem, 7vw, 3.6rem); + line-height: 1; + text-shadow: + 0 2px 0 rgba(101, 14, 10, 0.28), + 0 0 22px rgba(255, 226, 154, 0.24); +} + +.streak-banner small { + font-size: 1rem; + color: rgba(255, 248, 232, 0.92); +} + +.victory-banner { + position: fixed; + left: 50%; + top: 22%; + z-index: 86; + display: grid; + justify-items: center; + gap: 6px; + min-width: min(90vw, 460px); + padding: 22px 30px; + border-radius: 30px; + color: #4a2e1d; + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.34), transparent 40%), + linear-gradient(135deg, rgba(255, 243, 196, 0.98), rgba(255, 199, 92, 0.94)); + box-shadow: + 0 30px 56px rgba(8, 47, 73, 0.28), + 0 0 0 6px rgba(255, 243, 211, 0.16), + inset 0 0 0 1px rgba(196, 134, 32, 0.34); + transform: translate(-50%, 0); + pointer-events: none; + animation: victory-banner-burst 2.2s ease forwards; +} + +.victory-banner-kicker { + font-size: 0.9rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(101, 67, 22, 0.82); +} + +.victory-banner strong { + font-size: clamp(2.2rem, 8vw, 4rem); + line-height: 1; + text-shadow: + 0 2px 0 rgba(255, 255, 255, 0.4), + 0 0 22px rgba(255, 242, 176, 0.34); +} + +.victory-banner small, +.victory-banner em { + font-size: 1rem; + font-style: normal; + color: rgba(74, 46, 29, 0.9); +} + +@keyframes streak-banner-burst { + 0% { + opacity: 0; + transform: translate(-50%, 12px) scale(0.88); + } + + 15% { + opacity: 1; + transform: translate(-50%, 0) scale(1.04); + } + + 70% { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -12px) scale(0.96); + } +} + +@keyframes victory-banner-burst { + 0% { + opacity: 0; + transform: translate(-50%, 18px) scale(0.84); + } + + 14% { + opacity: 1; + transform: translate(-50%, 0) scale(1.06); + } + + 68% { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -18px) scale(0.96); + } +} + .scoreboard-court { display: grid; gap: 14px; @@ -1514,6 +1647,37 @@ border-radius: 16px; } + .streak-banner { + top: 12%; + min-width: min(92vw, 360px); + padding: 14px 18px; + border-radius: 22px; + } + + .streak-banner strong { + font-size: clamp(1.6rem, 8vw, 2.4rem); + } + + .streak-banner small { + font-size: 0.88rem; + } + + .victory-banner { + top: 14%; + min-width: min(92vw, 360px); + padding: 16px 18px; + border-radius: 22px; + } + + .victory-banner strong { + font-size: clamp(1.8rem, 9vw, 2.8rem); + } + + .victory-banner small, + .victory-banner em { + font-size: 0.88rem; + } + .scoreboard-team-head { grid-template-columns: minmax(0, 1fr) 54px; gap: 6px; diff --git a/src/App.tsx b/src/App.tsx index 885075b..0da5e30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,29 @@ type SettlementState = { uploading: boolean } +type StreakAnnouncement = { + count: number + key: number + teamName: string + title: string +} + +type VictoryAnnouncement = { + key: number + scoreLabel: string + teamName: string + title: string +} + +const STREAK_TITLES: Record = { + 3: '大殺特殺', + 4: '暴走', + 5: '無人能擋', + 6: '主宰比賽', + 7: '像神一般的', + 8: '成為傳說', +} + function App() { const location = useLocation() const isScoreboardRoute = location.pathname === '/scoreboard' @@ -90,6 +113,8 @@ function App() { open: false, uploading: false, }) + const [streakAnnouncement, setStreakAnnouncement] = useState(null) + const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) @@ -125,10 +150,36 @@ function App() { return () => window.clearTimeout(timer) }, [loadMessage, loadStatus]) + useEffect(() => { + if (!streakAnnouncement) { + return + } + + const timer = window.setTimeout(() => { + setStreakAnnouncement(null) + }, 1800) + + return () => window.clearTimeout(timer) + }, [streakAnnouncement]) + + useEffect(() => { + if (!victoryAnnouncement) { + return + } + + const timer = window.setTimeout(() => { + setVictoryAnnouncement(null) + }, 2200) + + return () => window.clearTimeout(timer) + }, [victoryAnnouncement]) + const resetScoring = (nextState: ScoreState = initialScoreState) => { setScoreState(nextState) setScoreHistory([]) setPointLog([]) + setStreakAnnouncement(null) + setVictoryAnnouncement(null) setSettlement({ error: '', open: false, @@ -294,6 +345,8 @@ function App() { const winner: 0 | 1 = side === 'left' ? 0 : 1 const previousPoint = pointLog.at(-1) const winCount = previousPoint?.winner === winner ? previousPoint.winCount + 1 : 0 + const streakCount = winCount + 1 + const streakTitle = STREAK_TITLES[streakCount] const nextPointLog = [ ...pointLog, @@ -323,6 +376,28 @@ function App() { setScoreHistory((current) => [...current, { pointLog, scoreState }]) setPointLog(nextPointLog) setScoreState(nextScoreState) + + if (streakTitle) { + setStreakAnnouncement({ + count: streakCount, + key: Date.now(), + teamName: side === 'left' ? getTeamDisplayName(leftTeam) : getTeamDisplayName(rightTeam), + title: streakTitle, + }) + } + + const reachedTarget = + nextScoreState.scoreLeft >= nextScoreState.targetScore || + nextScoreState.scoreRight >= nextScoreState.targetScore + + if (reachedTarget) { + setVictoryAnnouncement({ + key: Date.now() + 1, + scoreLabel: `${nextScoreState.scoreLeft} : ${nextScoreState.scoreRight}`, + teamName: side === 'left' ? getTeamDisplayName(leftTeam) : getTeamDisplayName(rightTeam), + title: '拿下勝利', + }) + } } const undoLastPoint = () => { @@ -335,6 +410,8 @@ function App() { setScoreHistory((current) => current.slice(0, -1)) setPointLog(previous.pointLog) setScoreState(previous.scoreState) + setStreakAnnouncement(null) + setVictoryAnnouncement(null) } const openSettlementDialog = () => { @@ -506,6 +583,8 @@ function App() { rightTeam={rightTeam} scoreState={scoreState} selectedGroup={selectedGroup} + streakAnnouncement={streakAnnouncement} + victoryAnnouncement={victoryAnnouncement} targetDate={targetDate} onApplyMatchup={applyMatchup} onCloseFinishDialog={closeSettlementDialog} diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index 8039383..2b5aeb5 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -27,6 +27,18 @@ type ScoreboardPageProps = { rightTeam: GroupTeam | null scoreState: ScoreState selectedGroup: RoundGroup | null + streakAnnouncement: { + count: number + key: number + teamName: string + title: string + } | null + victoryAnnouncement: { + key: number + scoreLabel: string + teamName: string + title: string + } | null targetDate: string onApplyMatchup: ( leftTeam: GroupTeam, @@ -55,6 +67,8 @@ export function ScoreboardPage({ rightTeam, scoreState, selectedGroup, + streakAnnouncement, + victoryAnnouncement, targetDate, onApplyMatchup, onCloseFinishDialog, @@ -262,6 +276,23 @@ export function ScoreboardPage({ return ( <> + {streakAnnouncement ? ( +
+ {streakAnnouncement.count} 連勝 + {streakAnnouncement.title} + {streakAnnouncement.teamName} +
+ ) : null} + + {victoryAnnouncement ? ( +
+ 目標分數達成 + {victoryAnnouncement.title} + {victoryAnnouncement.teamName} + {victoryAnnouncement.scoreLabel} +
+ ) : null} +