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>
|
<span id="progress-label">0 / 12 TOKEN KINDS THEMED</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="match-timer-hud" class="hidden"></div>
|
||||||
|
<div id="match-timer-warning" class="hidden"></div>
|
||||||
|
|
||||||
<main class="board">
|
<main class="board">
|
||||||
<div class="canvas-wrap">
|
<div class="canvas-wrap">
|
||||||
<canvas id="code-canvas"></canvas>
|
<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; }
|
#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 layout ---------- */
|
||||||
|
|
||||||
.board {
|
.board {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue