Skip to content

Fighter State Machine

Zicklag edited this page Oct 10, 2022 · 7 revisions

Introduction

Implemented in figher_state.rs.

The Punchy fighter state machine uses a novel technique that allows us to have any number of different fighter states, and lets us easily add new states, without requiring modification to existing states. This decentralizes the state machine, allowing each state to be implemented relatively independently, in separate systems.

This has a number of benefits:

  • It becomes easier to build new fighter moves and behaviors.
  • It increases the possibility for future status effects to be able to implemented/modded without requiring a change to core for every new status effect.
  • It makes it possible for mods/scripts to add new fighter attacks once scripting is implemented.

Core Concepts

1 State = 1 Component

For every player state, there is a separate ECS component representing that state. For instance, here are a few different state components inspired by the ones actually used in the game:

/// An idling state is just a marker component. It has no data.
pub struct Idling;

/// A moving state needs to have a velocity set so that we know how fast to move.
pub struct Moving {
    pub velocity: Vec2,
}

/// An attacking state stores metadata about the progress of the current attack.
pub struct Attacking {
    pub has_started: bool,
    pub is_finished: bool,
}

If a fighter entity has one of the state components on it, it is considered to be in that state. This lets us construct Bevy queries that filter based on fighters in certain states. For instance:

  1. Query the transforms of all idling fighters:
Query<&Transform, With<Idling>>
  1. Query the entity ID of all enemies that are either idling or moving:
Query<Entity, (With<Enemy>, Or<(With<Idling>, With<Moving>)>)>

State Transitions

Every frame, the following groups of systems will run in order. There may be any number of systems in each group.

  1. Transition Collectors:
    • These systems process things like user input, collisions, and enemy AI and emit state transition intents.
    • Each fighter has it's own state transition queue.
    • These systems will process events for each fighter, deciding what states it thinks the fighter should transition to next.
  2. Transition Processors:
    • These systems read all the state transition intents and decide whether to transition to another state.
    • Most states have their own transition processor.
    • This means that the only way to go from one state to another, is if the current state decides to transition.
  3. State Handlers:
    • There is one state handler system for each state.
    • The system is responsible for modifying the fighter while that fighter is in that state.

Transition Priorities

Each state transition comes with a priority. This priority is used when determining whether or not to transition to a new state.

For instance, most of the Transition Processors are very straight-forward, simply transitioning to whatever state is higher priority that the current state.

This makes it very easy to add brand new states without disrupting existing states, simply by adding a new state with an appropriate priority so that it overrides the states you want it to, and doesn't override the ones you don't want to.

Additive and Non-Additive States

Most state transitions are non-additive, which means that when you transition to the new state, it removes the previous state. But some states are additive, which means that transitioning to the new state will keep the old state.

This means that sometimes you may have two states active at the same time.

Additive states are useful for states that spawn items or perform additional tasks related to a fighter, but that don't modify core fighter state such as animations.

Additive states should be used carefully and you should avoid having two states active at the same time that both access the same components on the fighter.

Implementation Details

The fighter state implementation is in fighter_state.rs, and here we'll go through some important pieces and some implementation details.

The strategy we use makes use of some more dynamic bevy features such as Reflect and ReflectComponent. While these dynamic features may seem useful only for scripting, they allow us to achieve the workflow above, which is useful even if we only write Rust.


When we want to express the intent to transition to a new state we do something like this:

if action_state.just_pressed(PlayerAction::Attack) {
    transition_intents.push_back(StateTransition::new(
        Attacking::default(),
        Attacking::PRIORITY,
        false,
    ));
}

We express the desired to add an Attacking component to the fighter, and we push it into the VeqDeque in the fighter's StateTransitionIntents component.

Later in the same function we do this:

transition_intents.push_back(StateTransition::new(
    Throwing,
    Throwing::PRIORITY,
    true,
));

Here we express the desire to add a different component to the fighter, but we are pushing the intent to the same VecDeque!

This means we have to "erase" the component type so that we can store multiple different component types in the same VecDeque. This is conceptually similar to needing to do a Vec<Box<dyn Any>> if you want to store multiple different types in the same vector.

This type erasure is manged by the StateTransition::new function. It boxes the value and erases it's type while also querying the Bevy type registry to get the info needed to add the component to an entity later. And all that gets stored in the StateTransition, along with the priority and is_additive options.

Finally, when we are looping through state transition intents and deciding which one to transition to we do this:

if intent.priority > current_state_priority {
    // Apply the state
    intent.apply::<CurrentState>(entity, commands);
}

We are saying that we want to take the component stored in the StateTransition intent and add it to the fighter, even though we don't know what component that is, because it's been type erased.

StateTransition::apply looks like this:

pub fn apply<CurrentState: Component>(self, entity: Entity, commands: &mut Commands) -> bool {
    if !self.is_additive {
        commands.entity(entity).remove::<CurrentState>();
    }

    commands.add(move |world: &mut World| {
        // Insert the component stored in this state transition onto the entity
        self.reflect_component
            .insert(world, entity, self.data.as_reflect());
    });

    self.is_additive
}

The reflect_component has the type information needed to insert a new instance of the type-erased component onto the entity with the insert function. And we must pass it the world, the entity to add the component to, and our Box<dyn Reflect> which contains our actual component data.


That completes the workflow and allows us to store a bunch of transitions to any kind of new state in a single queue. And using components instead of an enum allows us to use the awesome Bevy component filtering syntax in our queries.

All that has "nothing" to do with scripting. But it does make it trivial for a script to decide to emit a state transition intent for a new, unknown state, that is handled by the script. As long as the priority is higher than the existing state, the state will transition in the Rust system, and at that point the script state handler takes over and can control the player however it wants at that point. Voilà! You have a new attack/status effect/whatever without modifying core.

And this pattern, again, is helpful even if we only ever wrote Rust.

Discussion: #211
PR: #203