mirror of
https://github.com/bevyengine/bevy
synced 2024-11-29 08:00:20 +00:00
a795de30b4
# Motivation When spawning entities into a scene, it is very common to create assets like meshes and materials and to add them via asset handles. A common setup might look like this: ```rust fn setup( mut commands: Commands, mut meshes: ResMut<Assets<Mesh>>, mut materials: ResMut<Assets<StandardMaterial>>, ) { commands.spawn(PbrBundle { mesh: meshes.add(Mesh::from(shape::Cube { size: 1.0 })), material: materials.add(StandardMaterial::from(Color::RED)), ..default() }); } ``` Let's take a closer look at the part that adds the assets using `add`. ```rust mesh: meshes.add(Mesh::from(shape::Cube { size: 1.0 })), material: materials.add(StandardMaterial::from(Color::RED)), ``` Here, "mesh" and "material" are both repeated three times. It's very explicit, but I find it to be a bit verbose. In addition to being more code to read and write, the extra characters can sometimes also lead to the code being formatted to span multiple lines even though the core task, adding e.g. a primitive mesh, is extremely simple. A way to address this is by using `.into()`: ```rust mesh: meshes.add(shape::Cube { size: 1.0 }.into()), material: materials.add(Color::RED.into()), ``` This is fine, but from the names and the type of `meshes`, we already know what the type should be. It's very clear that `Cube` should be turned into a `Mesh` because of the context it's used in. `.into()` is just seven characters, but it's so common that it quickly adds up and gets annoying. It would be nice if you could skip all of the conversion and let Bevy handle it for you: ```rust mesh: meshes.add(shape::Cube { size: 1.0 }), material: materials.add(Color::RED), ``` # Objective Make adding assets more ergonomic by making `Assets::add` take an `impl Into<A>` instead of `A`. ## Solution `Assets::add` now takes an `impl Into<A>` instead of `A`, so e.g. this works: ```rust commands.spawn(PbrBundle { mesh: meshes.add(shape::Cube { size: 1.0 }), material: materials.add(Color::RED), ..default() }); ``` I also changed all examples to use this API, which increases consistency as well because `Mesh::from` and `into` were being used arbitrarily even in the same file. This also gets rid of some lines of code because formatting is nicer. --- ## Changelog - `Assets::add` now takes an `impl Into<A>` instead of `A` - Examples don't use `T::from(K)` or `K.into()` when adding assets ## Migration Guide Some `into` calls that worked previously might now be broken because of the new trait bounds. You need to either remove `into` or perform the conversion explicitly with `from`: ```rust // Doesn't compile let mesh_handle = meshes.add(shape::Cube { size: 1.0 }.into()), // These compile let mesh_handle = meshes.add(shape::Cube { size: 1.0 }), let mesh_handle = meshes.add(Mesh::from(shape::Cube { size: 1.0 })), ``` ## Concerns I believe the primary concerns might be: 1. Is this too implicit? 2. Does this increase codegen bloat? Previously, the two APIs were using `into` or `from`, and now it's "nothing" or `from`. You could argue that `into` is slightly more explicit than "nothing" in cases like the earlier examples where a `Color` gets converted to e.g. a `StandardMaterial`, but I personally don't think `into` adds much value even in this case, and you could still see the actual type from the asset type. As for codegen bloat, I doubt it adds that much, but I'm not very familiar with the details of codegen. I personally value the user-facing code reduction and ergonomics improvements that these changes would provide, but it might be worth checking the other effects in more detail. Another slight concern is migration pain; apps might have a ton of `into` calls that would need to be removed, and it did take me a while to do so for Bevy itself (maybe around 20-40 minutes). However, I think the fact that there *are* so many `into` calls just highlights that the API could be made nicer, and I'd gladly migrate my own projects for it.
415 lines
14 KiB
Rust
415 lines
14 KiB
Rust
//! 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::<CollisionEvent>()
|
|
.add_systems(Startup, setup)
|
|
// Add our gameplay simulation systems to the fixed timestep schedule
|
|
// which runs at 64 Hz by default
|
|
.add_systems(
|
|
FixedUpdate,
|
|
(
|
|
apply_velocity,
|
|
move_paddle,
|
|
check_for_collisions,
|
|
play_collision_sound,
|
|
)
|
|
// `chain`ing systems together runs them in order
|
|
.chain(),
|
|
)
|
|
.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<AudioSource>);
|
|
|
|
// 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<Assets<Mesh>>,
|
|
mut materials: ResMut<Assets<ColorMaterial>>,
|
|
asset_server: Res<AssetServer>,
|
|
) {
|
|
// 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(),
|
|
material: materials.add(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<ButtonInput<KeyCode>>,
|
|
mut query: Query<&mut Transform, With<Paddle>>,
|
|
time: Res<Time>,
|
|
) {
|
|
let mut paddle_transform = query.single_mut();
|
|
let mut direction = 0.0;
|
|
|
|
if keyboard_input.pressed(KeyCode::ArrowLeft) {
|
|
direction -= 1.0;
|
|
}
|
|
|
|
if keyboard_input.pressed(KeyCode::ArrowRight) {
|
|
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.delta_seconds();
|
|
|
|
// 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: Res<Time>) {
|
|
for (mut transform, velocity) in &mut query {
|
|
transform.translation.x += velocity.x * time.delta_seconds();
|
|
transform.translation.y += velocity.y * time.delta_seconds();
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
// 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<CollisionEvent>,
|
|
sound: Res<CollisionSound>,
|
|
) {
|
|
// 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,
|
|
});
|
|
}
|
|
}
|