diff options
-rw-r--r-- | examples/breakout/breakout.rs | 421 | ||||
-rw-r--r-- | examples/breakout/client.rs | 413 | ||||
-rw-r--r-- | examples/breakout/protocol.rs | 50 | ||||
-rw-r--r-- | examples/breakout/server.rs | 212 |
4 files changed, 647 insertions, 449 deletions
diff --git a/examples/breakout/breakout.rs b/examples/breakout/breakout.rs index 2c26566..f6fd3ff 100644 --- a/examples/breakout/breakout.rs +++ b/examples/breakout/breakout.rs @@ -11,8 +11,12 @@ use bevy_quinnet::{ client::QuinnetClientPlugin, server::{QuinnetServerPlugin, Server}, }; -use client::{handle_server_messages, start_connection}; -use server::{handle_client_messages, handle_server_events, start_listening}; +use client::{ + handle_menu_buttons, handle_server_messages, move_paddle, play_collision_sound, setup_breakout, + setup_main_menu, start_connection, teardown_main_menu, update_scoreboard, NetworkMapping, + BACKGROUND_COLOR, +}; +use server::{handle_client_messages, handle_server_events, start_listening, update_paddles}; mod client; mod protocol; @@ -29,11 +33,8 @@ const PADDLE_SPEED: f32 = 500.0; // How close can the paddle get to the wall const PADDLE_PADDING: f32 = 10.0; -// We set the z-value of the ball to 1 so it renders on top in the case of overlapping sprites. -const BALL_STARTING_POSITION: Vec3 = Vec3::new(0.0, -50.0, 1.0); const BALL_SIZE: Vec3 = Vec3::new(30.0, 30.0, 0.0); const BALL_SPEED: f32 = 400.0; -const INITIAL_BALL_DIRECTION: Vec2 = Vec2::new(0.5, -0.5); const WALL_THICKNESS: f32 = 10.0; // x coordinates @@ -51,25 +52,6 @@ const GAP_BETWEEN_BRICKS: f32 = 5.0; const GAP_BETWEEN_BRICKS_AND_CEILING: f32 = 20.0; const GAP_BETWEEN_BRICKS_AND_SIDES: f32 = 20.0; -const SCOREBOARD_FONT_SIZE: f32 = 40.0; -const SCOREBOARD_TEXT_PADDING: Val = Val::Px(5.0); - -const BACKGROUND_COLOR: Color = Color::rgb(0.9, 0.9, 0.9); -const PADDLE_COLOR: Color = Color::rgb(0.3, 0.3, 0.7); -const BALL_COLOR: Color = Color::rgb(1.0, 0.5, 0.5); -const BRICK_COLOR: Color = Color::rgb(0.5, 0.5, 1.0); -const WALL_COLOR: Color = Color::rgb(0.8, 0.8, 0.8); -const TEXT_COLOR: Color = Color::rgb(0.5, 0.5, 1.0); -const SCORE_COLOR: Color = Color::rgb(1.0, 0.5, 0.5); -const NORMAL_BUTTON_COLOR: Color = Color::rgb(0.15, 0.15, 0.15); -const HOVERED_BUTTON_COLOR: Color = Color::rgb(0.25, 0.25, 0.25); -const PRESSED_BUTTON_COLOR: Color = Color::rgb(0.35, 0.75, 0.35); -const BUTTON_TEXT_COLOR: Color = Color::rgb(0.9, 0.9, 0.9); - -const BOLD_FONT: &str = "fonts/FiraSans-Bold.ttf"; -const NORMAL_FONT: &str = "fonts/FiraMono-Medium.ttf"; -const COLLISION_SOUND_EFFECT: &str = "sounds/breakout_collision.ogg"; - #[derive(Clone, Eq, PartialEq, Debug, Hash)] enum GameState { MainMenu, @@ -89,7 +71,8 @@ fn main() { .insert_resource(ClearColor(BACKGROUND_COLOR)) // Resources .insert_resource(server::Players::default()) //TODO Move ? - .insert_resource(client::Users::default()) + .insert_resource(client::ClientData::default()) + .insert_resource(NetworkMapping::default()) // Main menu .add_system_set(SystemSet::on_enter(GameState::MainMenu).with_system(setup_main_menu)) .add_system_set(SystemSet::on_update(GameState::MainMenu).with_system(handle_menu_buttons)) @@ -143,7 +126,7 @@ fn main() { }, )) // .with_system(check_for_collisions) - // .with_system(move_paddle.before(check_for_collisions)) + .with_system(update_paddles.before(check_for_collisions)) // .with_system(apply_velocity.before(check_for_collisions)) // .with_system(play_collision_sound.after(check_for_collisions)) // .with_system(update_scoreboard) @@ -155,9 +138,6 @@ fn main() { } #[derive(Component)] -struct Paddle; - -#[derive(Component)] struct Ball; #[derive(Component, Deref, DerefMut)] @@ -175,25 +155,8 @@ struct Brick; #[derive(Component)] struct Score; -/// The buttons in the main menu. -#[derive(Clone, Copy, Component)] -pub enum MenuItem { - Host, - Join, -} - struct CollisionSound(Handle<AudioSource>); -// This bundle is a collection of the components that define a "wall" in our game -#[derive(Bundle)] -struct WallBundle { - // You can nest bundles inside of other bundles like this - // Allowing you to compose their functionality - #[bundle] - sprite_bundle: SpriteBundle, - collider: Collider, -} - /// Which side of the arena is this wall located on? enum WallLocation { Left, @@ -230,33 +193,6 @@ impl WallLocation { } } -impl WallBundle { - // This "builder method" allows us to reuse logic across our wall entities, - // making our code easier to read and less prone to bugs when we change the logic - fn new(location: WallLocation) -> WallBundle { - WallBundle { - sprite_bundle: SpriteBundle { - transform: Transform { - // We need to convert our Vec2 into a Vec3, by giving it a z-coordinate - // This is used to determine the order of our sprites - translation: location.position().extend(0.0), - // The z-scale of 2D objects must always be 1.0, - // or their ordering will be affected in surprising ways. - // See https://github.com/bevyengine/bevy/issues/4149 - scale: location.size().extend(1.0), - ..default() - }, - sprite: Sprite { - color: WALL_COLOR, - ..default() - }, - ..default() - }, - collider: Collider, - } - } -} - // This resource tracks the game's score struct Scoreboard { score: usize, @@ -269,246 +205,6 @@ fn run_if_host(server: Res<Server>) -> ShouldRun { } } -fn setup_main_menu(mut commands: Commands, asset_server: Res<AssetServer>) { - // Camera - commands.spawn_bundle(Camera2dBundle::default()); - - let button_style = Style { - size: Size::new(Val::Px(150.0), Val::Px(65.0)), - // center button - margin: UiRect::all(Val::Auto), - // horizontally center child text - justify_content: JustifyContent::Center, - // vertically center child text - align_items: AlignItems::Center, - ..default() - }; - let text_style = TextStyle { - font: asset_server.load(BOLD_FONT), - font_size: 40.0, - color: BUTTON_TEXT_COLOR, - }; - commands - .spawn_bundle(ButtonBundle { - style: button_style.clone(), - color: NORMAL_BUTTON_COLOR.into(), - ..default() - }) - .insert(MenuItem::Host) - .with_children(|parent| { - parent.spawn_bundle(TextBundle::from_section("Host", text_style.clone())); - }); - commands - .spawn_bundle(ButtonBundle { - style: button_style, - color: NORMAL_BUTTON_COLOR.into(), - ..default() - }) - .insert(MenuItem::Join) - .with_children(|parent| { - parent.spawn_bundle(TextBundle::from_section("Join", text_style)); - }); -} - -fn handle_menu_buttons( - mut interaction_query: Query< - (&Interaction, &mut UiColor, &MenuItem), - (Changed<Interaction>, With<Button>), - >, - mut game_state: ResMut<State<GameState>>, -) { - for (interaction, mut color, item) in &mut interaction_query { - match *interaction { - Interaction::Clicked => { - *color = PRESSED_BUTTON_COLOR.into(); - match item { - MenuItem::Host => game_state.set(GameState::HostingLobby).unwrap(), - MenuItem::Join => game_state.set(GameState::JoiningLobby).unwrap(), - } - } - Interaction::Hovered => { - *color = HOVERED_BUTTON_COLOR.into(); - } - Interaction::None => { - *color = NORMAL_BUTTON_COLOR.into(); - } - } - } -} - -fn teardown_main_menu(mut commands: Commands, query: Query<Entity, With<Button>>) { - for entity in query.iter() { - commands.entity(entity).despawn_recursive(); - } -} - -// Add the game's entities to our world -fn setup_breakout(mut commands: Commands, asset_server: Res<AssetServer>) { - // Sound - let ball_collision_sound = asset_server.load(COLLISION_SOUND_EFFECT); - commands.insert_resource(CollisionSound(ball_collision_sound)); - - // Paddle - let paddle_y = BOTTOM_WALL + GAP_BETWEEN_PADDLE_AND_FLOOR; - - commands - .spawn() - .insert(Paddle) - .insert_bundle(SpriteBundle { - transform: Transform { - translation: Vec3::new(0.0, paddle_y, 0.0), - scale: PADDLE_SIZE, - ..default() - }, - sprite: Sprite { - color: PADDLE_COLOR, - ..default() - }, - ..default() - }) - .insert(Collider); - - // Ball - commands - .spawn() - .insert(Ball) - .insert_bundle(SpriteBundle { - transform: Transform { - scale: BALL_SIZE, - translation: BALL_STARTING_POSITION, - ..default() - }, - sprite: Sprite { - color: BALL_COLOR, - ..default() - }, - ..default() - }) - .insert(Velocity(INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED)); - - // Scoreboard - commands - .spawn() - .insert_bundle( - TextBundle::from_sections([ - TextSection::new( - "Score: ", - TextStyle { - font: asset_server.load(BOLD_FONT), - font_size: SCOREBOARD_FONT_SIZE, - color: TEXT_COLOR, - }, - ), - TextSection::from_style(TextStyle { - font: asset_server.load(NORMAL_FONT), - font_size: SCOREBOARD_FONT_SIZE, - color: SCORE_COLOR, - }), - ]) - .with_style(Style { - position_type: PositionType::Absolute, - position: UiRect { - top: SCOREBOARD_TEXT_PADDING, - left: SCOREBOARD_TEXT_PADDING, - ..default() - }, - ..default() - }), - ) - .insert(Score); - - // Walls - commands.spawn_bundle(WallBundle::new(WallLocation::Left)); - commands.spawn_bundle(WallBundle::new(WallLocation::Right)); - commands.spawn_bundle(WallBundle::new(WallLocation::Bottom)); - commands.spawn_bundle(WallBundle::new(WallLocation::Top)); - - // Bricks - // Negative scales result in flipped sprites / meshes, - // which is definitely not what we want here - assert!(BRICK_SIZE.x > 0.0); - assert!(BRICK_SIZE.y > 0.0); - - let total_width_of_bricks = (RIGHT_WALL - LEFT_WALL) - 2. * GAP_BETWEEN_BRICKS_AND_SIDES; - let bottom_edge_of_bricks = paddle_y + GAP_BETWEEN_PADDLE_AND_BRICKS; - let total_height_of_bricks = TOP_WALL - bottom_edge_of_bricks - GAP_BETWEEN_BRICKS_AND_CEILING; - - assert!(total_width_of_bricks > 0.0); - assert!(total_height_of_bricks > 0.0); - - // Given the space available, compute how many rows and columns of bricks we can fit - let n_columns = (total_width_of_bricks / (BRICK_SIZE.x + GAP_BETWEEN_BRICKS)).floor() as usize; - let n_rows = (total_height_of_bricks / (BRICK_SIZE.y + GAP_BETWEEN_BRICKS)).floor() as usize; - let n_vertical_gaps = n_columns - 1; - - // Because we need to round the number of columns, - // the space on the top and sides of the bricks only captures a lower bound, not an exact value - let center_of_bricks = (LEFT_WALL + RIGHT_WALL) / 2.0; - let left_edge_of_bricks = center_of_bricks - // Space taken up by the bricks - - (n_columns as f32 / 2.0 * BRICK_SIZE.x) - // Space taken up by the gaps - - n_vertical_gaps as f32 / 2.0 * GAP_BETWEEN_BRICKS; - - // In Bevy, the `translation` of an entity describes the center point, - // not its bottom-left corner - let offset_x = left_edge_of_bricks + BRICK_SIZE.x / 2.; - let offset_y = bottom_edge_of_bricks + BRICK_SIZE.y / 2.; - - for row in 0..n_rows { - for column in 0..n_columns { - let brick_position = Vec2::new( - offset_x + column as f32 * (BRICK_SIZE.x + GAP_BETWEEN_BRICKS), - offset_y + row as f32 * (BRICK_SIZE.y + GAP_BETWEEN_BRICKS), - ); - - // brick - commands - .spawn() - .insert(Brick) - .insert_bundle(SpriteBundle { - sprite: Sprite { - color: BRICK_COLOR, - ..default() - }, - transform: Transform { - translation: brick_position.extend(0.0), - scale: Vec3::new(BRICK_SIZE.x, BRICK_SIZE.y, 1.0), - ..default() - }, - ..default() - }) - .insert(Collider); - } - } -} - -fn move_paddle( - keyboard_input: Res<Input<KeyCode>>, - mut query: Query<&mut Transform, With<Paddle>>, -) { - let mut paddle_transform = query.single_mut(); - let mut direction = 0.0; - - if keyboard_input.pressed(KeyCode::Left) { - direction -= 1.0; - } - - if keyboard_input.pressed(KeyCode::Right) { - direction += 1.0; - } - - // Calculate the new horizontal paddle position based on player input - let new_paddle_position = paddle_transform.translation.x + direction * PADDLE_SPEED * TIME_STEP; - - // Update the paddle position, - // making sure it doesn't cause the paddle to leave the arena - let left_bound = LEFT_WALL + WALL_THICKNESS / 2.0 + PADDLE_SIZE.x / 2.0 + PADDLE_PADDING; - let right_bound = RIGHT_WALL - WALL_THICKNESS / 2.0 - PADDLE_SIZE.x / 2.0 - PADDLE_PADDING; - - paddle_transform.translation.x = new_paddle_position.clamp(left_bound, right_bound); -} - fn apply_velocity(mut query: Query<(&mut Transform, &Velocity)>) { for (mut transform, velocity) in &mut query { transform.translation.x += velocity.x * TIME_STEP; @@ -516,11 +212,6 @@ fn apply_velocity(mut query: Query<(&mut Transform, &Velocity)>) { } } -fn update_scoreboard(scoreboard: Res<Scoreboard>, mut query: Query<&mut Text, With<Score>>) { - let mut text = query.single_mut(); - text.sections[1].value = scoreboard.score.to_string(); -} - fn check_for_collisions( mut commands: Commands, mut scoreboard: ResMut<Scoreboard>, @@ -528,63 +219,51 @@ fn check_for_collisions( collider_query: Query<(Entity, &Transform, Option<&Brick>), With<Collider>>, mut collision_events: EventWriter<CollisionEvent>, ) { - let (mut ball_velocity, ball_transform) = ball_query.single_mut(); - let ball_size = ball_transform.scale.truncate(); - - // check collision with walls - for (collider_entity, transform, maybe_brick) in &collider_query { - let collision = collide( - ball_transform.translation, - ball_size, - transform.translation, - transform.scale.truncate(), - ); - if let Some(collision) = collision { - // Sends a collision event so that other systems can react to the collision - collision_events.send_default(); - - // Bricks should be despawned and increment the scoreboard on collision - if maybe_brick.is_some() { - scoreboard.score += 1; - commands.entity(collider_entity).despawn(); - } - - // reflect the ball when it collides - let mut reflect_x = false; - let mut reflect_y = false; + for (mut ball_velocity, ball_transform) in ball_query.iter_mut() { + let ball_size = ball_transform.scale.truncate(); + + // check collision with walls + for (collider_entity, transform, maybe_brick) in &collider_query { + let collision = collide( + ball_transform.translation, + ball_size, + transform.translation, + transform.scale.truncate(), + ); + if let Some(collision) = collision { + // Sends a collision event so that other systems can react to the collision + collision_events.send_default(); + + // Bricks should be despawned and increment the scoreboard on collision + if maybe_brick.is_some() { + scoreboard.score += 1; + commands.entity(collider_entity).despawn(); + } - // only reflect if the ball's velocity is going in the opposite direction of the - // collision - match collision { - Collision::Left => reflect_x = ball_velocity.x > 0.0, - Collision::Right => reflect_x = ball_velocity.x < 0.0, - Collision::Top => reflect_y = ball_velocity.y < 0.0, - Collision::Bottom => reflect_y = ball_velocity.y > 0.0, - Collision::Inside => { /* do nothing */ } - } + // reflect the ball when it collides + let mut reflect_x = false; + let mut reflect_y = false; + + // only reflect if the ball's velocity is going in the opposite direction of the + // collision + match collision { + Collision::Left => reflect_x = ball_velocity.x > 0.0, + Collision::Right => reflect_x = ball_velocity.x < 0.0, + Collision::Top => reflect_y = ball_velocity.y < 0.0, + Collision::Bottom => reflect_y = ball_velocity.y > 0.0, + Collision::Inside => { /* do nothing */ } + } - // reflect velocity on the x-axis if we hit something on the x-axis - if reflect_x { - ball_velocity.x = -ball_velocity.x; - } + // reflect velocity on the x-axis if we hit something on the x-axis + if reflect_x { + ball_velocity.x = -ball_velocity.x; + } - // reflect velocity on the y-axis if we hit something on the y-axis - if reflect_y { - ball_velocity.y = -ball_velocity.y; + // reflect velocity on the y-axis if we hit something on the y-axis + if reflect_y { + ball_velocity.y = -ball_velocity.y; + } } } } } - -fn play_collision_sound( - collision_events: EventReader<CollisionEvent>, - audio: Res<Audio>, - sound: Res<CollisionSound>, -) { - // Play a sound once per frame if a collision occurred. - if !collision_events.is_empty() { - // This prevents events staying active on the next frame. - collision_events.clear(); - audio.play(sound.0.clone()); - } -} diff --git a/examples/breakout/client.rs b/examples/breakout/client.rs index 2bc91b0..e45c035 100644 --- a/examples/breakout/client.rs +++ b/examples/breakout/client.rs @@ -1,50 +1,413 @@ use std::collections::HashMap; -use bevy::prelude::{warn, EventReader, ResMut, State}; +use bevy::{ + prelude::{ + default, AssetServer, Audio, BuildChildren, Bundle, Button, ButtonBundle, Camera2dBundle, + Changed, Color, Commands, Component, DespawnRecursiveExt, Entity, EventReader, Input, + KeyCode, Local, Query, Res, ResMut, State, TextBundle, Transform, Vec2, Vec3, With, + }, + sprite::{Sprite, SpriteBundle}, + text::{Text, TextSection, TextStyle}, + ui::{ + AlignItems, Interaction, JustifyContent, PositionType, Size, Style, UiColor, UiRect, Val, + }, +}; use bevy_quinnet::{ - client::{CertificateVerificationMode, Client, ClientConfigurationData, ConnectionEvent}, + client::{CertificateVerificationMode, Client, ClientConfigurationData}, ClientId, }; -use crate::{protocol::ServerMessage, GameState}; +use crate::{ + protocol::{ClientMessage, PaddleInput, ServerMessage}, + Ball, Brick, Collider, CollisionEvent, CollisionSound, GameState, Score, Scoreboard, Velocity, + WallLocation, BALL_SIZE, BALL_SPEED, BOTTOM_WALL, BRICK_SIZE, GAP_BETWEEN_BRICKS, + GAP_BETWEEN_BRICKS_AND_CEILING, GAP_BETWEEN_BRICKS_AND_SIDES, GAP_BETWEEN_PADDLE_AND_BRICKS, + GAP_BETWEEN_PADDLE_AND_FLOOR, LEFT_WALL, PADDLE_SIZE, RIGHT_WALL, TOP_WALL, +}; + +const SCOREBOARD_FONT_SIZE: f32 = 40.0; +const SCOREBOARD_TEXT_PADDING: Val = Val::Px(5.0); + +pub(crate) const BACKGROUND_COLOR: Color = Color::rgb(0.9, 0.9, 0.9); +const PADDLE_COLOR: Color = Color::rgb(0.3, 0.3, 0.7); +const BALL_COLOR: Color = Color::rgb(1.0, 0.5, 0.5); +const BRICK_COLOR: Color = Color::rgb(0.5, 0.5, 1.0); +const WALL_COLOR: Color = Color::rgb(0.8, 0.8, 0.8); +const TEXT_COLOR: Color = Color::rgb(0.5, 0.5, 1.0); +const SCORE_COLOR: Color = Color::rgb(1.0, 0.5, 0.5); +const NORMAL_BUTTON_COLOR: Color = Color::rgb(0.15, 0.15, 0.15); +const HOVERED_BUTTON_COLOR: Color = Color::rgb(0.25, 0.25, 0.25); +const PRESSED_BUTTON_COLOR: Color = Color::rgb(0.35, 0.75, 0.35); +const BUTTON_TEXT_COLOR: Color = Color::rgb(0.9, 0.9, 0.9); + +const BOLD_FONT: &str = "fonts/FiraSans-Bold.ttf"; +const NORMAL_FONT: &str = "fonts/FiraMono-Medium.ttf"; +const COLLISION_SOUND_EFFECT: &str = "sounds/breakout_collision.ogg"; #[derive(Debug, Clone, Default)] -pub(crate) struct Users { +pub(crate) struct ClientData { self_id: ClientId, - names: HashMap<ClientId, String>, +} + +#[derive(Default)] +pub(crate) struct NetworkMapping { + // Network entity id to local entity id + map: HashMap<Entity, Entity>, +} + +/// The buttons in the main menu. +#[derive(Clone, Copy, Component)] +pub enum MenuItem { + Host, + Join, +} + +// This bundle is a collection of the components that define a "wall" in our game +#[derive(Bundle)] +struct WallBundle { + // You can nest bundles inside of other bundles like this + // Allowing you to compose their functionality + #[bundle] + sprite_bundle: SpriteBundle, + collider: Collider, +} + +pub(crate) fn start_connection(client: ResMut<Client>) { + client + .connect( + ClientConfigurationData::new("127.0.0.1".to_string(), 6000, "0.0.0.0".to_string(), 0), + CertificateVerificationMode::SkipVerification, + ) + .unwrap(); +} + +fn spawn_paddle(commands: &mut Commands, position: &Vec3) -> Entity { + commands + .spawn() + // .insert(Paddle) + .insert_bundle(SpriteBundle { + transform: Transform { + translation: *position, + scale: PADDLE_SIZE, + ..default() + }, + sprite: Sprite { + color: PADDLE_COLOR, + ..default() + }, + ..default() + }) + .insert(Collider) + .id() +} + +fn spawn_ball(commands: &mut Commands, pos: &Vec3, direction: &Vec2) -> Entity { + commands + .spawn() + .insert(Ball) + .insert_bundle(SpriteBundle { + transform: Transform { + scale: BALL_SIZE, + translation: *pos, + ..default() + }, + sprite: Sprite { + color: BALL_COLOR, + ..default() + }, + ..default() + }) + .insert(Velocity(direction.normalize() * BALL_SPEED)) + .id() } pub(crate) fn handle_server_messages( + mut commands: Commands, mut client: ResMut<Client>, - mut users: ResMut<Users>, + mut client_data: ResMut<ClientData>, + mut entity_mapping: ResMut<NetworkMapping>, mut game_state: ResMut<State<GameState>>, ) { while let Ok(Some(message)) = client.receive_message::<ServerMessage>() { match message { - // ServerMessage::ClientConnected { client_id: _ } => {} - // ServerMessage::ClientDisconnected { client_id } => { - // if let Some(username) = users.names.remove(&client_id) { - // println!("{} left", username); - // } else { - // warn!("ClientDisconnected for an unknown client_id: {}", client_id) - // } - // } ServerMessage::InitClient { client_id } => { - users.self_id = client_id; + client_data.self_id = client_id; + } + ServerMessage::SpawnPaddle { + client_id, + entity, + position, + } => { + let paddle = spawn_paddle(&mut commands, &position); + entity_mapping.map.insert(entity, paddle); + } + ServerMessage::SpawnBall { + entity, + position, + direction, + } => { + let ball = spawn_ball(&mut commands, &position, &direction); + entity_mapping.map.insert(entity, ball); } - ServerMessage::BrickDestroyed {} => todo!(), - ServerMessage::BallPosition {} => todo!(), - ServerMessage::PaddlePosition {} => todo!(), - ServerMessage::GameStart {} => game_state.set(GameState::Running).unwrap(), + ServerMessage::StartGame {} => game_state.set(GameState::Running).unwrap(), + ServerMessage::BrickDestroyed { client_id } => todo!(), + ServerMessage::BallPosition { entity, position } => todo!(), + ServerMessage::PaddlePosition { entity, position } => todo!(), } } } -pub(crate) fn start_connection(client: ResMut<Client>) { - client - .connect( - ClientConfigurationData::new("127.0.0.1".to_string(), 6000, "0.0.0.0".to_string(), 0), - CertificateVerificationMode::SkipVerification, +#[derive(Default)] +pub(crate) struct PaddleState { + current_input: PaddleInput, +} + +pub(crate) fn move_paddle( + client: ResMut<Client>, + keyboard_input: Res<Input<KeyCode>>, + mut local: Local<PaddleState>, +) { + let mut paddle_input = PaddleInput::None; + + if keyboard_input.pressed(KeyCode::Left) { + paddle_input = PaddleInput::Left; + } + + if keyboard_input.pressed(KeyCode::Right) { + paddle_input = PaddleInput::Right; + } + + if local.current_input != paddle_input { + client + .send_message(ClientMessage::PaddleInput { + input: paddle_input.clone(), + }) + .unwrap(); + local.current_input = paddle_input; + } +} + +pub(crate) fn update_scoreboard( + scoreboard: Res<Scoreboard>, + mut query: Query<&mut Text, With<Score>>, +) { + let mut text = query.single_mut(); + text.sections[1].value = scoreboard.score.to_string(); +} + +pub(crate) fn play_collision_sound( + collision_events: EventReader<CollisionEvent>, + audio: Res<Audio>, + sound: Res<CollisionSound>, +) { + // Play a sound once per frame if a collision occurred. + if !collision_events.is_empty() { + // This prevents events staying active on the next frame. + collision_events.clear(); + audio.play(sound.0.clone()); + } +} + +pub(crate) fn setup_main_menu(mut commands: Commands, asset_server: Res<AssetServer>) { + // Camera + commands.spawn_bundle(Camera2dBundle::default()); + + let button_style = Style { + size: Size::new(Val::Px(150.0), Val::Px(65.0)), + // center button + margin: UiRect::all(Val::Auto), + // horizontally center child text + justify_content: JustifyContent::Center, + // vertically center child text + align_items: AlignItems::Center, + ..default() + }; + let text_style = TextStyle { + font: asset_server.load(BOLD_FONT), + font_size: 40.0, + color: BUTTON_TEXT_COLOR, + }; + commands + .spawn_bundle(ButtonBundle { + style: button_style.clone(), + color: NORMAL_BUTTON_COLOR.into(), + ..default() + }) + .insert(MenuItem::Host) + .with_children(|parent| { + parent.spawn_bundle(TextBundle::from_section("Host", text_style.clone())); + }); + commands + .spawn_bundle(ButtonBundle { + style: button_style, + color: NORMAL_BUTTON_COLOR.into(), + ..default() + }) + .insert(MenuItem::Join) + .with_children(|parent| { + parent.spawn_bundle(TextBundle::from_section("Join", text_style)); + }); +} + +pub(crate) fn handle_menu_buttons( + mut interaction_query: Query< + (&Interaction, &mut UiColor, &MenuItem), + (Changed<Interaction>, With<Button>), + >, + mut game_state: ResMut<State<GameState>>, +) { + for (interaction, mut color, item) in &mut interaction_query { + match *interaction { + Interaction::Clicked => { + *color = PRESSED_BUTTON_COLOR.into(); + match item { + MenuItem::Host => game_state.set(GameState::HostingLobby).unwrap(), + MenuItem::Join => game_state.set(GameState::JoiningLobby).unwrap(), + } + } + Interaction::Hovered => { + *color = HOVERED_BUTTON_COLOR.into(); + } + Interaction::None => { + *color = NORMAL_BUTTON_COLOR.into(); + } + } + } +} + +pub(crate) fn teardown_main_menu(mut commands: Commands, query: Query<Entity, With<Button>>) { + for entity in query.iter() { + commands.entity(entity).despawn_recursive(); + } +} + +// Add the game's entities to our world +pub(crate) fn setup_breakout(mut commands: Commands, asset_server: Res<AssetServer>) { + // Sound + let ball_collision_sound = asset_server.load(COLLISION_SOUND_EFFECT); + commands.insert_resource(CollisionSound(ball_collision_sound)); + + // Scoreboard + commands + .spawn() + .insert_bundle( + TextBundle::from_sections([ + TextSection::new( + "Score: ", + TextStyle { + font: asset_server.load(BOLD_FONT), + font_size: SCOREBOARD_FONT_SIZE, + color: TEXT_COLOR, + }, + ), + TextSection::from_style(TextStyle { + font: asset_server.load(NORMAL_FONT), + font_size: SCOREBOARD_FONT_SIZE, + color: SCORE_COLOR, + }), + ]) + .with_style(Style { + position_type: PositionType::Absolute, + position: UiRect { + top: SCOREBOARD_TEXT_PADDING, + left: SCOREBOARD_TEXT_PADDING, + ..default() + }, + ..default() + }), ) - .unwrap(); + .insert(Score); + + // Walls + commands.spawn_bundle(WallBundle::new(WallLocation::Left)); + commands.spawn_bundle(WallBundle::new(WallLocation::Right)); + commands.spawn_bundle(WallBundle::new(WallLocation::Bottom)); + commands.spawn_bundle(WallBundle::new(WallLocation::Top)); + + // Bricks + // Negative scales result in flipped sprites / meshes, + // which is definitely not what we want here + assert!(BRICK_SIZE.x > 0.0); + assert!(BRICK_SIZE.y > 0.0); + + let total_width_of_bricks = (RIGHT_WALL - LEFT_WALL) - 2. * GAP_BETWEEN_BRICKS_AND_SIDES; + let bottom_edge_of_bricks = + BOTTOM_WALL + GAP_BETWEEN_PADDLE_AND_FLOOR + GAP_BETWEEN_PADDLE_AND_BRICKS; + let total_height_of_bricks = TOP_WALL - bottom_edge_of_bricks - GAP_BETWEEN_BRICKS_AND_CEILING; + + assert!(total_width_of_bricks > 0.0); + assert!(total_height_of_bricks > 0.0); + + // Given the space available, compute how many rows and columns of bricks we can fit + let n_columns = (total_width_of_bricks / (BRICK_SIZE.x + GAP_BETWEEN_BRICKS)).floor() as usize; + let n_rows = (total_height_of_bricks / (BRICK_SIZE.y + GAP_BETWEEN_BRICKS)).floor() as usize; + let n_vertical_gaps = n_columns - 1; + + // Because we need to round the number of columns, + // the space on the top and sides of the bricks only captures a lower bound, not an exact value + let center_of_bricks = (LEFT_WALL + RIGHT_WALL) / 2.0; + let left_edge_of_bricks = center_of_bricks + // Space taken up by the bricks + - (n_columns as f32 / 2.0 * BRICK_SIZE.x) + // Space taken up by the gaps + - n_vertical_gaps as f32 / 2.0 * GAP_BETWEEN_BRICKS; + + // In Bevy, the `translation` of an entity describes the center point, + // not its bottom-left corner + let offset_x = left_edge_of_bricks + BRICK_SIZE.x / 2.; + let offset_y = bottom_edge_of_bricks + BRICK_SIZE.y / 2.; + + for row in 0..n_rows { + for column in 0..n_columns { + let brick_position = Vec2::new( + offset_x + column as f32 * (BRICK_SIZE.x + GAP_BETWEEN_BRICKS), + offset_y + row as f32 * (BRICK_SIZE.y + GAP_BETWEEN_BRICKS), + ); + + // brick + commands + .spawn() + .insert(Brick) + .insert_bundle(SpriteBundle { + sprite: Sprite { + color: BRICK_COLOR, + ..default() + }, + transform: Transform { + translation: brick_position.extend(0.0), + scale: Vec3::new(BRICK_SIZE.x, BRICK_SIZE.y, 1.0), + ..default() + }, + ..default() + }) + .insert(Collider); + } + } +} + +impl WallBundle { + // This "builder method" allows us to reuse logic across our wall entities, + // making our code easier to read and less prone to bugs when we change the logic + fn new(location: WallLocation) -> WallBundle { + WallBundle { + sprite_bundle: SpriteBundle { + transform: Transform { + // We need to convert our Vec2 into a Vec3, by giving it a z-coordinate + // This is used to determine the order of our sprites + translation: location.position().extend(0.0), + // The z-scale of 2D objects must always be 1.0, + // or their ordering will be affected in surprising ways. + // See https://github.com/bevyengine/bevy/issues/4149 + scale: location.size().extend(1.0), + ..default() + }, + sprite: Sprite { + color: WALL_COLOR, + ..default() + }, + ..default() + }, + collider: Collider, + } + } } diff --git a/examples/breakout/protocol.rs b/examples/breakout/protocol.rs index 9cb9232..766094a 100644 --- a/examples/breakout/protocol.rs +++ b/examples/breakout/protocol.rs @@ -1,25 +1,47 @@ +use bevy::prelude::{Entity, Vec2, Vec3}; use bevy_quinnet::ClientId; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Input {} +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum PaddleInput { + #[default] + None, + Left, + Right, +} // Messages from clients #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ClientMessage { - // Join {}, - // Disconnect {}, - PaddleInput {}, +pub(crate) enum ClientMessage { + PaddleInput { input: PaddleInput }, } // Messages from the server #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ServerMessage { - // ClientConnected { client_id: ClientId }, - // ClientDisconnected { client_id: ClientId }, - InitClient { client_id: ClientId }, - GameStart {}, - BrickDestroyed {}, - BallPosition {}, - PaddlePosition {}, +pub(crate) enum ServerMessage { + InitClient { + client_id: ClientId, + }, + SpawnPaddle { + client_id: ClientId, + entity: Entity, + position: Vec3, + }, + SpawnBall { + entity: Entity, + position: Vec3, + direction: Vec2, + }, + StartGame {}, + BrickDestroyed { + client_id: ClientId, + }, + BallPosition { + entity: Entity, + position: Vec3, + }, + PaddlePosition { + entity: Entity, + position: Vec3, + }, } diff --git a/examples/breakout/server.rs b/examples/breakout/server.rs index 150a1da..556fc4b 100644 --- a/examples/breakout/server.rs +++ b/examples/breakout/server.rs @@ -1,87 +1,221 @@ use std::collections::HashMap; -use bevy::prelude::{info, warn, EventReader, ResMut, State}; -use bevy_quinnet::{ - server::{ - CertificateRetrievalMode, ConnectionEvent, ConnectionLostEvent, Server, - ServerConfigurationData, +use bevy::{ + prelude::{ + default, Commands, Component, Entity, EventReader, Query, ResMut, Transform, Vec2, Vec3, }, + transform::TransformBundle, +}; +use bevy_quinnet::{ + server::{CertificateRetrievalMode, ConnectionEvent, Server, ServerConfigurationData}, ClientId, }; use crate::{ - protocol::{ClientMessage, ServerMessage}, - GameState, + protocol::{ClientMessage, PaddleInput, ServerMessage}, + Ball, Collider, Velocity, BALL_SIZE, BALL_SPEED, BOTTOM_WALL, GAP_BETWEEN_PADDLE_AND_FLOOR, + LEFT_WALL, PADDLE_PADDING, PADDLE_SIZE, PADDLE_SPEED, RIGHT_WALL, TIME_STEP, TOP_WALL, + WALL_THICKNESS, }; +const GAP_BETWEEN_PADDLE_AND_BALL: f32 = 35.; + +// We set the z-value of the ball to 1 so it renders on top in the case of overlapping sprites. +const BALLS_STARTING_POSITION: [Vec3; 2] = [ + Vec3::new( + 0.0, + BOTTOM_WALL + GAP_BETWEEN_PADDLE_AND_FLOOR + GAP_BETWEEN_PADDLE_AND_BALL, + 1.0, + ), + Vec3::new( + 0.0, + TOP_WALL - GAP_BETWEEN_PADDLE_AND_FLOOR - GAP_BETWEEN_PADDLE_AND_BALL, + 1.0, + ), +]; +const INITIAL_BALLS_DIRECTION: [Vec2; 2] = [Vec2::new(0.5, -0.5), Vec2::new(-0.5, 0.5)]; + +const PADDLES_STARTING_POSITION: [Vec3; 2] = [ + Vec3::new(0.0, BOTTOM_WALL + GAP_BETWEEN_PADDLE_AND_FLOOR, 0.0), + Vec3::new(0.0, TOP_WALL - GAP_BETWEEN_PADDLE_AND_FLOOR, 0.0), +]; + #[derive(Debug, Clone, Default)] pub(crate) struct Player { + // paddle: Option<Entity>, + input: PaddleInput, score: u64, } #[derive(Debug, Clone, Default)] pub(crate) struct Players { - scores: HashMap<ClientId, Player>, + map: HashMap<ClientId, Player>, +} + +#[derive(Component)] +pub(crate) struct Paddle { + player_id: ClientId, +} + +pub(crate) fn start_listening(mut server: ResMut<Server>) { + server + .start( + ServerConfigurationData::new("127.0.0.1".to_string(), 6000, "0.0.0.0".to_string()), + CertificateRetrievalMode::GenerateSelfSigned, + ) + .unwrap(); } -pub(crate) fn handle_client_messages(mut server: ResMut<Server>, mut users: ResMut<Players>) { +pub(crate) fn handle_client_messages(mut server: ResMut<Server>, mut players: ResMut<Players>) { while let Ok(Some((message, client_id))) = server.receive_message::<ClientMessage>() { match message { - // ClientMessage::Disconnect {} => { - // // We tell the server to disconnect this user - // server.disconnect_client(client_id); - // handle_disconnect(&mut server, &mut users, client_id); - // } - ClientMessage::PaddleInput {} => todo!(), + ClientMessage::PaddleInput { input } => { + if let Some(player) = players.map.get_mut(&client_id) { + player.input = input; + } + } } } } pub(crate) fn handle_server_events( + mut commands: Commands, mut connection_events: EventReader<ConnectionEvent>, - mut connection_lost_events: EventReader<ConnectionLostEvent>, mut server: ResMut<Server>, mut players: ResMut<Players>, - mut game_state: ResMut<State<GameState>>, ) { // The server signals us about new connections for client in connection_events.iter() { // Refuse connection once we already have two players - if players.scores.len() >= 2 { + if players.map.len() >= 2 { server.disconnect_client(client.id) } else { - players.scores.insert(client.id, Player { score: 0 }); - if players.scores.len() == 2 { + players.map.insert( + client.id, + Player { + score: 0, + input: PaddleInput::None, + // paddle: None, + }, + ); + if players.map.len() == 2 { + start_game(&mut commands, &mut server, &players); + } + } + } +} + +pub(crate) fn update_paddles( + mut server: ResMut<Server>, + players: ResMut<Players>, + // mut query: Query<&mut Transform, With<Paddle>>, + mut paddles: Query<(&mut Transform, &Paddle, Entity)>, +) { + for (mut paddle_transform, paddle, paddle_entity) in paddles.iter_mut() { + if let Some(player) = players.map.get(&paddle.player_id) { + if player.input != PaddleInput::None { + let mut direction = 0.0; + match player.input { + PaddleInput::Left => direction -= 1.0, + PaddleInput::Right => direction = 1.0, + _ => {} + } + // Calculate the new horizontal paddle position based on player input + let new_paddle_position = + paddle_transform.translation.x + direction * PADDLE_SPEED * TIME_STEP; + + // Update the paddle position, + // making sure it doesn't cause the paddle to leave the arena + let left_bound = + LEFT_WALL + WALL_THICKNESS / 2.0 + PADDLE_SIZE.x / 2.0 + PADDLE_PADDING; + let right_bound = + RIGHT_WALL - WALL_THICKNESS / 2.0 - PADDLE_SIZE.x / 2.0 - PADDLE_PADDING; + + paddle_transform.translation.x = new_paddle_position.clamp(left_bound, right_bound); + server .send_group_message( - players.scores.keys().into_iter(), - ServerMessage::GameStart {}, + players.map.keys().into_iter(), + ServerMessage::PaddlePosition { + entity: paddle_entity, + position: paddle_transform.translation, + }, ) .unwrap(); } } } - // The server signals us about users that lost connection - // for client in connection_lost_events.iter() { - // handle_disconnect(&mut server, &mut players, client.id); - // } } -// /// Shared disconnection behaviour, whether the client lost connection or asked to disconnect -// pub(crate) fn handle_disconnect( -// server: &mut ResMut<Server>, -// players: &mut ResMut<Players>, -// client_id: ClientId, -// ) { -// // Remove this user +fn start_game(commands: &mut Commands, server: &mut ResMut<Server>, players: &ResMut<Players>) { + // Paddles + for (index, (client_id, _)) in players.map.iter().enumerate() { + let paddle = spawn_paddle(commands, *client_id, &PADDLES_STARTING_POSITION[index]); + server + .send_group_message( + players.map.keys().into_iter(), + ServerMessage::SpawnPaddle { + client_id: *client_id, + entity: paddle, + position: PADDLES_STARTING_POSITION[index], + }, + ) + .unwrap(); + } -// } + // Balls + for (position, direction) in BALLS_STARTING_POSITION + .iter() + .zip(INITIAL_BALLS_DIRECTION.iter()) + { + let ball = spawn_ball(commands, position, direction); + server + .send_group_message( + players.map.keys().into_iter(), + ServerMessage::SpawnBall { + entity: ball, + position: *position, + direction: *direction, + }, + ) + .unwrap(); + } -pub(crate) fn start_listening(mut server: ResMut<Server>) { server - .start( - ServerConfigurationData::new("127.0.0.1".to_string(), 6000, "0.0.0.0".to_string()), - CertificateRetrievalMode::GenerateSelfSigned, - ) + .send_group_message(players.map.keys().into_iter(), ServerMessage::StartGame {}) .unwrap(); } + +fn spawn_paddle(commands: &mut Commands, client_id: ClientId, pos: &Vec3) -> Entity { + commands + .spawn() + .insert(Paddle { + player_id: client_id, + }) + .insert_bundle(TransformBundle { + local: Transform { + translation: *pos, + scale: PADDLE_SIZE, + ..default() + }, + ..default() + }) + .insert(Collider) + .id() +} + +fn spawn_ball(commands: &mut Commands, pos: &Vec3, direction: &Vec2) -> Entity { + commands + .spawn() + .insert(Ball) + .insert_bundle(TransformBundle { + local: Transform { + scale: BALL_SIZE, + translation: *pos, + ..default() + }, + ..default() + }) + .insert(Velocity(direction.normalize() * BALL_SPEED)) + .id() +} |