Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a sound effects example #14554

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
* `MorphStressTest.gltf`, [MorphStressTest] ([CC-BY 4.0] by Analytical Graphics, Inc, Model and textures by Ed Mackey)
* Mysterious acoustic guitar music sample from [florianreichelt](https://freesound.org/people/florianreichelt/sounds/412429/) (CC0 license)
* Epic orchestra music sample, modified to loop, from [Migfus20](https://freesound.org/people/Migfus20/sounds/560449/) ([CC BY 4.0 DEED](https://creativecommons.org/licenses/by/4.0/))
* `button_press` sound effects are from [Jaszunio15's 250 clicks collection](https://freesound.org/people/Jaszunio15/packs/23837/) (CC0)

[MorphStressTest]: https://github.com/KhronosGroup/glTF-Sample-Models/tree/master/2.0/MorphStressTest
[fox]: https://github.com/KhronosGroup/glTF-Sample-Models/tree/master/2.0/Fox
Expand Down
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,17 @@ description = "Shows how to create and register a custom audio source by impleme
category = "Audio"
wasm = true

[[example]]
name = "sound_effects"
path = "examples/audio/sound_effects.rs"
doc-scrape-examples = true

[package.metadata.example.sound_effects]
name = "Sound Effects"
description = "Shows how to load and play randomized sound effects"
category = "Audio"
wasm = true

[[example]]
name = "soundtrack"
path = "examples/audio/soundtrack.rs"
Expand Down
Binary file added assets/sounds/button_press_1.ogg
Binary file not shown.
Binary file added assets/sounds/button_press_2.ogg
Binary file not shown.
Binary file added assets/sounds/button_press_3.ogg
Binary file not shown.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ Example | Description
[Audio Control](../examples/audio/audio_control.rs) | Shows how to load and play an audio file, and control how it's played
[Decodable](../examples/audio/decodable.rs) | Shows how to create and register a custom audio source by implementing the `Decodable` type.
[Pitch](../examples/audio/pitch.rs) | Shows how to directly play a simple pitch
[Sound Effects](../examples/audio/sound_effects.rs) | Shows how to load and play randomized sound effects
[Soundtrack](../examples/audio/soundtrack.rs) | Shows how to play different soundtracks based on game state
[Spatial Audio 2D](../examples/audio/spatial_audio_2d.rs) | Shows how to play spatial audio, and moving the emitter in 2D
[Spatial Audio 3D](../examples/audio/spatial_audio_3d.rs) | Shows how to play spatial audio, and moving the emitter in 3D
Expand Down
207 changes: 207 additions & 0 deletions examples/audio/sound_effects.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//! Sound effects are short audio clips which are played in response to an event: a button click, a character taking damage, footsteps, etc.
//!
//! In this example, we'll showcase a simple abstraction that can be used to load and play randomized sound effects in response to an event.
//! The logic here is highly customizable: we encourage you to adapt it to meet your game's needs!

use bevy::{
color::palettes::tailwind::{BLUE_600, BLUE_700, BLUE_800},
ecs::world::Command,
prelude::*,
utils::HashMap,
};
use rand::{distributions::Uniform, Rng};

fn main() {
App::new()
.add_plugins(DefaultPlugins)
// This must be below the `DefaultPlugins` plugin, as it depends on the `AssetPlugin`.
alice-i-cecile marked this conversation as resolved.
Show resolved Hide resolved
.init_resource::<SoundEffects>()
.add_systems(Startup, spawn_button)
.add_systems(
Update,
(
play_sound_effect_when_button_pressed,
change_button_color_based_on_interaction,
),
)
.run();
}

#[derive(Resource)]
struct SoundEffects {
map: HashMap<String, Vec<Handle<AudioSource>>>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't the example use an enum key again? Is it showcase the filepath/asset workflow?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. I wanted to showcase both, but couldn't think of a good way to do so without substantially increasing the size of the example. The doc test was my compromise, but I'm not thrilled.

Copy link
Member

@janhohenheim janhohenheim Aug 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the doc comment conveys the intent fairly well. While this example in its current form could be simplified by using enums, I would prefer to merge the current, more future-proof code.

Edit: thinking about this, I'm not so sure anymore. Maybe a comment saying how one could extend the code would be enough?

Copy link
Member

@janhohenheim janhohenheim Aug 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this not be a tiny bit simpler with a newtype deriving Deref and DeferMut? Imo the name map doesn't add anything here.

}

impl SoundEffects {
/// Plays a random sound effect matching the given name.
///
/// When defining the settings for this method, you almost always want to use [`PlaybackMode::Despawn`](bevy::audio::PlaybackMode).
/// Every time a sound effect is played, a new entity is generated. Once the sound effect is complete,
/// the entity should be cleaned up, rather than looping or sitting around uselessly.
///
/// This method accepts any type which implements `AsRef<str>`.
/// This allows you to pass in `&str`, `String`, or a custom type that can be converted to a string.
///
/// These custom types can be useful for defining enums that represent specific sound effects.
/// Generally speaking, enum values should be used to represent one-off or special-cased sound effects,
/// while string keys are a better fit for sound effects corresponding to objects loaded from a data file.
///
/// # Example
///
/// ```
/// enum GameSfx {
/// SplashScreenJingle,
/// Victory,
/// Defeat,
/// }
///
/// impl AsRef<str> for GameSfx {
/// fn as_ref(&self) -> &str {
/// match self {
/// GameSfx::SplashScreenJingle => "splash_screen_jingle",
/// GameSfx::Victory => "victory",
/// GameSfx::Defeat => "defeat",
/// }
/// }
/// }
/// ```
fn play(&mut self, name: impl AsRef<str>, world: &mut World, settings: PlaybackSettings) {
let name = name.as_ref();
if let Some(sfx_list) = self.map.get_mut(name) {
// If you need precise control over the randomization order of your sound effects,
// store the RNG as a resource and modify these functions to take it as an argument.
let rng = &mut rand::thread_rng();

let index = rng.sample(Uniform::from(0..sfx_list.len()));
// We don't need a (slightly) more expensive strong handle here (which are used to keep assets loaded in memory)
// because a copy is always stored in the SoundEffects resource.
let source = sfx_list[index].clone_weak();
Comment on lines +75 to +78
Copy link
Contributor

@bash bash Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this use choose?


world.spawn(AudioBundle {
source,
// We want the sound effect to play once and then despawn.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment may be here by mistake, since we pass the settings in general.

settings,
});
} else {
warn!("Sound effect not found: {name}");
}
}
}

impl FromWorld for SoundEffects {
// `FromWorld` is the simplest way to load assets into a resource,
// but you likely want to integrate this into your own asset loading strategy.
fn from_world(world: &mut World) -> Self {
let asset_server = world.get_resource::<AssetServer>().unwrap();
let mut map = HashMap::default();

// Load sound effects here.
// Using string parsing to strip numbered suffixes + `AssetServer::load_folder` is a good way to load many sound effects at once, but is not supported on Wasm or Android.
let button_press_sfxs = vec![
asset_server.load("sounds/button_press_1.ogg"),
asset_server.load("sounds/button_press_2.ogg"),
asset_server.load("sounds/button_press_3.ogg"),
];
map.insert("button_press".to_string(), button_press_sfxs);

Self { map }
}
}

/// A custom command used to play sound effects.
struct PlaySoundEffect {
name: String,
settings: PlaybackSettings,
}

impl Command for PlaySoundEffect {
fn apply(self, world: &mut World) {
// Access both the world and the resource we need from it using resource_scope
// which temporarily removes the SoundEffects resource from the world
world.resource_scope(|world, mut sound_effects: Mut<SoundEffects>| {
sound_effects.play(self.name, world, self.settings);
});
}
}

/// An "extension trait" used to make it convenient to play sound effects via [`Commands`].
///
/// This technique allows us to implement methods for types that we don't own,
/// which can be used as long as the trait is in scope.
trait SfxCommands {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest of the structs use SoundEffects over Sfx. I have no preference for one or the other, but it would be nice to be consistent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping my preference for Sfx here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some back and forth in bevy_quickstart, I also prefer SFX, although it should not matter much for an example

fn play_sound_effect_with_settings(
&mut self,
name: impl AsRef<str>,
settings: PlaybackSettings,
);

fn play_sound_effect(&mut self, name: impl AsRef<str>) {
// This default method implementation saves work for types implementing this trait;
// if not overwritten, the trait's default method will be used here, forwarding to the
// more customizable method
self.play_sound_effect_with_settings(name, PlaybackSettings::DESPAWN);
}
}

impl<'w, 's> SfxCommands for Commands<'w, 's> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
impl<'w, 's> SfxCommands for Commands<'w, 's> {
impl SfxCommands for Commands<'_, '_> {

// By accepting an `AsRef<str>` here, we can be flexible about what we want to accept:
// &str literals are better for prototyping and data-driven sound effects,
// but enums are nicer for special-cased effects
fn play_sound_effect_with_settings(
&mut self,
name: impl AsRef<str>,
alice-i-cecile marked this conversation as resolved.
Show resolved Hide resolved
settings: PlaybackSettings,
) {
let name = name.as_ref().to_string();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as_ref().to_string() seems like an antipattern. In general converting to owned types should be explicit or be done by the user. Here, it seems like play_sound_effect_with_settings wants to borrow name, but it actually wants to own it! This should probably accept Into<String> instead.

self.add(PlaySoundEffect { name, settings });
}
}

fn spawn_button(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());

commands
.spawn(ButtonBundle {
style: Style {
width: Val::Px(300.0),
height: Val::Px(100.0),
align_self: AlignSelf::Center,
justify_self: JustifySelf::Center,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
background_color: BLUE_600.into(),
border_radius: BorderRadius::all(Val::Px(10.)),
..default()
})
.with_children(|child_builder| {
child_builder.spawn(TextBundle {
text: Text::from_section("Generate sound effect!", TextStyle::default()),
..default()
});
});
}

fn play_sound_effect_when_button_pressed(
button_query: Query<&Interaction, Changed<Interaction>>,
mut commands: Commands,
) {
for interaction in button_query.iter() {
if *interaction == Interaction::Pressed {
commands.play_sound_effect("button_press");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a const for this to lead by example?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's a good idea.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved; won't block on that

}
}
}

fn change_button_color_based_on_interaction(
mut button_query: Query<(&Interaction, &mut BackgroundColor), Changed<Interaction>>,
) {
for (interaction, mut color) in button_query.iter_mut() {
*color = match interaction {
Interaction::None => BLUE_600.into(),
Interaction::Hovered => BLUE_700.into(),
Interaction::Pressed => BLUE_800.into(),
}
}
}