Compare commits

...

11 Commits

Author SHA1 Message Date
7837ab49b0 Start button triggers connection & state change
When the start button is pressed, switch to a "connecting" state. This
triggers the spawning of the "connecting" UI message and the connection
startup.

When the connection task finishes, `fn handle_tasks()` collects it and
pushes the CommandQueue into the main world just as before. In addition,
it will change to the "playing" state, which triggers the despawning of
the UI notice.

There is no meaningful connection-error handling path. A failed
connection will print a warning to stdout, and that is all.

There is still no transmitter at all, nor is the receiver hooked up to
one of the paddles.
2025-10-21 13:55:58 -05:00
62c84aceaf Remove stale TODO note 2025-10-21 12:52:53 -05:00
7ac3882e9e Marker components for the ball & paddles 2025-10-21 12:36:47 -05:00
129e9ccc5e Spawn the gameplay elements
Now to start wiring them in for basic operation, then to pipe it all
through the WebSocket.
2025-10-21 08:37:18 -05:00
8b290fdd0d Implement menu despawner 2025-10-20 08:07:58 -05:00
845765513d Sort derive macro items
For consistency, or something.
2025-10-19 09:58:34 -05:00
857aefda03 Wire the 'Start' button to a state changer 2025-10-19 09:57:57 -05:00
7f651481f6 Quiet some lints, remove some dev logging 2025-10-19 09:57:36 -05:00
9b71fff4eb Begin work on a main menu & UI systems 2025-10-18 15:04:54 -05:00
09e05c9f8e autofmt 2025-10-18 11:00:04 -05:00
7128377924 Dummy client to learn the other half of WebSockets
This is mostly a copy of ambiso/bevy_websocket_example:
https://github.com/ambiso/bevy_websocket_example/tree/main

It demonstrates how to hold and use a (Tungstenite) WebSocket in a Bevy
app. I've altered it slightly (and skipped impl'ing the send function)
to fit my own chatroom dummy target.

This is completely useless as a chatroom app, but it's not supposed to
be one. Now I can move on to building the actual Pong game!
2025-10-18 10:57:22 -05:00
5 changed files with 435 additions and 5 deletions

View File

@@ -1,3 +1,9 @@
[workspace]
members = ["client", "server"]
resolver = "3"
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3

View File

@@ -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"] }

View File

