-
Notifications
You must be signed in to change notification settings - Fork 9
Customizing and Implementing a Finite State Machine with ScriptableObjects
Implementation of an Abstract Finite State Machine with ScriptableObjects
The full implementation for an enemy state machine of the above code can be found in the Enemy State Controller folder of this project.
First, the critical monobehaviours that the state controller will control are discovered and an implementation of CharStateDatais created:
public class EnemyStateData : CharStateData
{
[System.NonSerialized] public CharacterHealth characterHealth;
[System.NonSerialized] public Transform currentTarget = null;
...
void Awake()
{
characterHealth = GetComponent<CharacterHealth>();
...
The enemy's CharacterHealth component is definitely going to be a driving factor in its decision-making in the future, so we can add it to the EnemyStateData class out of the gate. We will create an "Attacking" state for our first example. In this example, enemies will need to scan for players and begin chasing them if they're "spotted". From this knowledge, we can assume that the EnemyStateData will need to keep track of a currentTarget transform to chase after, so I add that in here at the start. We will add additional needed Monobehaviours as implementation continues.
Since the EnemyStateData is a monobehaviour and will be attached to the enemy character it is controlling and CharacterHealth should be attach to the character as well, we can leverage the Awake() method to initialize. In order to avoid confusion for our designer, we also set those fields as NonSerialized to protect it from inspector changes (HideInInspector works similarly, but if the field was previously exposed to the inspector it doesn't "reset" this value and can cause even more confusion).
Now that we have a defined EnemyStateData, we can create a wrapper for our Action, Decision, State, StateController, and Transition classes using the EnemyStateData as the generic parameter.
public abstract class EnemyAction : Action<EnemyStateData> { }
public abstract class EnemyDecision : Decision<EnemyStateData> { }
[CreateAssetMenu(menuName = "Finite State Machine/Enemy/State")]
public class EnemyState : State<EnemyStateData>
{
public EnemyAction[] actions;
public EnemyTransition[] transitions;
public override Action<EnemyStateData>[] GetActions()
{
return actions;
}
public override Transition<EnemyStateData>[] GetTransitions()
{
return transitions;
}
}
[System.Serializable]
public class EnemyTransition : Transition<EnemyStateData>
{
public EnemyDecision decision;
public EnemyState falseState;
public EnemyState trueState;
public override Decision<EnemyStateData> GetDecision()
{
return decision;
}
public override State<EnemyStateData> GetFalseState()
{
return falseState;
}
public override State<EnemyStateData> GetTrueState()
{
return trueState;
}
}
public class EnemyStateController : StateController<EnemyStateData>
{
public EnemyState currentState;
public EnemyState remainInState;
public override State<EnemyStateData> GetCurrentState()
{
return currentState;
}
public override State<EnemyStateData> GetRemainState()
{
return remainInState;
}
public override void SetCurrentState(State<EnemyStateData> state)
{
currentState = state as EnemyState;
}
}
Notice in State, Transition, and StateController we use the newly implemented wrappers for the fields instead of the genericized State<EnemyStateData>, etc.:
This is due to Unity's inability to expose generic implementations in the inspector. Since each of these fields need to be configurable for a game designer via the inspector, we need to ensure that they are serializable and accessible via the inspector.
Now that we have the custom implementation complete, we can create a new state by right-clicking in the project folder and selecting "Finite State Machine/Enemy/State". If this menu is not available, make sure to add the CreateAssetMenu attribute to your custom State implementation.
We will create 3 states:
- Remain State
- Idle State
- Attacking State
The Remain State isn't a state that our character will enter, but instead a field that tells the StateController's underlying TransitionToState method remain in the current state. If an enemy is currently idle and a transition tells the StateController to move to the "Remain State", then the StateController knows to remain idle. If the transition tells the StateController to move to the "Attacking State", then it will change to Attacking.
For our Attacking State, we will need a custom Decision called "Scan For Target". We will need to add the following to our EnemyStateData for this:
- Collider2D scanningCollider
- ContactFilter2D cf2d
Then we can create a custom EnemyDecision implementation for ScanForTarget:
[CreateAssetMenu(menuName = "Finite State Machine/Enemy/Decisions/Scan For Target")]
public class ScanForTarget : EnemyDecision
{
public override bool Decide(EnemyStateData data)
{
Collider2D[] colliders = new Collider2D[1];
if (data.scanningCollider.OverlapCollider(data.cf2d, colliders) > 0)
{
data.currentTarget = colliders[0].transform;
return true;
}
return false;
}
}
Now, whenever this decision is checked it will check for any overlapping colliders within the scanningCollider. If a target is found, the first target is assigned to the EnemyStateData's currentTarget and the decision returns true, otherwise it returns false.
Now that have decision that can lead us to chasing an enemy, we can implement the action. For the action, we will need the following added to the EnemyStateData:
- EnemyMovementController enemyMovementController
The EnemyMovementController already has a "ChaseTarget" method, so implementation should be simple:
[CreateAssetMenu(menuName = "Finite State Machine/Enemy/Actions/Chase")]
public class EnemyChase : EnemyAction
{
public override void Act(EnemyStateData data)
{
float direction = data.enemyMovementController.ChaseTarget(data.currentTarget);
}
}
I could also add an animator to the EnemyStateData and tie in Animation changes to the state controller as well.
This can now be configured in the editor, allowing your designer to have autonomy over State Machine functionality. Keeping your Actions and Decisions smaller will allow your designer to have more freedom, but making them too small could end up creating more work than less. The decision on how big/small to make your custom actions/decisions comes with experience.
I have 3 states:
- Remain State
- Idle State
- Attacking State
1 Action:
- Enemy Chase
1 Decision:
- Scan For Target
My idle State should have: Actions: None Transitions: Scan For Target? True = Attacking State, False = Remain State
My Attacking State should have: Actions: Enemy Chase Transitions: Scan for Target? True = Remain State, False = Idle State
With our current example game being so simple, there aren't many more decisions that an enemy can make. Perhaps adding a "Dead" decision could be made rather than utilizing events, but this is a design decision that needs to be weighed carefully. A state machine can be useful in decoupling logic between two scripts, but the less coupling you have results in more configuration for the designer. I generally consider one-way coupling acceptable and sometimes best practice from an organizational perspective.
Please evaluate your use-cases for a state machine before implementing. Many games don't have a need for a custom state machine. This simple incremental game would actually be simpler without one. I'll likely remove the state machine functionality in the near future, but will leave this article up for learning.
I'm guilty of wielding new development techniques like Maslow's hammer. "When you have a hammer, every problem looks like a nail".
Script Documentation
General Scripts
Generic Character Scripts
Enemy Scripts
Player Scripts
Erik's ongoing laundry list of TODOs that he doesn't want to create issues for