Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Robust State Matching (formerly Sub state support) #9957

Closed
wants to merge 130 commits into from

Conversation

lee-orr
Copy link
Contributor

@lee-orr lee-orr commented Sep 29, 2023

Note - this PR has changed a lot since it's original inception, so I felt it's worth re-writing the description to make sure it is accurate. For the sake of keeping a good record, you can find the original description further down.

I'm also adding some additional notes with a date below.

Objective

The main purpose of this PR is to enable versatile state matching. The current implementation of States relies solely on equality when determining whether a given system or schedule should run:

enum AppState {
  Menu,
  Options,
  Credits,
  InGame(GameState)
}

enum GameState {
  Running,
  Paused
}

app.add_systems(OnExit(AppState::Menu), clear_ui);
app.add_systems(OnExit(AppState::Options), clear_ui);
app.add_systems(OnExit(AppState::Credits), clear_ui);

app.add_systems(Update, animate_idle_character.run_if((in_state(AppState::InGame(GameState::Running)),in_state(AppState::InGame(GameState::Paused)));
``

or, for an example based on structs:

```rust
struct AppState {
  menu: Option<Menu>
  in_game: Option<GameState>
}

enum Menu {
  Main,
  Options,
  Credits,
}

enum GameState {
  Running,
  Paused
}


app.add_systems(OnExit(AppState { menu: Some(Menu::Main), in_game: None}), clear_ui);
app.add_systems(OnExit(AppState { menu: Some(Menu::Options), in_game: None}), clear_ui);
app.add_systems(OnExit(AppState { menu: Some(Menu::Credits), in_game: None}), clear_ui);

app.add_systems(Update, animate_idle_character.run_if((in_state(AppState { menu: None, in_game: GameState::Running }),in_state(AppState { menu: None, in_game: GameState::Paused}));

This can lead to a variety of issues when the structure of the state object changes during development, or if you don't accurately foresee the way the states could change.

In addition, it is much harder to handle state changes that should only occur when entering/leaving a collection of states, but not when moving between them - like so:

// Enum
app.add_systems(OnEnter(AppState::Menu), setup_menu_backdrop);
app.add_systems(OnEnter(AppState::Options), setup_menu_backdrop);
app.add_systems(OnEnter(AppState::Credits), setup_menu_backdrop);

// State
app.add_systems(OnEnter(AppState { menu: Some(Menu::Main), in_game: None}), setup_menu_backdrop);
app.add_systems(OnEnter(AppState { menu: Some(Menu::Options), in_game: None}), setup_menu_backdrop);
app.add_systems(OnEnter(AppState { menu: Some(Menu::Credits), in_game: None}), setup_menu_backdrop);

fn setup_menu_backdrop(is_set_up_query: Query<Entity, With<Backdrop>>, ...) {
  if !is_set_up_query.is_empty() {
    return;
  }
  ...
}

This PR provides a set of tools for matching states in a more robust, reliable, and ergonomic way.

Solution

There are a few layers to the solution, and originally I went from "lowest level" to "highest level" - but I feel that added to some confusion on the reasoning/purpose behind each layer of the solution. So here, I will be going from the "highest level" stuff, which is most likely to be used directly by end users, to the "lowest level" stuff that serves as infrastructure for the higher level elements.

State Matching Macros

Notes (9 Oct): updated below:
The solution adds the following macros: entering!, exiting!, state_matches! and transitioning! that allow you to use pattern matching syntax to determine whether the current (and/or previous) state should cause the systems to run.

So, for the examples above we would be able to use:

// Enum Example
.add_systems(Entering, setup_menu_backdrop.run_if(entering!((AppState, Menu | Options | Credits)))
.add_systems(Exiting, clear_ui.run_if(exiting!(AppState, every Menu | Options | Credits)))
.add_sytstems(Update, animate_idle_character.run_if(state_matches!(AppState, InGame(_))));

// State Example
.add_systems(Entering, setup_menu_backdrop.run_if(entering!(AppState, AppState { menu: Some(_), .. })))
.add_systems(Exiting, clear_ui.run_if(exiting!(AppState, every AppState { menu: Some(_), .. })))
.add_sytstems(Update, animate_idle_character.run_if(state_matches!(AppState, AppState { in_game: Some(_), .. }))));

note the every keyword in the exiting! macro - this means that it will run every time you transition into one of the states regardless of the previous state. By contrast, the entering macro will only run when we enter a matching state from a non-matching state because we aren't using the every keyword.

Also added are run conditions with the same names: entering, exiting, state_matches, transitioning - these can be passed any StateMatchers - which are now either States or one of the Fn variants with auto implementations.

Previous Version

The solution adds the following macros: on_enter!, on_exit!, state_matches! and on_transition! that allow you to use pattern matching syntax to determine whether the current (and/or previous) state should cause the systems to run.

So, for the examples above we would be able to use:

// Enum Example
.add_systems(on_enter!(AppState, Menu | Options | Credits), setup_menu_backdrop)
.add_systems(on_exit!(AppState, every Menu | Options | Credits), clear_ui)
.add_sytstems(Update, animate_idle_character.run_if(state_matches!(AppState, InGame(_))));

// State Example
.add_systems(on_enter!(AppState, AppState { menu: Some(_), .. }), setup_menu_backdrop)
.add_systems(on_exit!(AppState, every AppState { menu: Some(_), .. }), clear_ui)
.add_sytstems(Update, animate_idle_character.run_if(state_matches!(AppState, AppState { in_game: Some(_), .. }))));

note the every keyword in the on_exit! macro - this means that it will run every time you transition into one of the states regardless of the previous state. By contrast, the on_enter macro will only run when we enter a matching state from a non-matching state because we aren't using the every keyword.

State Matchers

The state matching macros utilize StateMatchers under the hood. This is really the core of the solution space here - separating the data (implementors of States) and the mechanism for determining whether we should run in a given state (implementors of StateMatcher<S: States>).

State matchers provide 2 methods:

match_state(&self, state: &S) -> bool
match_state_transition(&self, main: Option<&S>, secondary: Option<&S>) -> MatchesStateTransition

The MatchesStateTransition Enum represents the different options for handling transitions:
TransitionMatches - when the whole transition matches, MainMatches - when the main matches, but the transition as a whole is invalid, NoMatch - when neither the main nor the transition match. This is used to enable every and normal options in the macros, as well as to allow for custom logic if you so choose.

The main parameter is always the primary state instance we care about - if looking at on_exit or the from in on_transition, this would be the previous state, while if looking at on_enter or the to in on_transition, this would be the next state. If we are not in a transition, main will be set but secondary will be None.

In normal use, you would probably rely on either the macros above (which generate state matchers), on on one of the default implementations - such as States<S> which relies on Eq, Fn(&S) -> bool, Fn(&S, Option<&S>) -> bool or Fn(Option<&S>, Option<&S>) -> MatchesStateTransition.

Note (9 Oct) - the derive option will be removed and the trait will be made un-implementable from a public standpoint. Only the auto-implemented versions will be available.

~~However, you can also use a derive macro to implement matchers from Unit structs:

#[derive(StateMatcher)]
#[state_type(AppState)]
#[matcher(AppState { in_game: Some(_), ..})]
struct InGame;

or field-less Enums:

#[derive(StateMatcher)]
#[state_type(AppState)]
enum InGame {
  #[matcher(AppState { in_game: Some(_), ..})]
  Any,
  #[matcher(AppState { in_game: Some(GameState::Running), ..})]
  Running
}

And of course, you can also manually implement it if you so choose.~~

Underlying Changes to State

  • Making all the systems relying on Res<State<S>> and Res<NextState<S>> within bevy_ecs rely on optional versions of the resources if possible
  • NextState<S> is no longer a struct, but rather an enum with 3 options: Keep, Value(S), Setter(Box<dyn Fn(S) -> S>). This is the only breaking change. The reasoning is: the previous use of NextState(Option<S>) created a confusion with None, where if one was not aware that states couldn't be removed they might assume setting the value to None removes the state. In addition, given that with this change states can be removed, that confusion would only get worse. In addition, with more complex States that aren't just a single, flat enum you might want to be able to "patch" a state rather than fully replace it. The Setter option allows you to provide a callback for patching the state, as it is at the time when apply_state_transition is called. By replacing the Option<S>, we opened up to a few additional use cases.
  • Adding schedules for OnStateEntry, OnStateTransition and OnStateExit - which will run systems using Matcher based states.
    Note (9 Oct) - The new scahedule labels were replaced with non-generic Entering, Exiting and Transitioning schedule labels

Underlying Changes to Conditions and App

Note (9 Oct) - this has been fully removed

  • Added a ConditionalScheduleLabel trait, with auto implementations for S: ScheduleLabel and (ScheduleLabel, Condition) - that can apply the condition to a given IntoSystemConfig/IntoSystemSetConfig, and return a (ScheduleLabel, SystemConfig) or (ScheduleLabel, SystemSetconfig). The macros generate (ScheduleLabel, Condition) tuple. This is necessary because SystemLabels don't expose any information on their sub-type from within the Schedules resource, so they can only be triggered or recognized via strict equality. As a result, we use special OnStateEntry<S>/OnStateTransition<S>/OnStateExit<S> schedules to house all the relevant systems - and rely on run conditions to filter them with a given matcher.
  • Replace ScheduleLabel with ConditionalScheduleLabel in .add_systems, .configure_set and .configure_sets.

Notes on the macro syntax

You'll notice that the macro syntax is similar, but not identical, to the matches! syntax. This originates from the need to be able to determine the state type S for our matchers & schedule labels (see below). As work on the PR continued, this also evolved into the marco's supporting a more robust syntax to resolve issues that came up.

I wanted to provide a full accounting of the macros, their syntax, and the reasoning behind them - but didn't want to place that within the earlier segments since I didn't want the more significant concepts to be lost to the details here.

This PR provides 5 macros:

  • on_enter!, on_exit! and on_transition! are used to trigger when entering/exiting matching states
  • state_matches! is used to create a run condition that is true when we are in a matching state
  • derive(StateMatcher) is used to derive the StateMatcher<S: States> trait, with the help of some associated attributes.

A quick overview of the structures for the macros:

  • on_enter!, on_exit! and state_matches! have one of two structures macro!(StateMatcher) or macro!(StateTypeIdent, MatchPattern, ...)
  • on_transition! is structured like so: on_transition!(StateTypeIdent, {MatchPattern, ...}, {MatchPattern, ...}) with the first set of MatchPatterns matching the from state, and the second set of MatchPatterns matching the to state
  • derive(StateMatcher) uses a #[state_type(StateTypeIdent)] attribute to define the type, and then one (or more for enums) #[matcher(MatchPattern, ...)] attributes to determine the actual matching process

StateMatcher

This section is only available for the 3 simplest macros - on_enter!, on_exit!, and state_matches! - and simply allows you to pass in an existing StateMatcher<S>. This is there to allow you to easily re-use existing state matchers in context. Note that since all States implement StateMatcher<Self>, you can pass in a state value as well. Just remember that when scheduling - it will use matching, rather than the OnEnter(S)/OnExit(S) schedule labels.

struct InGame; // implements StateMatcher<AppState>

app.add_systems(on_enter!(InGame), enter_game_system);

StateTypeIdent

This is the name/path to the state type. This is required for more complex patterns, since we can't count on type inference at that point.

MatchPattern

A MatchPattern starts with an optional every keyword - which ensure the match will be true whenever the main state matches, regardless of the secondary state(see the StateMatcher explanation for more on main and secondary states in a state matcher).

After that, you can either provide a Pattern or a Closure. The Pattern works like a normal pattern match, but you can exclude the root type name if you want (for example, InGame(_) instead of AppState::InGame(_)). The Closure should follow one of the default implementations of StateMatcher - Fn(&S) -> bool, Fn(&S, Option<&S>) -> bool, Fn(Option<&S>, Option<&S>) -> bool, Fn(&S, Option<&S>) -> MatchesStateTransition or Fn(Option<&S>, Option<&S>) -> MatchesStateTransition.

Note that you can have more than one MatchPattern in a macro, in a comma-separated list. If you do, they will be evaluated in order, and for transitions MatchesStateTransition::MainMatches will return immediately, only MatchesStateTransition::NoMatch will continue to evaluate the next pattern. This is needed because, at times, a simple every may not be enough.

For example, I might have a system for clearing the UI, and I want it to run whenever I move between distinct UI's. However, while I'm in game the UI remains consistent, even if the in game state changes - so what I want is:

add_systems(on_exit!(AppState, InGame(_), every _), clear_ui);

This will first check if I'm leaving InGame(_), and if so if I'm also moving to InGame(_) it will return false. However, otherwise - it will see that it should match every _ - meaning transitioning to and from any other state should return true and run the clear_ui system.

Changelog

At this point, due to the large amount of variation over the history of this PR, I believe it's better to rely on the summary above and the diff.

Migration Guide

The only breaking change is to NextState<S>. If it is being used with the .set(S) api, it won't break. However, if you are manually constructing NextState objects and inserting them via commands, you will need to change the way you construct them from NextState(Some(S::Variant)) to NextState::StateValue(S::Variant).


The Original Description:

Objective

This PR is meant to address #9942 - adding sub-state support. As discussions on the topic evolved in the discord chat, the PR became a more versatile state matching and handling system that is still mostly compatible with the existing version.

Solution

The solution involves multiple elements:

  • Inheriting the ability to use non-enums/more complex enums as States from: Remove States::variants and remove enum-only restriction its derive #9945

  • Adding an IntoConditionalScheduleLabel, and utilizing it in add_systems, configure_set and configure_sets instead of ScheduleLabel. IntoConditionalScheduleLabel exposes a function that emits a ScheduleLabel and an optional BoxedCondition, and applies the condition to the systems/sets if it is provided. This opens up the avenue for creating the ergonomics of a ScheduleLabel with a conditional, if you don't want the overhead of an exclusive Schedule run for your situation. All ScheduleLabel's automatically implement IntoConditionalScheduleLabel, so it should not break existing code.

  • Separating the States and StateMatcher - the State is the type representing the actual state, while the StateMatcher basically replaces strict equality for determining whether we are currently in/entering/exiting a state we want to act on. All States implement StateMatcher with strict equality, so existing code will not break.

  • Adding schedules for OnStateEntry, OnStateTransition and OnStateExit - which will run systems using Matcher based states.

  • Adding macros for ease of use when pattern matching - on_enter, on_exit, a strict variant of each, in_state and state_matcher

  • Making all the systems relying on Res<State<S>> and Res<NextState<S>> within bevy_ecs rely on optional versions of the resources if possible, to allow for states that aren't always present.

  • added add_conditional_state and add_strict_conditional_state to App, allowing for creating states that only exist while a parent state is in a matching configuration.

  • NextState<S> is no longer a struct, but rather an enum with 3 options: MaintainCurrent, StateValue, StateSetter. This is the only breaking change. The reasoning is: the previous use of NextState(Option<S>) created a confusion with None, where if one was not aware that states couldn't be removed they might assume setting the value to None removes the state. In addition, given that with this change states can be removed, that confusion would only get worse. In addition, with more complex States that aren't just a single, flat enum you might want to be able to "patch" a state rather than fully replace it. The StateSetter option allows you to provide a callback for patching the state, as it is at the time when apply_state_transition is called. By replacing the Option<S>, we opened up to a few additional use cases.

For a good example of how it all works, I'd recommend looking at the examples/ecs/state.rs file, since it has been updated to showcase most of these options.


Changelog

Changes are limited to the states.rs file in reach, but varied - I believe the summary in the solution section is the best way to process it.

Migration Guide

The only breaking change is to NextState<S>. If it is being used with the .set(S) api, it won't break. However, if you are manually constructing NextState objects and inserting them via commands, you will need to change the way you construct them from NextState(Some(S::Variant)) to NextState::StateValue(S::Variant).

lee-orr and others added 9 commits September 27, 2023 22:45
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
@gabriel-gheorghe
Copy link
Contributor

gabriel-gheorghe commented Sep 29, 2023

I love this. I was looking these days to implenent this pattern for my game because I need different behaviour in my plugins, like, when player is alive I run some systems, when player is almost dead I run another system. And I have some other fields.. and it gets pretty ugly to work with.

I think this PR improves ergonomics and reduces spaghetti a lot if you can do this:

enum GameState {
    Menu { screen: MenuScreen::Main },
    InGame { paused: false, lives: 0 },
}

It will also be nice if you can have other comparisions than ==, on fields, such as <, >, <=, >= and != but I think this implies some conditional properties.

@alice-i-cecile alice-i-cecile added C-Enhancement A new feature A-ECS Entities, components, systems, and events labels Sep 29, 2023
@lee-orr
Copy link
Contributor Author

lee-orr commented Sep 29, 2023

I love this. I was looking these days to implenent this pattern for my game because I need different behaviour in my plugins, like, when player is alive I run some systems, when player is almost dead I run another system. And I have some other fields.. and it gets pretty ugly to work with.

I think this PR improves ergonomics and reduces spaghetti a lot if you can do this:

enum GameState {
    Menu { screen: MenuScreen::Main },
    InGame { paused: false, lives: 0 },
}

It will also be nice if you can have other comparisions than ==, on fields, such as <, >, <=, >= and != but I think this implies some conditional properties.

I wouldn't necessarily recommend using this for that purpose, I think it's probably better to have a custom conditional system that could use your "health" resource directly. However, you can still do so using by implementing a custom StateMatcher.

For example:

#[derive(Debug, Eq, PartialEq, Hash, Clone)]
struct LowHealth;

impl StateMatcher<GameState> for LowHealth{
    fn match_state(&self, state: &GameState ) -> bool {
        match state {
            GameState ::InGame { lives, ..} if lives < 2 => true,
            _ => false
        }
    }
}

…ey treat the State resource as optional, and fail gracefully if it does not exist.

Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
@gabriel-gheorghe
Copy link
Contributor

I undrerstand. But can you actually check only for the in game state? For example you have a system that might run in both cases, when paused and when not paused.

@lee-orr
Copy link
Contributor Author

lee-orr commented Sep 29, 2023

I undrerstand. But can you actually check only for the in game state? For example you have a system that might run in both cases, when paused and when not paused.
Yes - in a few different ways actually!

First, if you're defining a custom matcher, it's just a function that takes a reference to the state and returns a boolean, so you can do whatever you want!

In addition, there are a few macros you can use if what you need is just a simple pattern match, for example:

.add_systems(
            Update,
            change_color.run_if(in_state!(AppState, AppState::InGame { .. })),
        )

for an ad-hoc solution. And if you want something re-usable, you can use the state_matcher macro to generate a state matcher from a pattern!.

state_matcher!(InGame, AppState, AppState::InGame { .. });

Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
@lee-orr lee-orr marked this pull request as ready for review September 29, 2023 16:25
@lee-orr
Copy link
Contributor Author

lee-orr commented Sep 29, 2023

Adjusted the PR description, cleaned some stuff up for clippy and the like, added important portions to the prelude, and added Conditional States, which cover the original use case of de-coupled states that can only exist when another state is in an appropriate state.

Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Copy link
Member

@alice-i-cecile alice-i-cecile left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I finally have the energy for a proper design review.

Things I like:

  • awesome examples
  • extremely helpful PR description
  • use of pattern matching to capture more complex patterns in states. There's complexity here, but it's familiar and well-justified
  • end user API is genuinely quite nice at this point

Blocking concerns:

  • I really dislike how ConditionalScheduleLabel blurs boundaries and adds a lot of complexity to the ECS internals
  • StateMatchers are a lot of internal complexity, and I have trouble seeing how manual implementations would be useful enough to end users

Unless we can substantially reduce implementation complexity, I'm opposed to adding this feature to Bevy itself. Definitely useful to some users, but the complexity isn't worth it. If there are simple changes you need to make to bevy_ecs to support this as an external crate, I'm happy to support that effort.

@lee-orr
Copy link
Contributor Author

lee-orr commented Oct 9, 2023

@alice-i-cecile - I wanted to note a couple of things:

  • ConditionalScheduleLabel no longer exist, as noted here: Robust State Matching (formerly Sub state support) #9957 (comment) (I understand there are waaay too many comments)

  • What element of state matcher's feels like too much complexity? The trait itself is just a way to abstract out two methods, and then there are a bunch of auto implementations.

I really feel that this is needed in order for the current not-just-flat-enums version of states to be useful, so I think it does need to be part of bevy. (Or, states as a whole shouldn't be a first-party part of bevy, or maybe should exist as a separate crate)

I am however uncertain which aspects to simplify - can you provide some more clarity on that?

@alice-i-cecile
Copy link
Member

ConditionalScheduleLabel no longer exist, as noted here: #9957 (comment) (I understand there are waaay too many comments)

Okay, great! I was mostly going off the updated PR description.

What element of state matcher's feels like too much complexity? The trait itself is just a way to abstract out two methods, and then there are a bunch of auto implementations.

Can we make this private for now, and remove the corresponding example? On the implementation side I don't have strong concerns, but the user-facing complexity of it is really high.

I really feel that this is needed in order for the current not-just-flat-enums version of states to be useful, so I think it does need to be part of bevy. (Or, states as a whole shouldn't be a first-party part of bevy, or maybe should exist as a separate crate)

Yeah, that's pretty reasonable: I'd really like to be able to get complexity down and ship this. I definitely think we want / need states in Bevy (they're too useful), but I'm mildly in favor of it living outside of bevy_ecs eventually.

@lee-orr
Copy link
Contributor Author

lee-orr commented Oct 9, 2023

Can we make this private for now, and remove the corresponding example? On the implementation side I don't have strong concerns, but the user-facing complexity of it is really high.

I will get make the trait un-implementable. Unless there is significant objection, I would like to modify the example to showcase a "black-box" state, replacing the manual implementations there with public functions. Is that something you think is an acceptable compromise there?

@alice-i-cecile
Copy link
Member

Yep, that sounds like a good way forward. Thanks again for being so receptive to the feedback <3

- remove the derive option
- add a sealed trait and better markers to prevent manually implementing state matchers
- replace the `custom state matchers` example with a `black box states` example

Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
while keeping `StateMatcher`'s internal implementation hidden.

- Renamed `StateMatcher` to `InternalStateMatcher` - kept as a `pub(crate)`
- Added a new `StateMatcher` trait that is public, and both requires and auto implemented for `InternalStateMatcher`'s
- Replace all public uses of `InternalStateMatcher` with `StateMatcher`

Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
@lee-orr
Copy link
Contributor Author

lee-orr commented Oct 12, 2023

Closing this in favour of #10088

@lee-orr lee-orr closed this Oct 12, 2023
@JMS55 JMS55 removed this from the 0.13 milestone Oct 27, 2023
github-merge-queue bot pushed a commit that referenced this pull request May 2, 2024
## Summary/Description
This PR extends states to allow support for a wider variety of state
types and patterns, by providing 3 distinct types of state:
- Standard [`States`] can only be changed by manually setting the
[`NextState<S>`] resource. These states are the baseline on which the
other state types are built, and can be used on their own for many
simple patterns. See the [state
example](https://github.com/bevyengine/bevy/blob/latest/examples/ecs/state.rs)
for a simple use case - these are the states that existed so far in
Bevy.
- [`SubStates`] are children of other states - they can be changed
manually using [`NextState<S>`], but are removed from the [`World`] if
the source states aren't in the right state. See the [sub_states
example](https://github.com/lee-orr/bevy/blob/derived_state/examples/ecs/sub_states.rs)
for a simple use case based on the derive macro, or read the trait docs
for more complex scenarios.
- [`ComputedStates`] are fully derived from other states - they provide
a [`compute`](ComputedStates::compute) method that takes in the source
states and returns their derived value. They are particularly useful for
situations where a simplified view of the source states is necessary -
such as having an `InAMenu` computed state derived from a source state
that defines multiple distinct menus. See the [computed state
example](https://github.com/lee-orr/bevy/blob/derived_state/examples/ecs/computed_states.rscomputed_states.rs)
to see a sampling of uses for these states.

# Objective

This PR is another attempt at allowing Bevy to better handle complex
state objects in a manner that doesn't rely on strict equality. While my
previous attempts (#10088 and
#9957) relied on complex matching
capacities at the point of adding a system to application, this one
instead relies on deterministically deriving simple states from more
complex ones.

As a result, it does not require any special macros, nor does it change
any other interactions with the state system once you define and add
your derived state. It also maintains a degree of distinction between
`State` and just normal application state - your derivations have to end
up being discreet pre-determined values, meaning there is less of a
risk/temptation to place a significant amount of logic and data within a
given state.

### Addition - Sub States
closes #9942 
After some conversation with Maintainers & SMEs, a significant concern
was that people might attempt to use this feature as if it were
sub-states, and find themselves unable to use it appropriately. Since
`ComputedState` is mainly a state matching feature, while `SubStates`
are more of a state mutation related feature - but one that is easy to
add with the help of the machinery introduced by `ComputedState`, it was
added here as well. The relevant discussion is here:
https://discord.com/channels/691052431525675048/1200556329803186316

## Solution
closes #11358 

The solution is to create a new type of state - one implementing
`ComputedStates` - which is deterministically tied to one or more other
states. Implementors write a function to transform the source states
into the computed state, and it gets triggered whenever one of the
source states changes.

In addition, we added the `FreelyMutableState` trait , which is
implemented as part of the derive macro for `States`. This allows us to
limit use of `NextState<S>` to states that are actually mutable,
preventing mis-use of `ComputedStates`.

---

## Changelog

- Added `ComputedStates` trait
- Added `FreelyMutableState` trait
- Converted `NextState` resource to an Enum, with `Unchanged` and
`Pending`
- Added `App::add_computed_state::<S: ComputedStates>()`, to allow for
easily adding derived states to an App.
- Moved the `StateTransition` schedule label from `bevy_app` to
`bevy_ecs` - but maintained the export in `bevy_app` for continuity.
- Modified the process for updating states. Instead of just having an
`apply_state_transition` system that can be added anywhere, we now have
a multi-stage process that has to run within the `StateTransition`
label. First, all the state changes are calculated - manual transitions
rely on `apply_state_transition`, while computed transitions run their
computation process before both call `internal_apply_state_transition`
to apply the transition, send out the transition event, trigger
dependent states, and record which exit/transition/enter schedules need
to occur. Once all the states have been updated, the transition
schedules are called - first the exit schedules, then transition
schedules and finally enter schedules.
- Added `SubStates` trait
- Adjusted `apply_state_transition` to be a no-op if the `State<S>`
resource doesn't exist

## Migration Guide

If the user accessed the NextState resource's value directly or created
them from scratch they will need to adjust to use the new enum variants:
- if they created a `NextState(Some(S))` - they should now use
`NextState::Pending(S)`
- if they created a `NextState(None)` -they should now use
`NextState::Unchanged`
- if they matched on the `NextState` value, they would need to make the
adjustments above

If the user manually utilized `apply_state_transition`, they should
instead use systems that trigger the `StateTransition` schedule.

---
## Future Work
There is still some future potential work in the area, but I wanted to
keep these potential features and changes separate to keep the scope
here contained, and keep the core of it easy to understand and use.
However, I do want to note some of these things, both as inspiration to
others and an illustration of what this PR could unlock.

- `NextState::Remove` - Now that the `State` related mechanisms all
utilize options (#11417), it's fairly easy to add support for explicit
state removal. And while `ComputedStates` can add and remove themselves,
right now `FreelyMutableState`s can't be removed from within the state
system. While it existed originally in this PR, it is a different
question with a separate scope and usability concerns - so having it as
it's own future PR seems like the best approach. This feature currently
lives in a separate branch in my fork, and the differences between it
and this PR can be seen here: lee-orr#5

- `NextState::ReEnter` - this would allow you to trigger exit & entry
systems for the current state type. We can potentially also add a
`NextState::ReEnterRecirsive` to also re-trigger any states that depend
on the current one.

- More mechanisms for `State` updates - This PR would finally make
states that aren't a set of exclusive Enums useful, and with that comes
the question of setting state more effectively. Right now, to update a
state you either need to fully create the new state, or include the
`Res<Option<State<S>>>` resource in your system, clone the state, mutate
it, and then use `NextState.set(my_mutated_state)` to make it the
pending next state. There are a few other potential methods that could
be implemented in future PRs:
- Inverse Compute States - these would essentially be compute states
that have an additional (manually defined) function that can be used to
nudge the source states so that they result in the computed states
having a given value. For example, you could use set the `IsPaused`
state, and it would attempt to pause or unpause the game by modifying
the `AppState` as needed.
- Closure-based state modification - this would involve adding a
`NextState.modify(f: impl Fn(Option<S> -> Option<S>)` method, and then
you can pass in closures or function pointers to adjust the state as
needed.
- Message-based state modification - this would involve either creating
states that can respond to specific messages, similar to Elm or Redux.
These could either use the `NextState` mechanism or the Event mechanism.

- ~`SubStates` - which are essentially a hybrid of computed and manual
states. In the simplest (and most likely) version, they would work by
having a computed element that determines whether the state should
exist, and if it should has the capacity to add a new version in, but
then any changes to it's content would be freely mutated.~ this feature
is now part of this PR. See above.

- Lastly, since states are getting more complex there might be value in
moving them out of `bevy_ecs` and into their own crate, or at least out
of the `schedule` module into a `states` module. #11087

As mentioned, all these future work elements are TBD and are explicitly
not part of this PR - I just wanted to provide them as potential
explorations for the future.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: Marcel Champagne <voiceofmarcel@gmail.com>
Co-authored-by: MiniaczQ <xnetroidpl@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events C-Breaking-Change A breaking change to Bevy's public API that needs to be noted in a migration guide C-Enhancement A new feature D-Complex Quite challenging from either a design or technical perspective. Ask for help! X-Controversial There is active debate or serious implications around merging this PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants