fuzzyworld

word-waltz / files / src / components / Scoreboard.ts

Scoreboard.ts

6.4 KB · history · blame

1import type { Player, PlayerId, Round } from '../types.js';
2import { guessesToEmoji } from '../utils/scoring.js';
3
4/**
5 * Scoreboard — running score display
6 *
7 * Shows each player's name and cumulative score, plus the round history.
8 * The current player's name can be edited inline.
9 * The history is collapsed by default (showing last 2 rounds) and can be expanded.
10 */
11
12interface ScoreboardProps {
13 players: { a: Player; b: Player };
14 rounds: Round[];
15 myRole: PlayerId;
16 turn: 'pick' | 'solve';
17 currentRound: number;
18 onNameChange: (newName: string) => void;
19}
20
21export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): () => void {
22 const { players, rounds, myRole, turn, currentRound, onNameChange } = props;
23
24 const root = document.createElement('div');
25 root.className = 'scoreboard';
26
27 // Exclude a live round (currently being solved) from the completed list.
28 const liveRoundIdx = turn === 'solve' ? currentRound : -1;
29 const completedRounds = rounds
30 .map((r, i) => ({ r, i }))
31 .filter(({ r, i }) => i !== liveRoundIdx && r.guesses.length > 0);
32 let historyVisible = false;
33
34 function render(): void {
35 root.innerHTML = '';
36
37 // --- Players row ---
38 const playersEl = document.createElement('div');
39 playersEl.className = 'scoreboard__players';
40
41 (['a', 'b'] as PlayerId[]).forEach((id, i) => {
42 if (i === 1) {
43 const vs = document.createElement('span');
44 vs.className = 'scoreboard__vs';
45 vs.textContent = 'vs';
46 playersEl.appendChild(vs);
47 }
48
49 const p: Player = players[id];
50 const displayName = p.name || (id === 'a' ? 'Player A' : 'Player B');
51 const card = document.createElement('div');
52 card.className = 'scoreboard__player';
53
54 const nameEl = document.createElement('span');
55 nameEl.className = `scoreboard__name scoreboard__name--${id}`;
56 nameEl.textContent = displayName;
57
58 if (id === myRole) {
59 nameEl.classList.add('scoreboard__name--editable');
60 nameEl.title = 'Click to rename';
61 nameEl.addEventListener('click', () => {
62 const input = document.createElement('input');
63 input.type = 'text';
64 input.className = 'scoreboard__name-input';
65 input.value = p.name;
66 input.maxLength = 20;
67 nameEl.replaceWith(input);
68 input.focus();
69 input.select();
70
71 function commit() {
72 const val = input.value.trim();
73 if (val && val !== p.name) onNameChange(val);
74 else render(); // revert
75 }
76 input.addEventListener('blur', commit);
77 input.addEventListener('keydown', (e) => {
78 if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
79 if (e.key === 'Escape') { input.removeEventListener('blur', commit); render(); }
80 });
81 });
82 }
83
84 card.appendChild(nameEl);
85
86 const scoreEl = document.createElement('span');
87 scoreEl.className = `scoreboard__score scoreboard__score--${id}`;
88 scoreEl.textContent = String(p.score);
89 card.appendChild(scoreEl);
90 playersEl.appendChild(card);
91 });
92
93 root.appendChild(playersEl);
94
95 // --- History ---
96 if (completedRounds.length === 0 && liveRoundIdx < 0) return;
97
98 const historyEl = document.createElement('div');
99 historyEl.className = 'scoreboard__history-wrap';
100
101 const toggle = document.createElement('button');
102 toggle.className = 'scoreboard__toggle';
103 toggle.textContent = historyVisible ? '▲ Hide history' : '▼ Show history';
104 toggle.addEventListener('click', () => { historyVisible = !historyVisible; render(); });
105 historyEl.appendChild(toggle);
106
107 if (historyVisible) {
108
109 const ol = document.createElement('ol');
110 ol.className = 'scoreboard__history';
111 completedRounds.forEach(({ r, i }) => {
112 const roundNum = i + 1;
113 const solver: PlayerId = r.challenger === 'a' ? 'b' : 'a';
114 const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B');
115 const result = r.solved
116 ? `solved "${r.word}" in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})`
117 : `failed to solve "${r.word}"`;
118 const li = document.createElement('li');
119 li.className = 'scoreboard__round';
120 const textEl = document.createElement('span');
121 const numSpan = document.createElement('span');
122 numSpan.className = 'scoreboard__round-num';
123 numSpan.textContent = `${roundNum}.`;
124 const nameSpan = document.createElement('span');
125 nameSpan.className = `scoreboard__name--${solver}`;
126 nameSpan.textContent = solverName;
127 textEl.appendChild(numSpan);
128 textEl.appendChild(nameSpan);
129 textEl.appendChild(document.createTextNode(`: ${result}`));
130 li.appendChild(textEl);
131 if (r.guesses.length > 0) {
132 const emojiEl = document.createElement('div');
133 emojiEl.className = 'scoreboard__emoji';
134 emojiEl.textContent = guessesToEmoji(r.guesses, r.word);
135 li.appendChild(emojiEl);
136 }
137 ol.appendChild(li);
138 });
139
140 // Live round — show pending entry to the challenger only.
141 if (liveRoundIdx >= 0) {
142 const liveRound = rounds[liveRoundIdx];
143 if (liveRound && myRole === liveRound.challenger) {
144 const roundNum = liveRoundIdx + 1;
145 const solver: PlayerId = liveRound.challenger === 'a' ? 'b' : 'a';
146 const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B');
147 const liveLi = document.createElement('li');
148 liveLi.className = 'scoreboard__round scoreboard__round--live';
149 const numSpan = document.createElement('span');
150 numSpan.className = 'scoreboard__round-num';
151 numSpan.textContent = `${roundNum}.`;
152 const nameSpan = document.createElement('span');
153 nameSpan.className = `scoreboard__name--${solver}`;
154 nameSpan.textContent = solverName;
155 liveLi.appendChild(numSpan);
156 liveLi.appendChild(nameSpan);
157 liveLi.appendChild(document.createTextNode(` is trying to solve ${liveRound.word}…`));
158 ol.appendChild(liveLi);
159 }
160 }
161
162 historyEl.appendChild(ol);
163 } // end if historyVisible
164 root.appendChild(historyEl);
165 }
166
167 render();
168 container.appendChild(root);
169
170 return function cleanup() {
171 container.removeChild(root);
172 };
173}