diff options
| author | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
| commit | a726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch) | |
| tree | 4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /crates/steam | |
| parent | 8c95e90fa54db565919ec323818f77e7256812c8 (diff) | |
| download | typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip | |
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer
- Quick match: POST /api/multiplayer/quickmatch returns whichever room is
still filling, or opens one. Players never see a room code; joining by
code stays for racing specific people.
- Bots fill quick-match rooms after a short wait so a new game is never an
empty lobby. They only ever join quick-match rooms, never a room opened
by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so
two bots are never near each other's pace, and they stall to correct
mistakes rather than typing a clean straight line.
- Live player count via GET /api/multiplayer/online, shown on the
Multiplayer control and under the main menu's Multiplayer button.
- Per-racer colours: you are the theme accent, opponents take distinct hues
that stay the same from lobby to race.
- The countdown no longer holds the room lock for its full three seconds,
which is what reset clients mid-countdown.
Typing languages
- 16 languages for the generated-word modes, each with its own
high-frequency vocabulary rather than a translation of the English list.
- Picker in the top-right rail; non-English uses its own list at every
difficulty tier instead of falling back to English words.
Fix UTF-8 accuracy in the game core
- update_game_state mixed byte and character counts: total_characters_typed
accumulated byte-length deltas while total_correct_characters compared a
char index against that byte count. Equal on ASCII, so it went unnoticed;
a correctly typed Spanish passage scored 6%. The old byte slicing would
also have panicked if an index landed inside a multi-byte character.
Rewritten char-based, with regression tests.
Programming mode
- Replaced prose about programming with real code: 26 syntax-highlighted
snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line
by necessity, since the typing input is a single-line field.
Layout and readability
- One icon rail arrangement on every screen: Settings/Store under the
wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/
Multiplayer bottom-right.
- Main menu: mode picker moved out of the Single Player button, which it was
notching a divider through and pushing the label off-centre.
- Escape returns to the menu, closing any open popover first, and confirms
before abandoning a live race.
- Split --text-color and --sub-color per theme; they shared one value that
measured 3.65:1 against the background, below the 4.5:1 body-text floor.
- Semantic colours used in exactly one place each: gold for a personal best,
amber for the race countdown and the mobile-result badge.
- Passage now sits in the same place on the typing and end screens, and its
column is a whole number of characters wide so wrapping cannot leave a
permanent gap on the right.
- End screen: keystrokes and a correct/wrong/extra/missed split, attribution
carried over from the typing screen, and a graph with a separate error
axis, axis titles including seconds, and smoothed lines.
Diffstat (limited to 'crates/steam')
| -rw-r--r-- | crates/steam/Cargo.toml | 21 | ||||
| -rw-r--r-- | crates/steam/assets/sprites/pulse.png | bin | 0 -> 495 bytes | |||
| -rw-r--r-- | crates/steam/assets/sprites/pulse.svg | 5 | ||||
| -rw-r--r-- | crates/steam/assets/sprites/racer_marker.png | bin | 0 -> 371 bytes | |||
| -rw-r--r-- | crates/steam/assets/sprites/racer_marker.svg | 6 | ||||
| -rw-r--r-- | crates/steam/assets/sprites/warden_layer.png | bin | 0 -> 309 bytes | |||
| -rw-r--r-- | crates/steam/assets/sprites/warden_layer.svg | 14 | ||||
| -rw-r--r-- | crates/steam/src/lore.rs | 24 | ||||
| -rw-r--r-- | crates/steam/src/main.rs | 55 | ||||
| -rw-r--r-- | crates/steam/src/multiplayer_plugin.rs | 179 | ||||
| -rw-r--r-- | crates/steam/src/net.rs | 178 | ||||
| -rw-r--r-- | crates/steam/src/race/breach.rs | 114 | ||||
| -rw-r--r-- | crates/steam/src/race/mod.rs | 16 | ||||
| -rw-r--r-- | crates/steam/src/race/signal.rs | 110 | ||||
| -rw-r--r-- | crates/steam/src/race/terminal.rs | 81 | ||||
| -rw-r--r-- | crates/steam/src/racer.rs | 66 | ||||
| -rw-r--r-- | crates/steam/src/typing.rs | 216 |
17 files changed, 1085 insertions, 0 deletions
diff --git a/crates/steam/Cargo.toml b/crates/steam/Cargo.toml new file mode 100644 index 0000000..b6c4851 --- /dev/null +++ b/crates/steam/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "typerpunk-steam" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[[bin]] +name = "typerpunk-desktop" +path = "src/main.rs" + +[dependencies] +typerpunk-core = { path = "../core", default-features = false, features = ["serde", "multiplayer"] } +bevy = { workspace = true } +serde.workspace = true +serde_json.workspace = true +rand.workspace = true +tokio = { workspace = true, features = ["full"] } +tokio-tungstenite.workspace = true +futures-util.workspace = true +reqwest.workspace = true diff --git a/crates/steam/assets/sprites/pulse.png b/crates/steam/assets/sprites/pulse.png Binary files differnew file mode 100644 index 0000000..a70d94d --- /dev/null +++ b/crates/steam/assets/sprites/pulse.png diff --git a/crates/steam/assets/sprites/pulse.svg b/crates/steam/assets/sprites/pulse.svg new file mode 100644 index 0000000..de5b98e --- /dev/null +++ b/crates/steam/assets/sprites/pulse.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="64" height="64"> + <!-- Angular diamond, matching the web icon set's straight-edge language. + Reads as a signal pulse travelling along the track. --> + <path d="M12 2L22 12L12 22L2 12Z" fill="#ffffff"/> +</svg> diff --git a/crates/steam/assets/sprites/racer_marker.png b/crates/steam/assets/sprites/racer_marker.png Binary files differnew file mode 100644 index 0000000..e7afbc3 --- /dev/null +++ b/crates/steam/assets/sprites/racer_marker.png diff --git a/crates/steam/assets/sprites/racer_marker.svg b/crates/steam/assets/sprites/racer_marker.svg new file mode 100644 index 0000000..60e973c --- /dev/null +++ b/crates/steam/assets/sprites/racer_marker.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48"> + <!-- Shared "this one is you" marker used above the local player's lane + in both Signal Run and Breach Protocol. A filled downward chevron, + angular like every other icon in the set. --> + <path d="M2 4H22L12 16Z" fill="#ffffff"/> +</svg> diff --git a/crates/steam/assets/sprites/warden_layer.png b/crates/steam/assets/sprites/warden_layer.png Binary files differnew file mode 100644 index 0000000..0b4f3f2 --- /dev/null +++ b/crates/steam/assets/sprites/warden_layer.png diff --git a/crates/steam/assets/sprites/warden_layer.svg b/crates/steam/assets/sprites/warden_layer.svg new file mode 100644 index 0000000..2fdf9a4 --- /dev/null +++ b/crates/steam/assets/sprites/warden_layer.svg @@ -0,0 +1,14 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 28" width="280" height="56"> + <!-- A circuit-board-style defensive layer: a solid bar with angular teeth + above and below, echoing the terminal/hacking aesthetic instead of a + flat rectangle. --> + <rect x="4" y="10" width="132" height="8" fill="#ffffff"/> + <rect x="14" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="42" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="70" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="98" y="2" width="14" height="8" fill="#ffffff"/> + <rect x="14" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="42" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="70" y="18" width="14" height="8" fill="#ffffff"/> + <rect x="98" y="18" width="14" height="8" fill="#ffffff"/> +</svg> diff --git a/crates/steam/src/lore.rs b/crates/steam/src/lore.rs new file mode 100644 index 0000000..193587c --- /dev/null +++ b/crates/steam/src/lore.rs @@ -0,0 +1,24 @@ +// In-universe naming for TyperPunk's multiplayer race modes. + +pub const APP_NAME: &str = "TyperPunk"; + +/// A player or bot in a race. "Runner" is our own term for a race participant. +pub const PARTICIPANT_LABEL: &str = "Runner"; + +pub mod modes { + /// Terminal Duel: two runners' text execution shown side by side, like + /// racing shells. Cheapest of the three to build well; on-brand. + pub const TERMINAL_DUEL: &str = "Terminal Duel"; + + /// Breach Protocol: each runner chips through a stack of defensive + /// layers ("Wardens") with correct keystrokes; first to clear them wins. + pub const BREACH_PROTOCOL: &str = "Breach Protocol"; + + /// Signal Run: runners are pulses of light racing along a circuit trace + /// toward a destination node. The animated, particle-heavy visual. + pub const SIGNAL_RUN: &str = "Signal Run"; +} + +/// A single defensive layer in Breach Protocol. Renamed from the generic +/// "firewall layer" to keep the game's own vocabulary distinct. +pub const WARDEN_LABEL: &str = "Warden"; diff --git a/crates/steam/src/main.rs b/crates/steam/src/main.rs new file mode 100644 index 0000000..3fafb61 --- /dev/null +++ b/crates/steam/src/main.rs @@ -0,0 +1,55 @@ +use bevy::prelude::*; + +mod lore; +mod multiplayer_plugin; +mod net; +mod race; +mod racer; +mod typing; + +use multiplayer_plugin::MultiplayerPlugin; +use race::{ + breach::BreachProtocolPlugin, signal::SignalRunPlugin, terminal::TerminalDuelPlugin, RaceVisual, +}; +use racer::DemoProgressPlugin; +use typing::TypingPlugin; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: lore::APP_NAME.to_string(), + resolution: (1280, 720).into(), + ..default() + }), + ..default() + })) + .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.03))) + .init_state::<RaceVisual>() + .add_plugins(DemoProgressPlugin) + .add_plugins(MultiplayerPlugin) + .add_plugins(TypingPlugin) + .add_plugins(TerminalDuelPlugin) + .add_plugins(BreachProtocolPlugin) + .add_plugins(SignalRunPlugin) + .add_systems(Startup, spawn_camera) + .add_systems(Update, cycle_visual_on_key) + .run(); +} + +fn spawn_camera(mut commands: Commands) { + commands.spawn(Camera2d); +} + +/// Demo-only: press 1/2/3 to switch which race visual is on screen, so all +/// three can be compared side by side before committing to one for real +/// multiplayer. Remove once there's a real mode-select flow. +fn cycle_visual_on_key(keys: Res<ButtonInput<KeyCode>>, mut next: ResMut<NextState<RaceVisual>>) { + if keys.just_pressed(KeyCode::Digit1) { + next.set(RaceVisual::TerminalDuel); + } else if keys.just_pressed(KeyCode::Digit2) { + next.set(RaceVisual::BreachProtocol); + } else if keys.just_pressed(KeyCode::Digit3) { + next.set(RaceVisual::SignalRun); + } +} diff --git a/crates/steam/src/multiplayer_plugin.rs b/crates/steam/src/multiplayer_plugin.rs new file mode 100644 index 0000000..05c1760 --- /dev/null +++ b/crates/steam/src/multiplayer_plugin.rs @@ -0,0 +1,179 @@ +// Bridges the background WebSocket worker in net.rs into Bevy's ECS: applies +// incoming NetEvents to Racer entities every frame, and forwards the local +// racer's (synthetic, see net.rs's module comment) progress out over the +// same connection. +use crate::net::{LocalUpdate, MultiplayerNet, NetEvent}; +use crate::racer::{DemoRace, Racer}; +use crate::typing::TypingSession; +use bevy::prelude::*; +use std::sync::mpsc::Sender; + +fn server_http_base() -> String { + std::env::var("TYPERPUNK_SERVER_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()) +} + +fn server_ws_base() -> String { + // Same host as the HTTP API, just a different scheme - ws:// unless the + // HTTP base was already secured, in which case wss://. + let http = server_http_base(); + if let Some(rest) = http.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = http.strip_prefix("http://") { + format!("ws://{rest}") + } else { + format!("ws://{http}") + } +} + +#[derive(Resource)] +struct NetHandle(MultiplayerNet); + +#[derive(Resource)] +struct LocalUpdateSender(Sender<LocalUpdate>); + +#[derive(Resource, Default)] +struct LocalPlayerId(Option<String>); + +// Without this, send_local_progress would re-send Finish every single frame +// once the local racer reaches 1.0 progress, flooding the connection +// forever instead of announcing it once. +#[derive(Resource, Default)] +struct LocalFinishSent(bool); + +/// Tags a Racer entity as backed by a specific server-assigned player id, so +/// incoming PlayerProgress/PlayerFinished events know which entity to +/// update, and PlayerList knows which remote players already have one. +#[derive(Component)] +struct RemotePlayer(String); + +pub struct MultiplayerPlugin; + +impl Plugin for MultiplayerPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(LocalPlayerId::default()) + .insert_resource(LocalFinishSent::default()) + .add_systems(Startup, connect) + .add_systems(Update, (poll_events, send_local_progress)); + } +} + +fn connect(mut commands: Commands) { + // A room code passed in means "join this existing room" (e.g. the code + // a web client printed) - otherwise a fresh room is created and its + // code logged so a second client can join it to actually see a live + // race between two real connections. + let room_code = std::env::var("TYPERPUNK_ROOM_CODE").ok(); + let name = std::env::var("TYPERPUNK_PLAYER_NAME").unwrap_or_else(|_| "Desktop".to_string()); + let (net, update_tx) = MultiplayerNet::spawn(server_http_base(), server_ws_base(), room_code, name); + commands.insert_resource(NetHandle(net)); + commands.insert_resource(LocalUpdateSender(update_tx)); +} + +fn poll_events( + mut commands: Commands, + net: Res<NetHandle>, + mut local_id: ResMut<LocalPlayerId>, + mut typing: ResMut<TypingSession>, + mut finish_sent: ResMut<LocalFinishSent>, + mut racers: Query<(&mut Racer, &RemotePlayer)>, + existing: Query<(Entity, &RemotePlayer)>, +) { + while let Some(event) = net.0.try_recv() { + match event { + NetEvent::RoomCreated { code } => { + info!("multiplayer room created - join it from the web app with code: {code}"); + } + NetEvent::Joined { player_id } => { + info!("joined multiplayer room as player {player_id}"); + local_id.0 = Some(player_id); + } + NetEvent::PlayerList(players) => { + for player in &players { + if local_id.0.as_deref() == Some(player.id.as_str()) { + continue; // that's us - our own Racer entity already exists + } + let already_spawned = existing.iter().any(|(_, r)| r.0 == player.id); + if !already_spawned { + commands.spawn(( + Racer { name: player.name.clone(), is_local: false, progress: 0.0, wpm: 0.0, accuracy: 100.0, mistakes: 0 }, + RemotePlayer(player.id.clone()), + DemoRace, + )); + } + } + // A player who left the room stops appearing in PlayerList -- + // their racer entity is stale and would otherwise sit frozen + // on screen forever. + let current_ids: Vec<&str> = players.iter().map(|p| p.id.as_str()).collect(); + for (entity, remote) in &existing { + if !current_ids.contains(&remote.0.as_str()) { + commands.entity(entity).despawn(); + } + } + } + NetEvent::Countdown(seconds) => { + info!("race starting in {seconds}s"); + } + NetEvent::Start(text) => { + info!("race started"); + typing.start(text); + // A fresh race needs its own Finish announcement - without + // resetting this, a second race in the same process would + // never send one, since the guard would still remember the + // first race's finish. + finish_sent.0 = false; + } + NetEvent::PlayerProgress { player_id, percent, wpm } => { + for (mut racer, remote) in &mut racers { + if remote.0 == player_id { + racer.progress = (percent / 100.0).clamp(0.0, 1.0); + racer.wpm = wpm; + } + } + } + NetEvent::PlayerFinished { player_id, wpm, .. } => { + for (mut racer, remote) in &mut racers { + if remote.0 == player_id { + racer.progress = 1.0; + racer.wpm = wpm; + } + } + } + NetEvent::RoomClosed(reason) => { + warn!("multiplayer room closed: {reason}"); + } + NetEvent::Error(message) => { + warn!("multiplayer error: {message}"); + } + } + } +} + +fn send_local_progress( + sender: Res<LocalUpdateSender>, + mut finish_sent: ResMut<LocalFinishSent>, + typing: Res<TypingSession>, + racers: Query<&Racer, With<DemoRace>>, +) { + // Nothing to report before a race has actually started - sending + // Progress{0%, 0wpm} on a loop while sitting in the lobby is meaningless + // and, worse, floods the connection right as the server's Ready handler + // is mid-countdown holding the room lock (see multiplayer.rs's Ready + // handler comment on why that hold is intentional). + if !typing.has_started() { + return; + } + for racer in &racers { + if !racer.is_local { + continue; + } + if racer.progress >= 1.0 { + if !finish_sent.0 { + let _ = sender.0.send(LocalUpdate::Finish { wpm: racer.wpm, accuracy: racer.accuracy, time: 0.0 }); + finish_sent.0 = true; + } + } else { + let _ = sender.0.send(LocalUpdate::Progress { percent: racer.progress * 100.0, wpm: racer.wpm }); + } + } +} diff --git a/crates/steam/src/net.rs b/crates/steam/src/net.rs new file mode 100644 index 0000000..4d213b4 --- /dev/null +++ b/crates/steam/src/net.rs @@ -0,0 +1,178 @@ +// Real multiplayer networking for the desktop client, replacing racer.rs's +// synthetic two-racer demo driver. Bevy's render loop is synchronous, so (as +// with the TUI's own net.rs) a dedicated OS thread owns a tokio runtime and +// the actual WebSocket connection, bridging to the ECS over plain +// std::sync::mpsc channels polled once per frame. +// +// The local racer's progress is still produced by a synthetic driver (see +// racer::drive_demo_progress) rather than real typing input - this crate +// has no typing UI yet, that's a separate, much larger feature. What's real +// here is the network path itself: the local racer's synthetic progress is +// sent to typerpunk-server over the same protocol the web client uses, and +// every *other* racer on screen is driven entirely by what the server +// actually broadcasts, so two real clients (this one and a browser, or two +// copies of this one) genuinely race against each other's live state. +use futures_util::{SinkExt, StreamExt}; +use std::sync::mpsc; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +#[derive(Debug, Clone)] +pub enum NetEvent { + RoomCreated { code: String }, + Joined { player_id: String }, + PlayerList(Vec<PlayerInfo>), + Countdown(u32), + Start(String), + PlayerProgress { player_id: String, percent: f32, wpm: f32 }, + PlayerFinished { player_id: String, wpm: f32, accuracy: f32, time: f32, place: u32 }, + RoomClosed(String), + Error(String), +} + +#[derive(Debug, Clone)] +pub enum LocalUpdate { + Progress { percent: f32, wpm: f32 }, + Finish { wpm: f32, accuracy: f32, time: f32 }, +} + +// std::sync::mpsc::Receiver is Send but not Sync, and Bevy's Resource trait +// requires Sync - a single-slot Mutex around it costs nothing here since +// poll_events is the only reader, called from a single system. +pub struct MultiplayerNet { + event_rx: std::sync::Mutex<mpsc::Receiver<NetEvent>>, +} + +impl MultiplayerNet { + /// `room_code: None` creates a fresh room and reports its code back via + /// `NetEvent::RoomCreated` (printed to the terminal by main.rs so a + /// tester can join it from the web app); `Some(code)` joins an existing + /// one instead. + pub fn spawn(http_base: String, ws_base: String, room_code: Option<String>, name: String) -> (Self, mpsc::Sender<LocalUpdate>) { + let (event_tx, event_rx) = mpsc::channel(); + let (update_tx, update_rx) = mpsc::channel::<LocalUpdate>(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to start steam network runtime"); + rt.block_on(run(http_base, ws_base, room_code, name, event_tx, update_rx)); + }); + + (Self { event_rx: std::sync::Mutex::new(event_rx) }, update_tx) + } + + pub fn try_recv(&self) -> Option<NetEvent> { + self.event_rx.lock().ok()?.try_recv().ok() + } +} + +#[derive(serde::Deserialize)] +struct CreateRoomResponse { + room_code: String, +} + +async fn create_room(http_base: &str) -> Result<String, String> { + let client = reqwest::Client::new(); + let url = format!("{http_base}/api/multiplayer/rooms"); + let resp = client.post(&url).send().await.map_err(|e| format!("could not reach server: {e}"))?; + if !resp.status().is_success() { + return Err(format!("room creation failed ({})", resp.status())); + } + resp.json::<CreateRoomResponse>().await.map(|b| b.room_code).map_err(|e| format!("bad response: {e}")) +} + +fn to_event(msg: ServerMessage) -> NetEvent { + match msg { + ServerMessage::Joined { player_id } => NetEvent::Joined { player_id }, + ServerMessage::PlayerList { players } => NetEvent::PlayerList(players), + ServerMessage::Countdown { seconds } => NetEvent::Countdown(seconds), + ServerMessage::Start { text } => NetEvent::Start(text), + ServerMessage::PlayerProgress { player_id, percent, wpm } => NetEvent::PlayerProgress { player_id, percent, wpm }, + ServerMessage::PlayerFinished { player_id, wpm, accuracy, time, place } => { + NetEvent::PlayerFinished { player_id, wpm, accuracy, time, place } + } + ServerMessage::RoomClosed { reason } => NetEvent::RoomClosed(reason), + ServerMessage::Error { message } => NetEvent::Error(message), + } +} + +async fn send_msg<S>(write: &mut S, msg: &ClientMessage) -> Result<(), ()> +where + S: futures_util::Sink<WsMessage> + Unpin, +{ + let text = serde_json::to_string(msg).map_err(|_| ())?; + write.send(WsMessage::Text(text)).await.map_err(|_| ()) +} + +async fn run( + http_base: String, + ws_base: String, + room_code: Option<String>, + name: String, + event_tx: mpsc::Sender<NetEvent>, + update_rx: mpsc::Receiver<LocalUpdate>, +) { + let code = match room_code { + Some(code) => code, + None => match create_room(&http_base).await { + Ok(code) => { + let _ = event_tx.send(NetEvent::RoomCreated { code: code.clone() }); + code + } + Err(err) => { + let _ = event_tx.send(NetEvent::Error(err)); + return; + } + }, + }; + + let url = format!("{ws_base}/ws/multiplayer/{code}"); + let (ws_stream, _) = match tokio_tungstenite::connect_async(&url).await { + Ok(pair) => pair, + Err(err) => { + let _ = event_tx.send(NetEvent::Error(format!("connect failed: {err}"))); + return; + } + }; + let (mut write, mut read) = ws_stream.split(); + + let join = ClientMessage::Join { name, device_type: "desktop".to_string() }; + if send_msg(&mut write, &join).await.is_err() { + let _ = event_tx.send(NetEvent::Error("failed to send join".to_string())); + return; + } + let _ = send_msg(&mut write, &ClientMessage::Ready).await; + + loop { + tokio::select! { + incoming = read.next() => { + match incoming { + Some(Ok(WsMessage::Text(text))) => { + if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) { + if event_tx.send(to_event(msg)).is_err() { return; } + } + } + Some(Ok(WsMessage::Close(_))) | None => { + let _ = event_tx.send(NetEvent::RoomClosed("connection closed".to_string())); + return; + } + Some(Err(err)) => { + let _ = event_tx.send(NetEvent::Error(format!("connection error: {err}"))); + return; + } + _ => {} + } + } + // std::sync::mpsc has no async recv - polled on a short interval + // instead of a third select branch reading it directly. + _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => { + while let Ok(update) = update_rx.try_recv() { + let client_msg = match update { + LocalUpdate::Progress { percent, wpm } => ClientMessage::Progress { percent, wpm }, + LocalUpdate::Finish { wpm, accuracy, time } => ClientMessage::Finish { wpm, accuracy, time }, + }; + if send_msg(&mut write, &client_msg).await.is_err() { return; } + } + } + } + } +} diff --git a/crates/steam/src/race/breach.rs b/crates/steam/src/race/breach.rs new file mode 100644 index 0000000..9151774 --- /dev/null +++ b/crates/steam/src/race/breach.rs @@ -0,0 +1,114 @@ +use super::RaceVisual; +use crate::lore; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Breach Protocol" - each runner chips through a vertical stack of +/// defensive layers ("Wardens") as they type correctly; first to clear +/// their whole stack wins. Doubles as the visual for a hacking-themed mode. +pub struct BreachProtocolPlugin; + +const LAYER_COUNT: usize = 8; +const LAYER_SIZE: Vec2 = Vec2::new(140.0, 28.0); +const LAYER_GAP: f32 = 6.0; +const LANE_GAP: f32 = 240.0; + +impl Plugin for BreachProtocolPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::BreachProtocol), spawn_lanes) + .add_systems(OnExit(RaceVisual::BreachProtocol), despawn_lanes) + .add_systems( + Update, + update_layers.run_if(in_state(RaceVisual::BreachProtocol)), + ); + } +} + +#[derive(Component)] +struct BreachRoot; + +#[derive(Component)] +struct WardenLayer { + racer: Entity, + index: usize, +} + +fn spawn_lanes( + mut commands: Commands, + asset_server: Res<AssetServer>, + racers: Query<(Entity, &Racer), With<DemoRace>>, +) { + let warden_texture: Handle<Image> = asset_server.load("sprites/warden_layer.png"); + let marker_texture: Handle<Image> = asset_server.load("sprites/racer_marker.png"); + let lanes: Vec<_> = racers.iter().collect(); + let start_x = -(LANE_GAP * (lanes.len().max(1) as f32 - 1.0)) / 2.0; + + for (lane_index, (entity, racer)) in lanes.iter().enumerate() { + let x = start_x + lane_index as f32 * LANE_GAP; + let stack_top = -200.0 + LAYER_COUNT as f32 * (LAYER_SIZE.y + LAYER_GAP); + + commands.spawn(( + BreachRoot, + Text2d::new(format!("{} - {}", racer.name, lore::WARDEN_LABEL)), + TextFont { + font_size: bevy::text::FontSize::Px(18.0), + ..default() + }, + TextColor(Color::srgb(0.6, 0.6, 0.65)), + Transform::from_xyz(x, stack_top + 20.0, 0.0), + )); + + // Shared racer marker (see racer.rs's is_local) sits above the + // local player's own stack, same asset Signal Run uses for the + // same purpose. + if racer.is_local { + commands.spawn(( + BreachRoot, + Sprite { + image: marker_texture.clone(), + color: Color::srgb(0.0, 1.0, 0.62), + custom_size: Some(Vec2::splat(20.0)), + ..default() + }, + Transform::from_xyz(x, stack_top + 44.0, 0.0), + )); + } + + for i in 0..LAYER_COUNT { + let y = -200.0 + i as f32 * (LAYER_SIZE.y + LAYER_GAP); + commands.spawn(( + BreachRoot, + WardenLayer { + racer: *entity, + index: i, + }, + Sprite { + image: warden_texture.clone(), + color: Color::srgb(0.15, 0.15, 0.18), + custom_size: Some(LAYER_SIZE), + ..default() + }, + Transform::from_xyz(x, y, 0.0), + )); + } + } +} + +fn update_layers(racers: Query<&Racer>, mut layers: Query<(&WardenLayer, &mut Sprite)>) { + for (layer, mut sprite) in &mut layers { + if let Ok(racer) = racers.get(layer.racer) { + let cleared = (racer.progress * LAYER_COUNT as f32).floor() as usize; + sprite.color = if layer.index < cleared { + Color::srgb(0.0, 1.0, 0.62) + } else { + Color::srgb(0.15, 0.15, 0.18) + }; + } + } +} + +fn despawn_lanes(mut commands: Commands, root: Query<Entity, With<BreachRoot>>) { + for entity in &root { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/race/mod.rs b/crates/steam/src/race/mod.rs new file mode 100644 index 0000000..15ad5f3 --- /dev/null +++ b/crates/steam/src/race/mod.rs @@ -0,0 +1,16 @@ +use bevy::prelude::*; + +pub mod breach; +pub mod signal; +pub mod terminal; + +/// Which race visualization is currently on screen. Switchable at runtime +/// (keys 1/2/3 in the demo) so all three can be compared side by side +/// during development instead of committing to one before they're built. +#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum RaceVisual { + #[default] + TerminalDuel, + BreachProtocol, + SignalRun, +} diff --git a/crates/steam/src/race/signal.rs b/crates/steam/src/race/signal.rs new file mode 100644 index 0000000..1046fc8 --- /dev/null +++ b/crates/steam/src/race/signal.rs @@ -0,0 +1,110 @@ +use super::RaceVisual; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Signal Run" - runners are pulses of light traveling along a circuit +/// trace toward a destination node. +pub struct SignalRunPlugin; + +const TRACK_START_X: f32 = -500.0; +const TRACK_END_X: f32 = 500.0; +const TRACK_Y: f32 = 0.0; +const LANE_SPACING: f32 = 60.0; + +impl Plugin for SignalRunPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::SignalRun), spawn_pulses) + .add_systems(OnExit(RaceVisual::SignalRun), despawn_pulses) + .add_systems( + Update, + (draw_track, move_pulses).run_if(in_state(RaceVisual::SignalRun)), + ); + } +} + +#[derive(Component)] +struct SignalEntity; + +#[derive(Component)] +struct Pulse { + racer: Entity, +} + +fn lane_y(lane: usize) -> f32 { + TRACK_Y - lane as f32 * LANE_SPACING +} + +fn spawn_pulses( + mut commands: Commands, + asset_server: Res<AssetServer>, + racers: Query<(Entity, &Racer), With<DemoRace>>, +) { + let pulse_texture: Handle<Image> = asset_server.load("sprites/pulse.png"); + let marker_texture: Handle<Image> = asset_server.load("sprites/racer_marker.png"); + + for (lane, (entity, racer)) in racers.iter().enumerate() { + let color = if racer.is_local { + Color::srgb(0.0, 1.0, 0.62) + } else { + Color::srgb(1.0, 0.3, 0.5) + }; + commands.spawn(( + SignalEntity, + Pulse { racer: entity }, + Sprite { + image: pulse_texture.clone(), + color, + custom_size: Some(Vec2::splat(22.0)), + ..default() + }, + Transform::from_xyz(TRACK_START_X, lane_y(lane), 1.0), + )); + + // Shared racer marker (see racer.rs's is_local) sits above the + // local player's lane, on-brand with the same asset the Breach + // Protocol visual uses for the same purpose. + if racer.is_local { + commands.spawn(( + SignalEntity, + // Tagged with Pulse too, purely so move_pulses (below) keeps + // its x position tracking the same racer's progress - its + // spawned y offset above the lane is left untouched since + // that system only ever writes .x. + Pulse { racer: entity }, + Sprite { + image: marker_texture.clone(), + color: Color::srgb(0.0, 1.0, 0.62), + custom_size: Some(Vec2::splat(20.0)), + ..default() + }, + Transform::from_xyz(TRACK_START_X, lane_y(lane) + 26.0, 1.0), + )); + } + } +} + +fn move_pulses(racers: Query<&Racer>, mut pulses: Query<(&Pulse, &mut Transform)>) { + for (pulse, mut transform) in &mut pulses { + if let Ok(racer) = racers.get(pulse.racer) { + transform.translation.x = + TRACK_START_X + (TRACK_END_X - TRACK_START_X) * racer.progress; + } + } +} + +fn draw_track(mut gizmos: Gizmos, racers: Query<&Racer, With<DemoRace>>) { + for lane in 0..racers.iter().len() { + let y = lane_y(lane); + gizmos.line_2d( + Vec2::new(TRACK_START_X, y), + Vec2::new(TRACK_END_X, y), + Color::srgba(0.0, 1.0, 0.62, 0.25), + ); + } +} + +fn despawn_pulses(mut commands: Commands, pulses: Query<Entity, With<SignalEntity>>) { + for entity in &pulses { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/race/terminal.rs b/crates/steam/src/race/terminal.rs new file mode 100644 index 0000000..d558e42 --- /dev/null +++ b/crates/steam/src/race/terminal.rs @@ -0,0 +1,81 @@ +use super::RaceVisual; +use crate::racer::{DemoRace, Racer}; +use bevy::prelude::*; + +/// "Terminal Duel" - each runner's line renders like a shell command +/// executing in real time. Cheapest of the three visuals: no sprites, no +/// physics, just text - and it reads as authentically on-brand for a +/// typing game about hacking rather than racing. +pub struct TerminalDuelPlugin; + +impl Plugin for TerminalDuelPlugin { + fn build(&self, app: &mut App) { + app.add_systems(OnEnter(RaceVisual::TerminalDuel), spawn_ui) + .add_systems(OnExit(RaceVisual::TerminalDuel), despawn_ui) + .add_systems( + Update, + update_lines.run_if(in_state(RaceVisual::TerminalDuel)), + ); + } +} + +#[derive(Component)] +struct TerminalDuelRoot; + +#[derive(Component)] +struct TerminalLine(Entity); + +fn spawn_ui(mut commands: Commands, racers: Query<(Entity, &Racer), With<DemoRace>>) { + commands + .spawn(( + TerminalDuelRoot, + Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + row_gap: Val::Px(24.0), + ..default() + }, + )) + .with_children(|parent| { + for (entity, racer) in &racers { + parent.spawn(( + TerminalLine(entity), + Text::new(line_for(racer)), + TextFont { + font_size: bevy::text::FontSize::Px(26.0), + ..default() + }, + TextColor(Color::srgb(0.0, 1.0, 0.62)), + )); + } + }); +} + +fn update_lines(racers: Query<&Racer>, mut lines: Query<(&TerminalLine, &mut Text)>) { + for (link, mut text) in &mut lines { + if let Ok(racer) = racers.get(link.0) { + *text = Text::new(line_for(racer)); + } + } +} + +fn line_for(racer: &Racer) -> String { + let width = 32; + let filled = ((racer.progress * width as f32).round() as usize).min(width); + let bar: String = "#".repeat(filled) + &".".repeat(width - filled); + format!( + "{:<8} [{bar}] {:>3.0}% {:>3.0}wpm", + racer.name, + racer.progress * 100.0, + racer.wpm + ) +} + +fn despawn_ui(mut commands: Commands, root: Query<Entity, With<TerminalDuelRoot>>) { + for entity in &root { + commands.entity(entity).despawn(); + } +} diff --git a/crates/steam/src/racer.rs b/crates/steam/src/racer.rs new file mode 100644 index 0000000..790833e --- /dev/null +++ b/crates/steam/src/racer.rs @@ -0,0 +1,66 @@ +use crate::typing::TypingSession; +use bevy::prelude::*; + +/// A participant in a race visual. Decoupled from networking on purpose -- +/// the three visualizations below only ever read this component, so they +/// work identically whether progress comes from a local typing session, a +/// bot, or the real multiplayer connection. +#[derive(Component, Debug, Clone)] +pub struct Racer { + pub name: String, + pub is_local: bool, + /// 0.0 (start) to 1.0 (finished). + pub progress: f32, + pub wpm: f32, + pub accuracy: f32, + pub mistakes: u32, +} + +/// Marks the set of racer entities that belong to the current demo race, so +/// visualization plugins can query just their own participants. +#[derive(Component)] +pub struct DemoRace; + +pub struct DemoProgressPlugin; + +impl Plugin for DemoProgressPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, spawn_demo_racers) + .add_systems(Update, drive_demo_progress); + } +} + +fn spawn_demo_racers(mut commands: Commands) { + // Every other racer on screen comes from multiplayer_plugin.rs, spawned + // and updated from the actual server. This one is "You" - driven for + // real by typing.rs once a race is underway (see update_local_racer), + // and by drive_demo_progress's idle animation before that. + commands.spawn(( + Racer { + name: "You".into(), + is_local: true, + progress: 0.0, + wpm: 0.0, + accuracy: 100.0, + mistakes: 0, + }, + DemoRace, + )); +} + +// Idle animation only, for the brief window before a real race has started +// (typing.rs's TypingSession isn't active yet) - once real keystrokes are +// driving the local racer, this backs off so it doesn't fight that. +fn drive_demo_progress(time: Res<Time>, session: Res<TypingSession>, mut racers: Query<&mut Racer, With<DemoRace>>) { + if session.is_active() { + return; + } + for mut racer in &mut racers { + if !racer.is_local || racer.progress >= 1.0 { + continue; + } + let base_speed = 0.12; + racer.progress = (racer.progress + base_speed * time.delta_secs()).min(1.0); + racer.wpm = 60.0 + (racer.progress * 40.0); + } +} diff --git a/crates/steam/src/typing.rs b/crates/steam/src/typing.rs new file mode 100644 index 0000000..c64a540 --- /dev/null +++ b/crates/steam/src/typing.rs @@ -0,0 +1,216 @@ +// Real typing input for the desktop client, replacing the local racer's +// synthetic progress with actual keystrokes against the passage the server +// sent for the current race. Reuses typerpunk_core::game::Game - the same +// correctness/WPM/accuracy engine the wasm/web build runs - instead of +// reimplementing typing-test logic a third time. +use crate::racer::{DemoRace, Racer}; +use bevy::input::keyboard::{Key, KeyboardInput}; +use bevy::input::ButtonState; +use bevy::prelude::*; +use typerpunk_core::game::Game; + +// Bounded the same way the TUI windows its own text rendering - a +// multiplayer passage could in principle be long, and this keeps the +// number of spawned per-character entities sane regardless. +const MAX_VISIBLE_CHARS: usize = 400; + +#[derive(Resource, Default)] +pub struct TypingSession { + game: Option<Game>, + spans_built: bool, +} + +impl TypingSession { + pub fn start(&mut self, text: String) { + let mut game = Game::new(); + game.set_text(text); + game.start(); + self.game = Some(game); + self.spans_built = false; + } + + pub fn is_active(&self) -> bool { + self.game.as_ref().is_some_and(|g| !g.is_finished()) + } + + /// True once a race's Start message has actually arrived - distinct + /// from is_active(), which also goes false again once finished. Used to + /// gate outgoing Progress messages: sending them while still sitting in + /// the lobby is meaningless (there's no race to report progress on) and + /// floods the connection during the server's countdown at the worst + /// possible moment (see multiplayer_plugin.rs's send_local_progress). + pub fn has_started(&self) -> bool { + self.game.is_some() + } +} + +#[derive(Component)] +struct TypingTextRoot; + +#[derive(Component)] +struct TypingCharSpan(usize); + +#[derive(Component)] +struct TypingStatsText; + +pub struct TypingPlugin; + +impl Plugin for TypingPlugin { + fn build(&self, app: &mut App) { + app.init_resource::<TypingSession>() + .add_systems(Startup, spawn_typing_overlay) + .add_systems( + Update, + (rebuild_spans_on_session_start, handle_keyboard_input, recolor_spans, update_local_racer, update_stats_text).chain(), + ); + } +} + +const CORRECT_COLOR: Color = Color::srgb(0.0, 1.0, 0.62); +const INCORRECT_COLOR: Color = Color::srgb(1.0, 0.3, 0.3); +const PENDING_COLOR: Color = Color::srgb(0.5, 0.5, 0.55); +const CURSOR_COLOR: Color = Color::srgb(1.0, 0.85, 0.2); + +fn spawn_typing_overlay(mut commands: Commands) { + commands.spawn(( + TypingTextRoot, + Text::new(""), + TextFont { font_size: bevy::text::FontSize::Px(22.0), ..default() }, + Node { + position_type: PositionType::Absolute, + bottom: Val::Px(70.0), + left: Val::Px(40.0), + right: Val::Px(40.0), + ..default() + }, + )); + commands.spawn(( + TypingStatsText, + Text::new(""), + TextColor(CORRECT_COLOR), + TextFont { font_size: bevy::text::FontSize::Px(18.0), ..default() }, + Node { + position_type: PositionType::Absolute, + bottom: Val::Px(24.0), + left: Val::Px(40.0), + ..default() + }, + )); +} + +// Spans are spawned once per race (character identities never change for a +// fixed passage) and only recolored afterward - rebuilding text entities +// every frame would be wasteful for no visual benefit over just updating +// TextColor on the ones that already exist. +fn rebuild_spans_on_session_start( + mut commands: Commands, + mut session: ResMut<TypingSession>, + root: Query<Entity, With<TypingTextRoot>>, + old_spans: Query<Entity, With<TypingCharSpan>>, +) { + let has_game = session.game.is_some(); + if !has_game { + if !old_spans.is_empty() { + for e in &old_spans { + commands.entity(e).despawn(); + } + } + return; + } + if session.spans_built { + return; + } + session.spans_built = true; + + for e in &old_spans { + commands.entity(e).despawn(); + } + + let Ok(root_entity) = root.single() else { return }; + let text = session.game.as_ref().unwrap().get_text(); + let children: Vec<Entity> = text + .chars() + .take(MAX_VISIBLE_CHARS) + .enumerate() + .map(|(i, c)| { + commands + .spawn((TypingCharSpan(i), TextSpan(c.to_string()), TextColor(PENDING_COLOR), TextFont { font_size: bevy::text::FontSize::Px(22.0), ..default() })) + .id() + }) + .collect(); + commands.entity(root_entity).add_children(&children); +} + +fn handle_keyboard_input(mut events: MessageReader<KeyboardInput>, mut session: ResMut<TypingSession>) { + let Some(game) = session.game.as_mut() else { + events.clear(); + return; + }; + if game.is_finished() { + events.clear(); + return; + } + for ev in events.read() { + if ev.state != ButtonState::Pressed { + continue; + } + match &ev.logical_key { + Key::Character(s) => { + let mut input = game.get_input(); + input.push_str(s); + let _ = game.handle_input(&input); + } + Key::Space => { + let mut input = game.get_input(); + input.push(' '); + let _ = game.handle_input(&input); + } + Key::Backspace => { + let _ = game.handle_backspace(false); + } + _ => {} + } + } +} + +fn recolor_spans(session: Res<TypingSession>, mut spans: Query<(&TypingCharSpan, &mut TextColor)>) { + let Some(game) = &session.game else { return }; + let input_len = game.get_input().chars().count(); + let errors = game.get_error_positions(); + for (span, mut color) in &mut spans { + let i = span.0; + color.0 = if i < input_len { + if errors.contains(&i) { INCORRECT_COLOR } else { CORRECT_COLOR } + } else if i == input_len { + CURSOR_COLOR + } else { + PENDING_COLOR + }; + } +} + +fn update_local_racer(session: Res<TypingSession>, mut racers: Query<&mut Racer, With<DemoRace>>) { + let Some(game) = &session.game else { return }; + let input_len = game.get_input().chars().count() as f32; + let text_len = (game.get_text().chars().count() as f32).max(1.0); + let progress = (input_len / text_len).min(1.0); + let wpm = game.get_wpm() as f32; + let accuracy = game.get_accuracy() as f32; + for mut racer in &mut racers { + if racer.is_local { + racer.progress = if game.is_finished() { 1.0 } else { progress }; + racer.wpm = wpm; + racer.accuracy = accuracy; + } + } +} + +fn update_stats_text(session: Res<TypingSession>, mut text_query: Query<&mut Text, With<TypingStatsText>>) { + let Ok(mut text) = text_query.single_mut() else { return }; + let Some(game) = &session.game else { + **text = String::new(); + return; + }; + let status = if game.is_finished() { " - finished" } else { "" }; + **text = format!("{:.0} wpm {:.0}% acc{status}", game.get_wpm(), game.get_accuracy()); +} |