@@ -1,3 +1,298 @@
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};
use crate::ui::{
despawn_connection_wait_screen, despawn_main_menu, spawn_connection_wait_screen,
spawn_main_menu,
};
mod ui;
const PADDLE_GAP: f32 = 20.0; // gap between the paddles and the wall (window border)
fn main() {
println!("Hello, world!");
App::new()
.add_plugins(DefaultPlugins)
.insert_state(GameState::MainMenu)
.add_systems(Startup, spawn_camera)
.add_systems(OnEnter(GameState::MainMenu), spawn_main_menu)
.add_systems(OnExit(GameState::MainMenu), despawn_main_menu)
.add_observer(ui::button_hover_start)
.add_observer(ui::button_hover_stop)
.add_systems(
OnEnter(GameState::Connecting),
(
spawn_connection_wait_screen,
// Closure to immediately dispatch a setup-connection request.
|mut messages: MessageWriter<WebSocketConnectionMessage>| {
messages.write(WebSocketConnectionMessage::SetupConnection);
},
),
)
.add_systems(
OnExit(GameState::Connecting),
despawn_connection_wait_screen,
)
.add_systems(
Update,
(setup_connection, handle_tasks).run_if(in_state(GameState::Connecting)),
)
.add_systems(OnEnter(GameState::Playing), setup_game)
.add_systems(
Update,
(
input_handler,
setup_connection,
handle_tasks,
send_info,
recv_info,
)
.run_if(in_state(GameState::ConnectionDemo)),
)
.add_message::<WebSocketConnectionMessage>()
.insert_resource(ChatTimer {
timer: Timer::new(Duration::from_secs(1), TimerMode::Repeating),
})
.run();
}
/// Main game state indicator
#[derive(Clone, Debug, Eq, Hash, PartialEq, States)]
enum GameState {
MainMenu,
Connecting,
Playing,
ConnectionDemo, // TODO: Remove this state.
}
/// Utility to despawn entities with a given component. Useful for scene
/// changes.
///
/// I'm using this primarily with marker components to delete UI elements when
/// they are no longer needed. E.g.: Removing the main menu after starting.
pub fn despawn<T: Component>(mut commands: Commands, targets: Query<Entity, With<T>>) {
targets
.iter()
.for_each(|entt| commands.entity(entt).despawn());
}
fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
/// Initialize the scene
fn setup_game(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window: Single<&Window>,
) {
// ball
commands.spawn((
Ball,
Mesh2d(meshes.add(Circle::new(10.0))),
MeshMaterial2d(materials.add(Color::srgb(1.0, 0.0, 0.0))),
Transform::default(),
));
let paddle_mesh = meshes.add(Rectangle::new(10.0, 100.0));
let paddle_material = materials.add(Color::WHITE);
// Player 1
commands.spawn((
Paddle,
Mesh2d(paddle_mesh.clone()),
MeshMaterial2d(paddle_material.clone()),
Transform::from_xyz(-window.width() / 2.0 + PADDLE_GAP, 0.0, 1.0),
));
// Player 2
commands.spawn((
Paddle,
Mesh2d(paddle_mesh),
MeshMaterial2d(paddle_material),
Transform::from_xyz(window.width() / 2.0 - PADDLE_GAP, 0.0, 1.0),
));
}
#[derive(Component)]
struct Ball;
/// Marker component for player paddles
///
/// Maybe one for each player?
/// Maybe it can hold the WebSocket, too.
/// *Maybe* I can have one struct with an Option<Ws> to know which
/// player is the local one (the one with a socket).
#[derive(Component)]
struct Paddle;
/// 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<MaybeTlsStream<TcpStream>>,
Response<Option<Vec<u8>>>,
),
);
/// Container component for a bevy task. Specifically, the async Websocket
/// setup.
#[derive(Component)]
struct WsSetupTask(Task<Result<CommandQueue, ConnSetupError>>);
/// 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(Debug, Error)]
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<ButtonInput<KeyCode>>,
mut messages: MessageWriter<WebSocketConnectionMessage>,
) {
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<WebSocketConnectionMessage>,
) {
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::<WsSetupTask>();
});
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>,
mut states: ResMut<NextState<GameState>>,
) {
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);
states.set(GameState::Playing);
}
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<Time>,
mut client_entt: Query<(&mut WsClient,)>,
mut cfg: ResMut<ChatTimer>,
) {
// cfg.timer.tick(time.delta());
// if cfg.timer.just_finished() {
// info!("Sending username to server.");
// for
// }
}
/// Reads & prints all messages from all websockets in [`WsClient`]s.
///
/// For a real chat app, this would need to print to the screen somehow. I'd
/// need a lot of other things, too, and this is supposed to be Pong. So I'm
/// not going to fix any of that.
fn recv_info(mut q: Query<&mut WsClient>) {
for mut client in q.iter_mut() {
match client.0.0.read() {
Ok(m) => info!("Received message {m:?}"),
Err(tungstenite::Error::Io(e)) if e.kind() == ErrorKind::WouldBlock => { /* ignore */ }
Err(e) => warn!("error receiving: {e}"),
}
}
}

126
client/src/ui.rs Normal file
View File

