import { Anim } from '../../shared/characters'; import type { MatchEndMessage, PlayerInfo, PlayerState, RoundEndMessage, RoundState } from '../../shared/protocol'; import { fr } from '../i18n/fr'; const el = (tag: K, className = '', text = ''): HTMLElementTagNameMap[K] => { const e = document.createElement(tag); if (className) e.className = className; if (text) e.textContent = text; return e; }; /** Round banner (round, timer, final rush), scoreboard and contextual hint. */ export class RoundHud { private readonly banner = el('div'); private readonly label = el('div', 'label'); private readonly timer = el('div', 'timer'); private readonly rush = el('div', 'rush-label', fr.hud.rush); private readonly board = el('ul'); private readonly hint = el('div'); private lastKey = ''; constructor() { this.banner.id = 'round-hud'; this.board.id = 'scoreboard'; this.hint.id = 'hint'; this.banner.append(this.label, this.timer, this.rush); document.body.append(this.banner, this.board, this.hint); this.banner.hidden = true; this.board.hidden = true; } update(round: number, rounds: number, state: RoundState | null, players: Map, infos: Map, localId: number): void { const playing = round > 0 && state?.timeLeft != null; this.banner.hidden = !playing; this.board.hidden = !playing; const me = players.get(localId); const alive = me !== undefined && (me.anim & Anim.Dead) === 0; this.hint.textContent = alive && me.item === 'candle' ? fr.hud.candleHint : ''; if (!playing || !state) return; const t = Math.ceil(state.timeLeft ?? 0); this.label.textContent = fr.hud.round(round, rounds); this.timer.textContent = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; this.banner.classList.toggle('rush', state.finalRush); this.rush.hidden = !state.finalRush; // Rebuild the scoreboard only when something visible changed. const rows = [...players.values()] .map((p) => ({ p, name: infos.get(p.id)?.name ?? '?' })) .sort((a, b) => b.p.score - a.p.score || a.p.id - b.p.id); const holder = state.cherry?.holder ?? 0; const key = rows.map(({ p, name }) => `${p.id}:${name}:${p.score}:${p.anim & Anim.Dead}`).join('|') + `#${holder}`; if (key === this.lastKey) return; this.lastKey = key; this.board.replaceChildren( ...rows.map(({ p, name }) => { const li = el('li'); li.append(el('span', '', name), el('span', '', String(p.score))); li.classList.toggle('me', p.id === localId); li.classList.toggle('holder', p.id === holder); li.classList.toggle('dead', (p.anim & Anim.Dead) !== 0); return li; }), ); } } /** Round results and final results (with trophies) overlay. */ export class ResultsScreen { private readonly root = el('div'); private timer: ReturnType | null = null; constructor() { this.root.id = 'results'; this.root.hidden = true; document.body.appendChild(this.root); } hide(): void { this.root.hidden = true; if (this.timer) clearInterval(this.timer); this.timer = null; } showRound(msg: RoundEndMessage, localId: number): void { this.show(fr.results.roundTitle(msg.round, msg.rounds), msg.results, localId, null, msg.nextIn, fr.results.next); } showMatch(msg: MatchEndMessage, localId: number): void { this.show(fr.results.matchTitle, msg.results, localId, msg, msg.lobbyIn, fr.results.lobby); } private show( title: string, results: RoundEndMessage['results'], localId: number, match: MatchEndMessage | null, seconds: number, footer: (s: number) => string, ): void { const card = el('div', 'menu-card'); card.appendChild(el('h1', 'small', title)); if (match && results[0]) card.appendChild(el('p', 'winner', fr.results.winner(results[0].name))); const table = el('table'); const head = el('tr'); for (const h of [fr.results.player, fr.results.round, fr.results.total]) head.appendChild(el('th', '', h)); table.appendChild(head); for (const r of results) { const tr = el('tr', r.id === localId ? 'me' : ''); tr.append(el('td', '', r.name), el('td', 'num', String(r.roundScore)), el('td', 'num', String(r.total))); table.appendChild(tr); } card.appendChild(table); if (match) { card.appendChild(el('h2', '', fr.results.trophies)); const grid = el('div', 'trophies'); const names = new Map(results.map((r) => [r.id, r.name])); for (const t of match.trophies) { const text = fr.trophies[t.id]; if (!text) continue; const box = el('div', 'trophy'); box.append(el('strong', '', `🏆 ${text.name}`), el('div', '', names.get(t.playerId) ?? '?'), el('span', '', text.desc(t.value))); grid.appendChild(box); } if (grid.childElementCount === 0) grid.appendChild(el('p', '', fr.results.noTrophy)); card.appendChild(grid); } const foot = el('p', 'footer'); card.appendChild(foot); this.root.replaceChildren(card); this.root.hidden = false; let left = Math.ceil(seconds); foot.textContent = footer(left); if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => { left = Math.max(0, left - 1); foot.textContent = footer(left); }, 1000); } }