diff options
| author | srdusr <[email protected]> | 2025-09-13 21:11:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-13 21:11:00 +0200 |
| commit | 0d3864bcebcdba1b1743367d0f84389fd53e37e1 (patch) | |
| tree | 54a5c028b9fe9b965ffc0240f734b2b5ac81046b /web/src/screens | |
| parent | 29992fe40703cf567177d70f152811bfedb0caad (diff) | |
| download | typerpunk-0d3864bcebcdba1b1743367d0f84389fd53e37e1.tar.gz typerpunk-0d3864bcebcdba1b1743367d0f84389fd53e37e1.zip | |
Rework the end screen figures, mode picker, corners and race view
End screen
- Every figure is now a dim label directly over its value, the two centred on
each other, grouped by what they qualify: RAW and PB under WPM, ERR and
CONSISTENCY under ACC, KEYSTROKES and CHARACTERS either side of TIME. The
row is a grid so TIME sits on the graph's exact centre line rather than
drifting with its neighbours' widths.
- Errors go back onto the wpm line. Their own y axis implied a magnitude a
mistake does not have; what matters is when one happened.
- Graph hover reads "wpm ... raw ... time" - the figures first, the second
they happened in as the qualifier.
Mode picker
- Opens from the Single Player button itself, and choosing a mode starts it:
picking what to type and starting it are one action. The control has now
been a chevron notched into that button, a caption between the two buttons,
a pill above them and a chip row below them; as the button's own menu it
needs no separate real estate at all.
Corners
- Settings and Store move to the bottom-left. The top-left is the wordmark's
alone.
- The global racer count is gone from the home screen - it is not something
you can act on there. The Friends control carries "N online" instead, which
is.
- Presence: users gain a last_seen column, touched at most once a minute per
active user on any authenticated request, and the friends list reports who
has been seen inside a five-minute window.
Multiplayer race view
- The standings move to the middle of the screen, the space the end screen's
graph occupies, and now include your own row rather than opponents only.
Pinned to the top-left corner they put what you are racing against in your
peripheral vision and left out the one bar you most need to see.
Programming mode
- Snippets carry an explanation of what the code does. Shown under the passage
while you type in single player; in multiplayer that space belongs to the
standings, so it waits for the end screen, where it appears either way.
Diffstat (limited to 'web/src/screens')
| -rw-r--r-- | web/src/screens/endScreen.js | 24 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 97 | ||||
| -rw-r--r-- | web/src/screens/multiplayerScreen.js | 24 | ||||
| -rw-r--r-- | web/src/screens/typingGame.js | 5 |
4 files changed, 69 insertions, 81 deletions
diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js index cd2a41d..551a91c 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, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { +export function renderEndScreen(root, { stats, text, attribution, explanation, 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 @@ -43,33 +43,37 @@ export function renderEndScreen(root, { stats, text, attribution, userInput, cha } // Don't announce "new best" on a mode's very first-ever run - there's // nothing to have beaten yet, so it just silently becomes the baseline. + // Sits under WPM alongside RAW, in the same label-over-value shape as + // every other secondary figure rather than as a floating badge. const pbLine = !modeKey || previous == null ? '' : isNewBest - ? '<div class="pb-badge pb-new">NEW BEST</div>' - : `<div class="pb-badge">PB ${Math.round(previous)}</div>`; + ? '<div class="end-stat pb-new"><div class="stat-label">PB</div><div class="stat-value">NEW BEST</div></div>' + : `<div class="end-stat"><div class="stat-label">PB</div><div class="stat-value">${Math.round(previous)}</div></div>`; root.innerHTML = ` <div class="end-screen"> <div class="logo" data-action="menu">TyperPunk</div> <div class="end-screen-text"><div class="text-display"></div></div> ${attribution ? `<div class="attribution end-screen-attribution">— ${escapeHtml(attribution)}</div>` : ''} + ${explanation ? `<div class="code-explainer"><span class="code-explainer-label">What this does</span>${escapeHtml(explanation)}</div>` : ''} <div class="end-screen-graph-row"> <div class="endscreen-side-stat wpm"> - <div class="stat-label">WPM</div><div class="stat-value">${Math.round(stats.wpm)}</div> + <div class="end-stat headline"><div class="stat-label">WPM</div><div class="stat-value">${Math.round(stats.wpm)}</div></div> + <div class="end-stat"><div class="stat-label">RAW</div><div class="stat-value">${Math.round(stats.rawWpm)}</div></div> ${pbLine} </div> <div class="graph-container"><canvas></canvas></div> <div class="endscreen-side-stat acc"> - <div class="stat-label">ACC</div><div class="stat-value">${Math.round(stats.accuracy)}%</div> + <div class="end-stat headline"><div class="stat-label">ACC</div><div class="stat-value">${Math.round(stats.accuracy)}%</div></div> + <div class="end-stat"><div class="stat-label">ERR</div><div class="stat-value">${stats.incorrectChars}</div></div> + <div class="end-stat"><div class="stat-label">CONSISTENCY</div><div class="stat-value">${consistency}%</div></div> </div> </div> <div class="end-screen-stat-row"> - <div class="end-stat"><div class="stat-label">RAW</div><div class="stat-value">${Math.round(stats.rawWpm)}</div></div> - <div class="end-stat"><div class="stat-label">CONSISTENCY</div><div class="stat-value">${consistency}%</div></div> <div class="end-stat"><div class="stat-label">KEYSTROKES</div><div class="stat-value">${stats.keystrokes}</div></div> + <div class="end-stat headline"><div class="stat-label">TIME</div><div class="stat-value">${stats.time.toFixed(1)}s</div></div> <div class="end-stat" data-tooltip="Correct / wrong / typed past the end / left untyped"> <div class="stat-label">CHARACTERS</div><div class="stat-value">${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}</div> </div> - <div class="end-stat"><div class="stat-label">TIME</div><div class="stat-value">${stats.time.toFixed(1)}s</div></div> </div> <div class="end-screen-buttons"> <button class="end-screen-button" data-action="again">Play Again</button> @@ -182,7 +186,9 @@ export function renderEndScreen(root, { stats, text, attribution, userInput, cha const linePoint = hitTestLine(canvas, graphPoints, xMax, mx); if (linePoint) { canvas.style.cursor = 'crosshair'; - tooltip.textContent = `${linePoint.time}s · wpm ${Math.round(linePoint.wpm)} · raw ${Math.round(linePoint.raw)}`; + // Time last: the figures are what you are reading for, and the + // second it happened is the qualifier on them. + tooltip.textContent = `wpm ${Math.round(linePoint.wpm)} · raw ${Math.round(linePoint.raw)} · ${linePoint.time}s`; tooltip.style.left = `${mx}px`; tooltip.style.top = `${my}px`; tooltip.style.display = 'block'; diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js index a5e9df1..d0553c3 100644 --- a/web/src/screens/mainMenu.js +++ b/web/src/screens/mainMenu.js @@ -6,8 +6,6 @@ import { getProfileStats } from '../profileStats.js'; import { attachTooltips } from '../tooltip.js'; import { wordListTiers } from '../wordGenerator.js'; import { renderTopRail } from '../topRail.js'; -import { onCountsChange, getCounts } from '../counts.js'; -import { MULTIPLAYER_ICON } from './icons.js'; function label(m) { if (m === 'random') return 'Random'; @@ -69,7 +67,7 @@ function wordListTierLabel(tier) { } export function renderMainMenu(root, props) { - const { onStartGame, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props; + const { onStartGame, onPickAndStart, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props; const modes = ['random', 'words', 'time', 'zen', 'practice', ...categories, 'custom']; // The picker used to be one flat list of 18 entries mixing two unrelated // things: how a test is generated (Random/Words/Timed/Zen/Practice/Custom) @@ -79,7 +77,6 @@ export function renderMainMenu(root, props) { const gameModes = [...GAME_MODES.filter(m => modes.includes(m)), 'custom']; const textCategories = modes.filter(m => !gameModes.includes(m)); const modeItem = m => `<button class="mode-popover-item${m === currentMode ? ' active' : ''}" data-mode="${escapeHtml(m)}" data-tooltip="${escapeHtml(modeDescription(m))}">${escapeHtml(label(m))}</button>`; - const modeChip = m => `<button class="mode-chip${m === currentMode ? ' active' : ''}" data-mode="${escapeHtml(m)}" data-tooltip="${escapeHtml(modeDescription(m))}">${escapeHtml(label(m))}</button>`; const currentIndex = Math.max(0, modes.indexOf(selectedCategory || 'random')); const currentMode = modes[currentIndex] || 'random'; const isCustom = currentMode === 'custom'; @@ -116,28 +113,24 @@ export function renderMainMenu(root, props) { <h1>TyperPunk</h1> <div class="menu-options"> <div class="start-group sp-group"> - <button class="menu-button start-main" data-action="start">Single Player</button> + <button class="menu-button start-main" data-action="pick-mode">Single Player</button> + <div class="mode-popover sp-popover" hidden> + <div class="mode-popover-group"> + <div class="mode-popover-heading">Modes</div> + ${gameModes.map(modeItem).join('')} + <button class="mode-popover-item" data-action="mode-lyrics" data-tooltip="Connect Spotify and type along to whatever's playing.">Lyrics</button> + </div> + <div class="mode-popover-group"> + <div class="mode-popover-heading">Text</div> + ${textCategories.map(modeItem).join('')} + </div> + </div> </div> <div class="start-group mp-group"> <button class="menu-button start-main" data-action="mode-multiplayer" data-tooltip="Race other typists live.">Multi-Player</button> </div> - <div class="mode-bar"> - ${gameModes.map(modeChip).join('')} - <button class="mode-chip" data-action="mode-lyrics" data-tooltip="Connect Spotify and type along to whatever's playing.">Lyrics</button> - <div class="mode-chip-group"> - <button class="mode-chip sp-chevron${textCategories.includes(currentMode) ? ' active' : ''}" data-tooltip="Passages from a topic"> - ${textCategories.includes(currentMode) ? escapeHtml(label(currentMode)) : 'Text'} ${CHEVRON_DOWN_ICON} - </button> - <div class="mode-popover sp-popover" hidden> - <div class="mode-popover-group"> - <div class="mode-popover-heading">Text</div> - ${textCategories.map(modeItem).join('')} - </div> - </div> - </div> - </div> - ${rulesSummary ? `<div class="start-caption sp-caption">${escapeHtml(rulesSummary)}</div>` : ''} + <div class="start-caption sp-caption">${escapeHtml(label(currentMode))}${rulesSummary ? ` · ${escapeHtml(rulesSummary)}` : ''}</div> ${isCustom && customText && customText.timed ? `<button class="menu-button" data-action="start-passive">Passive Mode</button>` : ''} ${isCustom && customText ? `<button class="menu-button" data-action="clear-custom">Clear Custom Text</button>` : ''} </div> @@ -204,11 +197,6 @@ export function renderMainMenu(root, props) { <button class="corner-icon-button" data-action="store" aria-label="Store" data-tooltip="Store">${STORE_ICON}</button> </div> - <div class="corner-rail-bottom-left"> - <button class="corner-icon-button" data-action="mode-multiplayer" aria-label="Multiplayer" data-tooltip="Multiplayer">${MULTIPLAYER_ICON}</button> - <span class="online-count" data-badge="online" hidden></span> - </div> - <div class="corner-rail-right"> ${profile.testsCompleted > 0 ? `<div class="stats-corner-figures" data-tooltip="Your average WPM across every test, and your best single result."> <div class="stats-corner-avg">avg ${profile.averageWpm}</div> @@ -224,23 +212,7 @@ export function renderMainMenu(root, props) { attachTooltips(root); const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); - // The Friends badge belongs to the top rail. The player count is this - // screen's own, since the menu builds its corner clusters inline. - const onlineEl = root.querySelector('.corner-rail-bottom-left [data-badge="online"]'); - const mpIconBtn = root.querySelector('.corner-rail-bottom-left [data-action="mode-multiplayer"]'); - function paintCounts({ online }) { - if (onlineEl) { - onlineEl.hidden = !online; - onlineEl.textContent = online === 1 ? '1 racing' : `${online} racing`; - } - if (mpIconBtn) { - mpIconBtn.dataset.tooltip = !online - ? 'Multiplayer' - : (online === 1 ? 'Multiplayer - 1 player racing now' : `Multiplayer - ${online} players racing now`); - } - } - paintCounts(getCounts()); - const unsubscribeCounts = onCountsChange(paintCounts); + // Friends-online lives in the top rail beside the Friends control. // Clicking a chevron reveals every mode as a direct pick, rather than // cycling blind through them one click at a time - Custom and the text @@ -282,32 +254,34 @@ export function renderMainMenu(root, props) { setupDropdown( root.querySelector('.sp-group'), - root.querySelector('.sp-chevron'), + root.querySelector('[data-action="pick-mode"]'), root.querySelector('.sp-popover'), - null, + root.querySelector('.sp-caption'), ); - root.querySelectorAll('.sp-popover .mode-popover-item[data-mode], .mode-bar .mode-chip[data-mode]').forEach(item => { - item.addEventListener('click', () => onSelectCategory(item.dataset.mode)); + root.querySelectorAll('.sp-popover .mode-popover-item[data-mode]').forEach(item => { + item.addEventListener('click', () => { + const mode = item.dataset.mode; + // Custom needs its text before it can start: with none loaded the + // picker hands off to the paste/upload panel instead. + if (mode === 'custom') { + onSelectCategory(mode); + return; + } + onPickAndStart(mode); + }); }); root.querySelector('[data-action="mode-lyrics"]').addEventListener('click', onShowLyrics); - // Two of these now: the big button and the bottom-left icon beside the - // live player count. - root.querySelectorAll('[data-action="mode-multiplayer"]').forEach(el => el.addEventListener('click', onShowMultiplayer)); + root.querySelector('[data-action="mode-multiplayer"]').addEventListener('click', onShowMultiplayer); const startBtn = root.querySelector('.sp-group .start-main'); const panel = root.querySelector('.custom-panel'); - startBtn.addEventListener('click', () => { - if (isCustom && !customText) { - panel.hidden = false; - return; - } - if (isCustom) { - onStartCustom(); - } else { - onStartGame(); - } - }); + // No click handler of its own: setupDropdown above owns this button and + // opens the mode picker with it. Starting happens when a mode is chosen. + + // Landing on Custom with nothing loaded goes straight to the paste/upload + // panel - there is nothing to start until text exists. + if (isCustom && !customText) panel.hidden = false; // Matches the "Enter to start" hint in the bottom-right corner. Skipped // while typing into a text field or with any popover/panel open, so it @@ -540,7 +514,6 @@ export function renderMainMenu(root, props) { return () => { cleanupTheme(); - unsubscribeCounts(); document.removeEventListener('keydown', handleEnterToStart); outsideClickHandlers.forEach(h => document.removeEventListener('click', h)); }; diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js index 0de0182..d2d2a2b 100644 --- a/web/src/screens/multiplayerScreen.js +++ b/web/src/screens/multiplayerScreen.js @@ -210,17 +210,25 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o } const opponents = document.createElement('div'); - opponents.className = 'mp-opponents'; + opponents.className = 'mp-racers'; const progressById = {}; + // Everyone in the room, yourself included. Showing only opponents left + // you guessing where you actually stood: your own bar is the one you + // are measuring the others against. The server echoes your Progress + // back to you along with everyone else's, so your row is fed the same + // way theirs are. function paintOpponents() { const colors = racerColors(players, myId); - opponents.innerHTML = players.filter(p => p.id !== myId).map(p => ` - <div class="mp-opponent-row" style="--racer-color: ${colors[p.id]}"> - <span class="mp-opponent-name">${escapeHtml(p.name)}</span> - <div class="mp-opponent-bar"><div class="mp-opponent-bar-fill" style="width:${(progressById[p.id]?.percent || 0)}%"></div></div> - <span class="mp-opponent-wpm">${Math.round(progressById[p.id]?.wpm || 0)}</span> - </div> - `).join(''); + opponents.innerHTML = players.map(p => { + const me = p.id === myId; + const prog = progressById[p.id] || {}; + return ` + <div class="mp-racer-row${me ? ' me' : ''}" style="--racer-color: ${colors[p.id]}"> + <span class="mp-racer-name">${escapeHtml(p.name)}${me ? ' (you)' : ''}</span> + <div class="mp-racer-bar"><div class="mp-racer-bar-fill" style="width:${prog.percent || 0}%"></div></div> + <span class="mp-racer-wpm">${Math.round(prog.wpm || 0)}</span> + </div>`; + }).join(''); } paintOpponents(); diff --git a/web/src/screens/typingGame.js b/web/src/screens/typingGame.js index 72442a2..d65d254 100644 --- a/web/src/screens/typingGame.js +++ b/web/src/screens/typingGame.js @@ -10,7 +10,7 @@ import { recordKeystroke } from '../keyStats.js'; import { getUser } from '../auth.js'; import { api } from '../api.js'; -export function renderTypingGame(root, { game, text, attribution, language, progress, timeLimit, modeKey, zenMode, multiplayer, onFinish, onMainMenu, onRestart, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { +export function renderTypingGame(root, { game, text, attribution, explanation, language, progress, timeLimit, modeKey, zenMode, multiplayer, onFinish, onMainMenu, onRestart, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { const syntaxClasses = language ? highlightClasses(text, language) : null; const { hideLiveStats, caretBlink, blindMode } = getSettings(); root.innerHTML = ` @@ -22,6 +22,7 @@ export function renderTypingGame(root, { game, text, attribution, language, prog <input class="typing-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" /> </div> ${attribution ? `<div class="attribution">- ${escapeHtml(attribution)}</div>` : ''} + ${explanation && !multiplayer ? `<div class="code-explainer"><span class="code-explainer-label">What this does</span>${escapeHtml(explanation)}</div>` : ''} <div class="wpm-stat"><div class="stat-label">WPM</div><div class="stat-value" data-field="wpm">0</div></div> <div class="acc-stat"><div class="stat-label">ACC</div><div class="stat-value" data-field="acc">100%</div></div> <div class="time-stat"><div class="stat-label">TIME</div><div class="stat-value" data-field="time">0.0</div></div> @@ -179,7 +180,7 @@ export function renderTypingGame(root, { game, text, attribution, language, prog stats.accuracy = accuracy; stats.incorrectChars = mistakes; if (multiplayer) multiplayer.connection.sendFinish(stats.wpm, accuracy, elapsed); - onFinish({ stats, text: finalText, attribution, userInput: finalInput, charTimings, keypressHistory: keystrokes, modeKey }); + onFinish({ stats, text: finalText, attribution, explanation, userInput: finalInput, charTimings, keypressHistory: keystrokes, modeKey }); } function handleChange() { |