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. #![cfg_attr( any(docsrs, docsrs_dep), @@ -56,8 +59,7 @@ pub mod condition; /// Provides definitions for the basic traits required by the state system pub mod state; -/// Provides [`StateScoped`](crate::state_scoped::StateScoped) 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