From df50e36748c7d600ba8611facf32fb2399500cd2 Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 9 Aug 2024 18:42:45 +0200 Subject: [PATCH 1/8] Use observers for callbacks --- docs/design.md | 20 +++++++++++++------- src/screens/credits.rs | 6 ++---- src/screens/title.rs | 17 ++++++----------- src/theme/interaction.rs | 39 +++++++++------------------------------ src/theme/widgets.rs | 5 ++--- 5 files changed, 32 insertions(+), 55 deletions(-) diff --git a/docs/design.md b/docs/design.md index 97651969..875a8a26 100644 --- a/docs/design.md +++ b/docs/design.md @@ -237,28 +237,34 @@ as custom commands don't return `Entity` or `EntityCommands`. This kind of usage ### Pattern When spawning an entity that can be interacted with, such as a button that can be pressed, -register a [one-shot system](https://bevyengine.org/news/bevy-0-12/#one-shot-systems) to handle the interaction: +use an observer to handle the interaction: ```rust fn spawn_button(mut commands: Commands) { - let pay_money = commands.register_one_shot_system(pay_money); - commands.button("Pay up!", pay_money); + // See the Widgets pattern for information on the `button` method + commands.button("Pay up!").observe(pay_money); +} + +fn pay_money(_trigger: Trigger, /* Bevy query parameters */) { + // Handle the interaction } ``` -The resulting `SystemId` is added as a newtype component on the button entity. -See the definition of [`OnPress`](../src/theme/interaction.rs) for how this is done. +The event `OnPress`, which is [defined in this template](../src/theme/interaction.rs), +is triggered when the button's [`Interaction`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html) +component becomes [`Interaction::Pressed`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html#variant.Pressed). ### Reasoning This pattern is inspired by [bevy_mod_picking](https://github.com/aevyrie/bevy_mod_picking). -By adding the system handling the interaction to the entity as a component, +By coupling the system handling the interaction to the entity as an observer, the code running on interactions can be scoped to the exact context of the interaction. For example, the code for what happens when you press a *specific* button is directly attached to that exact button. This also keeps the interaction logic close to the entity that is interacted with, -allowing for better code organization. If you want multiple buttons to do the same thing, consider triggering an event in their callbacks. +allowing for better code organization. If you want multiple buttons to do the same thing, +consider triggering a secondary event in their callbacks. ## Dev Tools diff --git a/src/screens/credits.rs b/src/screens/credits.rs index 3a83ced2..470c8db8 100644 --- a/src/screens/credits.rs +++ b/src/screens/credits.rs @@ -11,8 +11,6 @@ pub(super) fn plugin(app: &mut App) { } fn show_credits_screen(mut commands: Commands) { - let enter_title = commands.register_one_shot_system(enter_title); - commands .ui_root() .insert(StateScoped(Screen::Credits)) @@ -26,7 +24,7 @@ fn show_credits_screen(mut commands: Commands) { children.label("Ducky sprite - CC0 by Caz Creates Games"); children.label("Music - CC BY 3.0 by Kevin MacLeod"); - children.button("Back", enter_title); + children.button("Back").observe(enter_title); }); commands.play_bgm(BgmHandles::PATH_CREDITS); @@ -36,6 +34,6 @@ fn stop_bgm(mut commands: Commands) { commands.stop_bgm(); } -fn enter_title(mut next_screen: ResMut>) { +fn enter_title(_trigger: Trigger, mut next_screen: ResMut>) { next_screen.set(Screen::Title); } diff --git a/src/screens/title.rs b/src/screens/title.rs index fd0d0367..599f766b 100644 --- a/src/screens/title.rs +++ b/src/screens/title.rs @@ -10,32 +10,27 @@ pub(super) fn plugin(app: &mut App) { } fn show_title_screen(mut commands: Commands) { - let enter_playing = commands.register_one_shot_system(enter_playing); - let enter_credits = commands.register_one_shot_system(enter_credits); - #[cfg(not(target_family = "wasm"))] - let exit_app = commands.register_one_shot_system(exit_app); - commands .ui_root() .insert(StateScoped(Screen::Title)) .with_children(|children| { - children.button("Play", enter_playing); - children.button("Credits", enter_credits); + children.button("Play").observe(enter_playing); + children.button("Credits").observe(enter_credits); #[cfg(not(target_family = "wasm"))] - children.button("Exit", exit_app); + children.button("Exit").observe(exit_app); }); } -fn enter_playing(mut next_screen: ResMut>) { +fn enter_playing(_trigger: Trigger, mut next_screen: ResMut>) { next_screen.set(Screen::Playing); } -fn enter_credits(mut next_screen: ResMut>) { +fn enter_credits(_trigger: Trigger, mut next_screen: ResMut>) { next_screen.set(Screen::Credits); } #[cfg(not(target_family = "wasm"))] -fn exit_app(mut app_exit: EventWriter) { +fn exit_app(_trigger: Trigger, mut app_exit: EventWriter) { app_exit.send(AppExit::Success); } diff --git a/src/theme/interaction.rs b/src/theme/interaction.rs index 28da4564..ff5a7018 100644 --- a/src/theme/interaction.rs +++ b/src/theme/interaction.rs @@ -4,16 +4,14 @@ use crate::{assets::SfxHandles, audio::sfx::SfxCommands as _}; pub(super) fn plugin(app: &mut App) { app.register_type::(); - app.register_type::(); app.add_systems( Update, ( - apply_on_press, + trigger_on_press, apply_interaction_palette, trigger_interaction_sfx, ), ); - app.observe(despawn_one_shot_system); } /// Palette for widget interactions. Add this to an entity that supports @@ -27,24 +25,18 @@ pub struct InteractionPalette { pub pressed: Color, } -/// Component that calls a [one-shot system](https://bevyengine.org/news/bevy-0-12/#one-shot-systems) -/// when the [`Interaction`] component on the same entity changes to -/// [`Interaction::Pressed`]. Use this in conjuction with -/// [`Commands::register_one_shot_system`] to create a callback for e.g. a -/// button press. -#[derive(Component, Debug, Reflect, Deref, DerefMut)] -#[reflect(Component, from_reflect = false)] -// The reflect attributes are currently needed due to -// [`SystemId` not implementing `Reflect`](https://github.com/bevyengine/bevy/issues/14496) -pub struct OnPress(#[reflect(ignore)] pub SystemId); +/// Event triggered on a UI entity when the [`Interaction`] component on the same entity changes to +/// [`Interaction::Pressed`]. Observe this event to detect e.g. button presses. +#[derive(Event)] +pub struct OnPress; -fn apply_on_press( - interaction_query: Query<(&Interaction, &OnPress), Changed>, +fn trigger_on_press( + interaction_query: Query<(Entity, &Interaction), Changed>, mut commands: Commands, ) { - for (interaction, &OnPress(system_id)) in &interaction_query { + for (entity, interaction) in &interaction_query { if matches!(interaction, Interaction::Pressed) { - commands.run_system(system_id); + commands.trigger_targets(OnPress, entity); } } } @@ -77,16 +69,3 @@ fn trigger_interaction_sfx( } } } - -/// Remove the one-shot system entity when the [`OnPress`] component is removed. -/// This is necessary as otherwise, the system would still exist after the button -/// is removed, causing a memory leak. -fn despawn_one_shot_system( - trigger: Trigger, - mut commands: Commands, - on_press_query: Query<&OnPress>, -) { - let on_press = on_press_query.get(trigger.entity()).unwrap(); - let one_shot_system_entity = on_press.entity(); - commands.entity(one_shot_system_entity).despawn_recursive(); -} diff --git a/src/theme/widgets.rs b/src/theme/widgets.rs index 80e5bf2a..addaedd0 100644 --- a/src/theme/widgets.rs +++ b/src/theme/widgets.rs @@ -14,7 +14,7 @@ use super::{ /// An extension trait for spawning UI widgets. pub trait Widgets { /// Spawn a simple button with text. - fn button(&mut self, text: impl Into, on_press: SystemId) -> EntityCommands; + fn button(&mut self, text: impl Into) -> EntityCommands; /// Spawn a simple header label. Bigger than [`Widgets::label`]. fn header(&mut self, text: impl Into) -> EntityCommands; @@ -24,7 +24,7 @@ pub trait Widgets { } impl Widgets for T { - fn button(&mut self, text: impl Into, on_press: SystemId) -> EntityCommands { + fn button(&mut self, text: impl Into) -> EntityCommands { let mut entity = self.spawn(( Name::new("Button"), ButtonBundle { @@ -43,7 +43,6 @@ impl Widgets for T { hovered: BUTTON_HOVERED_BACKGROUND, pressed: BUTTON_PRESSED_BACKGROUND, }, - OnPress(on_press), )); entity.with_children(|children| { children.spawn(( From 4529b0fb7a9cd9fbd0a372f85952560ebd022d37 Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 9 Aug 2024 19:37:00 +0200 Subject: [PATCH 2/8] Add helper to design doc --- docs/design.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/design.md b/docs/design.md index 875a8a26..4416e2c8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -254,6 +254,20 @@ The event `OnPress`, which is [defined in this template](../src/theme/interactio is triggered when the button's [`Interaction`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html) component becomes [`Interaction::Pressed`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html#variant.Pressed). +If your interactions mostly consist of changing the game state, consider using the following helper function: + +```rust +fn spawn_button(mut commands: Commands) { + commands.button("Play the game").observe(change_state(Screen::Playing)); +} + +fn change_state( + new_state: S, +) -> impl Fn(Trigger, ResMut>) { + move |_trigger, mut next_state| next_state.set(new_state.clone()) +} +``` + ### Reasoning This pattern is inspired by [bevy_mod_picking](https://github.com/aevyrie/bevy_mod_picking). From 83682ce62368424efe6927f475275959c4a9e55a Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 9 Aug 2024 19:43:41 +0200 Subject: [PATCH 3/8] Update docs/design.md Co-authored-by: Ben Frankel --- docs/design.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/design.md b/docs/design.md index 4416e2c8..e575cce5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -251,8 +251,7 @@ fn pay_money(_trigger: Trigger, /* Bevy query parameters */) { ``` The event `OnPress`, which is [defined in this template](../src/theme/interaction.rs), -is triggered when the button's [`Interaction`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html) -component becomes [`Interaction::Pressed`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html#variant.Pressed). +is triggered when the button is [`Interaction::Pressed`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html#variant.Pressed). If your interactions mostly consist of changing the game state, consider using the following helper function: From 6d794b242347929155499c766cf7846d1aceef8e Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 9 Aug 2024 19:45:36 +0200 Subject: [PATCH 4/8] Add example --- docs/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design.md b/docs/design.md index e575cce5..b52686cd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -245,8 +245,8 @@ fn spawn_button(mut commands: Commands) { commands.button("Pay up!").observe(pay_money); } -fn pay_money(_trigger: Trigger, /* Bevy query parameters */) { - // Handle the interaction +fn pay_money(_trigger: Trigger, mut money: ResMut) { + money.0 -= 10.0; } ``` From 0f27c74a8c4d91d7c7c469195a32453b6e449ee2 Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 9 Aug 2024 19:46:09 +0200 Subject: [PATCH 5/8] Remove paragraph --- docs/design.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/design.md b/docs/design.md index b52686cd..eb228d54 100644 --- a/docs/design.md +++ b/docs/design.md @@ -276,8 +276,7 @@ the code running on interactions can be scoped to the exact context of the inter For example, the code for what happens when you press a *specific* button is directly attached to that exact button. This also keeps the interaction logic close to the entity that is interacted with, -allowing for better code organization. If you want multiple buttons to do the same thing, -consider triggering a secondary event in their callbacks. +allowing for better code organization. ## Dev Tools From 6fd702b3fda95461e7653a5997d1ea6634f660ab Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 9 Aug 2024 19:46:53 +0200 Subject: [PATCH 6/8] Remove word 'coupling' --- docs/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design.md b/docs/design.md index eb228d54..0c1bcad0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -270,7 +270,7 @@ fn change_state( ### Reasoning This pattern is inspired by [bevy_mod_picking](https://github.com/aevyrie/bevy_mod_picking). -By coupling the system handling the interaction to the entity as an observer, +By pairing the system handling the interaction with the entity as an observer, the code running on interactions can be scoped to the exact context of the interaction. For example, the code for what happens when you press a *specific* button is directly attached to that exact button. From 6c1c268ac547e4c2a15e7bb614efacb1d6b9c35c Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 9 Aug 2024 19:49:37 +0200 Subject: [PATCH 7/8] Update docs/design.md Co-authored-by: Ben Frankel --- docs/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design.md b/docs/design.md index 0c1bcad0..41fd0b66 100644 --- a/docs/design.md +++ b/docs/design.md @@ -253,7 +253,7 @@ fn pay_money(_trigger: Trigger, mut money: ResMut) { The event `OnPress`, which is [defined in this template](../src/theme/interaction.rs), is triggered when the button is [`Interaction::Pressed`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html#variant.Pressed). -If your interactions mostly consist of changing the game state, consider using the following helper function: +If you have many interactions that only change a state, consider using the following helper function: ```rust fn spawn_button(mut commands: Commands) { From 724986e4c60592832a20006861ab29b5440193dc Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Fri, 9 Aug 2024 19:50:28 +0200 Subject: [PATCH 8/8] Rename change_state -> enter_state --- docs/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design.md b/docs/design.md index 41fd0b66..669738fd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -257,10 +257,10 @@ If you have many interactions that only change a state, consider using the follo ```rust fn spawn_button(mut commands: Commands) { - commands.button("Play the game").observe(change_state(Screen::Playing)); + commands.button("Play the game").observe(enter_state(Screen::Playing)); } -fn change_state( +fn enter_state( new_state: S, ) -> impl Fn(Trigger, ResMut>) { move |_trigger, mut next_state| next_state.set(new_state.clone())