-
-
Notifications
You must be signed in to change notification settings - Fork 3.5k
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
State Pattern Matching - Alternative #10088
Conversation
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>
…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>
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>
…into sub-state-support
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
…uleLabels 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>
Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
…ate-support-alternative
…ate-support-alternative
…b-state-support-alternative' of https://github.com/lee-orr/bevy into sub-state-support-alternative
…ate-support-alternative Signed-off-by: lee-orr <lee-orr@users.noreply.github.com>
…ate-support-alternative
I'm catching up on the states hierarchy progress. |
I think we should do this regardless: I'd prefer to split this out into a seperate tiny PR to reduce the scope here. |
…-support-alternative
Will look into doing that when I have a chance |
@alice-i-cecile @MiniaczQ - I also had another potential approach to handling similar things in mind, and will mock something up in the next couple of days. This idea is one that is more likely to be usable as a 3rd party crate as well, though I'll ping y'all when I have that ready. |
# Objective Adjust bevy internals to utilize `Option<Res<State<S>>>` instead of `Res<State<S>>`, to allow for adding/removing states at runtime and avoid unexpected panics. As requested here: #10088 (comment) --- ## Changelog - Changed the use of `world.resource`/`world.resource_mut` to `world.get_resource`/`world.get_resource_mut` in the `run_enter_schedule` and `apply_state_transition` systems and handled the `None` option. - `in_state` now returns a ` FnMut(Option<Res<State<S>>>) -> bool + Clone`, returning `false` if the resource doesn't exist. - `state_exists_and_equals` was marked as deprecated, and now just runs and returns `in_state`, since their bevhaviour is now identical - `state_changed` now takes an `Option<Res<State<S>>>` and returns `false` if it does not exist. I would like to remove `state_exists_and_equals` fully, but wanted to ensure that is acceptable before doing so. --------- Co-authored-by: Mike <mike.hsu@gmail.com>
Closing since #11426 has more traction and supports the same use cases |
## 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>
This is an alternative solution to the one in #9957 - it builds on the same basis, but provides a different API. I provide a fuller explanation on the rational for the change in the [Reasons for the Change] section.
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:
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:
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 Macro
This solution adds the
state_matches!
macro for states and state transitions - allowing you to use pattern matching syntax with some small adjustments.So - for the example above, we would be able to use:
More details about the macro syntax exist below.
Note that the macro generates a
StateMatcher
, so it can be used anywhere those are used.State Matchers
Speaking of
StateMatcher
s - these form the core of the solution, providing a separation between the state storage/data (implementers ofStates
) and the mechanism for determining whether a system should run in a given state (implementers ofStateMatcher<S: States>
).There are a limited number of auto-implementations of
StateMatcher<S>
, as well as a couple of utility structs that implement it, but otherwise it is behind a sealed interface and inaccessible to the outside world.In addition,
StateMatcher
s implementIntoSystem<(), bool>
, making them a valid run condition! That's why we don't need to add anyin_state()
,entering()
orstate_matches()
calls.Any given
StateMatcher
can be used for two things:S::match(&self, matcher) -> bool
method)S::match_Transition(matcher, main: Option<&S>, secondary: Option<&S>) -> MatchesStateTransition
trait function)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 enableevery
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:Entering
- it is the incoming state, and thesecondary
parameter is the outgoing stateExiting
- it is the outgoing state, and thesecondary
parameter is the incoming statesecondary
parameter isNone
.This allows you to re-use the same matcher for exits, entrances, and while in the state for example:
In normal use, you would probably use either the
state_matches!
macro, theStates
itself (which is also aStateMatcher
usingEq
),Fn(&S) -> bool
orFn(&S, Option<&S>) -> bool
.The full list of auto implementations is:
S
itselfFn(&Self) -> bool
Fn(Option<&Self>) -> bool
Fn(&Self, &Self) -> bool
Fn(&Self, Option<&Self>) -> bool
Fn(Option<&Self>, Option<&Self>) -> bool
Fn(&Self, &Self) -> MatchesStateTransition
Fn(&Self, Option<&Self>) -> MatchesStateTransition
Fn(Option<&Self>, Option<&Self>) -> MatchesStateTransition
StateMatcher
's also expose 3 utility functions:.every()
- this sets it up to only use check if themain
state matches, and fully ignores thesecondary
state in a transition. This is particularly useful since by default, if both the main and secondary match the transition will be treated as false (returningMatchesStateTransition::MainMatches
)..invert_transition()
- this swaps themain
andsecondary
states. This is useful for setting up transitions where you care about both the incoming and outgoing state, but want to use pre-defined or simple matchers for each rather than writing a dedicated function..chain(other: impl StateMatcher<S>)
- if the current state matcher returnsfalse
(when testing a single state) orNoMatch
(when testing a transition), it will executeother
and return it's result.Underlying Changes to State
Res<State<S>>
andRes<NextState<S>>
withinbevy_ecs
rely on optional versions of the resources if possibleNextState<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 ofNextState(Option<S>)
created a confusion withNone
, 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 complexStates
that aren't just a single, flat enum you might want to be able to "patch" a state rather than fully replace it. TheSetter
option allows you to provide a callback for patching the state, as it is at the time whenapply_state_transition
is called. By replacing theOption<S>
, we opened up to a few additional use cases.Entering
andExiting
- which will run systems whenever a state is changed.Exiting
runs beforeState<S>
is set to the new value, whileEntering<S>
runs after.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 typeS
for our matchers & schedule labels (see below). As work on the PR continued, this also evolved into the marco supporting a more robust syntax to resolve issues that came up.I wanted to provide a full accounting of the macro syntax, and the reasoning behind it - 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.
The macro has the following structure:
state_matches!(StateTypeIdent, MatchPattern, ...)
. The sections are detailed below:StateTypeIdent
This is the name/path to the state type. This is required since we can't count on type inference when using patterns...
MatchPattern
A
MatchPattern
starts with an optionalevery
keyword - which ensure the match will be true whenever themain
state matches, regardless of thesecondary
state(see the StateMatcher explanation for more onmain
andsecondary
states in a state matcher).After that, you can either provide a
Pattern
, aClosure
, or anExpression
:InGame(_)
instead ofAppState::InGame(_)
).StateMatcher
-Fn(&S) -> bool
,Fn(&S, Option<&S>) -> bool
,Fn(Option<&S>, Option<&S>) -> bool
,Fn(&S, Option<&S>) -> MatchesStateTransition
orFn(Option<&S>, Option<&S>) -> MatchesStateTransition
.StateMatcher
, preceded by an=
symbol, like so:=AppState::MainMenu" or
=in_game.The
=` is used to differentiate expressions from patterns, which can't always be determined by syntax alone.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, onlyMatchesStateTransition::NoMatch
will continue to evaluate the next pattern. This is needed because, at times, a simpleevery
may not be enough. This is the same as the.chain()
method on a state matcher.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:
This will first check if I'm leaving
InGame(_)
, and if so if I'm also moving toInGame(_)
it will return false. However, otherwise - it will see that it should matchevery _
- meaning transitioning to and from any other state should return true and run theclear_ui
system.Reasons for the Change
Issues with the original
The previous PR had a lot of repetition both in the required syntax:
Here we need to set the schedule to
Entering
and use theentering!
macro (or, in some cases, theentering
function). The same was true forExiting
andTransitioning
.The same repetition also existed under the hood - with 3 run conditions that were virtually identical (
entering
,exiting
,state_matches
), and one that was more complex but only added a small amount of syntactic sugar (transitioning
). And again, the same was the case with the macros.We also had a robust set of tools for using closures & state values to match things (a variety of auto implementations of
StateMatcher
), but had to always use them through one of a variety of tools you'd have to learn and keep track of (the 4 macros and 4 run conditions).Another issue is that we had some capabilities that were simple via macro, but had no non-macro equivalent - like
every
or the sequencing of multiple patterns/closures for handling a transition.Lastly, it was hard to justify the existance of
transitioning
, since bothentering
andexiting
had situations where they would look at both sides of a transition...Changes
state_matches!
- and use it for all pattern matching:StateMatcherSystem
(that wraps a function system), and implementIntoSystem<(), bool>
forStateMatcher
. This means we can useStates
values andStateMatcher
closures directly as run conditions, and chain them:EveryTransition(StateMatcher)
- allowing access to theevery
feature outside of the macro. In addition, added a method.every()
toStateMatcher
that wraps it in the struct for you.CombineStateMatchers(StateMatcher1, StateMatcher2)
- this allows you to chain state matchers like you do in the macro. Added.chain(other: StateMatcher<S>)
as well.InvertTransition(StateMatcher)
- allowing the ability to invert the focus of a matcher when matching a transition. This means that, when we areEntering
- we can swap to make the primary focus of the matcher the outgoing state rather than the incoming one (and vice versa forExiting
). Added a.invert_transition()
method as well. These allow you to mimic thetransition(from, to)
functionality without needing it there, given it's limited use.Remove the
Transitioning
schedule label (OnTransition
still exists, just likeOnEnter
andOnExit
- which rely on strict equality to work). Note that this, as well as thetransitioning
run condition specifically, might still be worth including for ergonomics. They just aren't functionally necessary.Remove all the run conditions (except
in_state
for compatibility - it is functionally equivalent to just passing in the state value, but has a more limited set ofSystemParams
)Macro syntax adjustments - removed the
macro!(Expression)
variety, since it is not redundant since any valid expression would also be aStateMatcher
already. On the other hand, added the ability to use expressions withing theMatcherPattern
like somacro!(StateType, =existing_state_matcher)
. The=
is used to differentiate expressions from patterns, which can't always be determined by syntax alone. The addition allows you to chain existing state matchers, patterns, and closures together for a singlestate_matches!
statement.Migration Guide
This PR makes two breaking changes:
NextState<S>
moves from being defined as a tuple struct:NextState(Option<S>)
to an enum. When using the.set(S)
api the behavior will remain consistent, but if you manipulateNextState
directly - you'll need to go fromNextState(Some(S::Variant))
toNextState::Value(S::Variant)
state_exists_and_equals
run condition, as it's behavior is now identical toin_state