Skip to content

Commit

Permalink
Use observers for callbacks (#249)
Browse files Browse the repository at this point in the history
Co-authored-by: Ben Frankel <[email protected]>
  • Loading branch information
janhohenheim and benfrankel committed Aug 9, 2024
1 parent 830e3d5 commit 2f35840
Show file tree
Hide file tree
Showing 5 changed files with 44 additions and 55 deletions.
32 changes: 25 additions & 7 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,28 +237,46 @@ 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<OnPress>, mut money: ResMut<Money>) {
money.0 -= 10.0;
}
```

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 is [`Interaction::Pressed`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html#variant.Pressed).

If you have many interactions that only change a state, consider using the following helper function:

```rust
fn spawn_button(mut commands: Commands) {
commands.button("Play the game").observe(enter_state(Screen::Playing));
}

fn enter_state<S: FreelyMutableState>(
new_state: S,
) -> impl Fn(Trigger<OnPress>, ResMut<NextState<S>>) {
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).
By adding the system handling the interaction to the entity as a component,
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.

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.

## Dev Tools

Expand Down
6 changes: 2 additions & 4 deletions src/screens/credits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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);
Expand All @@ -36,6 +34,6 @@ fn stop_bgm(mut commands: Commands) {
commands.stop_bgm();
}

fn enter_title(mut next_screen: ResMut<NextState<Screen>>) {
fn enter_title(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}
17 changes: 6 additions & 11 deletions src/screens/title.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextState<Screen>>) {
fn enter_playing(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Playing);
}

fn enter_credits(mut next_screen: ResMut<NextState<Screen>>) {
fn enter_credits(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Credits);
}

#[cfg(not(target_family = "wasm"))]
fn exit_app(mut app_exit: EventWriter<AppExit>) {
fn exit_app(_trigger: Trigger<OnPress>, mut app_exit: EventWriter<AppExit>) {
app_exit.send(AppExit::Success);
}
39 changes: 9 additions & 30 deletions src/theme/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@ use crate::{assets::SfxHandles, audio::sfx::SfxCommands as _};

pub(super) fn plugin(app: &mut App) {
app.register_type::<InteractionPalette>();
app.register_type::<OnPress>();
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
Expand All @@ -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<Interaction>>,
fn trigger_on_press(
interaction_query: Query<(Entity, &Interaction), Changed<Interaction>>,
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);
}
}
}
Expand Down Expand Up @@ -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<OnRemove, OnPress>,
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();
}
5 changes: 2 additions & 3 deletions src/theme/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, on_press: SystemId) -> EntityCommands;
fn button(&mut self, text: impl Into<String>) -> EntityCommands;

/// Spawn a simple header label. Bigger than [`Widgets::label`].
fn header(&mut self, text: impl Into<String>) -> EntityCommands;
Expand All @@ -24,7 +24,7 @@ pub trait Widgets {
}

impl<T: Spawn> Widgets for T {
fn button(&mut self, text: impl Into<String>, on_press: SystemId) -> EntityCommands {
fn button(&mut self, text: impl Into<String>) -> EntityCommands {
let mut entity = self.spawn((
Name::new("Button"),
ButtonBundle {
Expand All @@ -43,7 +43,6 @@ impl<T: Spawn> Widgets for T {
hovered: BUTTON_HOVERED_BACKGROUND,
pressed: BUTTON_PRESSED_BACKGROUND,
},
OnPress(on_press),
));
entity.with_children(|children| {
children.spawn((
Expand Down

0 comments on commit 2f35840

Please sign in to comment.