diff --git a/frontend/index.html b/frontend/index.html index 8713b86..57524cd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,6 +33,9 @@ 0 / 12 TOKEN KINDS THEMED + + +
diff --git a/frontend/src/game/timerHud.ts b/frontend/src/game/timerHud.ts new file mode 100644 index 0000000..90855ac --- /dev/null +++ b/frontend/src/game/timerHud.ts @@ -0,0 +1,118 @@ +// In-match timer HUD: a countdown mode badge + progress bar driven purely +// by the server-authoritative `endsAt` from `round:start`, plus a one-shot +// "20 seconds left" toast for `round:timeWarning`. DOM-only, no class — +// mirrors the plain-function style of `resultView.ts`. The client never +// runs independent timer logic that could drift from the server: every +// tick recomputes `endsAt - Date.now()` rather than counting its own +// elapsed time. + +/** Discriminated union so a caller can't pass `endsAt` without a real + * deadline (or omit it with one). `timeMode` doubles as the mode-badge + * source and the progress bar's total duration (in minutes). `'none'` is + * solo's infinite-time mode: no deadline, so no progress bar — the clock + * counts elapsed time up from when `startCountdown` was called instead. */ +export type TimerHudOptions = + | { timeMode: 1 | 2; endsAt: number } + | { timeMode: 'none'; endsAt?: null }; + +const TIME_MODE_LABEL: Record<1 | 2 | 'none', string> = { + 1: '1 MIN', + 2: '2 MIN', + none: 'NO LIMIT', +}; + +const WARNING_DISMISS_MS = 4000; + +function formatClock(ms: number): string { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +function countdownMarkup(): string { + return ` +
+ +
+ 00:00 +
`; +} + +function elapsedMarkup(): string { + return ` +
+ + 00:00 +
`; +} + +/** Renders the mode badge + (for 1/2-minute modes) progress bar + mm:ss + * readout into `container`, and starts driving it from `options.endsAt` + * via requestAnimationFrame. Returns a `stop()` that cancels the animation + * loop; call it on round end/teardown to avoid leaking the RAF handle. */ +export function startCountdown(container: HTMLElement, options: TimerHudOptions): () => void { + const isCountdown = options.timeMode !== 'none'; + container.innerHTML = isCountdown ? countdownMarkup() : elapsedMarkup(); + container.classList.remove('hidden'); + + const badge = container.querySelector('.match-timer-badge')!; + const clock = container.querySelector('.match-timer-clock')!; + const fill = container.querySelector('.match-timer-fill'); + badge.textContent = TIME_MODE_LABEL[options.timeMode]; + + const startedAt = Date.now(); + let rafId: number | null = null; + + function stop(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + function tick(): void { + if (options.timeMode === 'none') { + clock.textContent = formatClock(Date.now() - startedAt); + rafId = requestAnimationFrame(tick); + return; + } + + const remaining = options.endsAt - Date.now(); + clock.textContent = formatClock(Math.max(0, remaining)); + if (fill) { + const total = options.timeMode * 60_000; + const pct = Math.max(0, Math.min(100, (remaining / total) * 100)); + fill.style.width = `${pct}%`; + } + if (remaining <= 0) { + stop(); + return; + } + rafId = requestAnimationFrame(tick); + } + + tick(); + return stop; +} + +const pendingDismiss = new WeakMap(); + +/** Renders a small, auto-dismissing "20 seconds left" toast into + * `container`. Positioned by CSS (`.match-timer-warning-toast`) to stay + * clear of the canvas and category panel, and with `pointer-events: none` + * so it never intercepts clicks on tokens or the color picker underneath. */ +export function showTimeWarning(container: HTMLElement): void { + const pending = pendingDismiss.get(container); + if (pending !== undefined) window.clearTimeout(pending); + + container.innerHTML = '
⏰ 20 seconds left!
'; + container.classList.remove('hidden'); + + const timeoutId = window.setTimeout(() => { + container.classList.add('hidden'); + container.innerHTML = ''; + pendingDismiss.delete(container); + }, WARNING_DISMISS_MS); + pendingDismiss.set(container, timeoutId); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 0bbb9f4..2809e99 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -169,6 +169,61 @@ body { } #progress-label { font-family: var(--pixel); font-size: 9px; color: var(--fg-dim); white-space: nowrap; } +/* ---------- match timer HUD (#11: countdown + time warning) ---------- */ +/* A second, visually distinct progress bar from .progress-wrap above — + * that one tracks "N/12 token kinds themed"; this one tracks time + * remaining in the round. Built by game/timerHud.ts into the empty + * #match-timer-hud / #match-timer-warning mounts in index.html. Hidden + * by default: not mounted into any live view until a later ticket. */ +.match-timer-hud { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--pixel); + font-size: 9px; + color: var(--fg); + background: var(--panel-2); + border: 3px solid var(--ink); + padding: 8px 12px; + box-shadow: 3px 3px 0 var(--ink); +} +#match-timer-hud.hidden { display: none; } +.match-timer-badge { color: var(--yellow); flex-shrink: 0; white-space: nowrap; } +.match-timer-track { + flex: 1; + min-width: 80px; + height: 12px; + background: var(--panel-3); + border: 2px solid var(--ink); + overflow: hidden; +} +.match-timer-fill { + height: 100%; + width: 100%; + background: var(--cyan); + transition: width .3s linear, background-color .3s linear; +} +.match-timer-clock { flex-shrink: 0; white-space: nowrap; color: var(--fg-dim); } +.match-timer-hud--elapsed .match-timer-clock { color: var(--fg); } + +.match-timer-warning-toast { + position: fixed; + top: 68px; + left: 50%; + transform: translateX(-50%); + z-index: 80; + pointer-events: none; + font-family: var(--pixel); + font-size: 10px; + color: var(--ink); + background: var(--yellow); + border: 3px solid var(--ink); + box-shadow: 3px 3px 0 var(--ink); + padding: 8px 14px; + animation: modalIn .2s steps(4); +} +#match-timer-warning.hidden { display: none; } + /* ---------- board layout ---------- */ .board {