docs/bevy/examples/showcase/breakout.rs 13.8 K · 422 lines · raw · history

1 //! A simplified implementation of the classic game "Breakout".
2 //!
3 //! Demonstrates Bevy's stepping capabilities if compiled with the `bevy_debug_stepping` feature.
4
5 use bevy::prelude::*;
6
7 mod stepping;
8
9 // These constants are defined in `Transform` units.
10 // Using the default 2D camera they correspond 1:1 with screen pixels.
11 const PADDLE_SIZE: Vec2 = Vec2::new(120.0, 20.0);
12 const GAP_BETWEEN_PADDLE_AND_FLOOR: f32 = 60.0;
13 const PADDLE_SPEED: f32 = 500.0;
14 // How close can the paddle get to the wall
15 const PADDLE_PADDING: f32 = 10.0;
16
17 // We set the z-value of the ball to 1 so it renders on top in the case of overlapping sprites.
18 const BALL_STARTING_POSITION: Vec3 = Vec3::new(0.0, -50.0, 1.0);
19 const BALL_DIAMETER: f32 = 30.;
20 const BALL_SPEED: f32 = 400.0;
21 const INITIAL_BALL_DIRECTION: Vec2 = Vec2::new(0.5, -0.5);
22
23 const WALL_THICKNESS: f32 = 10.0;
24 // x coordinates
25 const LEFT_WALL: f32 = -450.;
26 const RIGHT_WALL: f32 = 450.;
27 // y coordinates
28 const BOTTOM_WALL: f32 = -300.;
29 const TOP_WALL: f32 = 300.;
30
31 const BRICK_SIZE: Vec2 = Vec2::new(100., 30.);
32 // These values are exact
33 const GAP_BETWEEN_PADDLE_AND_BRICKS: f32 = 270.0;
34 const GAP_BETWEEN_BRICKS: f32 = 5.0;
35 // These values are lower bounds, as the number of bricks is computed
36 const GAP_BETWEEN_BRICKS_AND_CEILING: f32 = 20.0;
37 const GAP_BETWEEN_BRICKS_AND_SIDES: f32 = 20.0;
38
39 const SCOREBOARD_FONT_SIZE: FontSize = FontSize::Px(33.0);
40 const SCOREBOARD_TEXT_PADDING: Val = Val::Px(5.0);
41
42 const BACKGROUND_COLOR: Color = Color::srgb(0.9, 0.9, 0.9);
43 const PADDLE_COLOR: Color = Color::srgb(0.3, 0.3, 0.7);
44 const BALL_COLOR: Color = Color::srgb(1.0, 0.5, 0.5);
45 const BRICK_COLOR: Color = Color::srgb(0.5, 0.5, 1.0);
46 const WALL_COLOR: Color = Color::srgb(0.8, 0.8, 0.8);
47 const TEXT_COLOR: Color = Color::srgb(0.5, 0.5, 1.0);
48 const SCORE_COLOR: Color = Color::srgb(1.0, 0.5, 0.5);
49
50 fn main() {
51 App::new()
52 .add_plugins(DefaultPlugins)
53 .add_plugins(
54 stepping::SteppingPlugin::default()
55 .add_schedule(Update)
56 .at(percent(35), percent(50)),
57 )
58 .insert_resource(Score(0))
59 .insert_resource(ClearColor(BACKGROUND_COLOR))
60 .add_systems(Startup, setup)
61 // Add our simulation systems to the update schedule
62 // which is called once per frame.
63 .add_systems(
64 Update,
65 (apply_velocity, move_paddle, check_for_collisions)
66 // `chain`ing systems together runs them in order
67 .chain(),
68 )
69 .add_systems(Update, update_scoreboard)
70 .add_observer(play_collision_sound)
71 .run();
72 }
73
74 #[derive(Component)]
75 struct Paddle;
76
77 #[derive(Component)]
78 struct Ball;
79
80 #[derive(Component, Deref, DerefMut)]
81 struct Velocity(Vec2);
82
83 #[derive(Event)]
84 struct BallCollided;
85
86 #[derive(Component)]
87 struct Brick;
88
89 #[derive(Resource, Deref)]
90 struct CollisionSound(Handle<AudioSource>);
91
92 // Default must be implemented to define this as a required component for the Wall component below
93 #[derive(Component, Default)]
94 struct Collider;
95
96 // This is a collection of the components that define a "Wall" in our game
97 #[derive(Component)]
98 #[require(Sprite, Transform, Collider)]
99 struct Wall;
100
101 /// Which side of the arena is this wall located on?
102 enum WallLocation {
103 Left,
104 Right,
105 Bottom,
106 Top,
107 }
108
109 impl WallLocation {
110 /// Location of the *center* of the wall, used in `transform.translation()`
111 fn position(&self) -> Vec2 {
112 match self {
113 WallLocation::Left => Vec2::new(LEFT_WALL, 0.),
114 WallLocation::Right => Vec2::new(RIGHT_WALL, 0.),
115 WallLocation::Bottom => Vec2::new(0., BOTTOM_WALL),
116 WallLocation::Top => Vec2::new(0., TOP_WALL),
117 }
118 }
119
120 /// (x, y) dimensions of the wall, used in `transform.scale()`
121 fn size(&self) -> Vec2 {
122 let arena_height = TOP_WALL - BOTTOM_WALL;
123 let arena_width = RIGHT_WALL - LEFT_WALL;
124 // Make sure we haven't messed up our constants
125 assert!(arena_height > 0.0);
126 assert!(arena_width > 0.0);
127
128 match self {
129 WallLocation::Left | WallLocation::Right => {
130 Vec2::new(WALL_THICKNESS, arena_height + WALL_THICKNESS)
131 }
132 WallLocation::Bottom | WallLocation::Top => {
133 Vec2::new(arena_width + WALL_THICKNESS, WALL_THICKNESS)
134 }
135 }
136 }
137 }
138
139 impl Wall {
140 // This "builder method" allows us to reuse logic across our wall entities,
141 // making our code easier to read and less prone to bugs when we change the logic
142 // Notice the use of Sprite and Transform alongside Wall, overwriting the default values defined for the required components
143 fn new(location: WallLocation) -> (Wall, Sprite, Transform) {
144 (
145 Wall,
146 Sprite::from_color(WALL_COLOR, Vec2::ONE),
147 Transform {
148 // We need to convert our Vec2 into a Vec3, by giving it a z-coordinate
149 // This is used to determine the order of our sprites
150 translation: location.position().extend(0.0),
151 // The z-scale of 2D objects must always be 1.0,
152 // or their ordering will be affected in surprising ways.
153 // See https://github.com/bevyengine/bevy/issues/4149
154 scale: location.size().extend(1.0),
155 ..default()
156 },
157 )
158 }
159 }
160
161 // This resource tracks the game's score
162 #[derive(Resource, Deref, DerefMut)]
163 struct Score(usize);
164
165 #[derive(Component)]
166 struct ScoreboardUi;
167
168 // Add the game's entities to our world
169 fn setup(
170 mut commands: Commands,
171 mut meshes: ResMut<Assets<Mesh>>,
172 mut materials: ResMut<Assets<ColorMaterial>>,
173 asset_server: Res<AssetServer>,
174 ) {
175 // Camera
176 commands.spawn(Camera2d);
177
178 // Sound
179 let ball_collision_sound = asset_server.load("sounds/breakout_collision.ogg");
180 commands.insert_resource(CollisionSound(ball_collision_sound));
181
182 // Paddle
183 let paddle_y = BOTTOM_WALL + GAP_BETWEEN_PADDLE_AND_FLOOR;
184
185 commands.spawn((
186 Sprite::from_color(PADDLE_COLOR, Vec2::ONE),
187 Transform {
188 translation: Vec3::new(0.0, paddle_y, 0.0),
189 scale: PADDLE_SIZE.extend(1.0),
190 ..default()
191 },
192 Paddle,
193 Collider,
194 ));
195
196 // Ball
197 commands.spawn((
198 Mesh2d(meshes.add(Circle::default())),
199 MeshMaterial2d(materials.add(BALL_COLOR)),
200 Transform::from_translation(BALL_STARTING_POSITION)
201 .with_scale(Vec2::splat(BALL_DIAMETER).extend(1.)),
202 Ball,
203 Velocity(INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED),
204 ));
205
206 // Scoreboard
207 commands.spawn((
208 Text::new("Score: "),
209 TextFont {
210 font_size: SCOREBOARD_FONT_SIZE,
211 ..default()
212 },
213 TextColor(TEXT_COLOR),
214 ScoreboardUi,
215 Node {
216 position_type: PositionType::Absolute,
217 top: SCOREBOARD_TEXT_PADDING,
218 left: SCOREBOARD_TEXT_PADDING,
219 ..default()
220 },
221 children![(
222 TextSpan::default(),
223 TextFont {
224 font_size: SCOREBOARD_FONT_SIZE,
225 ..default()
226 },
227 TextColor(SCORE_COLOR),
228 )],
229 ));
230
231 // Walls
232 commands.spawn(Wall::new(WallLocation::Left));
233 commands.spawn(Wall::new(WallLocation::Right));
234 commands.spawn(Wall::new(WallLocation::Bottom));
235 commands.spawn(Wall::new(WallLocation::Top));
236
237 // Bricks
238 let total_width_of_bricks = (RIGHT_WALL - LEFT_WALL) - 2. * GAP_BETWEEN_BRICKS_AND_SIDES;
239 let bottom_edge_of_bricks = paddle_y + GAP_BETWEEN_PADDLE_AND_BRICKS;
240 let total_height_of_bricks = TOP_WALL - bottom_edge_of_bricks - GAP_BETWEEN_BRICKS_AND_CEILING;
241
242 assert!(total_width_of_bricks > 0.0);
243 assert!(total_height_of_bricks > 0.0);
244
245 // Given the space available, compute how many rows and columns of bricks we can fit
246 let n_columns = (total_width_of_bricks / (BRICK_SIZE.x + GAP_BETWEEN_BRICKS)).floor() as usize;
247 let n_rows = (total_height_of_bricks / (BRICK_SIZE.y + GAP_BETWEEN_BRICKS)).floor() as usize;
248 let n_vertical_gaps = n_columns - 1;
249
250 // Because we need to round the number of columns,
251 // the space on the top and sides of the bricks only captures a lower bound, not an exact value
252 let center_of_bricks = (LEFT_WALL + RIGHT_WALL) / 2.0;
253 let left_edge_of_bricks = center_of_bricks
254 // Space taken up by the bricks
255 - (n_columns as f32 / 2.0 * BRICK_SIZE.x)
256 // Space taken up by the gaps
257 - n_vertical_gaps as f32 / 2.0 * GAP_BETWEEN_BRICKS;
258
259 // In Bevy, the `translation` of an entity describes the center point,
260 // not its bottom-left corner
261 let offset_x = left_edge_of_bricks + BRICK_SIZE.x / 2.;
262 let offset_y = bottom_edge_of_bricks + BRICK_SIZE.y / 2.;
263
264 for row in 0..n_rows {
265 for column in 0..n_columns {
266 let brick_position = Vec2::new(
267 offset_x + column as f32 * (BRICK_SIZE.x + GAP_BETWEEN_BRICKS),
268 offset_y + row as f32 * (BRICK_SIZE.y + GAP_BETWEEN_BRICKS),
269 );
270
271 // brick
272 commands.spawn((
273 Sprite {
274 color: BRICK_COLOR,
275 ..default()
276 },
277 Transform {
278 translation: brick_position.extend(0.0),
279 scale: Vec3::new(BRICK_SIZE.x, BRICK_SIZE.y, 1.0),
280 ..default()
281 },
282 Brick,
283 Collider,
284 ));
285 }
286 }
287 }
288
289 fn move_paddle(
290 keyboard_input: Res<ButtonInput<KeyCode>>,
291 mut paddle_transform: Single<&mut Transform, With<Paddle>>,
292 time: Res<Time>,
293 ) {
294 let mut direction = 0.0;
295
296 if keyboard_input.pressed(KeyCode::ArrowLeft) {
297 direction -= 1.0;
298 }
299
300 if keyboard_input.pressed(KeyCode::ArrowRight) {
301 direction += 1.0;
302 }
303
304 // Calculate the new horizontal paddle position based on player input
305 let new_paddle_position =
306 paddle_transform.translation.x + direction * PADDLE_SPEED * time.delta_secs();
307
308 // Update the paddle position,
309 // making sure it doesn't cause the paddle to leave the arena
310 let left_bound = LEFT_WALL + WALL_THICKNESS / 2.0 + PADDLE_SIZE.x / 2.0 + PADDLE_PADDING;
311 let right_bound = RIGHT_WALL - WALL_THICKNESS / 2.0 - PADDLE_SIZE.x / 2.0 - PADDLE_PADDING;
312
313 paddle_transform.translation.x = new_paddle_position.clamp(left_bound, right_bound);
314 }
315
316 fn apply_velocity(mut query: Query<(&mut Transform, &Velocity)>, time: Res<Time>) {
317 for (mut transform, velocity) in &mut query {
318 transform.translation.x += velocity.x * time.delta_secs();
319 transform.translation.y += velocity.y * time.delta_secs();
320 }
321 }
322
323 fn update_scoreboard(
324 score: Res<Score>,
325 score_root: Single<Entity, (With<ScoreboardUi>, With<Text>)>,
326 mut writer: TextUiWriter,
327 ) {
328 *writer.text(*score_root, 1) = score.to_string();
329 }
330
331 fn check_for_collisions(
332 mut commands: Commands,
333 mut score: ResMut<Score>,
334 ball_query: Single<(&mut Velocity, &Transform), With<Ball>>,
335 collider_query: Query<(Entity, &Transform, Option<&Brick>), With<Collider>>,
336 ) {
337 let (mut ball_velocity, ball_transform) = ball_query.into_inner();
338
339 for (collider_entity, collider_transform, maybe_brick) in &collider_query {
340 let collision = ball_collision(
341 BoundingCircle::new(ball_transform.translation.truncate(), BALL_DIAMETER / 2.),
342 Aabb2d::new(
343 collider_transform.translation.truncate(),
344 collider_transform.scale.truncate() / 2.,
345 ),
346 );
347
348 if let Some(collision) = collision {
349 // Trigger observers of the "BallCollided" event
350 commands.trigger(BallCollided);
351
352 // Bricks should be despawned and increment the scoreboard on collision
353 if maybe_brick.is_some() {
354 commands.entity(collider_entity).despawn();
355 **score += 1;
356 }
357
358 // Reflect the ball's velocity when it collides
359 let mut reflect_x = false;
360 let mut reflect_y = false;
361
362 // Reflect only if the velocity is in the opposite direction of the collision
363 // This prevents the ball from getting stuck inside the bar
364 match collision {
365 Collision::Left => reflect_x = ball_velocity.x > 0.0,
366 Collision::Right => reflect_x = ball_velocity.x < 0.0,
367 Collision::Top => reflect_y = ball_velocity.y < 0.0,
368 Collision::Bottom => reflect_y = ball_velocity.y > 0.0,
369 }
370
371 // Reflect velocity on the x-axis if we hit something on the x-axis
372 if reflect_x {
373 ball_velocity.x = -ball_velocity.x;
374 }
375
376 // Reflect velocity on the y-axis if we hit something on the y-axis
377 if reflect_y {
378 ball_velocity.y = -ball_velocity.y;
379 }
380 }
381 }
382 }
383
384 fn play_collision_sound(
385 _collided: On<BallCollided>,
386 mut commands: Commands,
387 sound: Res<CollisionSound>,
388 ) {
389 commands.spawn((AudioPlayer(sound.clone()), PlaybackSettings::DESPAWN));
390 }
391
392 #[derive(Debug, PartialEq, Eq, Copy, Clone)]
393 enum Collision {
394 Left,
395 Right,
396 Top,
397 Bottom,
398 }
399
400 // Returns `Some` if `ball` collides with `bounding_box`.
401 // The returned `Collision` is the side of `bounding_box` that `ball` hit.
402 fn ball_collision(ball: BoundingCircle, bounding_box: Aabb2d) -> Option<Collision> {
403 if !ball.intersects(&bounding_box) {
404 return None;
405 }
406
407 let closest = bounding_box.closest_point(ball.center());
408 let offset = ball.center() - closest;
409 let side = if offset.x.abs() > offset.y.abs() {
410 if offset.x < 0. {
411 Collision::Left
412 } else {
413 Collision::Right
414 }
415 } else if offset.y > 0. {
416 Collision::Top
417 } else {
418 Collision::Bottom
419 };
420
421 Some(side)
422 }