新增連勝與獲勝特效並更新 README
This commit is contained in:
@@ -21,6 +21,14 @@
|
|||||||
- 選定先攻後,該方的先攻方框會直接顯示打勾
|
- 選定先攻後,該方的先攻方框會直接顯示打勾
|
||||||
- 第一分後 `設定隊伍` 會改成 `上一步`
|
- 第一分後 `設定隊伍` 會改成 `上一步`
|
||||||
- 支援上下交換兩隊位置、左右交換隊內站位
|
- 支援上下交換兩隊位置、左右交換隊內站位
|
||||||
|
- 三連勝以上會顯示連勝稱號動畫
|
||||||
|
- `3` 連勝:`大殺特殺`
|
||||||
|
- `4` 連勝:`暴走`
|
||||||
|
- `5` 連勝:`無人能擋`
|
||||||
|
- `6` 連勝:`主宰比賽`
|
||||||
|
- `7` 連勝:`像神一般的`
|
||||||
|
- `8` 連勝:`成為傳說`
|
||||||
|
- 達到目標分數獲勝時,會跳出獲勝動畫特效
|
||||||
- 歷史戰績頁
|
- 歷史戰績頁
|
||||||
- 直接從資料庫 `history` 表讀取列表
|
- 直接從資料庫 `history` 表讀取列表
|
||||||
- 點擊任一筆可開啟得分紀錄彈窗
|
- 點擊任一筆可開啟得分紀錄彈窗
|
||||||
|
|||||||
164
src/App.css
164
src/App.css
@@ -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;
|
||||||
|
|||||||
79
src/App.tsx
79
src/App.tsx
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user