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

Use observers for callbacks #249

Merged
merged 8 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
33 changes: 26 additions & 7 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,28 +237,47 @@ 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>, /* Bevy query parameters */) {
benfrankel marked this conversation as resolved.
Show resolved Hide resolved
// 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 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:
janhohenheim marked this conversation as resolved.
Show resolved Hide resolved

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

fn change_state<S: FreelyMutableState>(
janhohenheim marked this conversation as resolved.
Show resolved Hide resolved
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 coupling the system handling the interaction to the entity as an observer,
benfrankel marked this conversation as resolved.
Show resolved Hide resolved
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.
benfrankel marked this conversation as resolved.
Show resolved Hide resolved

## 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);
}
MiniaczQ marked this conversation as resolved.
Show resolved Hide resolved
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