From a726d9f5fb56e1fd7983c5ac806d408ad78daa86 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 11 Sep 2025 21:53:00 +0200 Subject: 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. --- crates/steam/src/lore.rs | 24 ++++ crates/steam/src/main.rs | 55 +++++++++ crates/steam/src/multiplayer_plugin.rs | 179 +++++++++++++++++++++++++++ crates/steam/src/net.rs | 178 +++++++++++++++++++++++++++ crates/steam/src/race/breach.rs | 114 +++++++++++++++++ crates/steam/src/race/mod.rs | 16 +++ crates/steam/src/race/signal.rs | 110 +++++++++++++++++ crates/steam/src/race/terminal.rs | 81 +++++++++++++ crates/steam/src/racer.rs | 66 ++++++++++ crates/steam/src/typing.rs | 216 +++++++++++++++++++++++++++++++++ 10 files changed, 1039 insertions(+) create mode 100644 crates/steam/src/lore.rs create mode 100644 crates/steam/src/main.rs create mode 100644 crates/steam/src/multiplayer_plugin.rs create mode 100644 crates/steam/src/net.rs create mode 100644 crates/steam/src/race/breach.rs create mode 100644 crates/steam/src/race/mod.rs create mode 100644 crates/steam/src/race/signal.rs create mode 100644 crates/steam/src/race/terminal.rs create mode 100644 crates/steam/src/racer.rs create mode 100644 crates/steam/src/typing.rs (limited to 'crates/steam/src') 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::() + .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>, mut next: ResMut>) { + 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); + +#[derive(Resource, Default)] +struct LocalPlayerId(Option); + +// 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, + mut local_id: ResMut, + mut typing: ResMut, + mut finish_sent: ResMut, + 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, + mut finish_sent: ResMut, + typing: Res, + racers: Query<&Racer, With>, +) { + // 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), + 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>, +} + +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, name: String) -> (Self, mpsc::Sender) { + let (event_tx, event_rx) = mpsc::channel(); + let (update_tx, update_rx) = mpsc::channel::(); + + 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 { + self.event_rx.lock().ok()?.try_recv().ok() + } +} + +#[derive(serde::Deserialize)] +struct CreateRoomResponse { + room_code: String, +} + +async fn create_room(http_base: &str) -> Result { + 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::().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(write: &mut S, msg: &ClientMessage) -> Result<(), ()> +where + S: futures_util::Sink + 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, + name: String, + event_tx: mpsc::Sender, + update_rx: mpsc::Receiver, +) { + 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::(&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, + racers: Query<(Entity, &Racer), With>, +) { + let warden_texture: Handle = asset_server.load("sprites/warden_layer.png"); + let marker_texture: Handle = 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>) { + 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, + racers: Query<(Entity, &Racer), With>, +) { + let pulse_texture: Handle = asset_server.load("sprites/pulse.png"); + let marker_texture: Handle = 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>) { + 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>) { + 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>) { + 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>) { + 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