新增連勝與獲勝特效並更新 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

@@ -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;

View File

@@ -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<number, string> = {
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<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(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}

View File

@@ -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 ? (
<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">
<div className="scoreboard-court">
<ScoreboardTeamPanel