Stateful behaviour
In this section, we will discuss an optional tool provided by AlchemyBow.Core that helps you manage the state of your app / game.
Introduction
Before we get into the implementation, we will introduce you to what we consider the stateful behavior through a series of examples.
First, imagine a simple PC game where the player can click the "Escape" button to toggle the pause menu.
Obviously, the game can be in 2 states: the play state - where the player is playing, and the pause state - where the player does other things.
It is easy to see that there are some relationships between these states - the play state can transition into the pause state and vice versa, but only when the player clicks the "Escape" button.
With that in mind, we can conclude that there are at least 3 elements: states, transitions connecting states, and conditions that trigger transitions.
For the next part, let's make two assumptions about the imaginary game:
- The states are used to toggle the game modules, for example enable/disable the player avatar.
- The pause menu doesn't cover the entire screen, the remaining elements are slightly dimmed.
Now, we should be able to spot a new problem. Since the player avatar is visible in both states, any transition would cause this module to toggle unnecessarily. This is bad for both the computer (more to compute) and the developer (more code to write and sustain). To address this issue, we can introduce another state to handle the shared modules - the game state, which is a parent for the play state and pause state.
The conclusion is that we can nest states to avoid redundant operations.
For the final thought, let's make some more assumptions:
- The game is very simple so it runs on one scene.
- There is a main menu that covers the entire screen.
Now, imagine that you launch this game. The first thing you see is the main menu. If you click the play button, the actual game will start. Then, you can pause it and exit to the main menu. If you click the play button again, the game will start and won't be paused.
So, we should note that there is a default state at each nesting level which is entered every time a parent state is entered.
Implementation
In this section, we recreate the concept of stateful behavior from the introduction. To make it more complete, let's add one more state - the credits state. It should look like this:
Creating a state
The most important part are states. A state can be entered and exited. To create it, you need to implement the IState interface. For example, let's create the pause state:
using AlchemyBow.Core.IoC;
using AlchemyBow.Core.States;
[InjectionTarget]
public class GamePauseState : IState
{
[Inject] // suppose such a module exists
private readonly PauseMenuViewController pauseMenuViewController;
[Inject] // suppose such a module exists
private readonly GameTimeController gameTimeController;
public void Enter()
{
pauseMenuViewController.SetActive(true);
gameTimeController.PauseTime();
}
public void Exit()
{
pauseMenuViewController.SetActive(false);
gameTimeController.UnpauseTime();
}
}
Since the IState is an interface, it can be anything - a plain c# class (as in the example) or a class inheriting from MonoBehaviour
, ScriptableObject
, ... . In order to have its fields injected, you must bind it (just like anything else).
Tip
The PrototypeState is a general implementation of the IState that uses actions and can be handy for prototyping.
Creating a condition
Similar to states, conditions are also created by implementing an interface (ICondition). There is no one perfect implementation for a condition. Depending on the situation, they can act as mediators between the state machine and the rest of the scripts, or they can be fully independent entities such as MonoBehaviour
with Update()
, and so on.
The important part is to understand how the state machine uses them.
- When a state is entered, all its conditions are activated and the state machine subscribes to their
Triggered
events. - When the
Triggered
event is raised, the state machine checks the active state transitions withICondition.CheckCondition()
and starts the first fulfilled transition. - When a state is exited, all its conditions are deactivated and the state machine unsubscribes to their
Triggered
events.
Let's create the pause condition.
using AlchemyBow.Core.States;
public class PauseCondition : ICondition
{
public event System.Action Triggered;
private bool conditionValue;
// Sets the internal value and raises the event.
public void Trigger()
{
conditionValue = true;
Triggered?.Invoke();
}
// Resets the internal value.
public void SetActive(bool value)
{
conditionValue = false;
}
public bool CheckCondition()
{
return conditionValue;
}
}
The condition above acts like a trigger button. The Trigger()
method raises the event, and the SetActive(bool value)
method resets its internal state. You can bind it, and then inject it wherever you need it, so that you can call the Trigger()
method as a consequence of clicking the Escape
button.
The same effect as above can be achieved with the PrototypeCondition.
using AlchemyBow.Core.States.Prototyping;
public class PauseCondition : PrototypeCondition
{
}
Tip
The condition can inherit from MonoBehaviour
to receive Unity messages or from ScriptableObject
to be convenient for the editor drag-and-drop.
Creating a state machine
Since we know how to create states and conditions, it's time to create the state machine itself. The framework provides an implementation of a predefined hierarchical finite state machine. Before we continue, let's make the name less scary and learn about its components:
- Predefined - means that all states, transitions and conditions are created during initialization, which is great because we can clearly plan and see the entire structure in one place.
- Hierarchical - means that we can nest states.
- Finite - means that there are a finite number of states.
The state machine is represented by StateGraph. To build it, we use StateGraphComposer which represents a single node (state). For now, let's assume that we have all the states and conditions created (we'll come back to them later).
private StateGraph CreateStateGraph()
{
// Similar to the case where gameState is a parent of
// gamePlayState and gamePauseState, we need to create
// a parent for menuState, creditsState and gameState.
// However, they don't have any shared elements so we can
// quickly create an empty state with null.
var rootComposer = new StateGraphComposer(null);
// Create composers for states.
var menuComposer = new StateGraphComposer(menuState);
var creditsComposer = new StateGraphComposer(creditsState);
var gameComposer = new StateGraphComposer(gameState);
var gamePlayComposer = new StateGraphComposer(gamePlayState);
var gamePauseComposer = new StateGraphComposer(gamePauseState);
// Mark gamePlayState and gamePauseState as children
// of gameState and link them accordingly.
// Note that the first added child is the default one!
gameComposer.AddNode(gamePlayComposer);
gameComposer.AddNode(gamePauseComposer);
gameComposer.AddLink(gamePlayComposer, gamePauseComposer, pauseCondition);
gameComposer.AddLink(gamePauseComposer, gamePlayComposer, pauseCondition);
// Mark menuState, creditsState and gameState as children
// of the root and link them accordingly.
// Note that the first added child is the default one!
rootComposer.AddNode(menuComposer);
rootComposer.AddNode(creditsComposer);
rootComposer.AddNode(gameComposer);
rootComposer.AddLink(menuComposer, creditsComposer, creditsCondition);
rootComposer.AddLink(creditsComposer, menuComposer, menuCondition);
rootComposer.AddLink(menuComposer, gameComposer, gameCondition);
rootComposer.AddLink(gameComposer, menuComposer, menuCondition);
// You can validate the graph before building it.
// However, consider wrapping the validation code
// with the #if UNITY_EDITOR directive.
#if UNITY_EDITOR
rootComposer.Validate();
string paths = "Graph paths:\n";
foreach (var path in rootComposer.GetGraphPaths())
{
paths += path + "\n";
}
Debug.Log(paths);
#endif
// Build the actual `StateGraph`.
return StateGraph.Build(rootComposer);
}
Note
You can reuse conditions and states (see pauseCondition
in the example). However, they cannot be active at the same time. The StateGraphComposer.Validate()
method ensures their correct composition.
Tip
The validation methods use the ToString()
method to determine the names of the states (IState). You can override the ToString()
method in your IState implementations for more readable output.
Connecting a state machine
Now how to connect the state machine to the framework? As always, it's a good idea to delegate the job to a different class, but for clarity, let's do it directly in the CoreController.
using AlchemyBow.Core;
using AlchemyBow.Core.IoC;
using AlchemyBow.Core.States;
using System.Collections.Generic;
using UnityEngine;
public class MyCoreController : CoreController<MyCoreProjectContext>
{
// We assume that the classes for states and conditions exist
// and work similar to the previous examples.
private readonly MenuState menuState = new MenuState();
private readonly CreditsState creditsState = new CreditsState();
private readonly GameState gameState = new GameState();
private readonly GamePlayState gamePlayState = new GamePlayState();
private readonly GamePauseState gamePauseState = new GamePauseState();
private readonly MenuCondition menuCondition = new MenuCondition();
private readonly CreditsCondition creditsCondition = new CreditsCondition();
private readonly GameCondition gameCondition = new GameCondition();
private readonly PauseCondition pauseCondition = new PauseCondition();
// The state machine.
private StateGraph stateGraph;
protected override void InstallAdditionalBindings(IBindOnlyContainer container)
{
// Bind the conditions to keys so that they can be requested as dependencies.
container.Bind(menuCondition);
container.Bind(creditsCondition);
container.Bind(gameCondition);
container.Bind(pauseCondition);
// The states need dependencies, but should not be used externally.
container.BindInaccessible(menuState);
container.BindInaccessible(creditsState);
container.BindInaccessible(gameState);
container.BindInaccessible(gamePlayState);
container.BindInaccessible(gamePauseState);
base.InstallAdditionalBindings(container);
}
protected override void OnLoadingFinished()
{
base.OnLoadingFinished();
// Create and activate the state machine when the loading
// is finished.
stateGraph = CreateStateGraph();
stateGraph.Enter();
}
protected override void OnSceneChangeStarted()
{
// Deactivate the state machine when the unloading
// is started.
stateGraph.Exit();
base.OnSceneChangeStarted();
}
private StateGraph CreateStateGraph()
{
// ...
}
protected override IEnumerable<ICoreLoadable> GetLoadables()
{
return null;
}
}
Remarks
In this section, you will find some interesting details on this topic.
Unity messages in states
You may be wondering how to get Unity messages (Update
, OnGUI
, ...) inside states. The most common options are:
- Delegate these tasks to other classes and just toggle them in state. (You should almost always pick this option.)
- States can inherit from the
MonoBehaviour
class. - You can combine the
StateGraph.EnumerateDown(...)
method with interfaces to invoke a message in order.
public class MyCoreController : CoreController<MyCoreProjectContext>
{
// ...
private StateGraph stateGraph;
// ...
private void Update()
{
stateGraph.EnumerateDown(state =>
{
if (state is IUpdatable updatable)
{
updatable.Update();
}
}, true);
}
}
public interface IUpdatable
{
void Update();
}
Conditions gotchas
- If multiple conditions are true when the state is activated, the order in which the links were added determines the transition.
- Raising the
ICondition.Triggered
event in theICondition.SetActive(bool value)
method does not affect the state machine. In case the state is activated, all conditions are checked when activation is complete. In case the state is deactivated, the event is completely ignored.