aboutsummaryrefslogtreecommitdiff
path: root/examples
diff options
context:
space:
mode:
authorHenauxg <19689618+Henauxg@users.noreply.github.com>2022-11-02 17:14:06 +0100
committerHenauxg <19689618+Henauxg@users.noreply.github.com>2022-11-02 17:14:06 +0100
commit1655dfb503af20f0de37cbca694443b8bac35def (patch)
treeffa689e20e9ec741c269059c60f20e234c853c8c /examples
parent2ab5dda6513f4995a10d25a040be643d666602b7 (diff)
[example] Versus breakout prototype
Diffstat (limited to 'examples')
-rw-r--r--examples/breakout/breakout.rs554
-rw-r--r--examples/breakout/client.rs68
-rw-r--r--examples/breakout/protocol.rs24
-rw-r--r--examples/breakout/server.rs74
4 files changed, 720 insertions, 0 deletions
diff --git a/examples/breakout/breakout.rs b/examples/breakout/breakout.rs
new file mode 100644
index 0000000..767fd9c
--- /dev/null
+++ b/examples/breakout/breakout.rs
@@ -0,0 +1,554 @@
+//! A simplified implementation of the classic game "Breakout".
+
+use bevy::{
+ ecs::schedule::ShouldRun,
+ prelude::*,
+ sprite::collide_aabb::{collide, Collision},
+ time::FixedTimestep,
+};
+use bevy_quinnet::{client::QuinnetClientPlugin, server::QuinnetServerPlugin};
+use client::{handle_server_messages, start_connection};
+use server::{handle_client_messages, handle_server_events, start_listening, Users};
+
+mod client;
+mod protocol;
+mod server;
+
+// Defines the amount of time that should elapse between each physics step.
+const TIME_STEP: f32 = 1.0 / 60.0;
+
+// These constants are defined in `Transform` units.
+// Using the default 2D camera they correspond 1:1 with screen pixels.
+const PADDLE_SIZE: Vec3 = Vec3::new(120.0, 20.0, 0.0);
+const GAP_BETWEEN_PADDLE_AND_FLOOR: f32 = 60.0;
+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
+const LEFT_WALL: f32 = -450.;
+const RIGHT_WALL: f32 = 450.;
+// y coordinates
+const BOTTOM_WALL: f32 = -300.;
+const TOP_WALL: f32 = 300.;
+
+const BRICK_SIZE: Vec2 = Vec2::new(100., 30.);
+// These values are exact
+const GAP_BETWEEN_PADDLE_AND_BRICKS: f32 = 270.0;
+const GAP_BETWEEN_BRICKS: f32 = 5.0;
+// These values are lower bounds, as the number of bricks is computed
+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,
+ Hosting,
+ Joining,
+ Running,
+}
+
+fn main() {
+ App::new()
+ .add_plugins(DefaultPlugins)
+ .add_plugin(QuinnetServerPlugin::default())
+ .add_plugin(QuinnetClientPlugin::default())
+ .add_event::<CollisionEvent>()
+ .add_state(GameState::MainMenu)
+ .insert_resource(Scoreboard { score: 0 })
+ .insert_resource(ClearColor(BACKGROUND_COLOR))
+ // Resources
+ .insert_resource(server::Users::default()) //TODO Move ?
+ .insert_resource(client::Users::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))
+ .add_system_set(SystemSet::on_exit(GameState::MainMenu).with_system(teardown_main_menu))
+ // Hosting
+ .add_system_set(SystemSet::on_enter(GameState::Hosting).with_system(start_listening))
+ .add_system_set(
+ SystemSet::on_update(GameState::Hosting)
+ .with_system(handle_client_messages)
+ .with_system(handle_server_events),
+ )
+ // or Joining
+ .add_system_set(SystemSet::on_enter(GameState::Joining).with_system(start_connection))
+ .add_system_set(
+ SystemSet::on_update(GameState::Joining)
+ .with_system(handle_server_messages)
+ .with_system(handle_server_events),
+ )
+ // Running the game
+ .add_system_set(SystemSet::on_enter(GameState::Running).with_system(setup_breakout))
+ .add_system_set(
+ SystemSet::new()
+ // https://github.com/bevyengine/bevy/issues/1839
+ .with_run_criteria(FixedTimestep::step(TIME_STEP as f64).chain(
+ |In(input): In<ShouldRun>, state: Res<State<GameState>>| match state.current() {
+ GameState::Running => input,
+ _ => ShouldRun::No,
+ },
+ ))
+ .with_system(check_for_collisions)
+ .with_system(move_paddle.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),
+ )
+ .add_system(bevy::window::close_on_esc)
+ .run();
+}
+
+#[derive(Component)]
+struct Paddle;
+
+#[derive(Component)]
+struct Ball;
+
+#[derive(Component, Deref, DerefMut)]
+struct Velocity(Vec2);
+
+#[derive(Component)]
+struct Collider;
+
+#[derive(Default)]
+struct CollisionEvent;
+
+#[derive(Component)]
+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,
+ Right,
+ Bottom,
+ Top,
+}
+
+impl WallLocation {
+ fn position(&self) -> Vec2 {
+ match self {
+ WallLocation::Left => Vec2::new(LEFT_WALL, 0.),
+ WallLocation::Right => Vec2::new(RIGHT_WALL, 0.),
+ WallLocation::Bottom => Vec2::new(0., BOTTOM_WALL),
+ WallLocation::Top => Vec2::new(0., TOP_WALL),
+ }
+ }
+
+ fn size(&self) -> Vec2 {
+ let arena_height = TOP_WALL - BOTTOM_WALL;
+ let arena_width = RIGHT_WALL - LEFT_WALL;
+ // Make sure we haven't messed up our constants
+ assert!(arena_height > 0.0);
+ assert!(arena_width > 0.0);
+
+ match self {
+ WallLocation::Left | WallLocation::Right => {
+ Vec2::new(WALL_THICKNESS, arena_height + WALL_THICKNESS)
+ }
+ WallLocation::Bottom | WallLocation::Top => {
+ Vec2::new(arena_width + WALL_THICKNESS, WALL_THICKNESS)
+ }
+ }
+ }
+}
+
+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,
+}
+
+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::Hosting).unwrap(),
+ MenuItem::Join => game_state.set(GameState::Joining).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;
+ transform.translation.y += velocity.y * TIME_STEP;
+ }
+}
+
+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>,
+ mut ball_query: Query<(&mut Velocity, &Transform), With<Ball>>,
+ 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;
+
+ // 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 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
new file mode 100644
index 0000000..0c723a3
--- /dev/null
+++ b/examples/breakout/client.rs
@@ -0,0 +1,68 @@
+use std::collections::HashMap;
+
+use bevy::prelude::{warn, EventReader, ResMut};
+use bevy_quinnet::{
+ client::{CertificateVerificationMode, Client, ClientConfigurationData, ConnectionEvent},
+ ClientId,
+};
+
+use crate::protocol::ServerMessage;
+
+#[derive(Debug, Clone, Default)]
+pub(crate) struct Users {
+ self_id: ClientId,
+ names: HashMap<ClientId, String>,
+}
+
+pub(crate) fn handle_server_messages(mut client: ResMut<Client>, mut users: ResMut<Users>) {
+ 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;
+ }
+ ServerMessage::BrickDestroyed {} => todo!(),
+ ServerMessage::BallPosition {} => todo!(),
+ ServerMessage::PaddlePosition {} => 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,
+ )
+ .unwrap();
+}
+
+pub(crate) fn handle_client_events(
+ connection_events: EventReader<ConnectionEvent>,
+ client: ResMut<Client>,
+) {
+ if !connection_events.is_empty() {
+ // We are connected
+ // let username: String = rand::thread_rng()
+ // .sample_iter(&Alphanumeric)
+ // .take(7)
+ // .map(char::from)
+ // .collect();
+
+ // println!("--- Joining with name: {}", username);
+ // println!("--- Type 'quit' to disconnect");
+
+ // client
+ // .send_message(ClientMessage::Join { name: username })
+ // .unwrap();
+
+ connection_events.clear();
+ }
+}
diff --git a/examples/breakout/protocol.rs b/examples/breakout/protocol.rs
new file mode 100644
index 0000000..7ed1710
--- /dev/null
+++ b/examples/breakout/protocol.rs
@@ -0,0 +1,24 @@
+use bevy_quinnet::ClientId;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Input {}
+
+// Messages from clients
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum ClientMessage {
+ Join {},
+ Disconnect {},
+ 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 },
+ BrickDestroyed {},
+ BallPosition {},
+ PaddlePosition {},
+}
diff --git a/examples/breakout/server.rs b/examples/breakout/server.rs
new file mode 100644
index 0000000..d015269
--- /dev/null
+++ b/examples/breakout/server.rs
@@ -0,0 +1,74 @@
+use std::collections::HashMap;
+
+use bevy::prelude::{info, warn, EventReader, ResMut};
+use bevy_quinnet::{
+ server::{CertificateRetrievalMode, ConnectionLostEvent, Server, ServerConfigurationData},
+ ClientId,
+};
+
+use crate::protocol::{ClientMessage, ServerMessage};
+
+#[derive(Debug, Clone, Default)]
+pub(crate) struct Users {
+ names: HashMap<ClientId, String>,
+}
+
+pub(crate) fn handle_client_messages(mut server: ResMut<Server>, mut users: ResMut<Users>) {
+ while let Ok(Some((message, client_id))) = server.receive_message::<ClientMessage>() {
+ match message {
+ ClientMessage::Join {} => {}
+ 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!(),
+ }
+ }
+}
+
+pub(crate) fn handle_server_events(
+ mut connection_lost_events: EventReader<ConnectionLostEvent>,
+ mut server: ResMut<Server>,
+ mut users: ResMut<Users>,
+) {
+ // The server signals us about users that lost connection
+ for client in connection_lost_events.iter() {
+ handle_disconnect(&mut server, &mut users, client.id);
+ }
+}
+
+/// Shared disconnection behaviour, whether the client lost connection or asked to disconnect
+pub(crate) fn handle_disconnect(
+ server: &mut ResMut<Server>,
+ users: &mut ResMut<Users>,
+ client_id: ClientId,
+) {
+ // Remove this user
+ if let Some(username) = users.names.remove(&client_id) {
+ // Broadcast its deconnection
+ server
+ .send_group_message(
+ users.names.keys().into_iter(),
+ ServerMessage::ClientDisconnected {
+ client_id: client_id,
+ },
+ )
+ .unwrap();
+ info!("{} disconnected", username);
+ } else {
+ warn!(
+ "Received a Disconnect from an unknown or disconnected client: {}",
+ client_id
+ )
+ }
+}
+
+pub(crate) fn start_listening(server: ResMut<Server>) {
+ server
+ .start(
+ ServerConfigurationData::new("127.0.0.1".to_string(), 6000, "0.0.0.0".to_string()),
+ CertificateRetrievalMode::GenerateSelfSigned,
+ )
+ .unwrap();
+}