diff --git a/Cargo.toml b/Cargo.toml index 3dfdf3ec77203..1f2f3a2e87a97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1774,6 +1774,7 @@ wasm = false name = "state" path = "examples/state/state.rs" doc-scrape-examples = true +required-features = ["bevy_dev_tools"] [package.metadata.example.state] name = "State" @@ -1785,6 +1786,7 @@ wasm = false name = "sub_states" path = "examples/state/sub_states.rs" doc-scrape-examples = true +required-features = ["bevy_dev_tools"] [package.metadata.example.sub_states] name = "Sub States" @@ -1796,6 +1798,7 @@ wasm = false name = "computed_states" path = "examples/state/computed_states.rs" doc-scrape-examples = true +required-features = ["bevy_dev_tools"] [package.metadata.example.computed_states] name = "Computed States" diff --git a/crates/bevy_dev_tools/Cargo.toml b/crates/bevy_dev_tools/Cargo.toml index 4599c15ce924c..08b9f89e61eae 100644 --- a/crates/bevy_dev_tools/Cargo.toml +++ b/crates/bevy_dev_tools/Cargo.toml @@ -36,6 +36,7 @@ bevy_ui = { path = "../bevy_ui", version = "0.14.0-dev", features = [ bevy_utils = { path = "../bevy_utils", version = "0.14.0-dev" } bevy_window = { path = "../bevy_window", version = "0.14.0-dev" } bevy_text = { path = "../bevy_text", version = "0.14.0-dev" } +bevy_state = { path = "../bevy_state", version = "0.14.0-dev" } # other serde = { version = "1.0", features = ["derive"], optional = true } diff --git a/crates/bevy_dev_tools/src/lib.rs b/crates/bevy_dev_tools/src/lib.rs index 2c4c8fb396b8b..58c1e8c17f416 100644 --- a/crates/bevy_dev_tools/src/lib.rs +++ b/crates/bevy_dev_tools/src/lib.rs @@ -18,6 +18,8 @@ pub mod fps_overlay; #[cfg(feature = "bevy_ui_debug")] pub mod ui_debug_overlay; +pub mod states; + /// Enables developer tools in an [`App`]. This plugin is added automatically with `bevy_dev_tools` /// feature. /// diff --git a/crates/bevy_dev_tools/src/states.rs b/crates/bevy_dev_tools/src/states.rs new file mode 100644 index 0000000000000..7e3aa237c60cf --- /dev/null +++ b/crates/bevy_dev_tools/src/states.rs @@ -0,0 +1,18 @@ +//! Tools for debugging states. + +use bevy_ecs::event::EventReader; +use bevy_state::state::{StateTransitionEvent, States}; +use bevy_utils::tracing::info; + +/// Logs state transitions into console. +/// +/// This system is provided to make debugging easier by tracking state changes. +pub fn log_transitions(mut transitions: EventReader>) { + // State internals can generate at most one event (of type) per frame. + let Some(transition) = transitions.read().last() else { + return; + }; + let name = std::any::type_name::(); + let StateTransitionEvent { exited, entered } = transition; + info!("{} transition: {:?} => {:?}", name, exited, entered); +} diff --git a/crates/bevy_state/Cargo.toml b/crates/bevy_state/Cargo.toml index 6919f9f0cb19e..39e8623e861ab 100644 --- a/crates/bevy_state/Cargo.toml +++ b/crates/bevy_state/Cargo.toml @@ -12,9 +12,10 @@ categories = ["game-engines", "data-structures"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["bevy_reflect", "bevy_app"] +default = ["bevy_reflect", "bevy_app", "bevy_hierarchy"] bevy_reflect = ["dep:bevy_reflect", "bevy_ecs/bevy_reflect"] bevy_app = ["dep:bevy_app"] +bevy_hierarchy = ["dep:bevy_hierarchy"] [dependencies] bevy_ecs = { path = "../bevy_ecs", version = "0.14.0-dev" } @@ -22,6 +23,7 @@ bevy_state_macros = { path = "macros", version = "0.14.0-dev" } bevy_utils = { path = "../bevy_utils", version = "0.14.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.14.0-dev", optional = true } bevy_app = { path = "../bevy_app", version = "0.14.0-dev", optional = true } +bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.14.0-dev", optional = true } [lints] workspace = true diff --git a/crates/bevy_state/src/app.rs b/crates/bevy_state/src/app.rs index 47c4f08405197..2ff97f6ee517b 100644 --- a/crates/bevy_state/src/app.rs +++ b/crates/bevy_state/src/app.rs @@ -1,10 +1,15 @@ use bevy_app::{App, MainScheduleOrder, Plugin, PreUpdate, Startup, SubApp}; -use bevy_ecs::{event::Events, schedule::ScheduleLabel, world::FromWorld}; +use bevy_ecs::{ + event::Events, + schedule::{IntoSystemConfigs, ScheduleLabel}, + world::FromWorld, +}; use crate::state::{ setup_state_transitions_in_world, ComputedStates, FreelyMutableState, NextState, State, - StateTransition, StateTransitionEvent, SubStates, + StateTransition, StateTransitionEvent, StateTransitionSteps, States, SubStates, }; +use crate::state_scoped::clear_state_scoped_entities; /// State installation methods for [`App`](bevy_app::App) and [`SubApp`](bevy_app::SubApp). pub trait AppExtStates { @@ -44,6 +49,11 @@ pub trait AppExtStates { /// /// This method is idempotent: it has no effect when called again using the same generic type. fn add_sub_state(&mut self) -> &mut Self; + + /// Enable state-scoped entity clearing for state `S`. + /// + /// For more information refer to [`StateScoped`](crate::state_scoped::StateScoped). + fn enable_state_scoped_entities(&mut self) -> &mut Self; } impl AppExtStates for SubApp { @@ -91,10 +101,13 @@ impl AppExtStates for SubApp { self.add_event::>(); let schedule = self.get_schedule_mut(StateTransition).unwrap(); S::register_computed_state_systems(schedule); - let state = self.world().resource::>().get().clone(); + let state = self + .world() + .get_resource::>() + .map(|s| s.get().clone()); self.world_mut().send_event(StateTransitionEvent { exited: None, - entered: Some(state), + entered: state, }); } @@ -111,15 +124,36 @@ impl AppExtStates for SubApp { self.add_event::>(); let schedule = self.get_schedule_mut(StateTransition).unwrap(); S::register_sub_state_systems(schedule); - let state = self.world().resource::>().get().clone(); + let state = self + .world() + .get_resource::>() + .map(|s| s.get().clone()); self.world_mut().send_event(StateTransitionEvent { exited: None, - entered: Some(state), + entered: state, }); } self } + + fn enable_state_scoped_entities(&mut self) -> &mut Self { + use bevy_utils::tracing::warn; + + if !self + .world() + .contains_resource::>>() + { + let name = std::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. + self.add_systems( + StateTransition, + clear_state_scoped_entities::.in_set(StateTransitionSteps::ExitSchedules), + ) + } } impl AppExtStates for App { @@ -142,6 +176,12 @@ impl AppExtStates for App { self.main_mut().add_sub_state::(); self } + + #[cfg(feature = "bevy_hierarchy")] + fn enable_state_scoped_entities(&mut self) -> &mut Self { + self.main_mut().enable_state_scoped_entities::(); + self + } } /// Registers the [`StateTransition`] schedule in the [`MainScheduleOrder`] to enable state processing. diff --git a/crates/bevy_state/src/lib.rs b/crates/bevy_state/src/lib.rs index 408ee9963b12e..57ed083436cef 100644 --- a/crates/bevy_state/src/lib.rs +++ b/crates/bevy_state/src/lib.rs @@ -35,6 +35,9 @@ pub mod condition; /// Provides definitions for the basic traits required by the state system pub mod state; +/// Provides [`StateScoped`] and [`clear_state_scoped_entities`] for managing lifetime of entities. +pub mod state_scoped; + /// Most commonly used re-exported types. pub mod prelude { #[cfg(feature = "bevy_app")] @@ -47,4 +50,6 @@ pub mod prelude { ComputedStates, NextState, OnEnter, OnExit, OnTransition, State, StateSet, StateTransition, StateTransitionEvent, States, SubStates, }; + #[doc(hidden)] + pub use crate::state_scoped::StateScoped; } diff --git a/crates/bevy_state/src/state/states.rs b/crates/bevy_state/src/state/states.rs index 6f2be17cd5759..cde092e68a9f1 100644 --- a/crates/bevy_state/src/state/states.rs +++ b/crates/bevy_state/src/state/states.rs @@ -27,10 +27,10 @@ use std::hash::Hash; /// /// #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, States)] /// enum GameState { -/// #[default] -/// MainMenu, -/// SettingsMenu, -/// InGame, +/// #[default] +/// MainMenu, +/// SettingsMenu, +/// InGame, /// } /// /// fn handle_escape_pressed(mut next_state: ResMut>) { diff --git a/crates/bevy_state/src/state_scoped.rs b/crates/bevy_state/src/state_scoped.rs new file mode 100644 index 0000000000000..5ca2b48ce9077 --- /dev/null +++ b/crates/bevy_state/src/state_scoped.rs @@ -0,0 +1,84 @@ +use bevy_ecs::{ + component::Component, + entity::Entity, + event::EventReader, + system::{Commands, Query}, +}; +#[cfg(feature = "bevy_hierarchy")] +use bevy_hierarchy::DespawnRecursiveExt; + +use crate::state::{StateTransitionEvent, States}; + +/// Entities marked with this component will be removed +/// when the world's state of the matching type no longer matches the supplied value. +/// +/// 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. +/// +/// If `bevy_hierarchy` feature is enabled, which it is by default, the despawn will be recursive. +/// +/// ``` +/// use bevy_state::prelude::*; +/// use bevy_ecs::prelude::*; +/// +/// #[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(( +/// StateScoped(GameState::InGame), +/// 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 IntoSystemConfigs) {} +/// # } +/// # struct Update; +/// # let mut app = AppMock; +/// +/// app.init_state::(); +/// app.enable_state_scoped_entities::(); +/// app.add_systems(OnEnter(GameState::InGame), spawn_player); +/// ``` +#[derive(Component)] +pub struct StateScoped(pub S); + +/// Removes entities marked with [`StateScoped`] +/// when their state no longer matches the world state. +/// +/// If `bevy_hierarchy` feature is enabled, which it is by default, the despawn will be recursive. +pub fn clear_state_scoped_entities( + mut commands: Commands, + mut transitions: EventReader>, + query: Query<(Entity, &StateScoped)>, +) { + // 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; + }; + let Some(exited) = &transition.exited else { + return; + }; + for (entity, binding) in &query { + if binding.0 == *exited { + #[cfg(feature = "bevy_hierarchy")] + commands.entity(entity).despawn_recursive(); + #[cfg(not(feature = "bevy_hierarchy"))] + commands.entity(entity).despawn(); + } + } +} diff --git a/examples/state/computed_states.rs b/examples/state/computed_states.rs index 77f99ad0c27ea..885c2f5801acb 100644 --- a/examples/state/computed_states.rs +++ b/examples/state/computed_states.rs @@ -16,7 +16,7 @@ //! And lastly, we'll add [`Tutorial`], a computed state deriving from [`TutorialState`], [`InGame`] and [`IsPaused`], with 2 distinct //! states to display the 2 tutorial texts. -use bevy::prelude::*; +use bevy::{dev_tools::states::*, prelude::*}; use ui::*; @@ -186,7 +186,7 @@ fn main() { .add_systems(OnEnter(InGame), setup_game) // And we only want to run the [`clear_game`] function when we leave the [`AppState::InGame`] state, regardless // of whether we're paused. - .add_systems(OnExit(InGame), clear_state_bound_entities(InGame)) + .enable_state_scoped_entities::() // We want the color change, toggle_pause and quit_to_menu systems to ignore the paused condition, so we can use the [`InGame`] derived // state here as well. .add_systems( @@ -200,26 +200,22 @@ fn main() { ) // We can continue setting things up, following all the same patterns used above and in the `states` example. .add_systems(OnEnter(IsPaused::Paused), setup_paused_screen) - .add_systems( - OnExit(IsPaused::Paused), - clear_state_bound_entities(IsPaused::Paused), - ) + .enable_state_scoped_entities::() .add_systems(OnEnter(TurboMode), setup_turbo_text) - .add_systems(OnExit(TurboMode), clear_state_bound_entities(TurboMode)) + .enable_state_scoped_entities::() .add_systems( OnEnter(Tutorial::MovementInstructions), movement_instructions, ) .add_systems(OnEnter(Tutorial::PauseInstructions), pause_instructions) + .enable_state_scoped_entities::() .add_systems( - OnExit(Tutorial::MovementInstructions), - clear_state_bound_entities(Tutorial::MovementInstructions), - ) - .add_systems( - OnExit(Tutorial::PauseInstructions), - clear_state_bound_entities(Tutorial::PauseInstructions), + Update, + ( + log_transitions::, + log_transitions::, + ), ) - .add_systems(Update, log_transitions) .run(); } @@ -277,22 +273,6 @@ fn menu( } } -#[derive(Component)] -struct StateBound(S); - -fn clear_state_bound_entities( - state: S, -) -> impl Fn(Commands, Query<(Entity, &StateBound)>) { - info!("Clearing entities for {state:?}"); - move |mut commands, query| { - for (entity, bound) in &query { - if bound.0 == state { - commands.entity(entity).despawn_recursive(); - } - } - } -} - fn toggle_pause( input: Res>, current_state: Res>, @@ -329,25 +309,6 @@ fn quit_to_menu(input: Res>, mut next_state: ResMut>, - mut tutorial_transitions: EventReader>, -) { - for transition in transitions.read() { - info!( - "transition: {:?} => {:?}", - transition.exited, transition.entered - ); - } - for transition in tutorial_transitions.read() { - info!( - "tutorial transition: {:?} => {:?}", - transition.exited, transition.entered - ); - } -} - mod ui { use crate::*; @@ -461,7 +422,7 @@ mod ui { pub fn setup_game(mut commands: Commands, asset_server: Res) { commands.spawn(( - StateBound(InGame), + StateScoped(InGame), SpriteBundle { texture: asset_server.load("branding/icon.png"), ..default() @@ -505,7 +466,7 @@ mod ui { info!("Printing Pause"); commands .spawn(( - StateBound(IsPaused::Paused), + StateScoped(IsPaused::Paused), NodeBundle { style: Style { // center button @@ -555,7 +516,7 @@ mod ui { pub fn setup_turbo_text(mut commands: Commands) { commands .spawn(( - StateBound(TurboMode), + StateScoped(TurboMode), NodeBundle { style: Style { // center button @@ -597,7 +558,7 @@ mod ui { pub fn movement_instructions(mut commands: Commands) { commands .spawn(( - StateBound(Tutorial::MovementInstructions), + StateScoped(Tutorial::MovementInstructions), NodeBundle { style: Style { // center button @@ -654,7 +615,7 @@ mod ui { pub fn pause_instructions(mut commands: Commands) { commands .spawn(( - StateBound(Tutorial::PauseInstructions), + StateScoped(Tutorial::PauseInstructions), NodeBundle { style: Style { // center button diff --git a/examples/state/state.rs b/examples/state/state.rs index d695e82e4a75b..ef329b0baa6e4 100644 --- a/examples/state/state.rs +++ b/examples/state/state.rs @@ -5,7 +5,7 @@ //! //! In this case, we're transitioning from a `Menu` state to an `InGame` state. -use bevy::prelude::*; +use bevy::{dev_tools::states::*, prelude::*}; fn main() { App::new() @@ -25,7 +25,7 @@ fn main() { Update, (movement, change_color).run_if(in_state(AppState::InGame)), ) - .add_systems(Update, log_transitions) + .add_systems(Update, log_transitions::) .run(); } @@ -163,14 +163,3 @@ fn change_color(time: Res