docs/bevy/examples/state/computed_states.rs 23.5 K · 638 lines · raw · history

1 //! This example illustrates the use of [`ComputedStates`] for more complex state handling patterns.
2 //!
3 //! In this case, we'll be implementing the following pattern:
4 //! - The game will start in a `Menu` state, which we can return to with `Esc`
5 //! - From there, we can enter the game - where our bevy symbol moves around and changes color
6 //! - While in game, we can pause and unpause the game using `Space`
7 //! - We can also toggle "Turbo Mode" with the `T` key - where the movement and color changes are all faster. This
8 //! is retained between pauses, but not if we exit to the main menu.
9 //!
10 //! In addition, we want to enable a "tutorial" mode, which will involve its own state that is toggled in the main menu.
11 //! This will display instructions about movement and turbo mode when in game and unpaused, and instructions on how to unpause when paused.
12 //!
13 //! To implement this, we will create 2 root-level states: `AppState` and `TutorialState`.
14 //! We will then create some computed states that derive from `AppState`: `InGame` and `TurboMode` are marker states implemented
15 //! as Zero-Sized Structs (ZSTs), while `IsPaused` is an enum with 2 distinct states.
16 //! And lastly, we'll add `Tutorial`, a computed state deriving from `TutorialState`, `InGame` and `IsPaused`, with 2 distinct
17 //! states to display the 2 tutorial texts.
18
19 use bevy::{
20 dev_tools::states::*,
21 input::keyboard::Key,
22 picking::hover::Hovered,
23 prelude::*,
24 ui::{Checked, Pressed},
25 ui_widgets::{checkbox_self_update, Activate, ActivateOnPress, Button, Checkbox, ValueChange},
26 };
27
28 use ui::*;
29
30 // To begin, we want to define our state objects.
31 #[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, States)]
32 enum AppState {
33 #[default]
34 Menu,
35 // Unlike in the `states` example, we're adding more data in this
36 // version of our AppState. In this case, we actually have
37 // 4 distinct "InGame" states - unpaused and no turbo, paused and no
38 // turbo, unpaused and turbo and paused and turbo.
39 InGame {
40 paused: bool,
41 turbo: bool,
42 },
43 }
44
45 // The tutorial state object, on the other hand, is a fairly simple enum.
46 #[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, States)]
47 enum TutorialState {
48 #[default]
49 Active,
50 Inactive,
51 }
52
53 // Because we have 4 distinct values of `AppState` that mean we're "InGame", we're going to define
54 // a separate "InGame" type and implement `ComputedStates` for it.
55 // This allows us to only need to check against one type
56 // when otherwise we'd need to check against multiple.
57 #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
58 struct InGame;
59
60 impl ComputedStates for InGame {
61 // Our computed state depends on `AppState`, so we need to specify it as the SourceStates type.
62 type SourceStates = AppState;
63
64 // This is necessary to prevent `setup_game` from running when the app is already in `AppState::InGame`
65 // and only `paused` and `turbo` are changed
66 const ALLOW_SAME_STATE_TRANSITIONS: bool = false;
67 // The compute function takes in the `SourceStates`
68 fn compute(sources: AppState) -> Option<Self> {
69 // You might notice that InGame has no values - instead, in this case, the `State<InGame>` resource only exists
70 // if the `compute` function would return `Some` - so only when we are in game.
71 match sources {
72 // No matter what the value of `paused` or `turbo` is, we're still in the game rather than a menu
73 AppState::InGame { .. } => Some(Self),
74 _ => None,
75 }
76 }
77 }
78
79 // Similarly, we want to have the TurboMode state - so we'll define that now.
80 //
81 // Having it separate from [`InGame`] and [`AppState`] like this allows us to check each of them separately, rather than
82 // needing to compare against every version of the AppState that could involve them.
83 //
84 // In addition, it allows us to still maintain a strict type representation - you can't Turbo
85 // if you aren't in game, for example - while still having the
86 // flexibility to check for the states as if they were completely unrelated.
87
88 #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
89 struct TurboMode;
90
91 impl ComputedStates for TurboMode {
92 type SourceStates = AppState;
93 const ALLOW_SAME_STATE_TRANSITIONS: bool = false;
94
95 fn compute(sources: AppState) -> Option<Self> {
96 match sources {
97 AppState::InGame { turbo: true, .. } => Some(Self),
98 _ => None,
99 }
100 }
101 }
102
103 // For the [`IsPaused`] state, we'll actually use an `enum` - because the difference between `Paused` and `NotPaused`
104 // involve activating different systems.
105 //
106 // To clarify the difference, `InGame` and `TurboMode` both activate systems if they exist, and there is
107 // no variation within them. So we defined them as Zero-Sized Structs.
108 //
109 // In contrast, pausing actually involve 3 distinct potential situations:
110 // - it doesn't exist - this is when being paused is meaningless, like in the menu.
111 // - it is `NotPaused` - in which elements like the movement system are active.
112 // - it is `Paused` - in which those game systems are inactive, and a pause screen is shown.
113 #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
114 enum IsPaused {
115 NotPaused,
116 Paused,
117 }
118
119 impl ComputedStates for IsPaused {
120 type SourceStates = AppState;
121 const ALLOW_SAME_STATE_TRANSITIONS: bool = false;
122
123 fn compute(sources: AppState) -> Option<Self> {
124 // Here we convert from our [`AppState`] to all potential [`IsPaused`] versions.
125 match sources {
126 AppState::InGame { paused: true, .. } => Some(Self::Paused),
127 AppState::InGame { paused: false, .. } => Some(Self::NotPaused),
128 // If `AppState` is not `InGame`, pausing is meaningless, and so we set it to `None`.
129 _ => None,
130 }
131 }
132 }
133
134 // Lastly, we have our tutorial, which actually has a more complex derivation.
135 //
136 // Like `IsPaused`, the tutorial has a few fully distinct possible states, so we want to represent them
137 // as an Enum. However - in this case they are all dependent on multiple states: the root [`TutorialState`],
138 // and both [`InGame`] and [`IsPaused`] - which are in turn derived from [`AppState`].
139 #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
140 enum Tutorial {
141 MovementInstructions,
142 PauseInstructions,
143 }
144
145 impl ComputedStates for Tutorial {
146 // We can also use tuples of types that implement [`States`] as our [`SourceStates`].
147 // That includes other [`ComputedStates`] - though circular dependencies are not supported
148 // and will produce a compile error.
149 //
150 // We could define this as relying on [`TutorialState`] and [`AppState`] instead, but
151 // then we would need to duplicate the derivation logic for [`InGame`] and [`IsPaused`].
152 // In this example that is not a significant undertaking, but as a rule it is likely more
153 // effective to rely on the already derived states to avoid the logic drifting apart.
154 //
155 // Notice that you can wrap any of the [`States`] here in [`Option`]s. If you do so,
156 // the computation will get called even if the state does not exist.
157 type SourceStates = (TutorialState, InGame, Option<IsPaused>);
158
159 // Notice that we aren't using InGame - we're just using it as a source state to
160 // prevent the computation from executing if we're not in game. Instead - this
161 // ComputedState will just not exist in that situation.
162 fn compute(
163 (tutorial_state, _in_game, is_paused): (TutorialState, InGame, Option<IsPaused>),
164 ) -> Option<Self> {
165 // If the tutorial is inactive we don't need to worry about it.
166 if !matches!(tutorial_state, TutorialState::Active) {
167 return None;
168 }
169
170 // If we're paused, we're in the PauseInstructions tutorial
171 // Otherwise, we're in the MovementInstructions tutorial
172 match is_paused? {
173 IsPaused::NotPaused => Some(Tutorial::MovementInstructions),
174 IsPaused::Paused => Some(Tutorial::PauseInstructions),
175 }
176 }
177 }
178
179 fn main() {
180 // We start the setup like we did in the states example.
181 App::new()
182 .add_plugins(DefaultPlugins)
183 .init_state::<AppState>()
184 .init_state::<TutorialState>()
185 // After initializing the normal states, we'll use `.add_computed_state::<CS>()` to initialize our `ComputedStates`
186 .add_computed_state::<InGame>()
187 .add_computed_state::<IsPaused>()
188 .add_computed_state::<TurboMode>()
189 .add_computed_state::<Tutorial>()
190 // we can then resume adding systems just like we would in any other case,
191 // using our states as normal.
192 .add_systems(Startup, setup)
193 .add_systems(OnEnter(AppState::Menu), setup_menu)
194 .add_systems(Update, menu_styling.run_if(in_state(AppState::Menu)))
195 .add_observer(menu_activate.run_if(in_state(AppState::Menu)))
196 .add_observer(menu_tutorial_checked.run_if(in_state(AppState::Menu)))
197 .add_observer(checkbox_self_update)
198 .add_systems(OnExit(AppState::Menu), cleanup_menu)
199 // We only want to run the [`setup_game`] function when we enter the [`AppState::InGame`] state, regardless
200 // of whether the game is paused or not.
201 .add_systems(OnEnter(InGame), setup_game)
202 // We want the color change, toggle_pause and quit_to_menu systems to ignore the paused condition, so we can use the [`InGame`] derived
203 // state here as well.
204 .add_systems(
205 Update,
206 (toggle_pause, change_color, quit_to_menu).run_if(in_state(InGame)),
207 )
208 // However, we only want to move or toggle turbo mode if we are not in a paused state.
209 .add_systems(
210 Update,
211 (toggle_turbo, movement).run_if(in_state(IsPaused::NotPaused)),
212 )
213 // We can continue setting things up, following all the same patterns used above and in the `states` example.
214 .add_systems(OnEnter(IsPaused::Paused), setup_paused_screen)
215 .add_systems(OnEnter(TurboMode), setup_turbo_text)
216 .add_systems(
217 OnEnter(Tutorial::MovementInstructions),
218 movement_instructions,
219 )
220 .add_systems(OnEnter(Tutorial::PauseInstructions), pause_instructions)
221 .add_systems(
222 Update,
223 (
224 log_transitions::<AppState>,
225 log_transitions::<TutorialState>,
226 ),
227 )
228 .run();
229 }
230
231 /// Updates the app state when the play button is activated.
232 fn menu_activate(_event: On<Activate>, mut next_state: ResMut<NextState<AppState>>) {
233 // The play button is the only button on the menu that would send the `Activate` event.
234 next_state.set(AppState::InGame {
235 paused: false,
236 turbo: false,
237 });
238 }
239
240 /// Updates the app state when the tutorial button is checked/unchecked.
241 /// The `checkbox_self_update` observer added to the app handles
242 /// updating the `Checked` component on the `Checkbox` itself.
243 fn menu_tutorial_checked(
244 event: On<ValueChange<bool>>,
245 mut next_tutorial: ResMut<NextState<TutorialState>>,
246 ) {
247 next_tutorial.set(if event.value {
248 TutorialState::Active
249 } else {
250 TutorialState::Inactive
251 });
252 }
253
254 /// Updates button and checkbox styling based on hovered and pressed states.
255 fn menu_styling(
256 mut widget_query: Query<
257 (
258 &Hovered,
259 Option<&Pressed>,
260 &mut BackgroundColor,
261 Has<Checkbox>,
262 ),
263 Or<(With<Button>, With<Checkbox>)>,
264 >,
265 tutorial_state: Res<State<TutorialState>>,
266 ) {
267 for (hovered, pressed, mut color, is_tutorial_checkbox) in &mut widget_query {
268 if pressed.is_some() {
269 *color = if is_tutorial_checkbox && tutorial_state.get() == &TutorialState::Active {
270 PRESSED_ACTIVE_BUTTON.into()
271 } else {
272 PRESSED_BUTTON.into()
273 };
274 } else if hovered.get() {
275 if is_tutorial_checkbox && tutorial_state.get() == &TutorialState::Active {
276 *color = HOVERED_ACTIVE_BUTTON.into();
277 } else {
278 *color = HOVERED_BUTTON.into();
279 }
280 } else {
281 if is_tutorial_checkbox && tutorial_state.get() == &TutorialState::Active {
282 *color = ACTIVE_BUTTON.into();
283 } else {
284 *color = NORMAL_BUTTON.into();
285 }
286 }
287 }
288 }
289
290 fn toggle_pause(
291 input: Res<ButtonInput<KeyCode>>,
292 current_state: Res<State<AppState>>,
293 mut next_state: ResMut<NextState<AppState>>,
294 ) {
295 if input.just_pressed(KeyCode::Space)
296 && let AppState::InGame { paused, turbo } = current_state.get()
297 {
298 next_state.set(AppState::InGame {
299 paused: !*paused,
300 turbo: *turbo,
301 });
302 }
303 }
304
305 fn toggle_turbo(
306 input: Res<ButtonInput<Key>>,
307 current_state: Res<State<AppState>>,
308 mut next_state: ResMut<NextState<AppState>>,
309 ) {
310 if input.just_pressed(Key::Character("t".into()))
311 && let AppState::InGame { paused, turbo } = current_state.get()
312 {
313 next_state.set(AppState::InGame {
314 paused: *paused,
315 turbo: !*turbo,
316 });
317 }
318 }
319
320 fn quit_to_menu(input: Res<ButtonInput<KeyCode>>, mut next_state: ResMut<NextState<AppState>>) {
321 if input.just_pressed(KeyCode::Escape) {
322 next_state.set(AppState::Menu);
323 }
324 }
325
326 mod ui {
327 use crate::*;
328
329 #[derive(Resource)]
330 pub struct MenuData {
331 pub root_entity: Entity,
332 }
333
334 #[derive(Component, PartialEq, Eq)]
335 pub enum MenuButton {
336 Play,
337 Tutorial,
338 }
339
340 pub const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
341 pub const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
342 pub const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
343
344 pub const ACTIVE_BUTTON: Color = Color::srgb(0.15, 0.85, 0.15);
345 pub const HOVERED_ACTIVE_BUTTON: Color = Color::srgb(0.25, 0.55, 0.25);
346 pub const PRESSED_ACTIVE_BUTTON: Color = Color::srgb(0.35, 0.95, 0.35);
347
348 pub fn setup(mut commands: Commands) {
349 commands.spawn(Camera2d);
350 }
351
352 pub fn setup_menu(mut commands: Commands, tutorial_state: Res<State<TutorialState>>) {
353 let button_entity = commands
354 .spawn((
355 Node {
356 // center button
357 width: percent(100),
358 height: percent(100),
359 justify_content: JustifyContent::Center,
360 align_items: AlignItems::Center,
361 flex_direction: FlexDirection::Column,
362 row_gap: px(10),
363 ..default()
364 },
365 children![(
366 Button,
367 Hovered::default(),
368 ActivateOnPress,
369 Node {
370 width: px(200),
371 height: px(65),
372 // horizontally center child text
373 justify_content: JustifyContent::Center,
374 // vertically center child text
375 align_items: AlignItems::Center,
376 ..default()
377 },
378 BackgroundColor(NORMAL_BUTTON),
379 MenuButton::Play,
380 children![(
381 Text::new("Play"),
382 TextFont {
383 font_size: FontSize::Px(33.0),
384 ..default()
385 },
386 TextColor(Color::srgb(0.9, 0.9, 0.9)),
387 )],
388 ),],
389 ))
390 .id();
391
392 let mut tutorial_checkbox = commands.spawn((
393 Checkbox,
394 Hovered::default(),
395 ActivateOnPress,
396 Node {
397 width: px(200),
398 height: px(65),
399 // horizontally center child text
400 justify_content: JustifyContent::Center,
401 // vertically center child text
402 align_items: AlignItems::Center,
403 ..default()
404 },
405 BackgroundColor(match tutorial_state.get() {
406 TutorialState::Active => ACTIVE_BUTTON,
407 TutorialState::Inactive => NORMAL_BUTTON,
408 }),
409 MenuButton::Tutorial,
410 children![(
411 Text::new("Tutorial"),
412 TextFont {
413 font_size: FontSize::Px(33.0),
414 ..default()
415 },
416 TextColor(Color::srgb(0.9, 0.9, 0.9)),
417 )],
418 ));
419 match tutorial_state.get() {
420 TutorialState::Active => {
421 tutorial_checkbox.insert(Checked);
422 }
423 TutorialState::Inactive => {}
424 };
425 let tutorial_checkbox_id = tutorial_checkbox.id();
426 commands
427 .entity(button_entity)
428 .add_child(tutorial_checkbox_id);
429
430 commands.insert_resource(MenuData {
431 root_entity: button_entity,
432 });
433 }
434
435 pub fn cleanup_menu(mut commands: Commands, menu_data: Res<MenuData>) {
436 commands.entity(menu_data.root_entity).despawn();
437 }
438
439 pub fn setup_game(mut commands: Commands, asset_server: Res<AssetServer>) {
440 commands.spawn((
441 DespawnOnExit(InGame),
442 Sprite::from_image(asset_server.load("branding/icon.png")),
443 ));
444 }
445
446 const SPEED: f32 = 100.0;
447 const TURBO_SPEED: f32 = 300.0;
448
449 pub fn movement(
450 time: Res<Time>,
451 input: Res<ButtonInput<KeyCode>>,
452 turbo: Option<Res<State<TurboMode>>>,
453 mut query: Query<&mut Transform, With<Sprite>>,
454 ) {
455 for mut transform in &mut query {
456 let mut direction = Vec3::ZERO;
457 if input.pressed(KeyCode::ArrowLeft) {
458 direction.x -= 1.0;
459 }
460 if input.pressed(KeyCode::ArrowRight) {
461 direction.x += 1.0;
462 }
463 if input.pressed(KeyCode::ArrowUp) {
464 direction.y += 1.0;
465 }
466 if input.pressed(KeyCode::ArrowDown) {
467 direction.y -= 1.0;
468 }
469
470 if direction != Vec3::ZERO {
471 transform.translation += direction.normalize()
472 * if turbo.is_some() { TURBO_SPEED } else { SPEED }
473 * time.delta_secs();
474 }
475 }
476 }
477
478 pub fn setup_paused_screen(mut commands: Commands) {
479 info!("Printing Pause");
480 commands.spawn((
481 DespawnOnExit(IsPaused::Paused),
482 Node {
483 // center button
484 width: percent(100),
485 height: percent(100),
486 justify_content: JustifyContent::Center,
487 align_items: AlignItems::Center,
488 flex_direction: FlexDirection::Column,
489 row_gap: px(10),
490 position_type: PositionType::Absolute,
491 ..default()
492 },
493 children![(
494 Node {
495 width: px(400),
496 height: px(400),
497 // horizontally center child text
498 justify_content: JustifyContent::Center,
499 // vertically center child text
500 align_items: AlignItems::Center,
501 ..default()
502 },
503 BackgroundColor(NORMAL_BUTTON),
504 MenuButton::Play,
505 children![(
506 Text::new("Paused"),
507 TextFont {
508 font_size: FontSize::Px(33.0),
509 ..default()
510 },
511 TextColor(Color::srgb(0.9, 0.9, 0.9)),
512 )],
513 ),],
514 ));
515 }
516
517 pub fn setup_turbo_text(mut commands: Commands) {
518 commands.spawn((
519 DespawnOnExit(TurboMode),
520 Node {
521 // center button
522 width: percent(100),
523 height: percent(100),
524 justify_content: JustifyContent::Start,
525 align_items: AlignItems::Center,
526 flex_direction: FlexDirection::Column,
527 row_gap: px(10),
528 position_type: PositionType::Absolute,
529 ..default()
530 },
531 children![(
532 Text::new("TURBO MODE"),
533 TextFont {
534 font_size: FontSize::Px(33.0),
535 ..default()
536 },
537 TextColor(Color::srgb(0.9, 0.3, 0.1)),
538 )],
539 ));
540 }
541
542 pub fn change_color(time: Res<Time>, mut query: Query<&mut Sprite>) {
543 for mut sprite in &mut query {
544 let new_color = LinearRgba {
545 blue: ops::sin(time.elapsed_secs() * 0.5) + 2.0,
546 ..LinearRgba::from(sprite.color)
547 };
548
549 sprite.color = new_color.into();
550 }
551 }
552
553 pub fn movement_instructions(mut commands: Commands) {
554 commands.spawn((
555 DespawnOnExit(Tutorial::MovementInstructions),
556 Node {
557 // center button
558 width: percent(100),
559 height: percent(100),
560 justify_content: JustifyContent::End,
561 align_items: AlignItems::Center,
562 flex_direction: FlexDirection::Column,
563 row_gap: px(10),
564 position_type: PositionType::Absolute,
565 ..default()
566 },
567 children![
568 (
569 Text::new("Move the bevy logo with the arrow keys"),
570 TextFont {
571 font_size: FontSize::Px(33.0),
572 ..default()
573 },
574 TextColor(Color::srgb(0.3, 0.3, 0.7)),
575 ),
576 (
577 Text::new("Press T to enter TURBO MODE"),
578 TextFont {
579 font_size: FontSize::Px(33.0),
580 ..default()
581 },
582 TextColor(Color::srgb(0.3, 0.3, 0.7)),
583 ),
584 (
585 Text::new("Press SPACE to pause"),
586 TextFont {
587 font_size: FontSize::Px(33.0),
588 ..default()
589 },
590 TextColor(Color::srgb(0.3, 0.3, 0.7)),
591 ),
592 (
593 Text::new("Press ESCAPE to return to the menu"),
594 TextFont {
595 font_size: FontSize::Px(33.0),
596 ..default()
597 },
598 TextColor(Color::srgb(0.3, 0.3, 0.7)),
599 ),
600 ],
601 ));
602 }
603
604 pub fn pause_instructions(mut commands: Commands) {
605 commands.spawn((
606 DespawnOnExit(Tutorial::PauseInstructions),
607 Node {
608 // center button
609 width: percent(100),
610 height: percent(100),
611 justify_content: JustifyContent::End,
612 align_items: AlignItems::Center,
613 flex_direction: FlexDirection::Column,
614 row_gap: px(10),
615 position_type: PositionType::Absolute,
616 ..default()
617 },
618 children![
619 (
620 Text::new("Press SPACE to resume"),
621 TextFont {
622 font_size: FontSize::Px(33.0),
623 ..default()
624 },
625 TextColor(Color::srgb(0.3, 0.3, 0.7)),
626 ),
627 (
628 Text::new("Press ESCAPE to return to the menu"),
629 TextFont {
630 font_size: FontSize::Px(33.0),
631 ..default()
632 },
633 TextColor(Color::srgb(0.3, 0.3, 0.7)),
634 ),
635 ],
636 ));
637 }
638 }