| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144 |
- import { Anim } from '../../shared/characters';
- import type { MatchEndMessage, PlayerInfo, PlayerState, RoundEndMessage, RoundState } from '../../shared/protocol';
- import { fr } from '../i18n/fr';
- const el = <K extends keyof HTMLElementTagNameMap>(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<number, PlayerState>, infos: Map<number, PlayerInfo>, 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<typeof setInterval> | 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);
- }
- }
|