diff options
| author | srdusr <[email protected]> | 2025-12-04 20:58:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-12-04 20:58:00 +0200 |
| commit | f48fa830b10d4122d145e9822522d9b71e3f00fa (patch) | |
| tree | 3ab1696b2fae87c49c056bbd56ad1975c3b7ca5d /web | |
| parent | 3d26cb457b2023d686136f1c9ee014c210951729 (diff) | |
| download | typerpunk-f48fa830b10d4122d145e9822522d9b71e3f00fa.tar.gz typerpunk-f48fa830b10d4122d145e9822522d9b71e3f00fa.zip | |
Fix the multiplayer race hand-off, fill the field, and polish controls
Standings on the end screen
- A race used to end on your own numbers alone. The end screen now shows the
whole field, filling in live as the slower racers come home.
- Two teardown paths were killing the feed it depends on. app.js tears the
race screen down before rendering the end screen, and that ran both the
inner cleanup (unsubscribing progress and finished) and the outer one
(closing the socket). Your own PlayerFinished arrives from the server a
moment after your finish callback runs, so nobody - including you - ever
got a place. The screen now marks the feed as handed off and leaves it to
the end screen, which drops it on its own teardown.
More racers
- Up to four bots per room instead of two, biased towards a fuller lobby.
With four, picking one of two speed tiers put pairs on top of each other,
so a bot now takes a pace at least 9 wpm clear of every other bot in the
room, drawn from the whole slow-to-fast span. Accuracy tracks speed rather
than being rolled independently, which had been pairing 80 wpm with 88% and
35 wpm with 99%.
Lobby
- The countdown is the whole screen for its three seconds, instead of a small
number under a large disabled button. Quick match says "Finding a race"
rather than offering a room code to share that nobody needs.
Controls
- Keyboard focus was invisible almost everywhere: only two inputs and the
theme toggle had a :focus rule, and four other controls set outline:none
outright. A :focus-visible ring now covers every interactive control,
drawn as a box-shadow so it survives those declarations.
- Disabled buttons no longer keep the filled hover state of a live primary
action, placeholders have a defined colour rather than a per-engine
default, inputs gained hover feedback and a consistent width, and pressed
controls acknowledge the press.
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; +} |