mirror of
https://github.com/bevyengine/bevy
synced 2024-09-20 14:32:06 +00:00
fb4c21e3e6
# Objective Improve the `bevy_audio` API to make it more user-friendly and ECS-idiomatic. This PR is a first-pass at addressing some of the most obvious (to me) problems. In the interest of keeping the scope small, further improvements can be done in future PRs. The current `bevy_audio` API is very clunky to work with, due to how it (ab)uses bevy assets to represent audio sinks. The user needs to write a lot of boilerplate (accessing `Res<Assets<AudioSink>>`) and deal with a lot of cognitive overhead (worry about strong vs. weak handles, etc.) in order to control audio playback. Audio playback is initiated via a centralized `Audio` resource, which makes it difficult to keep track of many different sounds playing in a typical game. Further, everything carries a generic type parameter for the sound source type, making it difficult to mix custom sound sources (such as procedurally generated audio or unofficial formats) with regular audio assets. Let's fix these issues. ## Solution Refactor `bevy_audio` to a more idiomatic ECS API. Remove the `Audio` resource. Do everything via entities and components instead. Audio playback data is now stored in components: - `PlaybackSettings`, `SpatialSettings`, `Handle<AudioSource>` are now components. The user inserts them to tell Bevy to play a sound and configure the initial playback parameters. - `AudioSink`, `SpatialAudioSink` are now components instead of special magical "asset" types. They are inserted by Bevy when it actually begins playing the sound, and can be queried for by the user in order to control the sound during playback. Bundles: `AudioBundle` and `SpatialAudioBundle` are available to make it easy for users to play sounds. Spawn an entity with one of these bundles (or insert them to a complex entity alongside other stuff) to play a sound. Each entity represents a sound to be played. There is also a new "auto-despawn" feature (activated using `PlaybackSettings`), which, if enabled, tells Bevy to despawn entities when the sink playback finishes. This allows for "fire-and-forget" sound playback. Users can simply spawn entities whenever they want to play sounds and not have to worry about leaking memory. ## Unsolved Questions I think the current design is *fine*. I'd be happy for it to be merged. It has some possibly-surprising usability pitfalls, but I think it is still much better than the old `bevy_audio`. Here are some discussion questions for things that we could further improve. I'm undecided on these questions, which is why I didn't implement them. We should decide which of these should be addressed in this PR, and what should be left for future PRs. Or if they should be addressed at all. ### What happens when sounds start playing? Currently, the audio sink components are inserted and the bundle components are kept. Should Bevy remove the bundle components? Something else? The current design allows an entity to be reused for playing the same sound with the same parameters repeatedly. This is a niche use case I'd like to be supported, but if we have to give it up for a simpler design, I'd be fine with that. ### What happens if users remove any of the components themselves? As described above, currently, entities can be reused. Removing the audio sink causes it to be "detached" (I kept the old `Drop` impl), so the sound keeps playing. However, if the audio bundle components are not removed, Bevy will detect this entity as a "queued" sound entity again (has the bundle compoenents, without a sink component), just like before playing the sound the first time, and start playing the sound again. This behavior might be surprising? Should we do something different? ### Should mutations to `PlaybackSettings` be applied to the audio sink? We currently do not do that. `PlaybackSettings` is just for the initial settings when the sound starts playing. This is clearly documented. Do we want to keep this behavior, or do we want to allow users to use `PlaybackSettings` instead of `AudioSink`/`SpatialAudioSink` to control sounds during playback too? I think I prefer for them to be kept separate. It is not a bad mental model once you understand it, and it is documented. ### Should `AudioSink` and `SpatialAudioSink` be unified into a single component type? They provide a similar API (via the `AudioSinkPlayback` trait) and it might be annoying for users to have to deal with both of them. The unification could be done using an enum that is matched on internally by the methods. Spatial audio has extra features, so this might make it harder to access. I think we shouldn't. ### Automatic synchronization of spatial sound properties from Transforms? Should Bevy automatically apply changes to Transforms to spatial audio entities? How do we distinguish between listener and emitter? Which one does the transform represent? Where should the other one come from? Alternatively, leave this problem for now, and address it in a future PR. Or do nothing, and let users deal with it, as shown in the `spatial_audio_2d` and `spatial_audio_3d` examples. --- ## Changelog Added: - `AudioBundle`/`SpatialAudioBundle`, add them to entities to play sounds. Removed: - The `Audio` resource. - `AudioOutput` is no longer `pub`. Changed: - `AudioSink`, `SpatialAudioSink` are now components instead of assets. ## Migration Guide // TODO: write a more detailed migration guide, after the "unsolved questions" are answered and this PR is finalized. Before: ```rust /// Need to store handles somewhere #[derive(Resource)] struct MyMusic { sink: Handle<AudioSink>, } fn play_music( asset_server: Res<AssetServer>, audio: Res<Audio>, audio_sinks: Res<Assets<AudioSink>>, mut commands: Commands, ) { let weak_handle = audio.play_with_settings( asset_server.load("music.ogg"), PlaybackSettings::LOOP.with_volume(0.5), ); // upgrade to strong handle and store it commands.insert_resource(MyMusic { sink: audio_sinks.get_handle(weak_handle), }); } fn toggle_pause_music( audio_sinks: Res<Assets<AudioSink>>, mymusic: Option<Res<MyMusic>>, ) { if let Some(mymusic) = &mymusic { if let Some(sink) = audio_sinks.get(&mymusic.sink) { sink.toggle(); } } } ``` Now: ```rust /// Marker component for our music entity #[derive(Component)] struct MyMusic; fn play_music( mut commands: Commands, asset_server: Res<AssetServer>, ) { commands.spawn(( AudioBundle::from_audio_source(asset_server.load("music.ogg")) .with_settings(PlaybackSettings::LOOP.with_volume(0.5)), MyMusic, )); } fn toggle_pause_music( // `AudioSink` will be inserted by Bevy when the audio starts playing query_music: Query<&AudioSink, With<MyMusic>>, ) { if let Ok(sink) = query.get_single() { sink.toggle(); } } ```
421 lines
14 KiB
Rust
421 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>()
|
|
// Configure how frequently our gameplay systems are run
|
|
.insert_resource(FixedTime::new_from_secs(1.0 / 60.0))
|
|
.add_systems(Startup, setup)
|
|
// Add our gameplay simulation systems to the fixed timestep schedule
|
|
.add_systems(
|
|
FixedUpdate,
|
|
(
|
|
check_for_collisions,
|
|
apply_velocity.before(check_for_collisions),
|
|
move_paddle
|
|
.before(check_for_collisions)
|
|
.after(apply_velocity),
|
|
play_collision_sound.after(check_for_collisions),
|
|
),
|
|
)
|
|
.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()).into(),
|
|
material: materials.add(ColorMaterial::from(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
|
|
// 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),
|
|
);
|
|
|
|
// 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<Input<KeyCode>>,
|
|
mut query: Query<&mut Transform, With<Paddle>>,
|
|
time_step: Res<FixedTime>,
|
|
) {
|
|
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;
|
|
}
|
|
|
|
// Calculate the new horizontal paddle position based on player input
|
|
let new_paddle_position =
|
|
paddle_transform.translation.x + direction * PADDLE_SPEED * time_step.period.as_secs_f32();
|
|
|
|
// 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_step: Res<FixedTime>) {
|
|
for (mut transform, velocity) in &mut query {
|
|
transform.translation.x += velocity.x * time_step.period.as_secs_f32();
|
|
transform.translation.y += velocity.y * time_step.period.as_secs_f32();
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|