Created
October 25, 2022 03:04
-
-
Save mikechambers/955ed0ae4e604b7102d1eafd494adc80 to your computer and use it in GitHub Desktop.
Bevy Rust Game Framework Breakout Example with comments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//original at: | |
//https://github.com/bevyengine/bevy/blob/main/examples/games/breakout.rs | |
use bevy::{ | |
prelude::*, | |
sprite::collide_aabb::{collide, Collision}, | |
time::FixedTimestep, | |
}; | |
//basically our FPS - 60FPS | |
const TIME_STEP: f32 = 1.0 / 60.0; | |
//paddle size x, y, z (x is width, y is height) | |
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; | |
const PADDLE_PADDING: f32 = 10.0; | |
//starting position. set z to 1 so it renders above other items | |
const BALL_STARTING_POSITION: Vec3 = Vec3::new(0.0, -50.0, 1.0); | |
//x = width, y = height | |
const BALL_SIZE: Vec3 = Vec3::new(30.0, 30.0, 0.0); | |
const BALL_SPEED :f32 = 400.0; | |
//x,y - down to the right | |
const INITIAL_BALL_DIRECTION : Vec2 = Vec2::new(0.5, -0.5); | |
const WALL_THICKNESS: f32 = 10.0; | |
//relative to 2d origin, which is center screen | |
const LEFT_WALL:f32 = -450.0; | |
const RIGHT_WALL:f32 = 450.0; | |
const BOTTOM_WALL:f32 = -300.0; | |
const TOP_WALL:f32 = 300.0; | |
//width / height | |
const BRICK_SIZE: Vec2 = Vec2::new(100.0, 30.0); | |
const GAP_BETWEEN_PADDLE_AND_BRICKS: f32 = 270.0; | |
const GAP_BETWEEN_BRICKS: f32 = 5.0; | |
const GAP_BETWEEN_BRICKS_AND_CEILING:f32 = 20.0; | |
const GAP_BETWEEN_BRICK_AND_SIDE:f32 = 20.0; | |
const SCOREBOARD_FONT_SIZE: f32 = 40.0; | |
//Val describes possible types of values in a flexbox layout (px or percent) | |
const SCOREBOARD_TEXT_PADDING: Val = Val::Px(5.0); | |
//Color has rgb, rgba and hex methods | |
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); | |
//main entry point for rust program | |
fn main() { | |
App::new() | |
//add default plugins, such as Windowing, etc... | |
.add_plugins(DefaultPlugins) | |
//Resources represent globally unique data | |
.insert_resource(Scoreboard {score: 0}) | |
//ClearColor defines the color used to clear the screen, default | |
//for all cameras, and basically the background color | |
.insert_resource(ClearColor(BACKGROUND_COLOR)) | |
//startup system is called once when app starts | |
.add_startup_system(setup) | |
//Listen for CollisionEvents, We use this to send an event when there is | |
//a collision, then listen for it to play a sound | |
.add_event::<CollisionEvent>() | |
//Note that the systems below run in order they are defined | |
// | |
//system sets allows you to group systems | |
//https://github.com/bevyengine/bevy/blob/main/examples/ecs/system_sets.rs | |
.add_system_set( | |
SystemSet::new() | |
.with_run_criteria(FixedTimestep::step(TIME_STEP as f64)) | |
.with_system(check_for_collisions) | |
.with_system(move_paddle.before(check_for_collisions)) | |
.with_system(apply_velocity.before(check_for_collisions)) | |
.with_system(player_collision_sound.after(check_for_collisions)), | |
) | |
.add_system(update_scoreboard) | |
.add_system(bevy::window::close_on_esc) | |
.run(); | |
} | |
//Components are basically Data types that entities can have | |
#[derive(Component)] | |
struct Paddle; | |
#[derive(Component)] | |
struct Ball; | |
//I think Deref and DerefMut means we may want to uniquely access the actual item | |
#[derive(Component, Deref, DerefMut)] | |
struct Velocity(Vec2); | |
#[derive(Component)] | |
struct Collider; | |
//default impliments default values for simple resource | |
#[derive(Default)] | |
struct CollisionEvent; | |
#[derive(Component)] | |
struct Brick; | |
//Handle contains a unique id that corresponds to a specific item in the | |
//assets collection | |
struct CollisionSound(Handle<AudioSource>); | |
//A bundle is a typed collection of components. | |
//in this case, a SpriteBundle, and a collider | |
#[derive(Bundle)] | |
struct WallBundle { | |
#[bundle] | |
sprite_bundle:SpriteBundle, | |
collider: Collider, | |
} | |
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; | |
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 { | |
fn new(location:WallLocation) -> WallBundle { | |
WallBundle { | |
sprite_bundle: SpriteBundle { | |
transform: Transform { | |
translation: location.position().extend(0.0), | |
scale: location.size().extend(1.0), | |
..default() | |
}, | |
sprite : Sprite { | |
color: WALL_COLOR, | |
..default() | |
}, | |
..default() | |
}, | |
collider: Collider, | |
} | |
} | |
} | |
struct Scoreboard { | |
score: usize, | |
} | |
//one time setup function | |
//commands are a queue of commands to run on the world. Can use to | |
//spawn / despawn entities, add / remove components and manage resource | |
//Command are queued to be performed later when it is safe to do so. | |
fn setup( | |
mut commands: Commands, | |
asset_server: Res<AssetServer> | |
) { | |
commands.spawn_bundle(Camera2dBundle::default()); | |
//AssetServer is used to load assets from the file system. Note that calling | |
//load() does not necessarily load the asset right away. Instead is returned | |
//a handle to that asset which can be used. (and then when the asset loads | |
//it will be avaliable) | |
let ball_collision_sound = asset_server.load("sounds/breakout_collision.ogg"); | |
commands.insert_resource(CollisionSound(ball_collision_sound)); | |
let paddle_y = BOTTOM_WALL + GAP_BETWEEN_PADDLE_AND_FLOOR; | |
//calling spawn spawns a single entity | |
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); | |
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)); | |
//Text items that make up the scoreboard | |
commands.spawn_bundle( | |
TextBundle::from_sections([ | |
TextSection::new( | |
"Score: ", | |
TextStyle { | |
font:asset_server.load("fonts/FiraSans-Bold.ttf"), | |
font_size: SCOREBOARD_FONT_SIZE, | |
color: TEXT_COLOR | |
}, | |
), | |
TextSection::from_style( | |
TextStyle { | |
font: asset_server.load("fonts/FiraSans-Bold.ttf"), | |
font_size: SCOREBOARD_FONT_SIZE, | |
color: SCORE_COLOR | |
} | |
), | |
]) | |
//Position is absolute, offset from top and left | |
.with_style( | |
Style { | |
position_type: PositionType::Absolute, | |
position: UiRect { | |
top: SCOREBOARD_TEXT_PADDING, | |
left: SCOREBOARD_TEXT_PADDING, | |
..default() | |
}, | |
..default() | |
} | |
) | |
); | |
//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)); | |
assert!(BRICK_SIZE.x > 0.0); | |
assert!(BRICK_SIZE.y > 0.0); | |
//note the 2. is the dot operator to autoconvert | |
//https://doc.rust-lang.org/nomicon/dot-operator.html | |
let total_width_of_bricks = (RIGHT_WALL - LEFT_WALL) - 2. * GAP_BETWEEN_BRICK_AND_SIDE; | |
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); | |
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; | |
let center_of_bricks = (LEFT_WALL + RIGHT_WALL) / 2.0; | |
let left_edge_of_bricks = center_of_bricks | |
- (n_columns as f32 / 2.0 * BRICK_SIZE.x) | |
- n_vertical_gaps as f32 / 2.0 * GAP_BETWEEN_BRICKS; | |
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) | |
); | |
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; | |
} | |
let new_paddle_position = paddle_transform.translation.x + | |
direction * PADDLE_SPEED * TIME_STEP; | |
let left_bounds = LEFT_WALL + WALL_THICKNESS / 2.0 + | |
PADDLE_SIZE.x / 2.0 + PADDLE_PADDING; | |
let right_bounds = RIGHT_WALL - WALL_THICKNESS / 2.0 - | |
PADDLE_SIZE.x / 2.0 - PADDLE_PADDING; | |
paddle_transform.translation.x = new_paddle_position.clamp(left_bounds, right_bounds); | |
} | |
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>) { | |
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(); | |
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 { | |
collision_events.send_default(); | |
if maybe_brick.is_some() { | |
scoreboard.score += 1; | |
commands.entity(collider_entity).despawn(); | |
} | |
let mut reflect_x = false; | |
let mut reflect_y = false; | |
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 => {}, | |
} | |
if reflect_x { | |
ball_velocity.x = -ball_velocity.x; | |
} | |
if reflect_y { | |
ball_velocity.y = -ball_velocity.y; | |
} | |
} | |
} | |
} | |
fn player_collision_sound( | |
collision_events: EventReader<CollisionEvent>, | |
audio:Res<Audio>, | |
sound: Res<CollisionSound> | |
) { | |
if !collision_events.is_empty() { | |
collision_events.clear(); | |
audio.play(sound.0.clone()); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment