diff --git a/src/main.rs b/src/main.rs index 5ab581a..11f4aaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,18 @@ //! 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; mod net_ui; +mod networking; fn main() { App::new() - .add_plugins((DefaultPlugins, NetUiPlugin)) + .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 39b6948..0b39f97 100644 --- a/src/net_ui.rs +++ b/src/net_ui.rs @@ -1,8 +1,8 @@ //! Bevy 0.19 demo: a server/client session launcher UI, as a plugin. //! -//! This is *UI only* — no aeronet networking is wired up yet. The Start/Connect/ -//! Stop handlers just log the aeronet calls they *would* make, so the wiring is -//! a drop-in replacement later. +//! 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`). @@ -17,6 +17,8 @@ use bevy::{ 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 @@ -26,7 +28,8 @@ pub struct NetUiPlugin; impl Plugin for NetUiPlugin { fn build(&self, app: &mut App) { - app.init_resource::() + app.configure_sets(Update, NetUiSystems) + .init_resource::() .init_resource::() .add_systems(Startup, spawn_camera) .add_systems( @@ -36,8 +39,8 @@ impl Plugin for NetUiPlugin { // 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. - handle_buttons, + // 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::), ), @@ -45,6 +48,12 @@ impl Plugin for NetUiPlugin { } } +/// 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 // --------------------------------------------------------------------------- @@ -165,6 +174,7 @@ fn rebuild_net_ui( #[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)>, @@ -176,7 +186,7 @@ fn handle_buttons( ) { use Interaction::Pressed; - // -------- Screen navigation ------------------------------------------- + // -------- 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(); @@ -191,46 +201,35 @@ fn handle_buttons( } // -------- 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 mut port = port_field + let port: u16 = port_field .single() .map(|e| e.value().to_string()) - .unwrap_or_default(); - if port.is_empty() { - port = "25570".into(); - } - ui.push_log(format!( - "// TODO aeronet: server -> commands.spawn_empty().queue(\n \ - WebSocketServer::open(ServerConfig::builder().with_bind_raw(None, {port}))\n \ - );" - )); - ui.status = format!("Hosting on 0.0.0.0:{port} (UI shell only)"); - ui.screen = Screen::Session; + .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 mut url = url_field + let url = url_field .single() .map(|e| e.value().to_string()) .unwrap_or_default(); - if url.is_empty() { - url = "ws://127.0.0.1:25570".into(); - } - ui.push_log(format!( - "// TODO aeronet: client -> commands.spawn_empty().queue(\n \ - WebSocketClient::connect(ClientConfig::builder().with_no_cert_validation(), \"{url}\")\n \ - );" - )); - ui.status = format!("Connecting to {url} (UI shell only)"); - ui.screen = Screen::Session; + 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) { - ui.push_log("// TODO aeronet: stop -> commands.trigger(Disconnect::new(session, \"user\"))".to_string()); - ui.status = "Idle — select an action.".into(); - ui.screen = Screen::Menu; + actions.write(NetworkAction::Disconnect); } } diff --git a/src/networking.rs b/src/networking.rs new file mode 100644 index 0000000..4d6c8e3 --- /dev/null +++ b/src/networking.rs @@ -0,0 +1,188 @@ +//! Scaffold for the aeronet networking logic. +//! +//! **This file is where the actual server/client code goes.** Right now it +//! depends only on `bevy::prelude`, so it compiles and runs standalone. The real +//! sockets/transport sit behind `// TODO(aeronet)` markers — enabling them means +//! adding `aeronet` + an IO layer + `aeronet_transport` to `Cargo.toml` and +//! swapping the marked bodies for the real calls (exact API noted below). +//! +//! ## Division of responsibility +//! +//! - **UI** (`net_ui`): presentation only. It emits [`NetworkAction`] intents +//! and renders whatever [`NetUi`](crate::net_ui::NetUi) says. +//! - **Networking** (this module): owns the connection lifecycle. It consumes +//! actions, talks to aeronet, and writes status/log/screen back into `NetUi`. +//! +//! So the UI never touches aeronet, and the networking layer never spawns UI. + +use bevy::{ + prelude::*, +}; + +use crate::net_ui::{NetUi, Screen}; + +// --------------------------------------------------------------------------- +// Intent channel: UI -> networking +// --------------------------------------------------------------------------- + +/// An action the user asked for through the UI. These are the *only* messages +/// the UI sends about networking; everything else it just displays. +// In bevy 0.19 events are "messages": `#[derive(Message)]` + `MessageReader`/ +// `MessageWriter` system params (the old `Event`/`EventReader`/`EventWriter`). +#[derive(Message, Debug, Clone)] +pub enum NetworkAction { + /// User pressed "Start server". + StartHost { port: u16 }, + /// User pressed "Connect". + Connect { url: String }, + /// User pressed "Disconnect / Stop". + Disconnect, +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// Owns all networking state and lifecycle. +pub struct NetworkingPlugin; + +impl Plugin for NetworkingPlugin { + fn build(&self, app: &mut App) { + // Register the UI->networking intent channel. + app.add_message::(); + + // TODO(aeronet): register the IO + transport plugins once the crates are + // added. The exact set depends on which IO layer(s) the app supports: + // host -> app.add_plugins((aeronet_websocket::server::WebSocketServerPlugin, AeronetTransportPlugin)); + // client-> app.add_plugins((aeronet_websocket::client::WebSocketClientPlugin, AeronetTransportPlugin)); + // + // Each *_Plugin pulls in `aeronet_io` automatically but NOT transport. + app.init_resource::() + .add_systems(Update, handle_actions.after(crate::net_ui::NetUiSystems)); + } +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// Networking state + the aeronet connection entity, once wired. +#[derive(Resource, Default)] +pub struct Network { + /// Which process role we're running as (server vs client). + role: Role, + /// Entity representing the current aeronet session/server, if any. + /// + /// TODO(aeronet): filled by the connection/disconnection observers below. + /// The entity *is* the peer identifier — there is no `ClientId` in aeronet. + session: Option, +} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +enum Role { + #[default] + None, + Host, + Client, +} + +/// TODO(aeronet): transport lanes must be declared up front. Add these (and a +/// `LaneKind` import) when the crate lands. Boilerplate an order-of-magnitude +/// easier done with the crate than comments: +/// +/// ```ignore +/// const LANES: [LaneKind; 2] = [ +/// LaneKind::ReliableOrdered, // game state / actions +/// LaneKind::UnreliableOrdered, // e.g. position snapshots +/// ]; +/// const SEND_LANE: LaneIndex = LaneIndex::new(0); +/// ``` +// #[allow(dead_code)] +// const _: () = (); + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// Consume user actions and drive the (future) aeronet connection. +fn handle_actions( + mut actions: MessageReader, + mut net: ResMut, + mut ui: ResMut, + mut commands: Commands, +) { + for action in actions.read() { + match action { + NetworkAction::StartHost { port } => { + start_host(&mut net, &mut ui, &mut commands, *port); + } + NetworkAction::Connect { url } => { + connect(&mut net, &mut ui, &mut commands, url); + } + NetworkAction::Disconnect => { + disconnect(&mut net, &mut ui, &mut commands); + } + } + } +} + +/// User wants to act as a server listening on `port`. +fn start_host(net: &mut Network, ui: &mut NetUi, commands: &mut Commands, port: u16) { + net.role = Role::Host; + ui.push_log(format!("starting server on 0.0.0.0:{port}")); + + // Only used once the TODO(aeronet) bodies are filled in. + let _ = commands; + + // TODO(aeronet): open a server entity. + // let identity = aeronet_websocket::server::Identity::self_signed(["localhost", "127.0.0.1"])?; + // commands.spawn_empty().queue(WebSocketServer::open( + // ServerConfig::builder().with_bind(([0,0,0,0], port).into()).with_identity(identity), + // )); + // Then register observers (in the plugin's `build`): + // app.add_observer(on_opened) // On -> record server entity + // .add_observer(on_client_conn) // On -> insert a Transport on the session + // .add_observer(on_close) // On -> clean up + push to NetUi.log + + ui.status = format!("hosting on 0.0.0.0:{port} (scaffold — no listener yet)"); + ui.screen = Screen::Session; +} + +/// User wants to connect to a server at `url`. +fn connect(net: &mut Network, ui: &mut NetUi, commands: &mut Commands, url: &str) { + net.role = Role::Client; + ui.push_log(format!("connecting to {url}")); + + // Only used once the TODO(aeronet) bodies are filled in. + let _ = commands; + + // TODO(aeronet): spawn a session entity and queue a connect. + // let config = ClientConfig::builder().with_no_cert_validation(); // demo only + // commands.spawn((TransportConfig::default(),)) + // .queue(WebSocketClient::connect(config, url)); + // Observers: + // on_connecting // On -> "connecting…" + // on_connected // On -> insert Transport w/ LANES, record session entity + // on_disconnected// On -> log reason, clear net.session, back to menu + + ui.status = format!("connecting to {url} (scaffold — no socket yet)"); + ui.screen = Screen::Session; +} + +/// User wants to tear down the current connection. +fn disconnect(net: &mut Network, ui: &mut NetUi, commands: &mut Commands) { + // `commands` is only used by the TODO(aeronet) block below. + let _ = commands; + ui.push_log("disconnecting"); + + // TODO(aeronet): never despawn/remove manually — trigger the event so + // cleanup observers run: + // if let Some(session) = net.session { + // commands.trigger(Disconnect::new(session, "user pressed stop")); + // } + + net.session = None; + net.role = Role::None; + ui.status = "idle — select an action.".into(); + ui.screen = Screen::Menu; +} \ No newline at end of file