srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'web/src')
-rw-r--r--web/src/screens/endScreen.js29
-rw-r--r--web/src/screens/multiplayerScreen.js94
-rw-r--r--web/src/styles.css153
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 : '&middot;'}</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;
+}