diff options
| -rw-r--r-- | crates/core/src/multiplayer.rs | 4 | ||||
| -rw-r--r-- | crates/server/src/main.rs | 19 | ||||
| -rw-r--r-- | crates/server/src/multiplayer.rs | 28 | ||||
| -rw-r--r-- | crates/server/src/state.rs | 13 | ||||
| -rw-r--r-- | crates/steam/src/multiplayer_plugin.rs | 1 | ||||
| -rw-r--r-- | crates/steam/src/net.rs | 9 | ||||
| -rw-r--r-- | crates/tui/src/main.rs | 1 | ||||
| -rw-r--r-- | crates/tui/src/multiplayer_net.rs | 9 | ||||
| -rw-r--r-- | web/src/multiplayer.js | 1 | ||||
| -rw-r--r-- | web/src/screens/accountScreen.js | 3 | ||||
| -rw-r--r-- | web/src/screens/endScreen.js | 6 | ||||
| -rw-r--r-- | web/src/screens/friendsScreen.js | 3 | ||||
| -rw-r--r-- | web/src/screens/icons.js | 7 | ||||
| -rw-r--r-- | web/src/screens/leaderboardScreen.js | 6 | ||||
| -rw-r--r-- | web/src/screens/lyricsScreen.js | 3 | ||||
| -rw-r--r-- | web/src/screens/multiplayerScreen.js | 67 | ||||
| -rw-r--r-- | web/src/screens/placeholderScreen.js | 5 | ||||
| -rw-r--r-- | web/src/screens/publicProfileScreen.js | 6 | ||||
| -rw-r--r-- | web/src/screens/statsScreen.js | 5 | ||||
| -rw-r--r-- | web/src/screens/storeScreen.js | 26 | ||||
| -rw-r--r-- | web/src/styles.css | 130 |
21 files changed, 306 insertions, 46 deletions
diff --git a/crates/core/src/multiplayer.rs b/crates/core/src/multiplayer.rs index a25a659..14d59e9 100644 --- a/crates/core/src/multiplayer.rs +++ b/crates/core/src/multiplayer.rs @@ -34,6 +34,10 @@ pub enum ServerMessage { /// assigned server-side per connection. Joined { player_id: String }, PlayerList { players: Vec<PlayerInfo> }, + /// Sent once, just before the countdown begins, so every client can put + /// the passage on screen and let players read it while they wait. Start + /// still carries the text, for clients that do not use this. + RaceText { text: String, attribution: Option<String> }, Countdown { seconds: u32 }, Start { text: String }, PlayerProgress { player_id: String, percent: f32, wpm: f32 }, diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 7e9e9a8..2769d4a 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -10,6 +10,7 @@ mod spotify; mod state; mod stats; +use crate::state::RaceText; use axum::http::{HeaderValue, Method}; use axum::routing::get; use axum::Router; @@ -24,6 +25,8 @@ use tower_http::trace::TraceLayer; #[derive(Deserialize)] struct TextEntry { content: String, + #[serde(default)] + attribution: Option<String>, } // Every multiplayer room draws from this same pool so every player in a @@ -32,15 +35,18 @@ struct TextEntry { // falls back to a couple of plain sentences if the file isn't reachable // (e.g. the server binary run from somewhere other than the repo root), // so a room can still start rather than erroring on an empty pool. -fn load_race_texts() -> Vec<String> { +fn load_race_texts() -> Vec<RaceText> { let path = std::env::var("TEXTS_JSON_PATH").unwrap_or_else(|_| "texts.json".to_string()); match std::fs::read_to_string(&path).ok().and_then(|raw| serde_json::from_str::<Vec<TextEntry>>(&raw).ok()) { - Some(entries) if !entries.is_empty() => entries.into_iter().map(|e| e.content).collect(), + Some(entries) if !entries.is_empty() => entries + .into_iter() + .map(|e| RaceText { text: e.content, attribution: e.attribution }) + .collect(), _ => { tracing::warn!("could not load race texts from {path} - using a small built-in fallback pool"); vec![ - "The quick brown fox jumps over the lazy dog.".to_string(), - "Pack my box with five dozen liquor jugs.".to_string(), + RaceText { text: "The quick brown fox jumps over the lazy dog.".to_string(), attribution: None }, + RaceText { text: "Pack my box with five dozen liquor jugs.".to_string(), attribution: None }, ] } } @@ -136,7 +142,10 @@ mod tests { let app_state = Arc::new(AppState::new( db, false, - vec!["The quick brown fox jumps over the lazy dog.".to_string()], + vec![RaceText { + text: "The quick brown fox jumps over the lazy dog.".to_string(), + attribution: None, + }], SpotifyConfig::default(), "http://localhost:4173".to_string(), )); diff --git a/crates/server/src/multiplayer.rs b/crates/server/src/multiplayer.rs index 8974c8c..7fca7c3 100644 --- a/crates/server/src/multiplayer.rs +++ b/crates/server/src/multiplayer.rs @@ -56,6 +56,10 @@ const BOT_NAMES: &[&str] = &[ "Pixel", "Quartz", "Sigil", "Tessa", "Umbra", ]; +// Long enough to read the passage's opening words and get your hands in +// position. Three seconds was barely enough to register that a race had +// started, let alone look at the text. +const COUNTDOWN_SECONDS: u32 = 5; const ROOM_CODE_CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I - easy to misread aloud #[derive(Debug, Clone, PartialEq)] @@ -318,16 +322,30 @@ async fn start_race(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) { return; } room.status = RoomStatus::Countdown; - let text = state + let picked = state .race_texts .get(rand::thread_rng().gen_range(0..state.race_texts.len())) .cloned() - .unwrap_or_default(); - room.text = Some(text.clone()); - text + .unwrap_or_else(|| crate::state::RaceText { + text: String::new(), + attribution: None, + }); + room.text = Some(picked.text.clone()); + // Sent before the countdown so every client can put the passage on + // screen and let players read it while they wait, the way TypeRacer + // and 10FastFingers both do. + broadcast( + &room, + &ServerMessage::RaceText { + text: picked.text.clone(), + attribution: picked.attribution.clone(), + }, + ) + .await; + picked.text }; - for seconds in (1..=3).rev() { + for seconds in (1..=COUNTDOWN_SECONDS).rev() { { let room = room_arc.lock().await; broadcast(&room, &ServerMessage::Countdown { seconds }).await; diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 267b6fb..7244955 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -17,6 +17,15 @@ impl SpotifyConfig { } } +/// A passage a race can be run on, with whoever wrote it. The attribution +/// travels with the text so every racer can see where the passage came from, +/// the same way single player does. +#[derive(Debug, Clone)] +pub struct RaceText { + pub text: String, + pub attribution: Option<String>, +} + #[derive(Clone)] pub struct AppState { pub db: SqlitePool, @@ -33,7 +42,7 @@ pub struct AppState { /// Race passages every multiplayer room draws from, so every player in /// a room types the identical text - loaded once at startup rather /// than per-room, since the pool itself never changes at runtime. - pub race_texts: Vec<String>, + pub race_texts: Vec<RaceText>, pub spotify: SpotifyConfig, pub frontend_origin: String, pub http: Client, @@ -43,7 +52,7 @@ impl AppState { pub fn new( db: SqlitePool, cookie_secure: bool, - race_texts: Vec<String>, + race_texts: Vec<RaceText>, spotify: SpotifyConfig, frontend_origin: String, ) -> Self { diff --git a/crates/steam/src/multiplayer_plugin.rs b/crates/steam/src/multiplayer_plugin.rs index 05c1760..02bd2e7 100644 --- a/crates/steam/src/multiplayer_plugin.rs +++ b/crates/steam/src/multiplayer_plugin.rs @@ -80,6 +80,7 @@ fn poll_events( ) { while let Some(event) = net.0.try_recv() { match event { + NetEvent::Ignored => {} NetEvent::RoomCreated { code } => { info!("multiplayer room created - join it from the web app with code: {code}"); } diff --git a/crates/steam/src/net.rs b/crates/steam/src/net.rs index 4d213b4..e08e9fa 100644 --- a/crates/steam/src/net.rs +++ b/crates/steam/src/net.rs @@ -19,6 +19,10 @@ use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; #[derive(Debug, Clone)] pub enum NetEvent { + /// A protocol message this client has no use for. Delivered rather than + /// dropped so the match on ServerMessage stays exhaustive and adding a + /// variant is a compile-time prompt rather than a silent parse failure. + Ignored, RoomCreated { code: String }, Joined { player_id: String }, PlayerList(Vec<PlayerInfo>), @@ -84,6 +88,11 @@ fn to_event(msg: ServerMessage) -> NetEvent { match msg { ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + // The passage ahead of the countdown, so a client can show it while + // players wait. This one does not yet, and Start still carries the + // text, so it is accepted and ignored rather than dropped as an + // unparseable message. + ServerMessage::RaceText { .. } => NetEvent::Ignored, ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), ServerMessage::Start { text } => NetEvent::Start(text), ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f5c4a68..27ea983 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -104,6 +104,7 @@ fn merge_player_list(existing: &[MpPlayer], incoming: Vec<typerpunk_core::multip fn apply_mp_event(app: &mut App, event: MpNetEvent) { match event { + MpNetEvent::Ignored => {} MpNetEvent::RoomCreated { code } => app.set_mp_room_created(code), MpNetEvent::Joined { player_id } => app.set_mp_joined(player_id), MpNetEvent::PlayerList(players) => { diff --git a/crates/tui/src/multiplayer_net.rs b/crates/tui/src/multiplayer_net.rs index 8c6c6b0..5d1b772 100644 --- a/crates/tui/src/multiplayer_net.rs +++ b/crates/tui/src/multiplayer_net.rs @@ -11,6 +11,10 @@ use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; #[derive(Debug, Clone)] pub enum NetEvent { + /// A protocol message this client has no use for. Delivered rather than + /// dropped so the match on ServerMessage stays exhaustive and adding a + /// variant is a compile-time prompt rather than a silent parse failure. + Ignored, RoomCreated { code: String }, Joined { player_id: String }, PlayerList(Vec<PlayerInfo>), @@ -81,6 +85,11 @@ fn to_event(msg: ServerMessage) -> NetEvent { match msg { ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + // The passage ahead of the countdown, so a client can show it while + // players wait. This one does not yet, and Start still carries the + // text, so it is accepted and ignored rather than dropped as an + // unparseable message. + ServerMessage::RaceText { .. } => NetEvent::Ignored, ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), ServerMessage::Start { text } => NetEvent::Start(text), ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, diff --git a/web/src/multiplayer.js b/web/src/multiplayer.js index 903d6f9..8592285 100644 --- a/web/src/multiplayer.js +++ b/web/src/multiplayer.js @@ -47,6 +47,7 @@ export function connectToRoom(roomCode, playerName) { switch (msg.type) { case 'Joined': connection.playerId = msg.player_id; emit('joined', msg.player_id); break; case 'PlayerList': emit('playerList', msg.players); break; + case 'RaceText': emit('raceText', msg.text, msg.attribution); break; case 'Countdown': emit('countdown', msg.seconds); break; case 'Start': emit('start', msg.text); break; case 'PlayerProgress': emit('playerProgress', msg.player_id, msg.percent, msg.wpm); break; diff --git a/web/src/screens/accountScreen.js b/web/src/screens/accountScreen.js index 30161d9..5ae2bd0 100644 --- a/web/src/screens/accountScreen.js +++ b/web/src/screens/accountScreen.js @@ -1,3 +1,4 @@ +import { CLOSE_ICON } from './icons.js'; import { escapeHtml } from '../util.js'; import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; @@ -59,10 +60,10 @@ export function renderAccountScreen(root, { onBack, onShowAccount, onShowStats, const user = getUser(); root.innerHTML = ` <div class="stats-screen"> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> <div class="logo" data-action="menu">TyperPunk</div> <h2>Account</h2> <div class="account-panel">${formMarkup(user)}</div> - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js index aa4e29b..2b859bb 100644 --- a/web/src/screens/endScreen.js +++ b/web/src/screens/endScreen.js @@ -1,6 +1,7 @@ import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLine, calculateConsistency } from '../chart.js'; import { escapeHtml } from '../util.js'; import { attachTooltips } from '../tooltip.js'; +import { onThemeChange } from '../theme.js'; import { recordResult } from '../pb.js'; import { recordTest } from '../profileStats.js'; import { renderCornerRail } from '../cornerRail.js'; @@ -137,6 +138,10 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s const redraw = () => drawChart(canvas, { graphPoints, errorPoints, xMax }); redraw(); window.addEventListener('resize', redraw); + // The chart is drawn into a canvas, so it cannot inherit a theme change + // the way the rest of the page does - it has to be redrawn. Without this + // switching theme on the end screen left the graph in the old palette. + const offTheme = onThemeChange(redraw); // A long passage wraps to more lines than a short one, growing // .end-screen-text's height by an amount no fixed CSS margin/graph-height @@ -243,6 +248,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s onLeaveRace?.(); cleanupTheme(); window.removeEventListener('resize', redraw); + offTheme(); canvas.removeEventListener('mousemove', handleMove); canvas.removeEventListener('mouseleave', handleLeave); document.removeEventListener('keydown', handleKeydown); diff --git a/web/src/screens/friendsScreen.js b/web/src/screens/friendsScreen.js index 89317ad..14a0b2d 100644 --- a/web/src/screens/friendsScreen.js +++ b/web/src/screens/friendsScreen.js @@ -1,3 +1,4 @@ +import { CLOSE_ICON } from './icons.js'; import { escapeHtml } from '../util.js'; import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; @@ -78,10 +79,10 @@ export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats, const user = getUser(); root.innerHTML = ` <div class="stats-screen"> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> <div class="logo" data-action="menu">TyperPunk</div> <h2>Friends</h2> ${user ? signedInMarkup() : signedOutMarkup()} - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; diff --git a/web/src/screens/icons.js b/web/src/screens/icons.js index c088c0d..21c0548 100644 --- a/web/src/screens/icons.js +++ b/web/src/screens/icons.js @@ -107,3 +107,10 @@ export const LANGUAGE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 <path d="M13 21L17 11L21 21"/> <path d="M14.4 18H19.6"/> </svg>`; + +// Close. A plain X drawn as two strokes rather than a "×" glyph, so it +// matches the weight and corner style of the rest of this set at any size. +export const CLOSE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="square"> + <path d="M5 5L19 19"/> + <path d="M19 5L5 19"/> +</svg>`; diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js index 2777b1c..2288400 100644 --- a/web/src/screens/leaderboardScreen.js +++ b/web/src/screens/leaderboardScreen.js @@ -3,7 +3,7 @@ import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; import { api, ApiError } from '../api.js'; -import { FLAIR_ICONS } from './icons.js'; +import { FLAIR_ICONS, CLOSE_ICON } from './icons.js'; const WORD_COUNTS = [10, 25, 50, 100]; const TIME_DURATIONS = [15, 30, 60, 120]; @@ -47,14 +47,14 @@ export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onS root.innerHTML = ` <div class="stats-screen"> - <div class="logo" data-action="menu">TyperPunk</div> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> + <div class="logo" data-action="menu">TyperPunk</div> <h2>Leaderboard</h2> <div class="leaderboard-modes"> ${MODES.map(m => `<button class="menu-button small${m.key === selectedMode ? '' : ' ghost'}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')} </div> <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Mobile results still count for personal stats - this only filters what's shown here.">Devices: All</button> <div class="leaderboard-results"></div> - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; diff --git a/web/src/screens/lyricsScreen.js b/web/src/screens/lyricsScreen.js index ac8b4f4..95af6b2 100644 --- a/web/src/screens/lyricsScreen.js +++ b/web/src/screens/lyricsScreen.js @@ -1,3 +1,4 @@ +import { CLOSE_ICON } from './icons.js'; import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; @@ -30,12 +31,12 @@ export function renderLyricsScreen(root, { onBack, onLyricsReady, onShowStats, o function render() { root.innerHTML = ` <div class="stats-screen"> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> <div class="logo" data-action="menu">TyperPunk</div> <h2>Lyrics</h2> <div class="stats-placeholder">${escapeHtml(bodyText())}</div> ${status === 'signed-out' ? `<button class="menu-button" data-action="go-account">Sign In</button>` : ''} ${status === 'not-connected' ? `<a class="menu-button" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>` : ''} - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', () => { stopped = true; onBack(); })); diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js index a89bfd4..59c4b3f 100644 --- a/web/src/screens/multiplayerScreen.js +++ b/web/src/screens/multiplayerScreen.js @@ -64,15 +64,16 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o <input class="account-input" type="text" id="mp-name" placeholder="Your name" value="${escapeHtml(getUser()?.username || '')}"> <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Who you get matched with, and the setting any room you open uses. Joining by code always uses that room's setting.">Match: Everyone</button> <button class="menu-button" data-action="quick" data-tooltip="Drops you straight into a race with whoever else is looking. No code to share.">Find a Race</button> - <div class="settings-hint">or race specific people</div> + + <div class="mp-divider"><span>or race friends</span></div> + <div class="mp-code-row"> - <input class="account-input" type="text" id="mp-room-code" placeholder="Room code"> - <button class="menu-button small ghost" data-action="join">Join</button> + <input class="account-input" type="text" id="mp-room-code" placeholder="Enter a room code" maxlength="5" autocomplete="off" spellcheck="false"> + <button class="menu-button" data-action="join">Join</button> </div> - <button class="menu-button small ghost" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button> + <button class="menu-button ghost" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button> <div class="custom-error mp-error">${escapeHtml(landingError || '')}</div> </div> - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); @@ -136,6 +137,11 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o renderLobby(roomCode, auto); } + // Set by startRace so the lobby's countdown handler can hand off to the + // race view once the passage is on screen. + let showCountdown = null; + let releaseRace = null; + function renderLobby(roomCode, auto = false) { root.innerHTML = ` <div class="stats-screen"> @@ -184,14 +190,19 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o const offPlayerList = connection.on('playerList', list => { players = list; paintPlayers(); }); const countdownEl = root.querySelector('.mp-countdown'); const offCountdown = connection.on('countdown', 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. + // Once the race view exists the count belongs over the passage, so + // players read the opening words while it runs. Before that (a + // slow RaceText) it still shows in the lobby rather than nowhere. + if (showCountdown) { showCountdown(s); return; } countdownEl.hidden = false; countdownEl.textContent = s; readyBtn.hidden = true; }); - const offStart = connection.on('start', text => startRace(text)); + // The passage arrives before the countdown so it can be read while the + // numbers run. The race view is built here, with typing locked; Start + // only unlocks it. + const offRaceText = connection.on('raceText', (text, attribution) => startRace(text, attribution)); + const offStart = connection.on('start', () => releaseRace?.()); // The server closes the connection right after an Error (e.g. a // desktop-only room rejecting a mobile joiner) - 'close' always // follows, so it's what actually routes back to the landing screen; @@ -210,12 +221,12 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o root.querySelector('[data-action="leave"]').addEventListener('click', () => { leaveRoom(); renderLanding(); }); cleanupInner = () => { - offJoined(); offPlayerList(); offCountdown(); offStart(); offError(); offClose(); + offJoined(); offPlayerList(); offCountdown(); offRaceText(); offStart(); offError(); offClose(); cleanupTheme(); cleanupRail(); }; } - async function startRace(text) { + async function startRace(text, attribution) { teardownInner(); try { game = await createGame(); @@ -293,7 +304,7 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o }); const cleanupTyping = renderTypingGame(root, { - game, text, modeKey: undefined, + game, text, attribution, modeKey: undefined, multiplayer: { connection }, onFinish: result => { freeGame(game); @@ -323,8 +334,40 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o }); root.appendChild(opponents); + // The passage is on screen but locked until Start. The count sits over + // it, so the seconds are spent reading the opening words rather than + // staring at an empty lobby. + const input = root.querySelector('.typing-input'); + if (input) input.disabled = true; + const gate = document.createElement('div'); + gate.className = 'mp-race-gate'; + gate.innerHTML = '<div class="mp-race-gate-count"></div><div class="mp-race-gate-label">Get ready</div>'; + root.appendChild(gate); + const gateCount = gate.querySelector('.mp-race-gate-count'); + // Its own subscription: building this view tears the lobby down, and + // the lobby owned the only countdown handler - so the count stopped + // arriving exactly when it was needed on screen. + const offGateCountdown = connection.on('countdown', seconds => { gateCount.textContent = seconds; }); + // Start has the same problem the countdown did: the lobby's handler is + // gone by the time this view exists, so the gate would never lift. + const offGateStart = connection.on('start', () => releaseRace?.()); + showCountdown = seconds => { gateCount.textContent = seconds; }; + releaseRace = () => { + offGateCountdown(); + offGateStart(); + gate.remove(); + if (input) { input.disabled = false; input.focus(); } + showCountdown = null; + releaseRace = null; + }; + cleanupInner = () => { if (!handedOff) { offProgress(); offFinished(); } + showCountdown = null; + releaseRace = null; + offGateCountdown(); + offGateStart(); + gate.remove(); cleanupTyping(); opponents.remove(); }; diff --git a/web/src/screens/placeholderScreen.js b/web/src/screens/placeholderScreen.js index 5d652ce..4375b8d 100644 --- a/web/src/screens/placeholderScreen.js +++ b/web/src/screens/placeholderScreen.js @@ -1,3 +1,4 @@ +import { CLOSE_ICON } from './icons.js'; import { escapeHtml } from '../util.js'; import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; @@ -11,10 +12,10 @@ import { renderTopRail } from '../topRail.js'; export function renderPlaceholderScreen(root, { title, description, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { root.innerHTML = ` <div class="stats-screen"> - <div class="logo" data-action="menu">TyperPunk</div> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> + <div class="logo" data-action="menu">TyperPunk</div> <h2>${escapeHtml(title)}</h2> <div class="stats-placeholder">${escapeHtml(description)}</div> - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; diff --git a/web/src/screens/publicProfileScreen.js b/web/src/screens/publicProfileScreen.js index 5ce50ed..1479df6 100644 --- a/web/src/screens/publicProfileScreen.js +++ b/web/src/screens/publicProfileScreen.js @@ -3,7 +3,7 @@ import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; import { api, ApiError } from '../api.js'; -import { FLAIR_ICONS } from './icons.js'; +import { FLAIR_ICONS, CLOSE_ICON } from './icons.js'; // modeKey format is set in app.js: "words-25", "time-30", "quote-<category>". function modeLabel(modeKey) { @@ -52,10 +52,10 @@ export function renderPublicProfileScreen(root, { username, onBack, onShowStats, root.innerHTML = ` <div class="stats-screen"> - <div class="logo" data-action="menu">TyperPunk</div> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> + <div class="logo" data-action="menu">TyperPunk</div> <h2 class="public-profile-heading">${escapeHtml(username)}</h2> <div class="public-profile-body"></div> - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; diff --git a/web/src/screens/statsScreen.js b/web/src/screens/statsScreen.js index a38e193..a76cbca 100644 --- a/web/src/screens/statsScreen.js +++ b/web/src/screens/statsScreen.js @@ -1,3 +1,4 @@ +import { CLOSE_ICON } from './icons.js'; import { getProfileStats } from '../profileStats.js'; import { getAllPersonalBests } from '../pb.js'; import { escapeHtml } from '../util.js'; @@ -26,7 +27,8 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou root.innerHTML = ` <div class="stats-screen"> - <div class="logo" data-action="menu">TyperPunk</div> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> + <div class="logo" data-action="menu">TyperPunk</div> <h2>Stats</h2> ${profile.testsCompleted > 0 ? ` @@ -58,7 +60,6 @@ export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccou ? `<div class="stats-chart"><canvas></canvas></div>` : `<div class="stats-placeholder">Complete a few more tests to see a progress chart.</div>`} - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js index 1ab2f84..5a533d9 100644 --- a/web/src/screens/storeScreen.js +++ b/web/src/screens/storeScreen.js @@ -4,7 +4,7 @@ import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; import { api, ApiError } from '../api.js'; import { getUser } from '../auth.js'; -import { FLAIR_ICONS } from './icons.js'; +import { FLAIR_ICONS, CLOSE_ICON } from './icons.js'; function formatPrice(cents) { return `$${(cents / 100).toFixed(2)}`; @@ -27,8 +27,9 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder let stopped = false; function itemMarkup(item) { - const owned = mine.owned.includes(item.id); - const equipped = mine.equipped_caret === item.id || mine.equipped_flair === item.id; + const signedOut = status === 'signed-out'; + const owned = !signedOut && mine.owned.includes(item.id); + const equipped = !signedOut && (mine.equipped_caret === item.id || mine.equipped_flair === item.id); const swatch = item.category === 'caret' ? `<span class="store-swatch" style="background:${escapeHtml(item.value)}"></span>` : `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`; @@ -39,18 +40,22 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder ${!owned ? `<div class="leaderboard-acc">${formatPrice(item.price_cents)}</div>` : ''} ${owned ? `<button class="menu-button small${equipped ? '' : ' ghost'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>` - : `<button class="menu-button small" data-action="buy" data-id="${item.id}">Buy</button>`} + : `<button class="menu-button small${signedOut ? ' ghost' : ''}" data-action="${signedOut ? 'go-account' : 'buy'}" data-id="${item.id}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy</button>`} </div> `; } function bodyMarkup() { - if (status === 'signed-out') return `<div class="stats-empty">Sign in to buy and equip cosmetics.</div><button class="menu-button" data-action="go-account">Sign In</button>`; if (status === 'loading') return `<div class="stats-empty">Loading...</div>`; if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`; + const signedOut = status === 'signed-out'; + const banner = signedOut + ? `<div class="store-signin-note">Sign in to buy and equip these. + <button class="menu-button small" data-action="go-account">Sign In</button></div>` + : ''; const categories = ['caret', 'flair']; - return categories.map(cat => { + return banner + categories.map(cat => { const items = catalog.filter(i => i.category === cat); if (items.length === 0) return ''; return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`; @@ -60,10 +65,10 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder function render() { root.innerHTML = ` <div class="stats-screen"> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> <div class="logo" data-action="menu">TyperPunk</div> <h2>Store</h2> ${bodyMarkup()} - <button class="menu-button small ghost" data-action="menu">Back</button> </div> `; root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); @@ -121,7 +126,12 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder } async function loadMine() { - if (!getUser()) { status = 'signed-out'; return; } + if (!getUser()) { + // The catalogue still renders - only ownership needs an account. + status = 'signed-out'; + mine = { owned: [], equipped_caret: null, equipped_flair: null }; + return; + } try { mine = await api.get('/api/cosmetics/me'); status = 'ok'; diff --git a/web/src/styles.css b/web/src/styles.css index 8462fbf..f7879e6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -308,10 +308,16 @@ body { text-align: center; } +/* Centred, not flex-start. These screens are a centred column, so a + left-hugging empty state sat out of line with the button underneath it -- + most visible on Friends, where "Sign in to add friends" and its Sign In + button were on two different axes. */ .stats-empty { color: var(--sub-color); font-size: 0.9rem; - align-self: flex-start; + align-self: center; + text-align: center; + max-width: 32rem; } .stats-pb-list { @@ -2123,9 +2129,48 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { min-width: 0; } +/* Sized to the five characters a room code actually is, rather than eating + whatever width was going. The Join button matches the field's height and + the weight of the other actions on the screen - as a `small ghost` it + read as an afterthought next to the field it belongs to. */ +.mp-code-row .account-input { + flex: 1 1 auto; + min-width: 0; + text-align: center; + text-transform: uppercase; + letter-spacing: 3px; + font-size: 1.1rem; +} + .mp-code-row .menu-button { flex: 0 0 auto; + min-width: 0; white-space: nowrap; + padding-left: 1.75rem; + padding-right: 1.75rem; +} + +/* A labelled rule, so the two ways into a race read as alternatives rather + than a list of buttons. */ +.mp-divider { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + margin: 0.5rem 0; + color: var(--sub-color); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; +} + +.mp-divider::before, +.mp-divider::after { + content: ''; + flex: 1 1 auto; + height: 1px; + background: var(--neutral-color); + opacity: 0.4; } /* The passage's attribution, repeated on the end screen so a quote still @@ -2617,3 +2662,86 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { .lang-item { transition: background-color 0.12s ease, color 0.12s ease; } + +/* The count, over the passage, while typing is locked. Sits above the text so + the seconds before a race are spent reading the opening words - the lobby + used to hold the count on a screen with nothing to read. */ +.mp-race-gate { + position: fixed; + inset: 0; + z-index: 95; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + pointer-events: none; + background: color-mix(in srgb, var(--background-color) 55%, transparent); +} + +.mp-race-gate-count { + font-family: 'JetBrains Mono', monospace; + font-size: 7rem; + line-height: 1; + font-weight: 700; + color: var(--warning-color); +} + +.mp-race-gate-label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 3px; + color: var(--sub-color); +} + +/* Shown above the catalogue when signed out. The store used to be replaced + entirely by a sign-in prompt, so nobody could see what signing in would get + them. */ +.store-signin-note { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent); + border-radius: 8px; + font-size: 0.85rem; + color: var(--sub-color); +} + +/* Close, at the top-right of the content. Replaces the full-width "Back" + button every screen carried at the bottom - a third way to do what the + wordmark and Escape already did, and the least reachable of the three. */ +.screen-close { + position: absolute; + top: 0; + right: 0; + width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--sub-color); + cursor: pointer; + border-radius: 6px; + transition: color 0.15s ease, background-color 0.15s ease; +} + +.screen-close:hover { + color: var(--text-color); + background: color-mix(in srgb, var(--text-color) 10%, transparent); +} + +.screen-close svg { + width: 1.1rem; + height: 1.1rem; +} + +/* The close button anchors to the content column. */ +.stats-screen { + position: relative; +} |