aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--examples/breakout/breakout.rs421
-rw-r--r--examples/breakout/client.rs413
-rw-r--r--examples/breakout/protocol.rs50
-rw-r--r--examples/breakout/server.rs212
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()
+}