srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/screens')
-rw-r--r--web/src/screens/endScreen.js29
-rw-r--r--web/src/screens/multiplayerScreen.js94
2 files changed, 108 insertions, 15 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);
};
}