diff options
| author | srdusr <[email protected]> | 2025-12-05 22:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-12-05 22:56:00 +0200 |
| commit | 076f29369e51dfb5798f70099a330ebeb1b66a4e (patch) | |
| tree | 202d355c47f1b698f8c96d688b2e268c0d53281d /crates | |
| parent | f48fa830b10d4122d145e9822522d9b71e3f00fa (diff) | |
| download | typerpunk-076f29369e51dfb5798f70099a330ebeb1b66a4e.tar.gz typerpunk-076f29369e51dfb5798f70099a330ebeb1b66a4e.zip | |
Show the passage during the countdown, and fix navigation across screens
Countdown
- Five seconds instead of three, and shown over the passage on the race
screen rather than in an empty lobby. New ServerMessage::RaceText carries
the text (and its attribution) just before the countdown starts, so every
client can put it on screen and let players read the opening words while
the numbers run - what TypeRacer and 10FastFingers both do. Typing is
locked until Start lifts the gate.
- Two subscriptions had to move with it. Building the race view tears the
lobby down, and the lobby owned the only handlers for Countdown and Start,
so the count stopped arriving exactly when it was needed and the gate would
never have lifted.
- The TUI and Steam clients match ServerMessage exhaustively, so both gained
an arm for the new variant; whole workspace checks clean.
Multiplayer attribution
- Race passages now carry who wrote them, so a race shows its source the way
single player always has. Previously the server sent bare strings and both
the race and its end screen showed nothing.
Navigation
- Every screen's full-width "Back" button becomes a close control at the top
of the content. The wordmark and Escape already went back, so it was a
third way to do the same thing and the least reachable of them.
- The multiplayer screen had a Back button on top of that; gone.
Fixes
- The end screen graph is drawn into a canvas, so it cannot inherit a theme
change the way the rest of the page does. It now redraws on one - switching
theme used to leave the chart in the old palette.
- .stats-empty was align-self:flex-start inside a centred column, so "Sign in
to add friends" sat on a different axis from its own Sign In button.
- The store showed a sign-in prompt instead of its catalogue, so nobody could
see what signing in would get them. The catalogue now renders signed out,
with prices, behind a sign-in note.
- The room code field is sized for the five characters a code actually is,
and Join carries the same weight as the field it belongs to.
Diffstat (limited to 'crates')
| -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 |
8 files changed, 72 insertions, 12 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 }, |