新增連勝與獲勝特效並更新 README
This commit is contained in:
164
src/App.css
164
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;
|
||||
|
||||
79
src/App.tsx
79
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<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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user