調整比賽結算長按回饋並更新 README

This commit is contained in:
2026-04-16 20:06:26 +08:00
parent 975732017f
commit 36a39f0b8f
3 changed files with 151 additions and 3 deletions

View File

@@ -16,6 +16,8 @@
- 需先指定先攻,之後點擊分數即可直接加分。
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
- `比賽結算` 為防誤觸設計,需長按 `1.5 秒` 才會觸發。
- 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。
- 連勝會出現特效提示:
- `3 連勝``大殺特殺`
- `4 連勝``暴走`

View File

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

View File

@@ -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<string[]>([])
@@ -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<VoiceSettings>(() =>
loadVoiceSettings(),
)
const finishHoldFrameRef = useRef<number | null>(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({
<div className="rail-clock">{clock}</div>
<button className="rail-pill rail-pill-danger" type="button" onClick={onOpenFinishDialog}>
</button>
<div
className={
finishHoldActive ? 'rail-pill-hold-wrap rail-pill-hold-wrap-active' : 'rail-pill-hold-wrap'
}
>
<button
className={
finishHoldActive
? 'rail-pill rail-pill-danger rail-pill-active-hold'
: 'rail-pill rail-pill-danger'
}
type="button"
onPointerDown={startFinishHold}
onPointerUp={cancelFinishHold}
onPointerLeave={cancelFinishHold}
onPointerCancel={cancelFinishHold}
onBlur={cancelFinishHold}
onKeyDown={(event) => {
if ((event.key === 'Enter' || event.key === ' ') && !event.repeat) {
event.preventDefault()
startFinishHold()
}
}}
onKeyUp={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
cancelFinishHold()
}
}}
>
</button>
{finishHoldActive ? (
<div aria-hidden="true" className="rail-hold-progress">
<span
className="rail-hold-progress-bar"
style={{ transform: `scaleX(${finishHoldProgress})` }}
/>
</div>
) : null}
</div>
</aside>
</section>