1use bevy::prelude::*;
6
7mod stepping;
8
9const PADDLE_SIZE: Vec2 = Vec2::new(120.0, 20.0);
12const GAP_BETWEEN_PADDLE_AND_FLOOR: f32 = 60.0;
13const PADDLE_SPEED: f32 = 500.0;
14const PADDLE_PADDING: f32 = 10.0;
16
17const BALL_STARTING_POSITION: Vec3 = Vec3::new(0.0, -50.0, 1.0);
19const BALL_DIAMETER: f32 = 30.;
20const BALL_SPEED: f32 = 400.0;
21const INITIAL_BALL_DIRECTION: Vec2 = Vec2::new(0.5, -0.5);
22
23const WALL_THICKNESS: f32 = 10.0;
24const LEFT_WALL: f32 = -450.;
26const RIGHT_WALL: f32 = 450.;
27const BOTTOM_WALL: f32 = -300.;
29const TOP_WALL: f32 = 300.;
30
31const BRICK_SIZE: Vec2 = Vec2::new(100., 30.);
32const GAP_BETWEEN_PADDLE_AND_BRICKS: f32 = 270.0;
34const GAP_BETWEEN_BRICKS: f32 = 5.0;
35const GAP_BETWEEN_BRICKS_AND_CEILING: f32 = 20.0;
37const GAP_BETWEEN_BRICKS_AND_SIDES: f32 = 20.0;
38
39const SCOREBOARD_FONT_SIZE: FontSize = FontSize::Px(33.0);
40const SCOREBOARD_TEXT_PADDING: Val = Val::Px(5.0);
41
42const BACKGROUND_COLOR: Color = Color::srgb(0.9, 0.9, 0.9);
43const PADDLE_COLOR: Color = Color::srgb(0.3, 0.3, 0.7);
44const BALL_COLOR: Color = Color::srgb(1.0, 0.5, 0.5);
45const BRICK_COLOR: Color = Color::srgb(0.5, 0.5, 1.0);
46const WALL_COLOR: Color = Color::srgb(0.8, 0.8, 0.8);
47const TEXT_COLOR: Color = Color::srgb(0.5, 0.5, 1.0);
48const SCORE_COLOR: Color = Color::srgb(1.0, 0.5, 0.5);
49
50fn 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_systems(
64 Update,
65 (apply_velocity, move_paddle, check_for_collisions)
66 .chain(),
68 )
69 .add_systems(Update, update_scoreboard)
70 .add_observer(play_collision_sound)
71 .run();
72}
73
74#[derive(Component)]
75struct Paddle;
76
77#[derive(Component)]
78struct Ball;
79
80#[derive(Component, Deref, DerefMut)]
81struct Velocity(Vec2);
82
83#[derive(Event)]
84struct BallCollided;
85
86#[derive(Component)]
87struct Brick;
88
89#[derive(Resource, Deref)]
90struct CollisionSound(Handle<AudioSource>);
91
92#[derive(Component, Default)]
94struct Collider;
95
96#[derive(Component)]
98#[require(Sprite, Transform, Collider)]
99struct Wall;
100
101enum WallLocation {
103 Left,
104 Right,
105 Bottom,
106 Top,
107}
108
109impl WallLocation {
110 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 fn size(&self) -> Vec2 {
122 let arena_height = TOP_WALL - BOTTOM_WALL;
123 let arena_width = RIGHT_WALL - LEFT_WALL;
124 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
139impl Wall {
140 fn new(location: WallLocation) -> (Wall, Sprite, Transform) {
144 (
145 Wall,
146 Sprite::from_color(WALL_COLOR, Vec2::ONE),
147 Transform {
148 translation: location.position().extend(0.0),
151 scale: location.size().extend(1.0),
155 ..default()
156 },
157 )
158 }
159}
160
161#[derive(Resource, Deref, DerefMut)]
163struct Score(usize);
164
165#[derive(Component)]
166struct ScoreboardUi;
167
168fn setup(
170 mut commands: Commands,
171 mut meshes: ResMut<Assets<Mesh>>,
172 mut materials: ResMut<Assets<ColorMaterial>>,
173 asset_server: Res<AssetServer>,
174) {
175 commands.spawn(Camera2d);
177
178 let ball_collision_sound = asset_server.load("sounds/breakout_collision.ogg");
180 commands.insert_resource(CollisionSound(ball_collision_sound));
181
182 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 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 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 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 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 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 let center_of_bricks = (LEFT_WALL + RIGHT_WALL) / 2.0;
253 let left_edge_of_bricks = center_of_bricks
254 - (n_columns as f32 / 2.0 * BRICK_SIZE.x)
256 - n_vertical_gaps as f32 / 2.0 * GAP_BETWEEN_BRICKS;
258
259 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 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
289fn 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 let new_paddle_position =
306 paddle_transform.translation.x + direction * PADDLE_SPEED * time.delta_secs();
307
308 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
316fn 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
323fn 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
331fn 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 commands.trigger(BallCollided);
351
352 if maybe_brick.is_some() {
354 commands.entity(collider_entity).despawn();
355 **score += 1;
356 }
357
358 let mut reflect_x = false;
360 let mut reflect_y = false;
361
362 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 if reflect_x {
373 ball_velocity.x = -ball_velocity.x;
374 }
375
376 if reflect_y {
378 ball_velocity.y = -ball_velocity.y;
379 }
380 }
381 }
382}
383
384fn 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)]
393enum Collision {
394 Left,
395 Right,
396 Top,
397 Bottom,
398}
399
400fn 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}