調整比賽結算長按回饋並更新 README
This commit is contained in:
@@ -16,6 +16,8 @@
|
|||||||
- 需先指定先攻,之後點擊分數即可直接加分。
|
- 需先指定先攻,之後點擊分數即可直接加分。
|
||||||
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。
|
- 第一分記錄後,右側 `設定隊伍` 會切成 `上一步`。
|
||||||
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
|
- 可交換上下隊伍位置,也可交換同隊左右球員位置。
|
||||||
|
- `比賽結算` 為防誤觸設計,需長按 `1.5 秒` 才會觸發。
|
||||||
|
- 長按 `比賽結算` 時,按鈕下方會顯示進度條,按鈕本身也會有亮起與下壓的視覺回饋。
|
||||||
- 連勝會出現特效提示:
|
- 連勝會出現特效提示:
|
||||||
- `3 連勝`:`大殺特殺`
|
- `3 連勝`:`大殺特殺`
|
||||||
- `4 連勝`:`暴走`
|
- `4 連勝`:`暴走`
|
||||||
|
|||||||
46
src/App.css
46
src/App.css
@@ -961,6 +961,15 @@
|
|||||||
filter 0.16s ease;
|
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 {
|
.rail-pill:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -978,11 +987,40 @@
|
|||||||
background: linear-gradient(180deg, #d95a44, #b53a28);
|
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 {
|
.rail-pill-muted {
|
||||||
color: #4d3a29;
|
color: #4d3a29;
|
||||||
background: linear-gradient(180deg, #f7f2e8, #e0d6c5);
|
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 {
|
.voice-settings-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1924,6 +1962,14 @@
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rail-pill-hold-wrap {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-hold-progress {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.finish-dialog {
|
.finish-dialog {
|
||||||
padding: 20px 14px 14px;
|
padding: 20px 14px 14px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export function ScoreboardPage({
|
|||||||
onSwapTeamPlayers,
|
onSwapTeamPlayers,
|
||||||
onUndoLastPoint,
|
onUndoLastPoint,
|
||||||
}: ScoreboardPageProps) {
|
}: ScoreboardPageProps) {
|
||||||
|
const FINISH_HOLD_DURATION = 1500
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
|
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
|
||||||
@@ -105,9 +106,14 @@ export function ScoreboardPage({
|
|||||||
String(scoreState.targetScore),
|
String(scoreState.targetScore),
|
||||||
)
|
)
|
||||||
const [clock, setClock] = useState(() => formatClock())
|
const [clock, setClock] = useState(() => formatClock())
|
||||||
|
const [finishHoldActive, setFinishHoldActive] = useState(false)
|
||||||
|
const [finishHoldProgress, setFinishHoldProgress] = useState(0)
|
||||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>(() =>
|
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>(() =>
|
||||||
loadVoiceSettings(),
|
loadVoiceSettings(),
|
||||||
)
|
)
|
||||||
|
const finishHoldFrameRef = useRef<number | null>(null)
|
||||||
|
const finishHoldStartRef = useRef(0)
|
||||||
|
const finishTriggeredRef = useRef(false)
|
||||||
const lastAnnouncedPointRef = useRef(0)
|
const lastAnnouncedPointRef = useRef(0)
|
||||||
const previousScoresRef = useRef({ left: 0, right: 0 })
|
const previousScoresRef = useRef({ left: 0, right: 0 })
|
||||||
|
|
||||||
@@ -128,6 +134,10 @@ export function ScoreboardPage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (finishHoldFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(finishHoldFrameRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
window.speechSynthesis.cancel()
|
window.speechSynthesis.cancel()
|
||||||
}
|
}
|
||||||
@@ -301,6 +311,57 @@ export function ScoreboardPage({
|
|||||||
setPickerOpen(true)
|
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) => {
|
const toggleDraftPlayer = (playerName: string) => {
|
||||||
setDraftPlayers((current) => {
|
setDraftPlayers((current) => {
|
||||||
if (current.includes(playerName)) {
|
if (current.includes(playerName)) {
|
||||||
@@ -461,9 +522,48 @@ export function ScoreboardPage({
|
|||||||
|
|
||||||
<div className="rail-clock">{clock}</div>
|
<div className="rail-clock">{clock}</div>
|
||||||
|
|
||||||
<button className="rail-pill rail-pill-danger" type="button" onClick={onOpenFinishDialog}>
|
<div
|
||||||
比賽結算
|
className={
|
||||||
</button>
|
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>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user