diff --git a/Cargo.lock b/Cargo.lock index e1ddfa0..f508b53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4986,7 +4986,7 @@ dependencies = [ ] [[package]] -name = "tt_net_test" +name = "tt_net_test_vibeless" version = "0.1.0" dependencies = [ "bevy", diff --git a/Cargo.toml b/Cargo.toml index 121e3c9..016f041 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "tt_net_test" +name = "tt_net_test_vibeless" version = "0.1.0" edition = "2024" diff --git a/src/main.rs b/src/main.rs index 11f4aaa..7ddfdb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,4 @@ -//! Bevy 0.19 demo: a server/client session launcher. -//! -//! The launcher UI lives in its own module/plugin: [`net_ui::NetUiPlugin`]. -//! Networking logic lives in [`networking::NetworkingPlugin`]. - use bevy::prelude::*; - use net_ui::NetUiPlugin; use networking::NetworkingPlugin; @@ -15,4 +9,4 @@ fn main() { App::new() .add_plugins((DefaultPlugins, NetUiPlugin, NetworkingPlugin)) .run(); -} \ No newline at end of file +} diff --git a/src/net_ui.rs b/src/net_ui.rs index 0b39f97..8b13789 100644 --- a/src/net_ui.rs +++ b/src/net_ui.rs @@ -1,457 +1 @@ -//! Bevy 0.19 demo: a server/client session launcher UI, as a plugin. -//! -//! This is *UI only*. Buttons emit [`NetworkAction`](crate::networking::NetworkAction) -//! intents to the networking layer; they never touch aeronet themselves. This -//! module renders whatever [`NetUi`] says, and reacts to that resource changing. -//! -//! UI is built with Bevy 0.19's new scene notation (`bsn!`) composing classic -//! `bevy_ui` components (`Node`, `Button`, `Text`, `EditableText`). -//! -//! Add [`NetUiPlugin`] to any app to get the launcher: it exposes the [`NetUi`] -//! resource as the UI's source of truth, so external systems (e.g. aeronet -//! observers) can drive the UI by writing status/log/screen into it. -use bevy::{ - input_focus::InputFocus, - prelude::*, - text::EditableText, -}; - -use crate::networking::NetworkAction; - -/// Plugin that provides the session-launcher UI. -/// -/// Registers the [`NetUi`] resource, spawns a 2D camera (required for UI), and -/// adds the systems that rebuild the screen, react to buttons, and repaint the -/// status/log text. -pub struct NetUiPlugin; - -impl Plugin for NetUiPlugin { - fn build(&self, app: &mut App) { - app.configure_sets(Update, NetUiSystems) - .init_resource::() - .init_resource::() - .add_systems(Startup, spawn_camera) - .add_systems( - Update, - ( - // Rebuild the whole UI whenever the screen/state changes. - // The `bsn!` scene is declarative, so the cheapest "re-render" - // is to despawn the previous scene root and spawn the current one. - rebuild_net_ui.run_if(resource_changed::), - // React to button presses and form fields, emitting intents. - handle_buttons.in_set(NetUiSystems), - // Repaint the status + log text from the resource. - paint_log.run_if(resource_changed::), - ), - ); - } -} - -/// Systems that read the UI widgets and emit [`NetworkAction`] intents. -/// The networking layer schedules after this set, so it sees intents the -/// same frame the button was pressed. -#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] -pub struct NetUiSystems; - -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- - -/// Source of truth for the launcher UI. -/// -/// This is a *view-model*: it decides what the UI shows. External systems (the -/// future aeronet observers) write status/log into it; they do not read network -/// truth *from* it. The authoritative connection state lives in aeronet session -/// entities. -#[derive(Resource)] -pub struct NetUi { - /// Which screen of the launcher is showing. Changing it rebuilds the UI. - pub screen: Screen, - /// Short status line shown at the bottom of every screen. - pub status: String, - /// Chronological log of (would-be) network actions, shown at the bottom. - pub log: Vec, -} - -impl Default for NetUi { - fn default() -> Self { - Self { - screen: Screen::Menu, - status: "Idle — no networking wired yet (UI only)".into(), - log: Vec::new(), - } - } -} - -impl NetUi { - /// Append a line to the log panel. - pub fn push_log(&mut self, line: impl Into) { - self.log.push(line.into()); - } -} - -/// Which screen of the launcher is showing. Changing it rebuilds the UI. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Screen { - Menu, - Host, - Client, - Session, -} - -// --------------------------------------------------------------------------- -// Marker components (also hooked into by the BSN scene builders) -// --------------------------------------------------------------------------- - -/// Marks the scene root so it can be despawned on rebuild. -#[derive(Component, Default, Clone, Copy)] -struct NetUiRoot; - -#[derive(Component, Default, Clone, Copy)] -struct HostButton; -#[derive(Component, Default, Clone, Copy)] -struct ClientButton; -#[derive(Component, Default, Clone, Copy)] -struct BackButton; -#[derive(Component, Default, Clone, Copy)] -struct StartHostButton; -#[derive(Component, Default, Clone, Copy)] -struct ConnectButton; -#[derive(Component, Default, Clone, Copy)] -struct StopButton; - -#[derive(Component, Default, Clone, Copy)] -struct ConnectUrlField; -#[derive(Component, Default, Clone, Copy)] -struct HostPortField; - -#[derive(Component, Default, Clone, Copy)] -struct StatusText; -#[derive(Component, Default, Clone, Copy)] -struct LogText; - -// --------------------------------------------------------------------------- -// Setup -// --------------------------------------------------------------------------- - -fn spawn_camera(mut commands: Commands) { - commands.spawn(Camera2d); -} - -// --------------------------------------------------------------------------- -// Rebuild -// --------------------------------------------------------------------------- - -fn rebuild_net_ui( - mut commands: Commands, - ui: Res, - roots: Query>, -) { - for root in &roots { - commands.entity(root).despawn(); - } - match ui.screen { - Screen::Menu => { - commands.spawn_scene(menu_screen()).insert(NetUiRoot); - } - Screen::Host => { - commands.spawn_scene(host_screen()).insert(NetUiRoot); - } - Screen::Client => { - commands.spawn_scene(client_screen()).insert(NetUiRoot); - } - Screen::Session => { - commands.spawn_scene(session_screen()).insert(NetUiRoot); - } - } -} - -// --------------------------------------------------------------------------- -// Buttons + fields -// --------------------------------------------------------------------------- - -#[allow(clippy::too_many_arguments)] -fn handle_buttons( - mut ui: ResMut, - mut actions: MessageWriter, - url_field: Query<&EditableText, With>, - port_field: Query<&EditableText, With>, - q_host: Query<&Interaction, (With, Changed)>, - q_client: Query<&Interaction, (With, Changed)>, - q_back: Query<&Interaction, (With, Changed)>, - q_start: Query<&Interaction, (With, Changed)>, - q_connect: Query<&Interaction, (With, Changed)>, - q_stop: Query<&Interaction, (With, Changed)>, -) { - use Interaction::Pressed; - - // -------- Screen navigation (pure UI, no networking) ------------------ - if q_host.iter().any(|i| *i == Pressed) { - ui.screen = Screen::Host; - ui.status = "Host setup — pick a listen port.".into(); - } - if q_client.iter().any(|i| *i == Pressed) { - ui.screen = Screen::Client; - ui.status = "Client setup — enter a server URL.".into(); - } - if q_back.iter().any(|i| *i == Pressed) { - ui.screen = Screen::Menu; - ui.status = "Idle — select an action.".into(); - } - - // -------- Host: start server ------------------------------------------ - // The next three only *request* the action; the networking layer owns the - // result and updates `NetUi`. None of this spawns sockets or reacts to them. - if q_start.iter().any(|i| *i == Pressed) { - let port: u16 = port_field - .single() - .map(|e| e.value().to_string()) - .ok() - .and_then(|p| p.trim().parse().ok()) - .unwrap_or(25570); - actions.write(NetworkAction::StartHost { port }); - } - - // -------- Client: connect --------------------------------------------- - if q_connect.iter().any(|i| *i == Pressed) { - let url = url_field - .single() - .map(|e| e.value().to_string()) - .unwrap_or_default(); - let url = if url.trim().is_empty() { - "ws://127.0.0.1:25570".to_string() - } else { - url.trim().to_string() - }; - actions.write(NetworkAction::Connect { url }); - } - - // -------- Session: stop ----------------------------------------------- - if q_stop.iter().any(|i| *i == Pressed) { - actions.write(NetworkAction::Disconnect); - } -} - -// --------------------------------------------------------------------------- -// Paint dynamic text -// --------------------------------------------------------------------------- - -fn paint_log( - ui: Res, - mut texts: Query<(Entity, &mut Text, Has, Has)>, -) { - for (_e, mut text, is_status, is_log) in &mut texts { - if is_status { - text.0 = ui.status.clone(); - } else if is_log { - text.0 = if ui.log.is_empty() { - "(nothing yet)".to_string() - } else { - ui.log.join("\n") - }; - } - } -} - -// --------------------------------------------------------------------------- -// Reusable BSN leaf scenes -// --------------------------------------------------------------------------- - -fn title(text: &str) -> impl Scene { - bsn! { - Text(text) - TextFont { font_size: px(30.) } - TextColor(Color::srgb(0.93, 0.94, 0.97)) - } -} - -fn subtitle(text: &str) -> impl Scene { - bsn! { - Text(text) - TextFont { font_size: px(15.) } - TextColor(Color::srgb(0.60, 0.64, 0.70)) - } -} - -fn field_label(text: &str) -> impl Scene { - bsn! { - Text(text) - TextFont { font_size: px(13.) } - TextColor(Color::srgb(0.55, 0.60, 0.66)) - } -} - -fn net_button(label: &str) -> impl Scene { - bsn! { - Button - Node { - width: px(300), - height: px(44), - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - border_radius: BorderRadius::MAX, - } - BackgroundColor(Color::srgb(0.16, 0.22, 0.32)) - Children [ - Text(label) - TextFont { font_size: px(16.) } - TextColor(Color::srgb(0.90, 0.92, 0.95)), - ] - } -} - -fn ghost_button(label: &str) -> impl Scene { - bsn! { - Button - Node { - width: px(300), - height: px(40), - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - border_radius: BorderRadius::MAX, - border: px(1), - } - BackgroundColor(Color::srgb(0.10, 0.12, 0.15)) - BorderColor::all(Color::srgb(0.30, 0.35, 0.40)) - Children [ - Text(label) - TextFont { font_size: px(14.) } - TextColor(Color::srgb(0.70, 0.73, 0.78)), - ] - } -} - -fn field_input() -> impl Scene { - bsn! { - EditableText { visible_width: {Some(20.)} } - Node { - width: px(300), - border: px(1), - border_radius: px(8), - padding: px(10), - } - BackgroundColor(Color::srgb(0.08, 0.10, 0.12)) - BorderColor::all(Color::srgb(0.28, 0.33, 0.40)) - } -} - -fn status_text(initial: &str) -> impl Scene { - bsn! { - Text(initial) - TextFont { font_size: px(13.) } - TextColor(Color::srgb(0.45, 0.55, 0.72)) - StatusText - } -} - -fn log_list() -> impl Scene { - bsn! { - Node { - margin: UiRect::top(px(20)), - width: px(420), - flex_direction: FlexDirection::Column, - align_items: AlignItems::Stretch, - padding: px(10), - border: px(1), - border_radius: px(8), - } - BackgroundColor(Color::srgb(0.06, 0.08, 0.10)) - BorderColor::all(Color::srgb(0.20, 0.24, 0.30)) - Children [ - ( - Text("Log") - TextFont { font_size: px(12.) } - TextColor(Color::srgb(0.50, 0.55, 0.62)) - ), - ( - Text("(nothing yet)") - LogText - TextFont { font_size: px(12.) } - TextColor(Color::srgb(0.55, 0.60, 0.66)) - TextLayout { linebreak: LineBreak::WordOrCharacter } - ), - ] - } -} - -fn spacer(height: f32) -> impl Scene { - bsn! { - Node { width: px(1), height: px(height) } - } -} - -/// A centered column that every screen shares. -fn screen_body(children: impl SceneList) -> impl Scene { - bsn! { - Node { - width: percent(100), - height: percent(100), - flex_direction: FlexDirection::Column, - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - row_gap: px(14), - } - BackgroundColor(Color::srgb(0.05, 0.06, 0.08)) - Children [ {children} ] - } -} - -// --------------------------------------------------------------------------- -// Per-screen scenes -// --------------------------------------------------------------------------- - -fn menu_screen() -> impl Scene { - screen_body(bsn_list![ - title("Aronet session launcher"), - subtitle("Host a dedicated server, or connect as a client."), - spacer(8.0), - (net_button("Host a server") HostButton), - (net_button("Connect to a client") ClientButton), - spacer(6.0), - status_text("Idle"), - log_list(), - ]) -} - -fn host_screen() -> impl Scene { - screen_body(bsn_list![ - title("Host a server"), - subtitle("WebSocket server; pick a listen port."), - spacer(8.0), - field_label("Listen port"), - (field_input() HostPortField), - spacer(10.0), - (net_button("Start server") StartHostButton), - (ghost_button("Back") BackButton), - status_text("Host setup"), - log_list(), - ]) -} - -fn client_screen() -> impl Scene { - screen_body(bsn_list![ - title("Connect to a server"), - subtitle("WebSocket URL the server is listening on."), - spacer(8.0), - field_label("Server URL (e.g. ws://127.0.0.1:25570)"), - (field_input() ConnectUrlField), - spacer(10.0), - (net_button("Connect") ConnectButton), - (ghost_button("Back") BackButton), - status_text("Client setup"), - log_list(), - ]) -} - -fn session_screen() -> impl Scene { - screen_body(bsn_list![ - title("In session"), - subtitle("UI shell only — the aeronet wiring goes here."), - spacer(10.0), - (net_button("Disconnect / Stop") StopButton), - (ghost_button("Back to menu") BackButton), - status_text("In session"), - log_list(), - ]) -}