create scaffolding for networking logic

This commit is contained in:
2026-08-11 00:47:37 -05:00
parent 2fafd25167
commit d27c8a6d2a
3 changed files with 224 additions and 34 deletions
+4 -1
View File
@@ -1,15 +1,18 @@
//! Bevy 0.19 demo: a server/client session launcher. //! Bevy 0.19 demo: a server/client session launcher.
//! //!
//! The launcher UI lives in its own module/plugin: [`net_ui::NetUiPlugin`]. //! The launcher UI lives in its own module/plugin: [`net_ui::NetUiPlugin`].
//! Networking logic lives in [`networking::NetworkingPlugin`].
use bevy::prelude::*; use bevy::prelude::*;
use net_ui::NetUiPlugin; use net_ui::NetUiPlugin;
use networking::NetworkingPlugin;
mod net_ui; mod net_ui;
mod networking;
fn main() { fn main() {
App::new() App::new()
.add_plugins((DefaultPlugins, NetUiPlugin)) .add_plugins((DefaultPlugins, NetUiPlugin, NetworkingPlugin))
.run(); .run();
} }
+32 -33
View File
@@ -1,8 +1,8 @@
//! Bevy 0.19 demo: a server/client session launcher UI, as a plugin. //! 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/ //! This is *UI only*. Buttons emit [`NetworkAction`](crate::networking::NetworkAction)
//! Stop handlers just log the aeronet calls they *would* make, so the wiring is //! intents to the networking layer; they never touch aeronet themselves. This
//! a drop-in replacement later. //! 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 //! UI is built with Bevy 0.19's new scene notation (`bsn!`) composing classic
//! `bevy_ui` components (`Node`, `Button`, `Text`, `EditableText`). //! `bevy_ui` components (`Node`, `Button`, `Text`, `EditableText`).
@@ -17,6 +17,8 @@ use bevy::{
text::EditableText, text::EditableText,
}; };
use crate::networking::NetworkAction;
/// Plugin that provides the session-launcher UI. /// Plugin that provides the session-launcher UI.
/// ///
/// Registers the [`NetUi`] resource, spawns a 2D camera (required for UI), and /// Registers the [`NetUi`] resource, spawns a 2D camera (required for UI), and
@@ -26,7 +28,8 @@ pub struct NetUiPlugin;
impl Plugin for NetUiPlugin { impl Plugin for NetUiPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<InputFocus>() app.configure_sets(Update, NetUiSystems)
.init_resource::<InputFocus>()
.init_resource::<NetUi>() .init_resource::<NetUi>()
.add_systems(Startup, spawn_camera) .add_systems(Startup, spawn_camera)
.add_systems( .add_systems(
@@ -36,8 +39,8 @@ impl Plugin for NetUiPlugin {
// The `bsn!` scene is declarative, so the cheapest "re-render" // The `bsn!` scene is declarative, so the cheapest "re-render"
// is to despawn the previous scene root and spawn the current one. // is to despawn the previous scene root and spawn the current one.
rebuild_net_ui.run_if(resource_changed::<NetUi>), rebuild_net_ui.run_if(resource_changed::<NetUi>),
// React to button presses and form fields. // React to button presses and form fields, emitting intents.
handle_buttons, handle_buttons.in_set(NetUiSystems),
// Repaint the status + log text from the resource. // Repaint the status + log text from the resource.
paint_log.run_if(resource_changed::<NetUi>), paint_log.run_if(resource_changed::<NetUi>),
), ),
@@ -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 // State
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -165,6 +174,7 @@ fn rebuild_net_ui(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn handle_buttons( fn handle_buttons(
mut ui: ResMut<NetUi>, mut ui: ResMut<NetUi>,
mut actions: MessageWriter<NetworkAction>,
url_field: Query<&EditableText, With<ConnectUrlField>>, url_field: Query<&EditableText, With<ConnectUrlField>>,
port_field: Query<&EditableText, With<HostPortField>>, port_field: Query<&EditableText, With<HostPortField>>,
q_host: Query<&Interaction, (With<HostButton>, Changed<Interaction>)>, q_host: Query<&Interaction, (With<HostButton>, Changed<Interaction>)>,
@@ -176,7 +186,7 @@ fn handle_buttons(
) { ) {
use Interaction::Pressed; use Interaction::Pressed;
// -------- Screen navigation ------------------------------------------- // -------- Screen navigation (pure UI, no networking) ------------------
if q_host.iter().any(|i| *i == Pressed) { if q_host.iter().any(|i| *i == Pressed) {
ui.screen = Screen::Host; ui.screen = Screen::Host;
ui.status = "Host setup — pick a listen port.".into(); ui.status = "Host setup — pick a listen port.".into();
@@ -191,46 +201,35 @@ fn handle_buttons(
} }
// -------- Host: start server ------------------------------------------ // -------- 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) { if q_start.iter().any(|i| *i == Pressed) {
let mut port = port_field let port: u16 = port_field
.single() .single()
.map(|e| e.value().to_string()) .map(|e| e.value().to_string())
.unwrap_or_default(); .ok()
if port.is_empty() { .and_then(|p| p.trim().parse().ok())
port = "25570".into(); .unwrap_or(25570);
} actions.write(NetworkAction::StartHost { port });
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;
} }
// -------- Client: connect --------------------------------------------- // -------- Client: connect ---------------------------------------------
if q_connect.iter().any(|i| *i == Pressed) { if q_connect.iter().any(|i| *i == Pressed) {
let mut url = url_field let url = url_field
.single() .single()
.map(|e| e.value().to_string()) .map(|e| e.value().to_string())
.unwrap_or_default(); .unwrap_or_default();
if url.is_empty() { let url = if url.trim().is_empty() {
url = "ws://127.0.0.1:25570".into(); "ws://127.0.0.1:25570".to_string()
} } else {
ui.push_log(format!( url.trim().to_string()
"// TODO aeronet: client -> commands.spawn_empty().queue(\n \ };
WebSocketClient::connect(ClientConfig::builder().with_no_cert_validation(), \"{url}\")\n \ actions.write(NetworkAction::Connect { url });
);"
));
ui.status = format!("Connecting to {url} (UI shell only)");
ui.screen = Screen::Session;
} }
// -------- Session: stop ----------------------------------------------- // -------- Session: stop -----------------------------------------------
if q_stop.iter().any(|i| *i == Pressed) { if q_stop.iter().any(|i| *i == Pressed) {
ui.push_log("// TODO aeronet: stop -> commands.trigger(Disconnect::new(session, \"user\"))".to_string()); actions.write(NetworkAction::Disconnect);
ui.status = "Idle — select an action.".into();
ui.screen = Screen::Menu;
} }
} }
+188
View File
@@ -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::<NetworkAction>();
// 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::<Network>()
.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<Entity>,
}
#[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<NetworkAction>,
mut net: ResMut<Network>,
mut ui: ResMut<NetUi>,
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<Add, Server> -> record server entity
// .add_observer(on_client_conn) // On<Add, Session> -> insert a Transport on the session
// .add_observer(on_close) // On<Disconnected> -> 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<Add, SessionEndpoint> -> "connecting…"
// on_connected // On<Add, Session> -> insert Transport w/ LANES, record session entity
// on_disconnected// On<Disconnected> -> 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;
}