//! A simplified implementation of the classic game "Breakout". use bevy::{ prelude::*, sprite::collide_aabb::{collide, Collision}, sprite::MaterialMesh2dBundle, }; // 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); fn main() { App::new() .add_plugins(DefaultPlugins) .insert_resource(Scoreboard { score: 0 }) .insert_resource(ClearColor(BACKGROUND_COLOR)) .add_event::() // Configure how frequently our gameplay systems are run .insert_resource(FixedTime::new_from_secs(1.0 / 60.0)) .add_systems(Startup, setup) // Add our gameplay simulation systems to the fixed timestep schedule .add_systems( FixedUpdate, ( check_for_collisions, apply_velocity.before(check_for_collisions), move_paddle .before(check_for_collisions) .after(apply_velocity), play_collision_sound.after(check_for_collisions), ), ) .add_systems(Update, (update_scoreboard, 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(Event, Default)] struct CollisionEvent; #[derive(Component)] struct Brick; #[derive(Resource)] struct CollisionSound(Handle); // 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 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 #[derive(Resource)] struct Scoreboard { score: usize, } // Add the game's entities to our world fn setup( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, asset_server: Res, ) { // Camera commands.spawn(Camera2dBundle::default()); // Sound let ball_collision_sound = asset_server.load("sounds/breakout_collision.ogg"); commands.insert_resource(CollisionSound(ball_collision_sound)); // Paddle let paddle_y = BOTTOM_WALL + GAP_BETWEEN_PADDLE_AND_FLOOR; commands.spawn(( SpriteBundle { transform: Transform { translation: Vec3::new(0.0, paddle_y, 0.0), scale: PADDLE_SIZE, ..default() }, sprite: Sprite { color: PADDLE_COLOR, ..default() }, ..default() }, Paddle, Collider, )); // Ball commands.spawn(( MaterialMesh2dBundle { mesh: meshes.add(shape::Circle::default().into()).into(), material: materials.add(ColorMaterial::from(BALL_COLOR)), transform: Transform::from_translation(BALL_STARTING_POSITION).with_scale(BALL_SIZE), ..default() }, Ball, Velocity(INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED), )); // Scoreboard commands.spawn( TextBundle::from_sections([ TextSection::new( "Score: ", TextStyle { font_size: SCOREBOARD_FONT_SIZE, color: TEXT_COLOR, ..default() }, ), TextSection::from_style(TextStyle { font_size: SCOREBOARD_FONT_SIZE, color: SCORE_COLOR, ..default() }), ]) .with_style(Style { position_type: PositionType::Absolute, top: SCOREBOARD_TEXT_PADDING, left: SCOREBOARD_TEXT_PADDING, ..default() }), ); // Walls commands.spawn(WallBundle::new(WallLocation::Left)); commands.spawn(WallBundle::new(WallLocation::Right)); commands.spawn(WallBundle::new(WallLocation::Bottom)); commands.spawn(WallBundle::new(WallLocation::Top)); // Bricks 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(( 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() }, Brick, Collider, )); } } } fn move_paddle( keyboard_input: Res>, mut query: Query<&mut Transform, With>, time_step: Res, ) { 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.period.as_secs_f32(); // 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)>, time_step: Res) { for (mut transform, velocity) in &mut query { transform.translation.x += velocity.x * time_step.period.as_secs_f32(); transform.translation.y += velocity.y * time_step.period.as_secs_f32(); } } fn update_scoreboard(scoreboard: Res, mut query: Query<&mut Text>) { let mut text = query.single_mut(); text.sections[1].value = scoreboard.score.to_string(); } fn check_for_collisions( mut commands: Commands, mut scoreboard: ResMut, mut ball_query: Query<(&mut Velocity, &Transform), With>, collider_query: Query<(Entity, &Transform, Option<&Brick>), With>, mut collision_events: EventWriter, ) { 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( mut commands: Commands, mut collision_events: EventReader, sound: Res, ) { // 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(); commands.spawn(AudioBundle { source: sound.0.clone(), // auto-despawn the entity when playback finishes settings: PlaybackSettings::DESPAWN, }); } }