From 7a1fcb7fe70b8c63ebd3e029a724b3ee88a301f9 Mon Sep 17 00:00:00 2001
From: mgi388 <135186256+mgi388@users.noreply.github.com>
Date: Tue, 6 May 2025 10:37:04 +1000
Subject: [PATCH] Rename `StateScoped` to `DespawnOnExitState` and add
`DespawnOnEnterState` (#18818)
# Objective
- Alternative to and builds on top of #16284.
- Fixes #15849.
## Solution
- Rename component `StateScoped` to `DespawnOnExitState`.
- Rename system `clear_state_scoped_entities` to
`despawn_entities_on_exit_state`.
- Add `DespawnOnEnterState` and `despawn_entities_on_enter_state` which
is the `OnEnter` equivalent.
> [!NOTE]
> Compared to #16284, the main change is that I did the rename in such a
way as to keep the terms `OnExit` and `OnEnter` together. In my own
game, I was adding `VisibleOnEnterState` and `HiddenOnExitState` and
when naming those, I kept the `OnExit` and `OnEnter` together. When I
checked #16284 it stood out to me that the naming was a bit awkward.
Putting the `State` in the middle and breaking up `OnEnter` and `OnExit`
also breaks searching for those terms.
## Open questions
1. Should we split `enable_state_scoped_entities` into two functions,
one for the `OnEnter` and one for the `OnExit`? I personally have zero
need thus far for the `OnEnter` version, so I'd be interested in not
having this enabled unless I ask for it.
2. If yes to 1., should we follow my lead in my `Visibility` state
components (see below) and name these
`app.enable_despawn_entities_on_enter_state()` and
`app.enable_despawn_entities_on_exit_state()`, which IMO says what it
does on the tin?
## Testing
Ran all changed examples.
## Side note: `VisibleOnEnterState` and `HiddenOnExitState`
For reference to anyone else and to help with the open questions, I'm
including the code I wrote for controlling entity visibility when a
state is entered/exited.
visibility.rs
```rust
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_reflect::prelude::*;
use bevy_render::prelude::*;
use bevy_state::{prelude::*, state::StateTransitionSteps};
use tracing::*;
pub trait AppExtStates {
fn enable_visible_entities_on_enter_state(&mut self) -> &mut Self;
fn enable_hidden_entities_on_exit_state(&mut self) -> &mut Self;
}
impl AppExtStates for App {
fn enable_visible_entities_on_enter_state(&mut self) -> &mut Self {
self.main_mut()
.enable_visible_entities_on_enter_state::();
self
}
fn enable_hidden_entities_on_exit_state(&mut self) -> &mut Self {
self.main_mut().enable_hidden_entities_on_exit_state::();
self
}
}
impl AppExtStates for SubApp {
fn enable_visible_entities_on_enter_state(&mut self) -> &mut Self {
if !self
.world()
.contains_resource::>>()
{
let name = core::any::type_name::();
warn!("Visible entities on enter state are enabled for state `{}`, but the state isn't installed in the app!", name);
}
// We work with [`StateTransition`] in set
// [`StateTransitionSteps::ExitSchedules`] as opposed to [`OnExit`],
// because [`OnExit`] only runs for one specific variant of the state.
self.add_systems(
StateTransition,
update_to_visible_on_enter_state::.in_set(StateTransitionSteps::ExitSchedules),
)
}
fn enable_hidden_entities_on_exit_state(&mut self) -> &mut Self {
if !self
.world()
.contains_resource::>>()
{
let name = core::any::type_name::();
warn!("Hidden entities on exit state are enabled for state `{}`, but the state isn't installed in the app!", name);
}
// We work with [`StateTransition`] in set
// [`StateTransitionSteps::ExitSchedules`] as opposed to [`OnExit`],
// because [`OnExit`] only runs for one specific variant of the state.
self.add_systems(
StateTransition,
update_to_hidden_on_exit_state::.in_set(StateTransitionSteps::ExitSchedules),
)
}
}
#[derive(Clone, Component, Debug, Reflect)]
#[reflect(Component, Debug)]
pub struct VisibleOnEnterState(pub S);
#[derive(Clone, Component, Debug, Reflect)]
#[reflect(Component, Debug)]
pub struct HiddenOnExitState(pub S);
/// Makes entities marked with [`VisibleOnEnterState`] visible when the state
/// `S` is entered.
pub fn update_to_visible_on_enter_state(
mut transitions: EventReader>,
mut query: Query<(&VisibleOnEnterState, &mut Visibility)>,
) {
// We use the latest event, because state machine internals generate at most
// 1 transition event (per type) each frame. No event means no change
// happened and we skip iterating all entities.
let Some(transition) = transitions.read().last() else {
return;
};
if transition.entered == transition.exited {
return;
}
let Some(entered) = &transition.entered else {
return;
};
for (binding, mut visibility) in query.iter_mut() {
if binding.0 == *entered {
visibility.set_if_neq(Visibility::Visible);
}
}
}
/// Makes entities marked with [`HiddenOnExitState`] invisible when the state
/// `S` is exited.
pub fn update_to_hidden_on_exit_state(
mut transitions: EventReader>,
mut query: Query<(&HiddenOnExitState, &mut Visibility)>,
) {
// We use the latest event, because state machine internals generate at most
// 1 transition event (per type) each frame. No event means no change
// happened and we skip iterating all entities.
let Some(transition) = transitions.read().last() else {
return;
};
if transition.entered == transition.exited {
return;
}
let Some(exited) = &transition.exited else {
return;
};
for (binding, mut visibility) in query.iter_mut() {
if binding.0 == *exited {
visibility.set_if_neq(Visibility::Hidden);
}
}
}
```
---------
Co-authored-by: Benjamin Brienen
Co-authored-by: Ben Frankel
---
Cargo.toml | 11 ++
crates/bevy_state/src/app.rs | 19 ++-
crates/bevy_state/src/lib.rs | 8 +-
crates/bevy_state/src/state/states.rs | 8 +-
crates/bevy_state/src/state_scoped.rs | 85 +++++++++++-
crates/bevy_state/src/state_scoped_events.rs | 3 +-
examples/README.md | 1 +
examples/ecs/state_scoped.rs | 127 ++++++++++++++++++
examples/games/alien_cake_addict.rs | 12 +-
examples/state/computed_states.rs | 10 +-
examples/state/sub_states.rs | 2 +-
examples/testbed/2d.rs | 28 ++--
examples/testbed/3d.rs | 30 ++---
examples/testbed/ui.rs | 36 ++---
.../migration-guides/rename_StateScoped.md | 10 ++
15 files changed, 313 insertions(+), 77 deletions(-)
create mode 100644 examples/ecs/state_scoped.rs
create mode 100644 release-content/migration-guides/rename_StateScoped.md
diff --git a/Cargo.toml b/Cargo.toml
index 96f9db5520..a3d3a2ab63 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2305,6 +2305,17 @@ description = "Pipe the output of one system into a second, allowing you to hand
category = "ECS (Entity Component System)"
wasm = false
+[[example]]
+name = "state_scoped"
+path = "examples/ecs/state_scoped.rs"
+doc-scrape-examples = true
+
+[package.metadata.example.state_scoped]
+name = "State Scoped"
+description = "Shows how to spawn entities that are automatically despawned either when entering or exiting specific game states."
+category = "ECS (Entity Component System)"
+wasm = false
+
[[example]]
name = "system_closure"
path = "examples/ecs/system_closure.rs"
diff --git a/crates/bevy_state/src/app.rs b/crates/bevy_state/src/app.rs
index 46a23c9f9a..dd963d4d84 100644
--- a/crates/bevy_state/src/app.rs
+++ b/crates/bevy_state/src/app.rs
@@ -8,7 +8,7 @@ use crate::{
setup_state_transitions_in_world, ComputedStates, FreelyMutableState, NextState, State,
StateTransition, StateTransitionEvent, StateTransitionSteps, States, SubStates,
},
- state_scoped::clear_state_scoped_entities,
+ state_scoped::{despawn_entities_on_enter_state, despawn_entities_on_exit_state},
};
#[cfg(feature = "bevy_reflect")]
@@ -62,7 +62,7 @@ pub trait AppExtStates {
/// If the [`States`] trait was derived with the `#[states(scoped_entities)]` attribute, it
/// will be called automatically.
///
- /// For more information refer to [`StateScoped`](crate::state_scoped::StateScoped).
+ /// For more information refer to [`crate::state_scoped`].
fn enable_state_scoped_entities(&mut self) -> &mut Self;
#[cfg(feature = "bevy_reflect")]
@@ -222,11 +222,20 @@ impl AppExtStates for SubApp {
let name = core::any::type_name::();
warn!("State scoped entities are enabled for state `{}`, but the state isn't installed in the app!", name);
}
- // We work with [`StateTransition`] in set [`StateTransitionSteps::ExitSchedules`] as opposed to [`OnExit`],
- // because [`OnExit`] only runs for one specific variant of the state.
+
+ // Note: We work with `StateTransition` in set
+ // `StateTransitionSteps::ExitSchedules` rather than `OnExit`, because
+ // `OnExit` only runs for one specific variant of the state.
self.add_systems(
StateTransition,
- clear_state_scoped_entities::.in_set(StateTransitionSteps::ExitSchedules),
+ despawn_entities_on_exit_state::.in_set(StateTransitionSteps::ExitSchedules),
+ )
+ // Note: We work with `StateTransition` in set
+ // `StateTransitionSteps::EnterSchedules` rather than `OnEnter`, because
+ // `OnEnter` only runs for one specific variant of the state.
+ .add_systems(
+ StateTransition,
+ despawn_entities_on_enter_state::.in_set(StateTransitionSteps::EnterSchedules),
)
}
diff --git a/crates/bevy_state/src/lib.rs b/crates/bevy_state/src/lib.rs
index b2714b50c5..db40adeeb4 100644
--- a/crates/bevy_state/src/lib.rs
+++ b/crates/bevy_state/src/lib.rs
@@ -28,6 +28,9 @@
//! - A [`StateTransitionEvent`](crate::state::StateTransitionEvent) that gets fired when a given state changes.
//! - The [`in_state`](crate::condition::in_state) and [`state_changed`](crate::condition::state_changed) run conditions - which are used
//! to determine whether a system should run based on the current state.
+//!
+//! Bevy also provides ("state-scoped entities")[`crate::state_scoped`] functionality for managing the lifetime of entities in the context of game states.
+//! This, especially in combination with system scheduling, enables a flexible and expressive way to manage spawning and despawning entities.
# and
-/// [`clear_state_scoped_entities`](crate::state_scoped::clear_state_scoped_entities) for managing lifetime of entities.
+/// Provides tools for managing the lifetime of entities based on state transitions.
pub mod state_scoped;
#[cfg(feature = "bevy_app")]
/// Provides [`App`](bevy_app::App) and [`SubApp`](bevy_app::SubApp) with methods for registering
@@ -89,6 +91,6 @@ pub mod prelude {
OnExit, OnTransition, State, StateSet, StateTransition, StateTransitionEvent, States,
SubStates, TransitionSchedules,
},
- state_scoped::StateScoped,
+ state_scoped::{DespawnOnEnterState, DespawnOnExitState},
};
}
diff --git a/crates/bevy_state/src/state/states.rs b/crates/bevy_state/src/state/states.rs
index 163e689f0a..2bbdd615ba 100644
--- a/crates/bevy_state/src/state/states.rs
+++ b/crates/bevy_state/src/state/states.rs
@@ -65,7 +65,11 @@ pub trait States: 'static + Send + Sync + Clone + PartialEq + Eq + Hash + Debug
/// `ComputedState` dependencies.
const DEPENDENCY_DEPTH: usize = 1;
- /// Should [`StateScoped`](crate::state_scoped::StateScoped) be enabled for this state? If set to `true`,
- /// the `StateScoped` component will be used to remove entities when changing state.
+ /// Should [state scoping](crate::state_scoped) be enabled for this state?
+ /// If set to `true`, the
+ /// [`DespawnOnEnterState`](crate::state_scoped::DespawnOnEnterState) and
+ /// [`DespawnOnExitState`](crate::state_scoped::DespawnOnEnterState)
+ /// components are used to remove entities when entering or exiting the
+ /// state.
const SCOPED_ENTITIES_ENABLED: bool = false;
}
diff --git a/crates/bevy_state/src/state_scoped.rs b/crates/bevy_state/src/state_scoped.rs
index b58017d6e3..c591d0c108 100644
--- a/crates/bevy_state/src/state_scoped.rs
+++ b/crates/bevy_state/src/state_scoped.rs
@@ -36,7 +36,7 @@ use crate::state::{StateTransitionEvent, States};
///
/// fn spawn_player(mut commands: Commands) {
/// commands.spawn((
-/// StateScoped(GameState::InGame),
+/// DespawnOnExitState(GameState::InGame),
/// Player
/// ));
/// }
@@ -55,9 +55,9 @@ use crate::state::{StateTransitionEvent, States};
/// ```
#[derive(Component, Clone)]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Component, Clone))]
-pub struct StateScoped(pub S);
+pub struct DespawnOnExitState(pub S);
-impl Default for StateScoped
+impl Default for DespawnOnExitState
where
S: States + Default,
{
@@ -66,12 +66,12 @@ where
}
}
-/// Removes entities marked with [`StateScoped`]
-/// when their state no longer matches the world state.
-pub fn clear_state_scoped_entities(
+/// Despawns entities marked with [`DespawnOnExitState`] when their state no
+/// longer matches the world state.
+pub fn despawn_entities_on_exit_state(
mut commands: Commands,
mut transitions: EventReader>,
- query: Query<(Entity, &StateScoped)>,
+ query: Query<(Entity, &DespawnOnExitState)>,
) {
// We use the latest event, because state machine internals generate at most 1
// transition event (per type) each frame. No event means no change happened
@@ -91,3 +91,74 @@ pub fn clear_state_scoped_entities(
}
}
}
+
+/// Entities marked with this component will be despawned
+/// upon entering the given state.
+///
+/// To enable this feature remember to configure your application
+/// with [`enable_state_scoped_entities`](crate::app::AppExtStates::enable_state_scoped_entities) on your state(s) of choice.
+///
+/// ```
+/// use bevy_state::prelude::*;
+/// use bevy_ecs::{prelude::*, system::ScheduleSystem};
+///
+/// #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, States)]
+/// enum GameState {
+/// #[default]
+/// MainMenu,
+/// SettingsMenu,
+/// InGame,
+/// }
+///
+/// # #[derive(Component)]
+/// # struct Player;
+///
+/// fn spawn_player(mut commands: Commands) {
+/// commands.spawn((
+/// DespawnOnEnterState(GameState::MainMenu),
+/// Player
+/// ));
+/// }
+///
+/// # struct AppMock;
+/// # impl AppMock {
+/// # fn init_state(&mut self) {}
+/// # fn enable_state_scoped_entities(&mut self) {}
+/// # fn add_systems(&mut self, schedule: S, systems: impl IntoScheduleConfigs) {}
+/// # }
+/// # struct Update;
+/// # let mut app = AppMock;
+///
+/// app.init_state::();
+/// app.enable_state_scoped_entities::();
+/// app.add_systems(OnEnter(GameState::InGame), spawn_player);
+/// ```
+#[derive(Component, Clone)]
+#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Component))]
+pub struct DespawnOnEnterState(pub S);
+
+/// Despawns entities marked with [`DespawnOnEnterState`] when their state
+/// matches the world state.
+pub fn despawn_entities_on_enter_state(
+ mut commands: Commands,
+ mut transitions: EventReader>,
+ query: Query<(Entity, &DespawnOnEnterState)>,
+) {
+ // We use the latest event, because state machine internals generate at most 1
+ // transition event (per type) each frame. No event means no change happened
+ // and we skip iterating all entities.
+ let Some(transition) = transitions.read().last() else {
+ return;
+ };
+ if transition.entered == transition.exited {
+ return;
+ }
+ let Some(entered) = &transition.entered else {
+ return;
+ };
+ for (entity, binding) in &query {
+ if binding.0 == *entered {
+ commands.entity(entity).despawn();
+ }
+ }
+}
diff --git a/crates/bevy_state/src/state_scoped_events.rs b/crates/bevy_state/src/state_scoped_events.rs
index c84f5c60bf..3b881d094d 100644
--- a/crates/bevy_state/src/state_scoped_events.rs
+++ b/crates/bevy_state/src/state_scoped_events.rs
@@ -89,7 +89,8 @@ fn add_state_scoped_event_impl(
pub trait StateScopedEventsAppExt {
/// Adds an [`Event`] that is automatically cleaned up when leaving the specified `state`.
///
- /// Note that event cleanup is ordered ambiguously relative to [`StateScoped`](crate::prelude::StateScoped) entity
+ /// Note that event cleanup is ordered ambiguously relative to [`DespawnOnEnterState`](crate::prelude::DespawnOnEnterState)
+ /// and [`DespawnOnExitState`](crate::prelude::DespawnOnExitState) entity
/// cleanup and the [`OnExit`] schedule for the target state. All of these (state scoped
/// entities and events cleanup, and `OnExit`) occur within schedule [`StateTransition`](crate::prelude::StateTransition)
/// and system set `StateTransitionSteps::ExitSchedules`.
diff --git a/examples/README.md b/examples/README.md
index 0ab0e37cdb..060683f96d 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -329,6 +329,7 @@ Example | Description
[Run Conditions](../examples/ecs/run_conditions.rs) | Run systems only when one or multiple conditions are met
[Send and receive events](../examples/ecs/send_and_receive_events.rs) | Demonstrates how to send and receive events of the same type in a single system
[Startup System](../examples/ecs/startup_system.rs) | Demonstrates a startup system (one that runs once when the app starts up)
+[State Scoped](../examples/ecs/state_scoped.rs) | Shows how to spawn entities that are automatically despawned either when entering or exiting specific game states.
[System Closure](../examples/ecs/system_closure.rs) | Show how to use closures as systems, and how to configure `Local` variables by capturing external state
[System Parameter](../examples/ecs/system_param.rs) | Illustrates creating custom system parameters with `SystemParam`
[System Piping](../examples/ecs/system_piping.rs) | Pipe the output of one system into a second, allowing you to handle any errors gracefully
diff --git a/examples/ecs/state_scoped.rs b/examples/ecs/state_scoped.rs
new file mode 100644
index 0000000000..e0844b119d
--- /dev/null
+++ b/examples/ecs/state_scoped.rs
@@ -0,0 +1,127 @@
+//! Shows how to spawn entities that are automatically despawned either when
+//! entering or exiting specific game states.
+//!
+//! This pattern is useful for managing menus, levels, or other state-specific
+//! content that should only exist during certain states.
+
+use bevy::prelude::*;
+
+fn main() {
+ App::new()
+ .add_plugins(DefaultPlugins)
+ .init_state::()
+ .enable_state_scoped_entities::()
+ .add_systems(Startup, setup_camera)
+ .add_systems(OnEnter(GameState::A), on_a_enter)
+ .add_systems(OnEnter(GameState::B), on_b_enter)
+ .add_systems(OnExit(GameState::A), on_a_exit)
+ .add_systems(OnExit(GameState::B), on_b_exit)
+ .add_systems(Update, toggle)
+ .insert_resource(TickTock(Timer::from_seconds(1.0, TimerMode::Repeating)))
+ .run();
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, States)]
+enum GameState {
+ #[default]
+ A,
+ B,
+}
+
+#[derive(Resource)]
+struct TickTock(Timer);
+
+fn on_a_enter(mut commands: Commands) {
+ info!("on_a_enter");
+ commands.spawn((
+ DespawnOnExitState(GameState::A),
+ Text::new("Game is in state 'A'"),
+ TextFont {
+ font_size: 33.0,
+ ..default()
+ },
+ TextColor(Color::srgb(0.5, 0.5, 1.0)),
+ Node {
+ position_type: PositionType::Absolute,
+ top: Val::Px(0.0),
+ left: Val::Px(0.0),
+ ..default()
+ },
+ ));
+}
+
+fn on_a_exit(mut commands: Commands) {
+ info!("on_a_exit");
+ commands.spawn((
+ DespawnOnEnterState(GameState::A),
+ Text::new("Game state 'A' will be back in 1 second"),
+ TextFont {
+ font_size: 33.0,
+ ..default()
+ },
+ TextColor(Color::srgb(0.5, 0.5, 1.0)),
+ Node {
+ position_type: PositionType::Absolute,
+ top: Val::Px(0.0),
+ left: Val::Px(500.0),
+ ..default()
+ },
+ ));
+}
+
+fn on_b_enter(mut commands: Commands) {
+ info!("on_b_enter");
+ commands.spawn((
+ DespawnOnExitState(GameState::B),
+ Text::new("Game is in state 'B'"),
+ TextFont {
+ font_size: 33.0,
+ ..default()
+ },
+ TextColor(Color::srgb(0.5, 0.5, 1.0)),
+ Node {
+ position_type: PositionType::Absolute,
+ top: Val::Px(50.0),
+ left: Val::Px(0.0),
+ ..default()
+ },
+ ));
+}
+
+fn on_b_exit(mut commands: Commands) {
+ info!("on_b_exit");
+ commands.spawn((
+ DespawnOnEnterState(GameState::B),
+ Text::new("Game state 'B' will be back in 1 second"),
+ TextFont {
+ font_size: 33.0,
+ ..default()
+ },
+ TextColor(Color::srgb(0.5, 0.5, 1.0)),
+ Node {
+ position_type: PositionType::Absolute,
+ top: Val::Px(50.0),
+ left: Val::Px(500.0),
+ ..default()
+ },
+ ));
+}
+
+fn setup_camera(mut commands: Commands) {
+ commands.spawn(Camera3d::default());
+}
+
+fn toggle(
+ time: Res