新增連勝與獲勝特效並更新 README

This commit is contained in:
2026-04-16 16:54:59 +08:00
parent b3809b5d4f
commit 860e7adc0e
4 changed files with 282 additions and 0 deletions

View File

@@ -21,6 +21,14 @@
- 選定先攻後,該方的先攻方框會直接顯示打勾 - 選定先攻後,該方的先攻方框會直接顯示打勾
- 第一分後 `設定隊伍` 會改成 `上一步` - 第一分後 `設定隊伍` 會改成 `上一步`
- 支援上下交換兩隊位置、左右交換隊內站位 - 支援上下交換兩隊位置、左右交換隊內站位
- 三連勝以上會顯示連勝稱號動畫
- `3` 連勝:`大殺特殺`
- `4` 連勝:`暴走`
- `5` 連勝:`無人能擋`
- `6` 連勝:`主宰比賽`
- `7` 連勝:`像神一般的`
- `8` 連勝:`成為傳說`
- 達到目標分數獲勝時,會跳出獲勝動畫特效
- 歷史戰績頁 - 歷史戰績頁
- 直接從資料庫 `history` 表讀取列表 - 直接從資料庫 `history` 表讀取列表
- 點擊任一筆可開啟得分紀錄彈窗 - 點擊任一筆可開啟得分紀錄彈窗

View File

@@ -436,6 +436,139 @@
align-items: start; 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 { .scoreboard-court {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -1514,6 +1647,37 @@
border-radius: 16px; 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 { .scoreboard-team-head {
grid-template-columns: minmax(0, 1fr) 54px; grid-template-columns: minmax(0, 1fr) 54px;
gap: 6px; gap: 6px;

View File

@@ -57,6 +57,29 @@ type SettlementState = {
uploading: boolean 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<number, string> = {
3: '大殺特殺',
4: '暴走',
5: '無人能擋',
6: '主宰比賽',
7: '像神一般的',
8: '成為傳說',
}
function App() { function App() {
const location = useLocation() const location = useLocation()
const isScoreboardRoute = location.pathname === '/scoreboard' const isScoreboardRoute = location.pathname === '/scoreboard'
@@ -90,6 +113,8 @@ function App() {
open: false, open: false,
uploading: false, uploading: false,
}) })
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
@@ -125,10 +150,36 @@ function App() {
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [loadMessage, loadStatus]) }, [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) => { const resetScoring = (nextState: ScoreState = initialScoreState) => {
setScoreState(nextState) setScoreState(nextState)
setScoreHistory([]) setScoreHistory([])
setPointLog([]) setPointLog([])
setStreakAnnouncement(null)
setVictoryAnnouncement(null)
setSettlement({ setSettlement({
error: '', error: '',
open: false, open: false,
@@ -294,6 +345,8 @@ function App() {
const winner: 0 | 1 = side === 'left' ? 0 : 1 const winner: 0 | 1 = side === 'left' ? 0 : 1
const previousPoint = pointLog.at(-1) const previousPoint = pointLog.at(-1)
const winCount = previousPoint?.winner === winner ? previousPoint.winCount + 1 : 0 const winCount = previousPoint?.winner === winner ? previousPoint.winCount + 1 : 0
const streakCount = winCount + 1
const streakTitle = STREAK_TITLES[streakCount]
const nextPointLog = [ const nextPointLog = [
...pointLog, ...pointLog,
@@ -323,6 +376,28 @@ function App() {
setScoreHistory((current) => [...current, { pointLog, scoreState }]) setScoreHistory((current) => [...current, { pointLog, scoreState }])
setPointLog(nextPointLog) setPointLog(nextPointLog)
setScoreState(nextScoreState) 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 = () => { const undoLastPoint = () => {
@@ -335,6 +410,8 @@ function App() {
setScoreHistory((current) => current.slice(0, -1)) setScoreHistory((current) => current.slice(0, -1))
setPointLog(previous.pointLog) setPointLog(previous.pointLog)
setScoreState(previous.scoreState) setScoreState(previous.scoreState)
setStreakAnnouncement(null)
setVictoryAnnouncement(null)
} }
const openSettlementDialog = () => { const openSettlementDialog = () => {
@@ -506,6 +583,8 @@ function App() {
rightTeam={rightTeam} rightTeam={rightTeam}
scoreState={scoreState} scoreState={scoreState}
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
streakAnnouncement={streakAnnouncement}
victoryAnnouncement={victoryAnnouncement}
targetDate={targetDate} targetDate={targetDate}
onApplyMatchup={applyMatchup} onApplyMatchup={applyMatchup}
onCloseFinishDialog={closeSettlementDialog} onCloseFinishDialog={closeSettlementDialog}

View File

@@ -27,6 +27,18 @@ type ScoreboardPageProps = {
rightTeam: GroupTeam | null rightTeam: GroupTeam | null
scoreState: ScoreState scoreState: ScoreState
selectedGroup: RoundGroup | null 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 targetDate: string
onApplyMatchup: ( onApplyMatchup: (
leftTeam: GroupTeam, leftTeam: GroupTeam,
@@ -55,6 +67,8 @@ export function ScoreboardPage({
rightTeam, rightTeam,
scoreState, scoreState,
selectedGroup, selectedGroup,
streakAnnouncement,
victoryAnnouncement,
targetDate, targetDate,
onApplyMatchup, onApplyMatchup,
onCloseFinishDialog, onCloseFinishDialog,
@@ -262,6 +276,23 @@ export function ScoreboardPage({
return ( return (
<> <>
{streakAnnouncement ? (
<div className="streak-banner" key={streakAnnouncement.key}>
<span className="streak-banner-count">{streakAnnouncement.count} </span>
<strong>{streakAnnouncement.title}</strong>
<small>{streakAnnouncement.teamName}</small>
</div>
) : null}
{victoryAnnouncement ? (
<div className="victory-banner" key={victoryAnnouncement.key}>
<span className="victory-banner-kicker"></span>
<strong>{victoryAnnouncement.title}</strong>
<small>{victoryAnnouncement.teamName}</small>
<em>{victoryAnnouncement.scoreLabel}</em>
</div>
) : null}
<section className="scoreboard-screen"> <section className="scoreboard-screen">
<div className="scoreboard-court"> <div className="scoreboard-court">
<ScoreboardTeamPanel <ScoreboardTeamPanel