hud.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. import { Anim } from '../../shared/characters';
  2. import type { MatchEndMessage, PlayerInfo, PlayerState, RoundEndMessage, RoundState } from '../../shared/protocol';
  3. import { fr } from '../i18n/fr';
  4. const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className = '', text = ''): HTMLElementTagNameMap[K] => {
  5. const e = document.createElement(tag);
  6. if (className) e.className = className;
  7. if (text) e.textContent = text;
  8. return e;
  9. };
  10. /** Round banner (round, timer, final rush), scoreboard and contextual hint. */
  11. export class RoundHud {
  12. private readonly banner = el('div');
  13. private readonly label = el('div', 'label');
  14. private readonly timer = el('div', 'timer');
  15. private readonly rush = el('div', 'rush-label', fr.hud.rush);
  16. private readonly board = el('ul');
  17. private readonly hint = el('div');
  18. private lastKey = '';
  19. constructor() {
  20. this.banner.id = 'round-hud';
  21. this.board.id = 'scoreboard';
  22. this.hint.id = 'hint';
  23. this.banner.append(this.label, this.timer, this.rush);
  24. document.body.append(this.banner, this.board, this.hint);
  25. this.banner.hidden = true;
  26. this.board.hidden = true;
  27. }
  28. update(round: number, rounds: number, state: RoundState | null, players: Map<number, PlayerState>, infos: Map<number, PlayerInfo>, localId: number): void {
  29. const playing = round > 0 && state?.timeLeft != null;
  30. this.banner.hidden = !playing;
  31. this.board.hidden = !playing;
  32. const me = players.get(localId);
  33. const alive = me !== undefined && (me.anim & Anim.Dead) === 0;
  34. this.hint.textContent = alive && me.item === 'candle' ? fr.hud.candleHint : '';
  35. if (!playing || !state) return;
  36. const t = Math.ceil(state.timeLeft ?? 0);
  37. this.label.textContent = fr.hud.round(round, rounds);
  38. this.timer.textContent = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
  39. this.banner.classList.toggle('rush', state.finalRush);
  40. this.rush.hidden = !state.finalRush;
  41. // Rebuild the scoreboard only when something visible changed.
  42. const rows = [...players.values()]
  43. .map((p) => ({ p, name: infos.get(p.id)?.name ?? '?' }))
  44. .sort((a, b) => b.p.score - a.p.score || a.p.id - b.p.id);
  45. const holder = state.cherry?.holder ?? 0;
  46. const key = rows.map(({ p, name }) => `${p.id}:${name}:${p.score}:${p.anim & Anim.Dead}`).join('|') + `#${holder}`;
  47. if (key === this.lastKey) return;
  48. this.lastKey = key;
  49. this.board.replaceChildren(
  50. ...rows.map(({ p, name }) => {
  51. const li = el('li');
  52. li.append(el('span', '', name), el('span', '', String(p.score)));
  53. li.classList.toggle('me', p.id === localId);
  54. li.classList.toggle('holder', p.id === holder);
  55. li.classList.toggle('dead', (p.anim & Anim.Dead) !== 0);
  56. return li;
  57. }),
  58. );
  59. }
  60. }
  61. /** Round results and final results (with trophies) overlay. */
  62. export class ResultsScreen {
  63. private readonly root = el('div');
  64. private timer: ReturnType<typeof setInterval> | null = null;
  65. constructor() {
  66. this.root.id = 'results';
  67. this.root.hidden = true;
  68. document.body.appendChild(this.root);
  69. }
  70. hide(): void {
  71. this.root.hidden = true;
  72. if (this.timer) clearInterval(this.timer);
  73. this.timer = null;
  74. }
  75. showRound(msg: RoundEndMessage, localId: number): void {
  76. this.show(fr.results.roundTitle(msg.round, msg.rounds), msg.results, localId, null, msg.nextIn, fr.results.next);
  77. }
  78. showMatch(msg: MatchEndMessage, localId: number): void {
  79. this.show(fr.results.matchTitle, msg.results, localId, msg, msg.lobbyIn, fr.results.lobby);
  80. }
  81. private show(
  82. title: string,
  83. results: RoundEndMessage['results'],
  84. localId: number,
  85. match: MatchEndMessage | null,
  86. seconds: number,
  87. footer: (s: number) => string,
  88. ): void {
  89. const card = el('div', 'menu-card');
  90. card.appendChild(el('h1', 'small', title));
  91. if (match && results[0]) card.appendChild(el('p', 'winner', fr.results.winner(results[0].name)));
  92. const table = el('table');
  93. const head = el('tr');
  94. for (const h of [fr.results.player, fr.results.round, fr.results.total]) head.appendChild(el('th', '', h));
  95. table.appendChild(head);
  96. for (const r of results) {
  97. const tr = el('tr', r.id === localId ? 'me' : '');
  98. tr.append(el('td', '', r.name), el('td', 'num', String(r.roundScore)), el('td', 'num', String(r.total)));
  99. table.appendChild(tr);
  100. }
  101. card.appendChild(table);
  102. if (match) {
  103. card.appendChild(el('h2', '', fr.results.trophies));
  104. const grid = el('div', 'trophies');
  105. const names = new Map(results.map((r) => [r.id, r.name]));
  106. for (const t of match.trophies) {
  107. const text = fr.trophies[t.id];
  108. if (!text) continue;
  109. const box = el('div', 'trophy');
  110. box.append(el('strong', '', `🏆 ${text.name}`), el('div', '', names.get(t.playerId) ?? '?'), el('span', '', text.desc(t.value)));
  111. grid.appendChild(box);
  112. }
  113. if (grid.childElementCount === 0) grid.appendChild(el('p', '', fr.results.noTrophy));
  114. card.appendChild(grid);
  115. }
  116. const foot = el('p', 'footer');
  117. card.appendChild(foot);
  118. this.root.replaceChildren(card);
  119. this.root.hidden = false;
  120. let left = Math.ceil(seconds);
  121. foot.textContent = footer(left);
  122. if (this.timer) clearInterval(this.timer);
  123. this.timer = setInterval(() => {
  124. left = Math.max(0, left - 1);
  125. foot.textContent = footer(left);
  126. }, 1000);
  127. }
  128. }