word-waltz / files / src / components / Scoreboard.ts
Scoreboard.ts
| 1 | import type { Player, PlayerId, Round } from '../types.js'; |
| 2 | import { 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 | |
| 12 | interface 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 | |
| 21 | export 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 | } |