-
Notifications
You must be signed in to change notification settings - Fork 0
Finite State Machines
When I first learned about ECS, it took me a while to grasp my head around the fundamental concepts. Once I had those understood though, you realize that simple things you used to rely on like finite state machines, get turned upside down and you end up having no idea how to implement them. I spent a long time doing research on how to implement a FSM inside of an ECS. I eventually stumbled upon this article by Richard Lord. This method made FSM's within the context of ECS finally click for me. The basic idea is that the entity's state is what collection of components are currently defined on the entity. This means that the player's idle state might have a SpriteComponent
, and that's it. Whereas the run state might have a SpriteComponent
, SpeedComponent
, and DirectionComponent
.
So let's jump right in to how to use the included StateMachineComponent
and StateComponent
.
To define a FSM on an object you would do:
// Initialize StateMachineComponent
var _fsm = new StateMachineComponent();
// Add FSM to entity
component_add(id, _fsm);
This is pretty straightforward, you create the data of the FSM, and then you add it to the entity which registers it to be processed by the StateMachineSystem
. Though you will need to define an initial state for the state machine, which we'll go over in the next section.
Of course the StateMachineComponent
provides methods to create states for the StateMachineSystem
to use. Take a look at the following code:
// Initialize StateMachineComponent
var _fsm = new StateMachineComponent();
// Define Idle state
_fsm.createState("Idle")
.addComponent(PlayerIdleTag).endComponent()
// Set initial state
_fsm.setInitialState("Idle");
// Add FSM to entity
component_add(id, _fsm);
This code defines an Idle
state on the entity and sets the FSM's initial state to it. When the game is run, the entity will be placed in the Idle
state and a new PlayerIdleTag
will be placed on the entity. If the entity leaves the Idle
state, this PlayerIdleTag
will be removed and deleted. If the entity re-enters the Idle
state, it will be recreated and added to the entity again. The .endComponent()
is required and tells GM that we are done messing with that component data and to return to the FSM context where we can add another component if we wish.
Of course more complex components and data can be attached to each state. For example:
// Initialize StateMachineComponent
var _fsm = new StateMachineComponent();
// Define Idle state
_fsm.createState("Idle")
.addComponent(PlayerIdleTag).endComponent()
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_player_idle)).endComponent()
// Define Move state
_fsm.createState("Move")
.addComponent(PlayerMoveTag).endComponent()
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_player_move)).endComponent()
.addComponent(VelocityComponent).withData(new VelocityComponent(4, 90)).endComponent()
// Set initial state
_fsm.setInitialState("Idle");
// Add FSM to entity
component_add(id, [
_fsm,
new MoveComponent(CollisionType.SIMPLE) // This ensures the MoveSystem will process this entity along with the VelocityComponent on the move state
]);
Here we have two states defined, an Idle
state and a Move
state that will change the entity's sprite and move the entity 4px per frame upwards. As you can see there are three variations of the .addComponent
line. These define how the component data is manipulated when entering states.
This tells the StateMachineSystem
when adding the component to the entity on state change, always use this reference to the component. The StateMachineSystem
will not reset any values or create a new component, it is essentially a singleton component the system will always reference.
This tells the StateMachineSystem
when adding the component to the entity on state change, create a new component of this type, and copy the values we defined here initially into the new component and discard the old component. So even if different systems change the data within this component, it will always be reset to these initial values when entering the state.
This one is mostly used for tags as you can see above with .addComponent(PlayerIdleTag).endComponent()
, since tags don't have any data contained within them. Tags are purely used for identification. You can technically do .addComponent(PlayerIdleTag).withType(PlayerIdleTag).endComponent()
, but this is redundant and .withType()
is the default behavior. This will always create a new component of this type when entering the state, with no data passed in or changed.
To define a transition from one state to another, it would look like this:
// Initialize StateMachineComponent
var _fsm = new StateMachineComponent();
// Define Idle state
_fsm.createState("Idle")
.addComponent(PlayerIdleTag).endComponent()
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_player_idle)).endComponent()
// Create transition to move state
.createTransition("Move")
.endTransition()
// Define Move state
_fsm.createState("Move")
.addComponent(PlayerMoveTag).endComponent()
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_player_move)).endComponent()
.addComponent(VelocityComponent).withData(new VelocityComponent(4, 90)).endComponent()
// Create transition to idle state
.createTransition("Idle")
.endTransition()
// Set initial state
_fsm.setInitialState("Idle");
// Add FSM to entity
component_add(id, [
_fsm,
new MoveComponent(CollisionType.SIMPLE) // This ensures the MoveSystem will process this entity along with the VelocityComponent on the move state
]);
You can see there is a new method to use named createTransition
. Of course by itself, this won't do anything because we have nothing to dictate the conditions on when a state transitions occurs. We need to define some Considerations
for that. Also of note, we use a required .endTransition()
to return to the FSM context in case we need to add more transitions or components.
Considerations are basically the conditionals that dictate whether or not a state transition occurs. Here is the full example of what we've been building so far with considerations in place:
// Initialize StateMachineComponent
var _fsm = new StateMachineComponent();
// Define Idle state
_fsm.createState("Idle")
.addComponent(PlayerIdleTag).endComponent()
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_player_idle)).endComponent()
// Create transition to move state
.createTransition("Move")
.addConsideration(new MoveInputConsideration())
.endTransition()
// Define Move state
_fsm.createState("Move")
.addComponent(PlayerMoveTag).endComponent()
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_player_move)).endComponent()
.addComponent(VelocityComponent).withData(new VelocityComponent(4, 90)).endComponent()
// Create transition to idle state
.createTransition("Idle")
.addConsideration(new NoMoveInputConsideration())
.endTransition()
// Set initial state
_fsm.setInitialState("Idle");
// Add FSM to entity
component_add(id, [
_fsm,
new MoveComponent(CollisionType.SIMPLE) // This ensures the MoveSystem will process this entity along with the VelocityComponent on the move state
]);
Considerations inherit from IConsideration
and all have an evaluate function on them. The StateMachineSystem
runs this evaluate function to see if the transition considerations are met. If all the considerations are met, then it will transition into the new state. Keep in mind, you can define multiple considerations on one transition by chaining together the .addConsideration()
method.
There is also .addBypassConsideration()
. Typically all considerations need to be met in order for the state transition to proceed, however if any bypass consideration is evaluated to true, it will skip all other considerations and bypass considerations and proceed to the state transition.
.addConsideration()
can also accept an array of considerations. Typically considerations work as an AND and will only proceed with the state transition if all considerations are met. However, if you pass an array into this function, it will treat the considerations within the array as an OR. So if one of them is true, then the total considerations are true. You can use daisy chaining of the .addConsideration()
function to mix and match AND and OR conditionals to get complex transitions if you wish.
Note: At the current time, .addBypassConsideration()
does not support arrays.
This is an example taken from one of Toasty: Ashes of Dusk's enemies. Just to give you an example of what an actual enemy's create event looks like in this ECS. Note that some of these considerations, components, and systems are not present in this library's codebase.
// Inherit the parent event
event_inherited();
mask_index = sprite_index;
#region STATE MACHINE
var _fsm = new StateMachineComponent();
#region RECURRING TRANSITIONS
var _hurt_transition = new StateComponentTransition(undefined, "Hurt")
.addConsideration(new HasComponentConsideration(HurtTag))
#endregion RECURRING TRANSITIONS
#region WANDER STATE
_fsm.createState("Wander")
.addComponent(WanderComponent).withData(new WanderComponent(100)).endComponent()
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_fat_bat_fly, SpritePlaybackType.LOOP, 1, false)).endComponent()
.addComponent(VelocityComponent).withSingleton(new VelocityComponent(0.5)).endComponent()
.addTransition(_hurt_transition)
.createTransition("Wander")
.addConsideration(new RandomTimerConsideration(1, 3))
.endTransition()
.createTransition("Attack Prepare")
.addConsideration(new OnScreenConsideration())
.addConsideration(new PlayerDistanceConsideration(188, ComparisonType.LTE))
.endTransition()
#endregion WANDER STATE
#region ATTACK PREPARE STATE
_fsm.createState("Attack Prepare")
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_fat_bat_attack_prepare)).endComponent()
.addTransition(_hurt_transition)
.createTransition("Attack")
.addConsideration(new AnimationEndConsideration())
.endTransition()
#endregion ATTACK PREPARE STATE
#region ATTACK STATE
_fsm.createState("Attack")
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_fat_bat_attack)).endComponent()
.addComponent(VelocityComponent).withSingleton(new VelocityComponent(8)).endComponent()
.addComponent(ChaseComponent).withData(new ChaseComponent(ChaseMode.BEELINE)).endComponent()
.addComponent(TargetComponent).withData(new TargetComponent(obj_player, true)).endComponent()
.addTransition(_hurt_transition)
.createTransition("Stall")
.addConsideration([
new AttackHitConsideration(),
new TimerConsideration(0.4),
new OffScreenConsideration(),
])
.endTransition()
#endregion ATTACK STATE
#region STALL STATE
_fsm.createState("Stall")
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_fat_bat_fly, SpritePlaybackType.LOOP, 2)).endComponent()
.addComponent(FrictionComponent).withSingleton(new FrictionComponent(0.4)).endComponent()
.addTransition(_hurt_transition)
.createTransition("Wander")
.addConsideration(new TimerConsideration(2))
.endTransition()
#endregion STALL STATE
#region HURT STATE
_fsm.createState("Hurt")
.addComponent(InvincibleTag).endComponent()
.addComponent(HurtEffectComponent).withSingleton(new HurtEffectComponent(spr_fat_bat_hurt)).endComponent()
.createTransition("Death")
.addConsideration(new TimerConsideration(20, true))
.addConsideration(new HealthDepletedConsideration())
.endTransition()
.createTransition("Wander")
.addConsideration(new TimerConsideration(20, true))
.endTransition()
#endregion HURT STATE
#region DEATH STATE
_fsm.createState("Death")
.addComponent(EnemyDeathComponent).withSingleton(new EnemyDeathComponent(-20, 2)).endComponent()
.addComponent(InvincibleTag).endComponent()
.addComponent(SpawnOnDeathComponent).withSingleton(new SpawnOnDeathComponent(obj_lesser_fat_bat, 3)).endComponent()
.addComponent(SpriteComponent).withSingleton(new SpriteComponent(spr_fat_bat_fly, SpritePlaybackType.FREEZE)).endComponent()
#endregion DEATHSTATE
_fsm.setInitialState("Wander");
#endregion STATE MACHINE
component_add(id, [
_fsm,
// STATS
new HealthComponent(3),
// COMBAT
new AttackComponent(TAG_PLAYER),
new BodyAttackComponent(mask_get_width(), mask_get_height(), mask_get_offset_x(), mask_get_offset_y()),
new HurtboxComponent(TAG_ENEMY, mask_get_width(), mask_get_height(), mask_get_offset_x(), mask_get_offset_y()),
// RENDERING
new SortComponent(0),
new SilhouetteComponent(c_maroon, 0, 0),
new ShadowComponent(30, 10, -1, 5),
new FlyingTag(),
// MISC
new MoveComponent(CollisionType.NONE),
]);