diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/src/screens/endScreen.js | 29 | ||||
| -rw-r--r-- | web/src/screens/multiplayerScreen.js | 94 | ||||
| -rw-r--r-- | web/src/styles.css | 153 |
3 files changed, 259 insertions, 17 deletions
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js index 551a91c..aa4e29b 100644 --- a/web/src/screens/endScreen.js +++ b/web/src/screens/endScreen.js @@ -8,7 +8,7 @@ import { renderTopRail } from '../topRail.js'; import { submitTestResult } from '../auth.js'; import { detectDeviceType } from '../deviceDetect.js'; -export function renderEndScreen(root, { stats, text, attribution, explanation, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { +export function renderEndScreen(root, { stats, text, attribution, explanation, standings, onStandingsUpdate, onLeaveRace, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { // Monkeytype's four-way split. "Extra" is anything typed past the end of // the passage, "missed" is passage left untyped - neither is visible in // a plain correct/incorrect pair, and the two mean very different things @@ -75,12 +75,35 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, u <div class="stat-label">CHARACTERS</div><div class="stat-value">${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}</div> </div> </div> + ${standings && standings.length ? ` + <div class="mp-standings"> + <div class="mp-standings-heading">Standings</div> + <div class="mp-standings-rows"></div> + </div>` : ''} <div class="end-screen-buttons"> <button class="end-screen-button" data-action="again">Play Again</button> </div> </div> `; + // Rendered separately from the markup above because it keeps changing: + // finishing first leaves the rest of the field still typing, and their + // rows fill in as they come home. + const standingsRows = root.querySelector('.mp-standings-rows'); + function paintStandings(list) { + if (!standingsRows) return; + standingsRows.innerHTML = list.map(r => ` + <div class="mp-standings-row${r.me ? ' me' : ''}${r.place ? '' : ' racing'}" style="--racer-color: ${r.color || 'var(--primary-color)'}"> + <span class="mp-standings-place">${r.place ? r.place : '·'}</span> + <span class="mp-standings-name">${escapeHtml(r.name)}${r.me ? ' (you)' : ''}</span> + <span class="mp-standings-wpm">${Math.round(r.wpm)} wpm</span> + <span class="mp-standings-acc">${r.place ? `${Math.round(r.accuracy)}%` : `${Math.round(r.percent)}%`}</span> + </div> + `).join(''); + } + if (standings && standings.length) paintStandings(standings); + const offStandings = onStandingsUpdate ? onStandingsUpdate(paintStandings) : null; + const textDisplay = root.querySelector('.text-display'); const inputChars = userInput ? userInput.split('') : []; const wordMatches = text.match(/[^\s]+\s*/g) || []; @@ -214,6 +237,10 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, u const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); return () => { + // Leaving the end screen is what finally drops the race connection -- + // it was kept open so the remaining racers could still come in. + offStandings?.(); + onLeaveRace?.(); cleanupTheme(); window.removeEventListener('resize', redraw); canvas.removeEventListener('mousemove', handleMove); diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js index d2d2a2b..a89bfd4 100644 --- a/web/src/screens/multiplayerScreen.js +++ b/web/src/screens/multiplayerScreen.js @@ -33,6 +33,13 @@ function racerColors(players, myId) { export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore }) { let connection = null; let cleanupInner = null; + // Set when the end screen takes over the live race feed. Both this + // screen's teardown paths check it: app.js tears the race screen down + // before rendering the end screen, and the outer cleanup closes the + // socket - which killed the feed the standings are built from, including + // your own PlayerFinished, which the server sends back a moment after you + // finish. + let handedOff = false; let players = []; let myId = null; let game = null; @@ -133,12 +140,14 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o root.innerHTML = ` <div class="stats-screen"> <div class="logo" data-action="menu">TyperPunk</div> - <h2>Room ${escapeHtml(roomCode)}</h2> - <div class="settings-hint">Share this code with whoever you're racing.</div> + <h2>${auto ? 'Finding a race' : `Room ${escapeHtml(roomCode)}`}</h2> + <div class="settings-hint">${auto + ? 'Racing whoever else is looking right now.' + : `Share the code <strong>${escapeHtml(roomCode)}</strong> with whoever you're racing.`}</div> + <div class="mp-countdown" hidden></div> <div class="leaderboard-list mp-player-list"></div> <button class="menu-button" data-action="ready">Ready</button> - <div class="mp-countdown"></div> - <button class="menu-button ghost" data-action="leave">Leave Room</button> + <button class="menu-button small ghost" data-action="leave">Leave Room</button> </div> `; root.querySelector('[data-action="menu"]').addEventListener('click', () => { leaveRoom(); onBack(); }); @@ -169,11 +178,18 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o connection.ready(); readyBtn.textContent = 'Waiting for players...'; readyBtn.disabled = true; + readyBtn.classList.add('ghost'); } }); const offPlayerList = connection.on('playerList', list => { players = list; paintPlayers(); }); + const countdownEl = root.querySelector('.mp-countdown'); const offCountdown = connection.on('countdown', s => { - root.querySelector('.mp-countdown').textContent = s; + // Takes over the lobby while it runs: it is the only thing that + // matters in those three seconds, and it used to be a small number + // tucked underneath a large disabled button. + countdownEl.hidden = false; + countdownEl.textContent = s; + readyBtn.hidden = true; }); const offStart = connection.on('start', text => startRace(text)); // The server closes the connection right after an Error (e.g. a @@ -189,6 +205,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o connection.ready(); readyBtn.textContent = 'Waiting for players...'; readyBtn.disabled = true; + readyBtn.classList.add('ghost'); }); root.querySelector('[data-action="leave"]').addEventListener('click', () => { leaveRoom(); renderLanding(); }); @@ -235,21 +252,70 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o const offProgress = connection.on('playerProgress', (id, percent, wpm) => { progressById[id] = { percent, wpm }; paintOpponents(); + emitStandings(); + }); + // The whole field, so the end screen can show where everyone placed. + // Finishing first means the others are still typing, so this keeps + // filling in after your own race is over rather than freezing on + // whoever happened to be done at that instant. + const results = new Map(); + const standingsListeners = new Set(); + function buildStandings() { + const colors = racerColors(players, myId); + return players.map(p => { + const done = results.get(p.id); + return { + id: p.id, + name: p.name, + me: p.id === myId, + color: colors[p.id], + place: done ? done.place : null, + wpm: done ? done.wpm : (progressById[p.id]?.wpm || 0), + accuracy: done ? done.accuracy : null, + percent: done ? 100 : (progressById[p.id]?.percent || 0), + }; + }).sort((a, b) => { + // Finishers first in placing order, then whoever is furthest along. + if (a.place && b.place) return a.place - b.place; + if (a.place) return -1; + if (b.place) return 1; + return b.percent - a.percent; + }); + } + function emitStandings() { + const snapshot = buildStandings(); + for (const fn of standingsListeners) fn(snapshot); + } + const offFinished = connection.on('playerFinished', (id, wpm, accuracy, time, place) => { + results.set(id, { wpm, accuracy, time, place }); + paintOpponents(); + emitStandings(); }); - // Reserved for a future shared-standings view - for now the local - // player just falls through to the normal end screen on their own - // finish, same as any other mode. - const offFinished = connection.on('playerFinished', () => {}); const cleanupTyping = renderTypingGame(root, { game, text, modeKey: undefined, multiplayer: { connection }, onFinish: result => { - offProgress(); offFinished(); freeGame(game); game = null; - leaveRoom(); - onFinish(result); + // The end screen takes ownership of these subscriptions, so + // this screen's own teardown must stop dropping them: app.js + // tears the race screen down before rendering the end screen, + // which was unsubscribing the very feed the standings need -- + // including your own PlayerFinished, which arrives from the + // server a moment after this callback runs. + handedOff = true; + // The connection stays open: the rest of the field is still + // typing, and the end screen keeps showing them come in. + onFinish({ + ...result, + standings: buildStandings(), + onStandingsUpdate: fn => { + standingsListeners.add(fn); + return () => standingsListeners.delete(fn); + }, + onLeaveRace: () => { offProgress(); offFinished(); leaveRoom(); }, + }); }, onMainMenu: () => { leaveRoom(); onBack(); }, onRestart: null, @@ -258,7 +324,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o root.appendChild(opponents); cleanupInner = () => { - offProgress(); offFinished(); + if (!handedOff) { offProgress(); offFinished(); } cleanupTyping(); opponents.remove(); }; @@ -267,7 +333,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o renderLanding(); return () => { teardownInner(); - leaveRoom(); + if (!handedOff) leaveRoom(); freeGame(game); }; } diff --git a/web/src/styles.css b/web/src/styles.css index 4131f64..8462fbf 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1780,10 +1780,13 @@ body { screen, so it carried no urgency. --warning-color is used here and on the device badge, nowhere else. */ .mp-countdown { - font-size: 3rem; + font-size: 6rem; + line-height: 1; + font-weight: 700; color: var(--warning-color); text-align: center; - min-height: 4rem; + margin: 0.5rem 0 1rem; + font-family: 'JetBrains Mono', monospace; } .stat-label { @@ -2468,3 +2471,149 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { color: var(--primary-color); margin-bottom: 0.3rem; } + + +/* A disabled control must not still look like the live primary action. The + lobby's "Waiting for players..." button kept the filled hover state and + read as the thing to press. */ +.menu-button:disabled, +.menu-button[disabled] { + opacity: 0.55; + cursor: default; +} + +.menu-button:disabled:hover, +.menu-button[disabled]:hover { + background: none; + color: var(--sub-color); +} + +/* Where everyone placed, on the end screen after a race. A multiplayer result + used to end on your own numbers alone, with no way to see who beat you or + by how much. */ +.mp-standings { + width: 100%; + max-width: 460px; + margin: 1.75rem auto 0 auto; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.mp-standings-heading { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--sub-color); + text-align: center; + margin-bottom: 0.35rem; +} + +.mp-standings-row { + display: grid; + grid-template-columns: 1.75rem 1fr auto auto; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0.75rem; + border: 1px solid transparent; + border-radius: 6px; + font-size: 0.85rem; + color: var(--sub-color); +} + +/* Your own row is the one being looked for. */ +.mp-standings-row.me { + border-color: color-mix(in srgb, var(--primary-color) 45%, transparent); + color: var(--text-color); +} + +.mp-standings-place { + font-family: 'JetBrains Mono', monospace; + color: var(--primary-color); + text-align: center; +} + +.mp-standings-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mp-standings-wpm, +.mp-standings-acc { + font-family: 'JetBrains Mono', monospace; + white-space: nowrap; +} + +/* Still typing: no place yet, so the row shows how far along they are. */ +.mp-standings-row.racing { + opacity: 0.65; +} + +.mp-standings-place { + color: var(--racer-color, var(--primary-color)); +} + +/* ---- Interactive control polish ------------------------------------- + Focus was invisible almost everywhere: only the two text inputs and the + theme toggle had a :focus rule, while .corner-icon-button, .theme-toggle, + .restart-toggle and .zen-finish-button all set `outline: none` outright. + Tabbing through the app showed nothing at all. + + A box-shadow ring rather than an outline, so it works regardless of those + existing `outline: none` declarations, and :focus-visible rather than + :focus so it appears for keyboard users without ringing every mouse + click. */ +.menu-button:focus-visible, +.corner-icon-button:focus-visible, +.theme-toggle:focus-visible, +.restart-toggle:focus-visible, +.zen-finish-button:focus-visible, +.top-rail-link:focus-visible, +.mode-popover-item:focus-visible, +.lang-item:focus-visible, +.theme-swatch:focus-visible, +.end-screen-button:focus-visible, +.account-input:focus-visible, +.custom-textarea:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--background-color), 0 0 0 4px var(--primary-color); + border-radius: 6px; +} + +/* Placeholders were inheriting the input's own text colour at whatever + opacity the browser chose, which differed between engines. */ +.account-input::placeholder, +.custom-textarea::placeholder { + color: var(--sub-color); + opacity: 1; +} + +.account-input:hover:not(:focus), +.custom-textarea:hover:not(:focus) { + border-color: color-mix(in srgb, var(--primary-color) 45%, var(--neutral-color)); +} + +/* Inputs sat at whatever width their container gave them, which left the + multiplayer name field and its Join row on different widths. */ +.account-input { + width: 100%; + box-sizing: border-box; + transition: border-color 0.15s ease; +} + +/* A pressed control should acknowledge the press. Nothing in the app moved + on :active, so a click on a button that opens a panel elsewhere on screen + gave no feedback at the point of contact. */ +.menu-button:active:not(:disabled), +.corner-icon-button:active, +.end-screen-button:active { + transform: translateY(1px); +} + +/* Popover rows are buttons but read as list items; they need the same hover + affordance the chips and menu buttons have. */ +.mode-popover-item, +.lang-item { + transition: background-color 0.12s ease, color 0.12s ease; +} |