From 79a5b7ae7a9b7e37f6e4cf5d9471dda0559604f1 Mon Sep 17 00:00:00 2001 From: Alice Cecile Date: Thu, 20 Jun 2024 11:16:49 -0400 Subject: [PATCH] Add code samples to hooks-and-observers release notes (#1437) Co-authored-by: iiYese <83026177+iiYese@users.noreply.github.com> --- .../10756_hooks_and_observers.md | 114 ++++++++++++++++-- 1 file changed, 106 insertions(+), 8 deletions(-) diff --git a/release-content/0.14/release-notes/10756_hooks_and_observers.md b/release-content/0.14/release-notes/10756_hooks_and_observers.md index 9a0f4dcadb..2267e9499b 100644 --- a/release-content/0.14/release-notes/10756_hooks_and_observers.md +++ b/release-content/0.14/release-notes/10756_hooks_and_observers.md @@ -39,12 +39,6 @@ These are intended for enforcing lower level ECS invariants required to use comp Hooks always run before observers and cannot be removed and so are more suitable for maintaining critical safety or correctness invariants. Additionally, hooks are somewhat faster than observers, as their reduced flexibility means that fewer lookups are involved. -By contrast, observers are a flexible tool intended for gameplay logic. -They can listen to the same lifecycle events as hooks, but can also respond to custom, user-defined triggers. -Observers can be attached to a single entity, listening only to triggers targeted at that entity (callbacks anyone?), but they can also be used to listen for triggers without an associated entity. -Their advantages over buffered events are clearest when combined with commands that emit triggers (to avoid ever entering a bad state), -or when you're taking advantage of observers' ability to emit triggers which are then immediately processed, chaining recursively. - Let's examine a simple example where we care about maintaining invariants: trying to target a specific `Enemy`. ```rust @@ -62,13 +56,117 @@ We want to automatically clear the target when it's despawned (or made untargeta If we use a pull-based approach (`RemovedComponents` is the most natural here), there can be gaps (within a single frame) between the entity being despawned and the `Target` component being updated. This can lead to all sorts of bizarre bugs: fun for speedrunners, but not for game developers. -Instead, we can set up a hook on the `Targetable` component: whenever it is despawned, go through the list of entities stored in the `targeted_by` field and set their `Target` to `None`. + +Let's see what this looks like with a hook on `Targetable`: + +```rust +// Rather than a derive, let's configure the hooks with a custom implementation of Component +impl Component for Targetable { + const STORAGE_TYPE: StorageType = StorageType::Table; + + fn register_component_hooks(hooks: &mut ComponentHooks) { + // Whenever this component is removed, or an entity with this component is despawned... + hooks.on_remove(|mut world, targeted_entity, _component_id|{ + // Grab the data that's about to be removed + let targetable = world.get::(targeted_entity).unwrap(); + for targeting_entity in targetable.targeted_by { + // Track down the entity that's targeting us + let mut targeting = world.get::(targeting_entity).unwrap(); + // And clear its target, cleaning up any dangling references + targeting.0 = None; + } + }) + } +} +``` + Because adding and removing components can only be done in the context of exclusive world access, hooks are always run *immediately*, leaving no opportunity for desynchronization. +By contrast, observers are a flexible tool intended for higher level application logic. +They can listen to the same lifecycle events as hooks, but can also respond to custom, user-defined triggers. +Their advantages over buffered events are clearest when you're targeting a specific entity, +when combined with commands that emit triggers (to avoid ever entering a bad state), +or when you're taking advantage of observers' ability to emit triggers which are then immediately processed, chaining recursively. + +Let's examine the API through a simple gameplay-flavored example: + +```rust +// Any event type can be used as a trigger +#[derive(Event)] +struct DealDamage { + damage: u8 +} + +#[derive(Event)] +struct LoseLife { + life_lost: u8, +} + +#[derive(Event)] +struct PlayerDeath; + +// Observers are stored as components on entities, +// and can be set up to watch specific entities. +fn spawn_player(mut commands: Commands) { + let player_entity = commands + // Setting up some ordinary components + .spawn((Player, Life(10), Defense(2))).id(); + + // Now, we're adding some callback-style behavior using observers, + // watching the entity itself. + // By attaching the observer to the entity it's watching, we ensure that it gets cleaned up. + commands.insert(Observer::new(respond_to_damage_taken).with_entity(player_entity)); + commands.insert(Observer::new(respond_to_life_lost).with_entity(player_entity)); +} + +// We can send triggers using commands (or methods on `World`) +fn attack_player(monster_query: Query<&Damage, With>, player_query: Query>, mut commands: Commands) { + let player_entity = player_query.single(); + + for damage in monster_query { + // We could target multiple entities here just as easily! + commands.trigger_targets(DealDamage {damage}, player_entity); + } +} + +// Observers use system syntax, with a special `Trigger` parameter as the first param, +// and can request any other `SystemParam` from the world +fn respond_to_damage_taken(trigger: Trigger, query: Query<&Defense>, mut commands: Commands) { + // We can access information about the entity responding to the event by reading data from the trigger, + // and combining it with additional queries + let defense = query.get(trigger.entity).unwrap_or_default(); + let damage = trigger.event().damage; + let life_lost = damage.0.saturating_sub(defense.0); + // Observers can be chained into each other, by sending more triggers using commands + commands.trigger_targets(trigger.entity) +} + +fn respond_to_losing_life(trigger: Trigger, mut life_query: Query<&mut Life>, player_query: Query>, mut commands: Commands) { + let mut life = life_query.single_mut(trigger.entity); + let life_lost = trigger.event().life_lost; + life.0 = life.0.saturating_sub(life_lost); + + if player_query.contains(trigger.entity){ + // Triggers can be sent globally, targeting no entity in particular + commands.trigger(PlayerDeath); + } +} + +app + .add_systems(Startup, spawn_player) + .add_systems(Update, attack_player) + // Similarly, observers can also be registered globally, listening to any matching event, + // regardless of its entity target + .observe(|_trigger: Trigger, app_exit: EventWriter| { + println!("You died. Game over!") + app_exit.send_default(); + }); +``` + In the future, we intend to use hooks and observers to [replace `RemovedComponents`], [make our hierarchy management more robust], create a first-party replacement for [`bevy_eventlistener`] as part of our UI work and [build out relations]. These are powerful, abstract tools: we can't wait to see the mad science the community cooks up! -When you're ready to get started, check out the [`component hooks`] and [`observers`] examples for the API details. +When you're ready to get started, check out the [`component hooks`] and [`observers`] examples for more API details. [`Event`]: https://dev-docs.bevyengine.org/bevy/ecs/event/trait.Event.html [`Added`]: https://dev-docs.bevyengine.org/bevy/ecs/prelude/struct.Added.html