From 36a39f0b8f65c139259b7d0f0d6fb569c5dad724 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Thu, 16 Apr 2026 20:06:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AA=BF=E6=95=B4=E6=AF=94=E8=B3=BD=E7=B5=90?= =?UTF-8?q?=E7=AE=97=E9=95=B7=E6=8C=89=E5=9B=9E=E9=A5=8B=E4=B8=A6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + src/App.css | 46 +++++++++++++++ src/pages/ScoreboardPage.tsx | 106 ++++++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b1a7b7d..ea263d4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ - 需先指定先攻,之後點擊分數即可直接加分。 - 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。 - 可交換上下隊伍位置,也可交換同隊左右球員位置。 + - `比賽結算` 為防誤觸設計,需長按 `1.5 秒` 才會觸發。 + - 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。 - 連勝會出現特效提示: - `3 連勝`:`大殺特殺` - `4 連勝`:`暴走` diff --git a/src/App.css b/src/App.css index 7d38d5e..e80b8bc 100644 --- a/src/App.css +++ b/src/App.css @@ -961,6 +961,15 @@ filter 0.16s ease; } +.rail-pill-hold-wrap { + display: grid; + gap: 8px; +} + +.rail-pill-hold-wrap-active { + filter: drop-shadow(0 12px 20px rgba(217, 90, 68, 0.2)); +} + .rail-pill:hover { transform: translateY(-1px); box-shadow: @@ -978,11 +987,40 @@ background: linear-gradient(180deg, #d95a44, #b53a28); } +.rail-pill-active-hold { + transform: translateY(1px) scale(0.99); + filter: brightness(1.08) saturate(1.08); + box-shadow: + inset 0 0 0 1px rgba(255, 227, 214, 0.5), + inset 0 12px 18px rgba(255, 255, 255, 0.1), + 0 0 0 4px rgba(255, 224, 194, 0.2), + 0 14px 26px rgba(181, 58, 40, 0.28); +} + .rail-pill-muted { color: #4d3a29; background: linear-gradient(180deg, #f7f2e8, #e0d6c5); } +.rail-hold-progress { + position: relative; + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 240, 199, 0.32); + box-shadow: inset 0 0 0 1px rgba(255, 237, 208, 0.12); +} + +.rail-hold-progress-bar { + display: block; + width: 100%; + height: 100%; + transform-origin: left center; + background: linear-gradient(90deg, #ffe9a8, #fff7d0, #ffffff); + box-shadow: 0 0 12px rgba(255, 246, 203, 0.5); +} + .voice-settings-overlay { position: fixed; inset: 0; @@ -1924,6 +1962,14 @@ font-size: 0.92rem; } + .rail-pill-hold-wrap { + gap: 6px; + } + + .rail-hold-progress { + height: 6px; + } + .finish-dialog { padding: 20px 14px 14px; border-radius: 18px; diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index ae8321a..cb9c704 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -98,6 +98,7 @@ export function ScoreboardPage({ onSwapTeamPlayers, onUndoLastPoint, }: ScoreboardPageProps) { + const FINISH_HOLD_DURATION = 1500 const [pickerOpen, setPickerOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) const [draftPlayers, setDraftPlayers] = useState([]) @@ -105,9 +106,14 @@ export function ScoreboardPage({ String(scoreState.targetScore), ) const [clock, setClock] = useState(() => formatClock()) + const [finishHoldActive, setFinishHoldActive] = useState(false) + const [finishHoldProgress, setFinishHoldProgress] = useState(0) const [voiceSettings, setVoiceSettings] = useState(() => loadVoiceSettings(), ) + const finishHoldFrameRef = useRef(null) + const finishHoldStartRef = useRef(0) + const finishTriggeredRef = useRef(false) const lastAnnouncedPointRef = useRef(0) const previousScoresRef = useRef({ left: 0, right: 0 }) @@ -128,6 +134,10 @@ export function ScoreboardPage({ useEffect(() => { return () => { + if (finishHoldFrameRef.current !== null) { + window.cancelAnimationFrame(finishHoldFrameRef.current) + } + if ('speechSynthesis' in window) { window.speechSynthesis.cancel() } @@ -301,6 +311,57 @@ export function ScoreboardPage({ setPickerOpen(true) } + const stopFinishHold = () => { + if (finishHoldFrameRef.current !== null) { + window.cancelAnimationFrame(finishHoldFrameRef.current) + finishHoldFrameRef.current = null + } + + finishHoldStartRef.current = 0 + finishTriggeredRef.current = false + setFinishHoldActive(false) + setFinishHoldProgress(0) + } + + const startFinishHold = () => { + if (finishDialogOpen || finishDialogUploading || finishHoldActive) { + return + } + + finishTriggeredRef.current = false + finishHoldStartRef.current = performance.now() + setFinishHoldActive(true) + setFinishHoldProgress(0) + + const tick = (now: number) => { + const elapsed = now - finishHoldStartRef.current + const progress = Math.min(elapsed / FINISH_HOLD_DURATION, 1) + setFinishHoldProgress(progress) + + if (progress >= 1) { + finishTriggeredRef.current = true + setFinishHoldActive(false) + setFinishHoldProgress(0) + finishHoldFrameRef.current = null + onOpenFinishDialog() + return + } + + finishHoldFrameRef.current = window.requestAnimationFrame(tick) + } + + finishHoldFrameRef.current = window.requestAnimationFrame(tick) + } + + const cancelFinishHold = () => { + if (finishTriggeredRef.current) { + finishTriggeredRef.current = false + return + } + + stopFinishHold() + } + const toggleDraftPlayer = (playerName: string) => { setDraftPlayers((current) => { if (current.includes(playerName)) { @@ -461,9 +522,48 @@ export function ScoreboardPage({
{clock}
- +
+ + + {finishHoldActive ? ( + + ) : null} +