Compare commits

..

9 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
2 changed files with 226 additions and 6 deletions

View File

@@ -8,10 +8,43 @@ use bevy::{
use thiserror::Error; use thiserror::Error;
use tungstenite::{WebSocket, http::Response, stream::MaybeTlsStream}; 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() { fn main() {
App::new() App::new()
.add_plugins(DefaultPlugins) .add_plugins(DefaultPlugins)
.add_systems(Startup, setup) .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( .add_systems(
Update, Update,
( (
@@ -20,7 +53,8 @@ fn main() {
handle_tasks, handle_tasks,
send_info, send_info,
recv_info, recv_info,
), )
.run_if(in_state(GameState::ConnectionDemo)),
) )
.add_message::<WebSocketConnectionMessage>() .add_message::<WebSocketConnectionMessage>()
.insert_resource(ChatTimer { .insert_resource(ChatTimer {
@@ -29,20 +63,75 @@ fn main() {
.run(); .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 /// Initialize the scene
fn setup( fn setup_game(
mut commands: Commands, mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>, mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>, mut materials: ResMut<Assets<ColorMaterial>>,
window: Single<&Window>,
) { ) {
commands.spawn(Camera2d); // ball
commands.spawn(( commands.spawn((
Ball,
Mesh2d(meshes.add(Circle::new(10.0))), Mesh2d(meshes.add(Circle::new(10.0))),
MeshMaterial2d(materials.add(Color::srgb(1.0, 0.0, 0.0))), MeshMaterial2d(materials.add(Color::srgb(1.0, 0.0, 0.0))),
Transform::default(), 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 /// ECS Component to hold the WebSocket. I guess there's going to be a magic
/// entity that controls the networking. /// entity that controls the networking.
#[derive(Component)] #[derive(Component)]
@@ -66,7 +155,7 @@ enum WebSocketConnectionMessage {
// TODO: Presumably a TeardownConnection, right? // TODO: Presumably a TeardownConnection, right?
} }
#[derive(Error, Debug)] #[derive(Debug, Error)]
enum ConnSetupError { enum ConnSetupError {
#[error("IO")] #[error("IO")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
@@ -149,12 +238,17 @@ fn setup_connection(
/// ///
/// The task is self-removing, so we don't need to delete the [`WsSetupTask`] /// The task is self-removing, so we don't need to delete the [`WsSetupTask`]
/// component here. /// component here.
fn handle_tasks(mut commands: Commands, mut transform_tasks: Query<&mut WsSetupTask>) { fn handle_tasks(
mut commands: Commands,
mut transform_tasks: Query<&mut WsSetupTask>,
mut states: ResMut<NextState<GameState>>,
) {
for mut task in &mut transform_tasks { for mut task in &mut transform_tasks {
if let Some(result) = block_on(future::poll_once(&mut task.0)) { if let Some(result) = block_on(future::poll_once(&mut task.0)) {
match result { match result {
Ok(mut commands_queue) => { Ok(mut commands_queue) => {
commands.append(&mut commands_queue); commands.append(&mut commands_queue);
states.set(GameState::Playing);
} }
Err(e) => info!("Connection failed. Err: {e:?}"), Err(e) => info!("Connection failed. Err: {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;
}
}