custom_transitions/
custom_transitions.rs

1//! This example illustrates how to register custom state transition behavior.
2//!
3//! In this case we are trying to add `OnReenter` and `OnReexit`
4//! which will work much like `OnEnter` and `OnExit`,
5//! but additionally trigger if the state changed into itself.
6//!
7//! While identity transitions exist internally in [`StateTransitionEvent`]s,
8//! the default schedules intentionally ignore them, as this behavior is not commonly needed or expected.
9//!
10//! While this example displays identity transitions for a single state,
11//! identity transitions are propagated through the entire state graph,
12//! meaning any change to parent state will be propagated to [`ComputedStates`] and [`SubStates`].
13
14use std::marker::PhantomData;
15
16use bevy::{dev_tools::states::*, ecs::schedule::ScheduleLabel, prelude::*};
17
18use custom_transitions::*;
19
20#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, States)]
21enum AppState {
22    #[default]
23    Menu,
24    InGame,
25}
26
27fn main() {
28    App::new()
29        // We insert the custom transitions plugin for `AppState`.
30        .add_plugins((
31            DefaultPlugins,
32            IdentityTransitionsPlugin::<AppState>::default(),
33        ))
34        .init_state::<AppState>()
35        .add_systems(Startup, setup)
36        .add_systems(OnEnter(AppState::Menu), setup_menu)
37        .add_systems(Update, menu.run_if(in_state(AppState::Menu)))
38        .add_systems(OnExit(AppState::Menu), cleanup_menu)
39        // We will restart the game progress every time we re-enter into it.
40        .add_systems(OnReenter(AppState::InGame), setup_game)
41        .add_systems(OnReexit(AppState::InGame), teardown_game)
42        // Doing it this way allows us to restart the game without any additional in-between states.
43        .add_systems(
44            Update,
45            ((movement, change_color, trigger_game_restart).run_if(in_state(AppState::InGame)),),
46        )
47        .add_systems(Update, log_transitions::<AppState>)
48        .run();
49}
50
51/// This module provides the custom `OnReenter` and `OnReexit` transitions for easy installation.
52mod custom_transitions {
53    use crate::*;
54
55    /// The plugin registers the transitions for one specific state.
56    /// If you use this for multiple states consider:
57    /// - installing the plugin multiple times,
58    /// - create an [`App`] extension method that inserts
59    ///   those transitions during state installation.
60    #[derive(Default)]
61    pub struct IdentityTransitionsPlugin<S: States>(PhantomData<S>);
62
63    impl<S: States> Plugin for IdentityTransitionsPlugin<S> {
64        fn build(&self, app: &mut App) {
65            app.add_systems(
66                StateTransition,
67                // The internals can generate at most one transition event of specific type per frame.
68                // We take the latest one and clear the queue.
69                last_transition::<S>
70                    // We insert the optional event into our schedule runner.
71                    .pipe(run_reenter::<S>)
72                    // State transitions are handled in three ordered steps, exposed as system sets.
73                    // We can add our systems to them, which will run the corresponding schedules when they're evaluated.
74                    // These are:
75                    // - [`ExitSchedules`] - Ran from leaf-states to root-states,
76                    // - [`TransitionSchedules`] - Ran in arbitrary order,
77                    // - [`EnterSchedules`] - Ran from root-states to leaf-states.
78                    .in_set(EnterSchedules::<S>::default()),
79            )
80            .add_systems(
81                StateTransition,
82                last_transition::<S>
83                    .pipe(run_reexit::<S>)
84                    .in_set(ExitSchedules::<S>::default()),
85            );
86        }
87    }
88
89    /// Custom schedule that will behave like [`OnEnter`], but run on identity transitions.
90    #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)]
91    pub struct OnReenter<S: States>(pub S);
92
93    /// Schedule runner which checks conditions and if they're right
94    /// runs out custom schedule.
95    fn run_reenter<S: States>(transition: In<Option<StateTransitionEvent<S>>>, world: &mut World) {
96        // We return early if no transition event happened.
97        let Some(transition) = transition.0 else {
98            return;
99        };
100
101        // If we wanted to ignore identity transitions,
102        // we'd compare `exited` and `entered` here,
103        // and return if they were the same.
104
105        // We check if we actually entered a state.
106        // A [`None`] would indicate that the state was removed from the world.
107        // This only happens in the case of [`SubStates`] and [`ComputedStates`].
108        let Some(entered) = transition.entered else {
109            return;
110        };
111
112        // If all conditions are valid, we run our custom schedule.
113        let _ = world.try_run_schedule(OnReenter(entered));
114
115        // If you want to overwrite the default `OnEnter` behavior to act like re-enter,
116        // you can do so by running the `OnEnter` schedule here. Note that you don't want
117        // to run `OnEnter` when the default behavior does so.
118        // ```
119        // if transition.entered != transition.exited {
120        //     return;
121        // }
122        // let _ = world.try_run_schedule(OnReenter(entered));
123        // ```
124    }
125
126    /// Custom schedule that will behave like [`OnExit`], but run on identity transitions.
127    #[derive(ScheduleLabel, Clone, Debug, PartialEq, Eq, Hash)]
128    pub struct OnReexit<S: States>(pub S);
129
130    fn run_reexit<S: States>(transition: In<Option<StateTransitionEvent<S>>>, world: &mut World) {
131        let Some(transition) = transition.0 else {
132            return;
133        };
134        let Some(exited) = transition.exited else {
135            return;
136        };
137
138        let _ = world.try_run_schedule(OnReexit(exited));
139    }
140}
141
142fn menu(
143    mut next_state: ResMut<NextState<AppState>>,
144    mut interaction_query: Query<
145        (&Interaction, &mut BackgroundColor),
146        (Changed<Interaction>, With<Button>),
147    >,
148) {
149    for (interaction, mut color) in &mut interaction_query {
150        match *interaction {
151            Interaction::Pressed => {
152                *color = PRESSED_BUTTON.into();
153                next_state.set(AppState::InGame);
154            }
155            Interaction::Hovered => {
156                *color = HOVERED_BUTTON.into();
157            }
158            Interaction::None => {
159                *color = NORMAL_BUTTON.into();
160            }
161        }
162    }
163}
164
165fn cleanup_menu(mut commands: Commands, menu_data: Res<MenuData>) {
166    commands.entity(menu_data.button_entity).despawn();
167}
168
169const SPEED: f32 = 100.0;
170fn movement(
171    time: Res<Time>,
172    input: Res<ButtonInput<KeyCode>>,
173    mut query: Query<&mut Transform, With<Sprite>>,
174) {
175    for mut transform in &mut query {
176        let mut direction = Vec3::ZERO;
177        if input.pressed(KeyCode::ArrowLeft) {
178            direction.x -= 1.0;
179        }
180        if input.pressed(KeyCode::ArrowRight) {
181            direction.x += 1.0;
182        }
183        if input.pressed(KeyCode::ArrowUp) {
184            direction.y += 1.0;
185        }
186        if input.pressed(KeyCode::ArrowDown) {
187            direction.y -= 1.0;
188        }
189
190        if direction != Vec3::ZERO {
191            transform.translation += direction.normalize() * SPEED * time.delta_secs();
192        }
193    }
194}
195
196fn change_color(time: Res<Time>, mut query: Query<&mut Sprite>) {
197    for mut sprite in &mut query {
198        let new_color = LinearRgba {
199            blue: ops::sin(time.elapsed_secs() * 0.5) + 2.0,
200            ..LinearRgba::from(sprite.color)
201        };
202
203        sprite.color = new_color.into();
204    }
205}
206
207// We can restart the game by pressing "R".
208// This will trigger an [`AppState::InGame`] -> [`AppState::InGame`]
209// transition, which will run our custom schedules.
210fn trigger_game_restart(
211    input: Res<ButtonInput<KeyCode>>,
212    mut next_state: ResMut<NextState<AppState>>,
213) {
214    if input.just_pressed(KeyCode::KeyR) {
215        // Although we are already in this state setting it again will generate an identity transition.
216        // While default schedules ignore those kinds of transitions, our custom schedules will react to them.
217        next_state.set(AppState::InGame);
218    }
219}
220
221fn setup(mut commands: Commands) {
222    commands.spawn(Camera2d);
223}
224
225fn setup_game(mut commands: Commands, asset_server: Res<AssetServer>) {
226    commands.spawn(Sprite::from_image(asset_server.load("branding/icon.png")));
227    info!("Setup game");
228}
229
230fn teardown_game(mut commands: Commands, player: Single<Entity, With<Sprite>>) {
231    commands.entity(*player).despawn();
232    info!("Teardown game");
233}
234
235#[derive(Resource)]
236struct MenuData {
237    pub button_entity: Entity,
238}
239
240const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
241const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
242const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
243
244fn setup_menu(mut commands: Commands) {
245    let button_entity = commands
246        .spawn((
247            Node {
248                // center button
249                width: Val::Percent(100.),
250                height: Val::Percent(100.),
251                justify_content: JustifyContent::Center,
252                align_items: AlignItems::Center,
253                ..default()
254            },
255            children![(
256                Button,
257                Node {
258                    width: Val::Px(150.),
259                    height: Val::Px(65.),
260                    // horizontally center child text
261                    justify_content: JustifyContent::Center,
262                    // vertically center child text
263                    align_items: AlignItems::Center,
264                    ..default()
265                },
266                BackgroundColor(NORMAL_BUTTON),
267                children![(
268                    Text::new("Play"),
269                    TextFont {
270                        font_size: 33.0,
271                        ..default()
272                    },
273                    TextColor(Color::srgb(0.9, 0.9, 0.9)),
274                )]
275            )],
276        ))
277        .id();
278    commands.insert_resource(MenuData { button_entity });
279}