Clean up Breakout logic (#4311)

# Objective

1. Spawning walls in the Breakout example was hard to follow and error-prone.
2. The strategy used in `paddle_movement_system` was somewhat convoluted.
3. Correctly modifying the size of the arena was hard, due to implicit coupling between the bounds and the bounds that the paddle can move in.

## Solution

1. Refactor this to use a WallBundle struct with a builder; neatly demonstrating some essential patterns along the way.
2. Use clamp and avoid using weird &mut strategies.
3. Refactor logic to allow users to tweak the brick size, and automatically adjust the number of rows and columns to match.
4. Make the brick layout more like classic breakout!

![image](https://user-images.githubusercontent.com/3579909/160019864-06747361-3b5b-4944-b3fd-4978604e2ef5.png)
This commit is contained in:
Alice Cecile 2022-03-24 23:57:36 +00:00
parent a190cd59db
commit 8570b651f9

View file

@ -13,10 +13,11 @@ 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.
// The `const_vec3!` macros are needed as functions that operate on floats cannot be constant in Rust.
const PADDLE_HEIGHT: f32 = -215.0;
const PADDLE_SIZE: Vec3 = const_vec3!([120.0, 30.0, 0.0]);
const PADDLE_SIZE: Vec3 = const_vec3!([120.0, 20.0, 0.0]);
const GAP_BETWEEN_PADDLE_AND_FLOOR: f32 = 60.0;
const PADDLE_SPEED: f32 = 500.0;
const PADDLE_BOUNDS: f32 = 380.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 = const_vec3!([0.0, -50.0, 1.0]);
@ -24,19 +25,27 @@ const BALL_SIZE: Vec3 = const_vec3!([30.0, 30.0, 0.0]);
const BALL_SPEED: f32 = 400.0;
const INITIAL_BALL_DIRECTION: Vec2 = const_vec2!([0.5, -0.5]);
const PLAY_AREA_BOUNDS: Vec2 = const_vec2!([900.0, 600.0]);
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_ROWS: u8 = 4;
const BRICK_COLUMNS: u8 = 5;
const BRICK_SPACING: f32 = 20.0;
const BRICK_SIZE: Vec3 = const_vec3!([150.0, 30.0, 1.0]);
const BRICK_SIZE: Vec2 = const_vec2!([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.5, 0.5, 1.0);
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);
@ -52,9 +61,9 @@ fn main() {
.add_system_set(
SystemSet::new()
.with_run_criteria(FixedTimestep::step(TIME_STEP as f64))
.with_system(move_paddle)
.with_system(check_for_collisions)
.with_system(apply_velocity),
.with_system(move_paddle.before(check_for_collisions))
.with_system(apply_velocity.before(check_for_collisions)),
)
.add_system(update_scoreboard)
.add_system(bevy::input::system::exit_on_esc_system)
@ -76,24 +85,97 @@ struct Collider;
#[derive(Component)]
struct Brick;
// 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 => Vec2::new(WALL_THICKNESS, arena_height + WALL_THICKNESS),
WallLocation::Right => Vec2::new(WALL_THICKNESS, arena_height + WALL_THICKNESS),
WallLocation::Bottom => Vec2::new(arena_width + WALL_THICKNESS, WALL_THICKNESS),
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,
}
// Add the game's entities to our world
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// Add the game's entities to our world
// cameras
// Cameras
commands.spawn_bundle(OrthographicCameraBundle::new_2d());
commands.spawn_bundle(UiCameraBundle::default());
// paddle
// 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_HEIGHT, 0.0),
translation: Vec3::new(0.0, paddle_y, 0.0),
scale: PADDLE_SIZE,
..default()
},
@ -104,9 +186,8 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
..default()
})
.insert(Collider);
// ball
let ball_velocity = INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED;
// Ball
commands
.spawn()
.insert(Ball)
@ -122,8 +203,9 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
},
..default()
})
.insert(Velocity(ball_velocity));
// scoreboard
.insert(Velocity(INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED));
// Scoreboard
commands.spawn_bundle(TextBundle {
text: Text {
sections: vec![
@ -158,79 +240,51 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
..default()
});
// left
commands
.spawn_bundle(SpriteBundle {
transform: Transform {
translation: Vec3::new(-PLAY_AREA_BOUNDS.x / 2.0, 0.0, 0.0),
scale: Vec3::new(WALL_THICKNESS, PLAY_AREA_BOUNDS.y + WALL_THICKNESS, 1.0),
..default()
},
sprite: Sprite {
color: WALL_COLOR,
..default()
},
..default()
})
.insert(Collider);
// right
commands
.spawn_bundle(SpriteBundle {
transform: Transform {
translation: Vec3::new(PLAY_AREA_BOUNDS.x / 2.0, 0.0, 0.0),
scale: Vec3::new(WALL_THICKNESS, PLAY_AREA_BOUNDS.y + WALL_THICKNESS, 1.0),
..default()
},
sprite: Sprite {
color: WALL_COLOR,
..default()
},
..default()
})
.insert(Collider);
// bottom
commands
.spawn_bundle(SpriteBundle {
transform: Transform {
translation: Vec3::new(0.0, -PLAY_AREA_BOUNDS.y / 2.0, 0.0),
scale: Vec3::new(PLAY_AREA_BOUNDS.x + WALL_THICKNESS, WALL_THICKNESS, 1.0),
..default()
},
sprite: Sprite {
color: WALL_COLOR,
..default()
},
..default()
})
.insert(Collider);
// top
commands
.spawn_bundle(SpriteBundle {
transform: Transform {
translation: Vec3::new(0.0, PLAY_AREA_BOUNDS.y / 2.0, 0.0),
scale: Vec3::new(PLAY_AREA_BOUNDS.x + WALL_THICKNESS, WALL_THICKNESS, 1.0),
..default()
},
sprite: Sprite {
color: WALL_COLOR,
..default()
},
..default()
})
.insert(Collider);
// 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),
);
// Add bricks
let bricks_width = BRICK_COLUMNS as f32 * (BRICK_SIZE.x + BRICK_SPACING) - BRICK_SPACING;
// center the bricks and move them up a bit
let bricks_offset = Vec3::new(-(bricks_width - BRICK_SIZE.x) / 2.0, 100.0, 0.0);
for row in 0..BRICK_ROWS {
let y_position = row as f32 * (BRICK_SIZE.y + BRICK_SPACING);
for column in 0..BRICK_COLUMNS {
let brick_position = Vec3::new(
column as f32 * (BRICK_SIZE.x + BRICK_SPACING),
y_position,
0.0,
) + bricks_offset;
// brick
commands
.spawn()
@ -241,8 +295,8 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
..default()
},
transform: Transform {
translation: brick_position,
scale: BRICK_SIZE,
translation: brick_position.extend(0.0),
scale: Vec3::new(BRICK_SIZE.x, BRICK_SIZE.y, 1.0),
..default()
},
..default()
@ -256,8 +310,9 @@ fn move_paddle(
keyboard_input: Res<Input<KeyCode>>,
mut query: Query<&mut Transform, With<Paddle>>,
) {
let mut transform = query.single_mut();
let mut paddle_transform = query.single_mut();
let mut direction = 0.0;
if keyboard_input.pressed(KeyCode::Left) {
direction -= 1.0;
}
@ -266,11 +321,15 @@ fn move_paddle(
direction += 1.0;
}
let translation = &mut transform.translation;
// move the paddle horizontally
translation.x += direction * PADDLE_SPEED * TIME_STEP;
// bound the paddle within the walls
translation.x = translation.x.min(PADDLE_BOUNDS).max(-PADDLE_BOUNDS);
// 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)>) {