feat(frontend): add in-match timer HUD with countdown and time warning
This commit is contained in:
parent
4b8c402e43
commit
30a2d9f3dd
3 changed files with 176 additions and 0 deletions
|
|
@ -33,6 +33,9 @@
|
|||
<span id="progress-label">0 / 12 TOKEN KINDS THEMED</span>
|
||||
</div>
|
||||
|
||||
<div id="match-timer-hud" class="hidden"></div>
|
||||
<div id="match-timer-warning" class="hidden"></div>
|
||||
|
||||
<main class="board">
|
||||
<div class="canvas-wrap">
|
||||
<canvas id="code-canvas"></canvas>
|
||||
|
|
|
|||
118
frontend/src/game/timerHud.ts
Normal file
118
frontend/src/game/timerHud.ts
Normal file
|
|
@ -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 `
|
||||
<div class="match-timer-hud">
|
||||
<span class="match-timer-badge"></span>
|
||||
<div class="match-timer-track"><div class="match-timer-fill"></div></div>
|
||||
<span class="match-timer-clock">00:00</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function elapsedMarkup(): string {
|
||||
return `
|
||||
<div class="match-timer-hud match-timer-hud--elapsed">
|
||||
<span class="match-timer-badge"></span>
|
||||
<span class="match-timer-clock">00:00</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 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<HTMLElement>('.match-timer-badge')!;
|
||||
const clock = container.querySelector<HTMLElement>('.match-timer-clock')!;
|
||||
const fill = container.querySelector<HTMLElement>('.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<HTMLElement, number>();
|
||||
|
||||
/** 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 = '<div class="match-timer-warning-toast">⏰ 20 seconds left!</div>';
|
||||
container.classList.remove('hidden');
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
container.classList.add('hidden');
|
||||
container.innerHTML = '';
|
||||
pendingDismiss.delete(container);
|
||||
}, WARNING_DISMISS_MS);
|
||||
pendingDismiss.set(container, timeoutId);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue