bevy icon indicating copy to clipboard operation
bevy copied to clipboard

Observers that can observe multiple different event types

Open ItsDoot opened this issue 1 year ago • 5 comments

What problem does this solve or what need does it fill?

Being able to trigger the same observer with multiple different event types would be useful for code reuse and centralizing related logic. This is different from enum Event types, which don't allow you to listen to individual variants for separate observers.

What solution would you like?

#[derive(Event)]
struct FooEvent { foo: i32 }
#[derive(Event)]
struct BarEvent { bar: bool }

// I imagine `Or<(A, B)>` would deref into some `enum Or2<A, B> { A(A), B(B) }`
// `Or<(A, B, C)>` would deref into some `enum Or3<A, B, C> { A(A), B(B), C(C) }`
// etc
// Also, I imagine we'll need a separate `world.observe_any_of()`-style method to correctly handle the internal details
world.observe(|trigger: Trigger<Or<(FooEvent, BarEvent)>>| {
    match trigger.event() {
        Or2::A(FooEvent { foo }) => { /* do something with foo... */ },
        Or2::B(BarEvent { bar }) => { /* do something with bar... */ },
    }
});

What alternative(s) have you considered?

Use an enum:

#[derive(Event)]
enum MyEvent {
    Foo { foo: i32 },
    Bar { bar: bool },
}

world.observe(|trigger: Trigger<MyEvent>| {
    match trigger.event() {
        MyEvent::Foo { foo } => { /* ... */ },
        MyEvent::Bar { bar } => { /* ... */ },
    }
});

However, that prevents us from listening to only 1 of the event types, or a subset of them.

Additional context

This was mentioned on discord as currently possible, albeit with unsafe APIs, so we should introduce a safe wrapper:

Diddykonga — Today at 8:41 PM Can observers 'observe' multiple events? if not, then the query would need to be split from the observer at some point. James 🦃 — Today at 9:02 PM Observers can absolutely observe multiple events (unsafely) nth — Today at 9:03 PM What does the trigger return for the event? James 🦃 — Today at 9:04 PM You have to use unsafe APIs So you can make the trigger return any type and you just have to promise it's safe The observer for a query should set the ObserverRunner manually to not pay for system overhead Diddykonga — Today at 9:06 PM Right since you cant carry that information via generics, unsafe is the only way (variadics when?) James 🦃 — Today at 9:06 PM And the ObserverRunner API just gets a pointer and deferred world (similar to a hook) doot — Today at 9:13 PM I wonder if you could wrap that safely in a Trigger<Either<A, B>> type api actually probably something like a Trigger<Or<(A, B, C, ...)>>

ItsDoot avatar Aug 08 '24 03:08 ItsDoot

Rather than implement a new "query style" API, we could also consider:

#[derive(Event)]
struct Foo { foo: i32 }
#[derive(Event)]
struct Bar { bar: bool }


#[derive(Event)]
enum Combined {
    Foo(Foo),
    Bar(Bar),
}

// Pretty sure this type elision works out
world
  .observe(trigger_map(Combined::Foo))
  .observe(trigger_map(Combined::Bar))

world.observe(|trigger: Trigger<Combined>| {
    match trigger.event() {
        MyEvent::Foo { foo } => { /* ... */ },
        MyEvent::Bar { bar } => { /* ... */ },
    }
});

cart avatar Aug 08 '24 18:08 cart

Notably, this would require essentially no changes. Just an implementation of the trigger_map observer system, which would watch for the wrapped type and trigger the Combined enum type.

cart avatar Aug 08 '24 18:08 cart

Just put together a PR for consideration, given how simple this was to implement.

cart avatar Aug 08 '24 19:08 cart

Before we go too deep down this rabbit hole, can we discuss actual use cases? It would be good to come up with some real-world scenarios to illustrate why this is necessary.

cart avatar Aug 09 '24 03:08 cart

Here is a real-world use case that came up for me: I have Highlight component whose value is derived from the presences of two marker components Hovered and Selected. It will only need to change if either Has<Hovered> or Has<Selected> changes. I currently have that logic attached to 4 observers: Trigger<OnAdd, Hovered>, Trigger<OnRemove, Hovered>, Trigger<OnAdd, Selected>, Trigger<OnRemove, Selected>, but the logic inside them is the same. With #14664, I would be able to create a combined trigger HoverOrSelectionChanged to write it as a single observer.

If I understand correctly, #14674 would not solve this particular case since I couldn't listen to two components at once?

Azorlogh avatar Aug 23 '24 12:08 Azorlogh

This is just an observation, but if the eventual plan is to use observers to keep query caches updated, those observers would know when archetypes start/stop matching and could share those events as triggers for other observers.

In the use case described in the previous comment, a hypothetical observer keeping some Query<(With<Hovered>, With<Selected>)> in sync with the world would be in an ideal position to report the relevant changes.

You might point out that ambiguity between many same/similar queries would be an issue, but that issue would simply disappear if both components and queries become entities (and Entity replaces ComponentId).

We can see that asking if entity e has T is equivalent to asking if e matches Query<With<T>>. Similarly, e beginning or ceasing to match Query<With<T>> coincides with adding or removing T (true for bundles as well).

If components and queries are both entities, you could actually start thinking of queries as "inferred components" (more commonly known as rules), i.e. started matching Q and stopped matching Q can be treated as added "component" Q and removed "component" Q events for observers to subscribe to, where Q is a specific entity (avoiding ambiguity).

flecs has a pattern like this it calls "monitors" and an interesting way people use it is to subscribe to queries that express "invalid" bundles. That way, they can see if entities are getting into bad states.

maniwani avatar Aug 27 '24 01:08 maniwani

I think we should prioritize adding support for this.

To make this generically usable (especially in cases like UI), I think we'd need to support writing an observer that accepts both Trigger<OnAdd, Pressed> and Trigger<OnInsert, Hovered>. Ex: Trigger<(OnAdd<Pressed>, OnInsert<Hovered>)>. However, making these events generic would cause challenges for the more dynamic use cases we have planned in the future. The separation currently allows for us to trigger things like OnInsert for an arbitrary ComponentId (and removes the need to generate and look up a new component type for each OnInsert<T> event).

Here is a motivating use case from our Cored UI Widgets work:

fn on_button_change(
    trigger: Trigger<(
        OnAdd<Pressed>,
        OnRemove<Pressed>,
        OnAdd<InteractionDisabled>,
        OnRemove<InteractionDisabled>,
        OnInsert<Hovered>,
    )>,
    mut buttons: Query<
        (
            Has<Depressed>,
            &IsHovered,
            Has<InteractionDisabled>,
            &mut BackgroundColor,
            &mut BorderColor,
            &Children,
        ),
        With<DemoButton>,
    >,
    mut text_query: Query<&mut Text>,
) {
    if let Ok((pressed, hovered, disabled, mut color, mut border_color, children)) =
        buttons.get_mut(trigger.target())
    {
        let mut text = text_query.get_mut(children[0]).unwrap();
        set_button_style(
            disabled,
            hovered.get(),
            pressed,
            &mut color,
            &mut border_color,
            &mut text,
        );
    }
}

Currently, we need to write six different duplicate observers to do this, which is prohibitively bad UX.

cart avatar Jun 10 '25 19:06 cart

You probably have enough examples by now, but I'd like to add one more. By default, I want to avoid writing systems for things that rarely happen. In this case, I want to ensure that the visual representation of how many upgrades the player has stays up to date. I can listen to the OnAdd trigger for the upgrades, but that leaves a problem of showing the right value when the display is enabled. The code is exactly the same, just needs to react to multiple different triggers.

Right now, I used generics to achieve that, but it's definitely not ergonomic:

.add_observer(BuildingInfoPanelTowerUpgradeCountText::refresh_upgrade_count_on::<OnInsert, ModifierSourceUpgrade>) // Refresh upgrade text on new upgrade
.add_observer(BuildingInfoPanelTowerUpgradeCountText::refresh_upgrade_count_on::<BuildingInfoPanelEnabledTrigger, ()>) // Refresh upgrade text on panel enabled 

Arrekin avatar Jul 31 '25 19:07 Arrekin

what exactly would need to happen for this to progress?

mersenne-twister avatar Oct 19 '25 19:10 mersenne-twister