@@ -0,0 +1,126 @@
//! All the UI elements for the Pong game client.
use bevy::{
color::palettes::css::{DARK_GRAY, GRAY},
prelude::*,
};
use crate::GameState;
pub const BTN_BORDER_COLOR: Color = Color::WHITE;
pub const BTN_BG_COLOR: Color = Color::BLACK;
pub const BTN_BG_SELECTED: Color = bevy::prelude::Color::Srgba(DARK_GRAY);
pub fn spawn_main_menu(mut commands: Commands) {
commands
.spawn((Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..Default::default()
},))
.with_children(|cmds| {
cmds.spawn((
// TODO: A more sharp and square font, maybe "pixel art" bitmap
// to really get the chunky feel of Pong.
Text::new("Robert's Bad Pong Game"),
TextFont::from_font_size(50.0),
TextLayout::new_with_justify(Justify::Center),
TextShadow::default(),
));
let mut start_button = cmds.spawn(button_bundle("Start game"));
start_button.observe(
|_trigger: On<Pointer<Click>>, mut game_state: ResMut<NextState<GameState>>| {
game_state.set(GameState::Connecting);
},
);
let mut quit_button = cmds.spawn(button_bundle("Quit Game"));
quit_button.observe(
|_trigger: On<Pointer<Click>>, mut messages: MessageWriter<AppExit>| {
// Quit the game if the quit button was pressed.
messages.write(AppExit::Success);
},
);
});
}
/// Despawns the main menu (which is assumed to be the top-most node)
///
/// TODO: Add a marker component, but only in debug builds. A unit test can
/// assert this condition so I don't actually have to check it at runtime
/// on release builds.
pub fn despawn_main_menu(
mut commands: Commands,
top_node: Single<Entity, (With<Node>, Without<ChildOf>)>,
) {
commands.entity(top_node.into_inner()).despawn();
}
pub fn spawn_connection_wait_screen(mut commands: Commands) {
info!("Spawning connecting notice.");
commands.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..Default::default()
},
children![Text::new("Connecting...")],
));
}
pub fn despawn_connection_wait_screen(
mut commands: Commands,
text_nodes: Single<Entity, (With<Node>, With<Text>)>,
) {
info!("Despawning connecting notice.");
commands.entity(text_nodes.into_inner()).despawn();
}
/// The basic bundle for generic buttons.
///
/// It's mostly so I don't have to copy & paste this everywhere I want to use it.
fn button_bundle(text: &str) -> impl Bundle {
(
Button,
Node {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(4.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
margin: UiRect::all(Val::Px(10.0)),
..Default::default()
},
BorderColor::all(BTN_BORDER_COLOR),
BorderRadius::ZERO,
BackgroundColor(BTN_BG_COLOR),
children![(
Text::new(text),
TextColor(GRAY.into()),
TextShadow::default(),
)],
)
}
pub fn button_hover_start(
trigger: On<Pointer<Over>>,
mut button_colors: Query<(&mut BackgroundColor, &mut BorderColor), With<Button>>,
) {
if let Ok((mut bg, mut _border)) = button_colors.get_mut(trigger.entity) {
bg.0 = BTN_BG_SELECTED;
}
}
pub fn button_hover_stop(
trigger: On<Pointer<Out>>,
mut button_colors: Query<(&mut BackgroundColor, &mut BorderColor), With<Button>>,
) {
if let Ok((mut bg, mut _border)) = button_colors.get_mut(trigger.entity) {
bg.0 = BTN_BG_COLOR;
}
}

View File

@@ -78,7 +78,6 @@ async fn websocket(socket: WebSocket, state: Arc<AppState>) {
let mut rx = state.tx.subscribe();
let _ = state.tx.send(format!("{username} joined the lobby!"));
// Read messages broadcast through the server, write them to this socket.
// If any error is returned, break the loop to terminate the task. We're
// not dealing with them right now.
@@ -111,18 +110,18 @@ async fn websocket(socket: WebSocket, state: Arc<AppState>) {
}
/// Sets the requested username into buffer `name_out` if it is currently unused in the lobby.
///
///
/// Check for presence of `name` in `state.user_set`. If taken, the `name_out` out-parameter
/// is left unchanged (which should be empty, signaling to the caller that the name is
/// unavailable).
///
/// If the name is available, it is added to `state.user_set` (thus making it unavailable
/// going forward) and written into the `name_out` buffer for use by the caller (non-empty
/// values signal that the name has been accepted for use).
/// values signal that the name has been accepted for use).
fn check_username(state: &AppState, name_out: &mut String, name: &str) {
// TODO: Return a Result instead of using out-parameters. This isn't C,
// we can do better.
let mut user_set= state.user_set.lock().unwrap();
let mut user_set = state.user_set.lock().unwrap();
if !user_set.contains(name) {
user_set.insert(name.to_owned());
name_out.push_str(name);