State Machine Pattern
In the world of game development, creating lifelike and interactive experiences often requires managing complex behaviors and transitions. Enter the State Machine pattern, a powerful architectural concept that offers a structured approach to handling dynamic behavior in Unity using the versatile capabilities of C#. A well-implemented State Machine empowers your game objects to seamlessly shift between different states, responding intelligently to player input, environmental changes, and internal triggers. In this article, I’ll do my best to describe my understanding of the State Machine Pattern and how I’ve implemented it in my own projects. By the end, hopefully, you'll have a good idea of my implementation and perhaps be able to draw on some of its ideas.
First, I must acknowledge the many great developers on YouTube who are leading the way in enabling formerly inexperienced developers, such as myself, to learn and understand efficiently. This implementation of the State Machine Pattern is an accumulation of examples I have adapted to fit the needs of my personal projects.
Now, let's delve into the details. There are several classes involved in setting up the framework of the state machine. They are as follows:
Player Class:
This class is attached to the Player Character Game Object. It initializes the state machine and stores references to each state. It also holds references to components needed by the state machine, passing these references at initialization via a constructor.
State Machine Class:
This is the class containing functions for initializing and changing the states. Each function takes a parameter of type PlayerState. This data is sent via the Player class calling the function and passing in the desired state. Notably, in the ChangeState function, the state.Exit() function is called first, allowing the previous state to execute any "ending" logic before the transition. The current state is then set to the received parameter, and the Enter() function is called to execute any "beginning" logic for the newly entered state.
PlayerState:
The base class that all state classes derive from. It has a constructor that receives and sets all protected variables the states will need and receive via references from the Player class. These variables could include components attached to the Player game object such as InputHandler, Animator, collision detections, PlayerData, Movement Control, Particle Controller, etc. PlayerState defines common functions for all states, including LogicUpdate, Enter, Exit, and DoChecks. Functions can be added as required by all states, or unique functions can be implemented in deriving states.
SuperStates:
The state classes that derive from PlayerState. They are organized based on common functionality. For example, in-air behaviors, touching wall behaviors, swimming behaviors, and grounded behaviors. SuperStates can be organized according to game functionality.
SubStates:
Substate classes derive from their respective superstate and implement the unique logic and checks required for a single behavior. For instance, "MoveState" derives from the superstate "Grounded State" and applies velocity and direction to the game object.
You might be wondering, how does this state system gain access to an update method? This is a crucial requirement, especially if the states are responsible for moving a game object. The implementation is quite straightforward. As mentioned earlier, the Player class utilizes its update method for this system. By calling StateMachine.currentState.LogicUpdate within its update method, each currentState (the currently active state) can utilize their common method called "LogicUpdate," which provides access to the functionality of an Update Method found in a MonoBehaviour.
Remember, LogicUpdate is a function found in the base PlayerState class. By generating overrides for these inherited functions, each substate gains access to the same Player class' Update function. The implementation looks like this:
//Player Class
void Update()
{
StateMachine.CurrentState.LogicUpdate();
}
//The LogicUpdate() called in the PlayerState
// To be derived by all states.
protected override void LogicUpdate()
{
base.LogicUpdate();
}
With an understanding of how LogicUpdate works, it's a good time to discuss the DoChecks function. DoChecks is also derived from PlayerState. It's where all collision checks occur, allowing access to individual raycasts, colliders, etc., found in the scene from the CollisionChecks Core component. Typically, in the appropriate SuperState, I have stored the reference in local protected variables for the deriving SubStates to utilize. To enable continuous updates and collision detection, DoChecks is called in LogicUpdate in the base PlayerState class. These checks are vital as they often trigger state transitions and must be handled effectively.
It's essential to recognize the critical relationship between the StateMachine system and the core components. While the "Core" system won't be discussed in detail here, it's important that the states set references to them in a way that prevents "race conditions" between core initialization and the states storing references to them.
Input Handling:
The input handler is another crucial system for the state machine, although it isn't the focus here. Like collisions, SuperStates also detect various 'playerInput' to switch states. Input checks are stored in local variables and examined via LogicUpdate to fulfill conditions that trigger state transitions. For example, detecting 'moveX' input transitions the player from idle state to move state.
Working with the Animator:
Each state requires a graphical representation of the corresponding state. To achieve this, you need an Animator Controller containing conditions for state transitions. If the State Machine concept is challenging to grasp, the Animator and Animator Controller can provide a useful visual representation. Unity's animation system is powerful and can become quite complex with features like blend trees, conditions, and additional layers within states. While I haven't extensively explored these advanced features, I've hand-drawn my 2D sprites for my project. However, our focus here is solely on the relationship between the animator and the State Pattern.
Consider the entire State Pattern as branching from the Player class. The monobehaviour initializes the system, stores references, and offers its update() method to power the system. Therefore, the animator required by the state system is found on the Player Game Object. The Player class stores a reference to the animator, much like it does for Core Components. Then, in the constructor for each state, it provides the animator with a matching string to set a boolean value to true or false. In essence, each state has its own animator value that it passes to the animator controller. For example, when the character is standing idle, the animator controller's 'idle' boolean is set to true. When the state changes to MoveState via XInput detection, the MoveState sets the Animator's 'move' value to true, and the idle value is set to false. This consistent transition of the player character's animations aligns with the respective state transitions.
To wrap up, this overview provides a basic description of the State Machine Pattern I use for my Player classes. As you can tell, it's effective in keeping code clean, organized, and easily debuggable. Furthermore, it's highly expandable, able to connect and work in coordination with various other systems in your game. Otherwise, I hope that explanation was a clear and somewhat simplified description of the basic structure and concept of my State Machine Pattern. For myself, being able to wrap my head around its basic concept was difficult, so hopefully this is an article that achieves simplicity and understanding. If you want to see a more complex expansion of the system and how it works with other aspects of my project, feel free to check out my portfolio for details.
Top comments (0)