diff --git a/Cargo.toml b/Cargo.toml index e294870..f9a7405 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,9 @@ [workspace] members = ["client", "server"] resolver = "3" + +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 3 diff --git a/client/Cargo.toml b/client/Cargo.toml index 2ac12a5..c206162 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,3 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +bevy = { version = "0.17.2", features = ["serialize"] } +rustls = "0.23.33" +thiserror = "2.0.17" +tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "rustls"] } diff --git a/client/src/main.rs b/client/src/main.rs index e7a11a9..4c58adb 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,3 +1,204 @@ +use std::{io::ErrorKind, net::TcpStream, time::Duration}; + +use bevy::{ + ecs::world::CommandQueue, + prelude::*, + tasks::{AsyncComputeTaskPool, Task, block_on, futures_lite::future}, +}; +use thiserror::Error; +use tungstenite::{WebSocket, http::Response, stream::MaybeTlsStream}; + fn main() { - println!("Hello, world!"); + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + input_handler, + setup_connection, + handle_tasks, + send_info, + recv_info, + ), + ) + .add_message::() + .insert_resource(ChatTimer { + timer: Timer::new(Duration::from_secs(1), TimerMode::Repeating), + }) + .run(); +} + +/// Initialize the scene +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + commands.spawn(Camera2d); + commands.spawn(( + Mesh2d(meshes.add(Circle::new(10.0))), + MeshMaterial2d(materials.add(Color::srgb(1.0, 0.0, 0.0))), + Transform::default(), + )); +} + +/// ECS Component to hold the WebSocket. I guess there's going to be a magic +/// entity that controls the networking. +#[derive(Component)] +struct WsClient( + ( + WebSocket>, + Response>>, + ), +); + +/// Container component for a bevy task. Specifically, the async Websocket +/// setup. +#[derive(Component)] +struct WsSetupTask(Task>); + +/// Used to signal user input to other systems -- in particular, the notice to +/// open a connection to the server. +#[derive(Message)] +enum WebSocketConnectionMessage { + SetupConnection, + // TODO: Presumably a TeardownConnection, right? +} + +#[derive(Error, Debug)] +enum ConnSetupError { + #[error("IO")] + Io(#[from] std::io::Error), + #[error("WebSocket")] + WebSocket(#[from] tungstenite::Error), +} + +/// The keyboard input handler (mainly to trigger WS connection) +fn input_handler( + input: Res>, + mut messages: MessageWriter, +) { + if input.just_pressed(KeyCode::Space) { + messages.write(WebSocketConnectionMessage::SetupConnection); + } +} + +/// The system which establishes the WS connection. It responds to [`WebSocketConnectionMessage`]s. +/// +/// When a [`WebSocketConnectionMessage::SetupConnection`] message is emitted, +/// this system responds by creating a new (bevy) [`Task`]. This task is stored +/// in the component [`WsSetupTask`] which is attached to a new entity. +/// +/// The closure executed by this task attempts to reach out to a WebSocket URL. +/// Upon successful connection, the socket is placed in a [`WsClient`] +/// component and added to the same entity. At the same time, the [`WsSetupTask`] +/// is removed since the task has now finished. +/// +/// Monitoring for completion of the task is done by the system [`handle_tasks()`]. +fn setup_connection( + mut commands: Commands, + mut messages: MessageReader, +) { + for msg in messages.read() { + match msg { + WebSocketConnectionMessage::SetupConnection => { + info!("Connecting to server..."); + let url = "ws://localhost:4000/websocket"; + let entity = commands.spawn_empty().id(); + { + let pool = AsyncComputeTaskPool::get(); + let task = pool.spawn(async move { + let mut client = tungstenite::connect(url)?; + match client.0.get_mut() { + MaybeTlsStream::Plain(p) => p.set_nonblocking(true)?, + MaybeTlsStream::Rustls(stream_owned) => { + stream_owned.get_mut().set_nonblocking(true)? + } + _ => todo!(), + }; + + info!("Connected!"); + // First message is this client's username. I'll hard-code + // a value for this PoC. + client.0.send(tungstenite::Message::Text("Dingus".into()))?; + let mut command_queue = CommandQueue::default(); + + command_queue.push(move |world: &mut World| { + world + .entity_mut(entity) + .insert(WsClient(client)) + .remove::(); + }); + + Ok(command_queue) + }); + commands.entity(entity).insert(WsSetupTask(task)); + } + } // _ => { there are no other variants right now, so this is pretty silly } + } + } +} + +/// Polls the websocket setup task(s) to check for progress. +/// +/// If the task has succeeded, it will give back a [`CommandQueue`]. This is +/// appended to the current [`Commands`] so they are acted upon this tick. +/// +/// If it has failed, an error is printed to the console. +/// +/// The task is self-removing, so we don't need to delete the [`WsSetupTask`] +/// component here. +fn handle_tasks(mut commands: Commands, mut transform_tasks: Query<&mut WsSetupTask>) { + for mut task in &mut transform_tasks { + if let Some(result) = block_on(future::poll_once(&mut task.0)) { + match result { + Ok(mut commands_queue) => { + commands.append(&mut commands_queue); + } + Err(e) => info!("Connection failed. Err: {e:?}"), + } + } + } +} + +#[derive(Resource)] +struct ChatTimer { + timer: Timer, +} + +/// Unused, left over from example code. +/// +/// It *would* tick a timer, sending a message to the echo server each time the +/// timer expires. But I have a chatroom not an echo server, so this bit +/// doesn't make sense. +/// +/// If I were to make a full chat client, I would replace this with some kind +/// of event handler. +fn send_info( + some_data: Query<(&Transform,)>, + time: Res