Two years ago, I got acquainted with the most wonderful framework for developing games on Actionscript
Ash (almost immediately after meeting him, it came to be understood that the framework would suit any language. And even a little later, the ports of the framework for other languages ​​appeared - at the time of writing the translation there are 7 pieces). For some time, I was bursting with the desire to share the find with the Russian-speaking community, until I was ahead of the
transfer to Habré . Now I want to put in a translation regarding the implementation of the almost indispensable
Finite-State Machine pattern for the
Ash Entity System framework.
Note: the naming of basic concepts from the
Entity System framework (ES framework) will be written with a capital letter - to avoid possible ambiguities: System (
System ), Component (
Component ), Node (
Entity )
Pattern state machines for Ash Entity System framework
Finite state machines (FSM) - one of the main constructions in game dev. During the game, game objects can repeatedly change their states and effective management of these states is very important.
The complexity of the FSM in the ES framework can be expressed in one sentence - FSM does not work with the ES framework. Any ES framework uses a data-oriented approach (data-oriented paradigm), in which gaming entities are not encapsulated OOP objects. Thus, you will not be able to use FSM or its variants. All data is in Components, all logic is in Systems.
')
If there are few states or they are simple, then you can use the good old
switch statement inside the System, if the data for all possible states of the gaming entity (enclosed in the corresponding Components) are also used by this System. But I would not recommend it.
When developing
Stick Tennis, I faced the problem of managing states. The sequence of changes in these states was similar to something similar ...
- prepare to serve
- filing start
- tossing the ball
- swing of the racket
- kick the ball
- completion of the blow
- moving to a good position
- reaction to a ball hit by an opponent
- moving to intercept the ball
- swing of the racket
- kick the ball
- completion of the blow
- moving to a good position
- reaction to a ball hit by an opponent
- etc.
Stick Tennis is a complex example, and I cannot show the program code, instead I will show something simpler.
ExampleLet's imagine the game character - the guard. A guard patrols along a route, looking around carefully. When an enemy is detected, it attacks.
In traditional OOP-oriented FSM, we need to create classes for each state.
public class PatrolState { private var guard : Character; private var path : Vector.<Point>; public function PatrolState( guard : Character, path : Vector.<Point> ) { this.guard = guard; this.path = path; } public function update( time : Number ) : void { moveAlongPath( time ); var enemy : Character = lookForEnemies(); if( enemy ) { guard.changeState( new AttackState( guard, enemy ) ); } } }
public class AttackState { private var guard : Character; private var enemy : Character; public function AttackState( guard : Character, enemy : Character ) { this.guard = guard; this.enemy = enemy; } public function update( time : Number ) : void { guard.attack( enemy ); if( enemy.isDead ) { guard.changeState( new PatrolState( guard, PatrolPathFactory.getPath( guard.id ) ); } } }
In the
Entity System architecture, we must take a slightly different approach. In this case, the basic principle of FSM, where it is possible to change states by means of a set of corresponding classes, when a particular state corresponds to each class, can still be applied. To implement FSM in the ES framework in this case we can use one System for one particular state.
public class PatrolSystem extends ListIteratingSystem { public function PatrolSystem() { super( PatrolNode, updateNode ); } private function updateNode( node : PatrolNode, time : Number ) : void { moveAlongPath( node ); var enemy : Enemy = lookForEnemies( node ); if( enemy ) { node.entity.remove( Patrol ); var attack : Attack = new Attack(); attack.enemy = enemy; node.entity.add( attack ); } } }
public class AttackSystem extends ListIteratingSystem { public function AttackSystem() { super( AttackNode, updateNode ); } private function updateNode( node : PatrolNode, time : Number ) : void { attack( node.entity, node.attack.enemy ); if( node.attack.enemy.get( Health ).energy == 0 ) { node.entity.remove( Attack ); var patrol : Patrol = new Patrol(); patrol.path = PatrolPathFactory.getPath( node.entity.name ); node.entity.add( patrol ); } } }
PatrolSystem will be responsible for the behavior of the guard
character , if the
PatrolComponent Component is included in it, and
AttakSystem will be responsible for the behavior during the Attack Component component, if it is included in it. By changing these components, we can switch the status of the character guard.
These Components and Nodes will look something like this ...
public class Patrol { public var path : Vector.<Point>; } public class Attack { public var enemy : Entity; } public class Position { public var point : Point; } public class Health { public var energy : Number; } public class PatrolNode extends Node { public var patrol : Patrol; public var position : Position; } public class AttackNode extends Node { public var attack : Attack; }
So, by changing the Components of the Entity, we change the states of the Entity and System that are responsible for their behavior.
Another exampleHere I will present another example, more complex from the game
Asteroids example game , with the help of which I illustrate the work of the
Ash Framework . I had to add another state for the spacecraft - the death of the ship. Instead of simply removing the spacecraft at the time of death, I show a short animation of its destruction. While I am demonstrating this animation, the player is deprived of the ability to control the ship, and the ship itself is not involved in handling collisions with other objects.
This required the following two states of the ship:
- the ship is alive
- looks like a spaceship
- the player can control it
- the player can fire his weapon
- the ship may collide with asteroids
- the ship is dead
- looks like wreckage drifting in space
- the player cannot control it
- the player cannot fire his weapon
- the ship cannot collide with asteroids
- after a certain time the ship is removed from the game
The corresponding code where the ship is killed is in the
CollisionSystem . Without the second state, it looks like this:
for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next ) { for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next ) { if ( Point.distance( asteroid.position.position, spaceship.position.position ) <= asteroid.position.collisionRadius + spaceship.position.collisionRadius ) { creator.destroyEntity( spaceship.entity ); break; } } }
The code checks whether the ship collided with an asteroid, and if this happened, removes the ship from the game. On the other hand,
GameManager handles the situation when there is no space ship in the game and creates a new one. If all “lives” are exhausted - the end of the game. So - instead of simply removing the ship from the game, we have to change its state — show flying debris. Let's try…
We can deprive the player of control by simply removing the components of
MotionControls and
GunControls from the Essence of the spacecraft. We also need to remove the
Motion and
Gun components, since they are still not used without the appropriate controllers. Those. we measure the code above:
for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next ) { for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next ) { if ( Point.distance( asteroid.position.position, spaceship.position.position ) <= asteroid.position.collisionRadius + spaceship.position.collisionRadius ) { spaceship.entity.remove( MotionControls ); spaceship.entity.remove( Motion ); spaceship.entity.remove( GunControls ); spaceship.entity.remove( Gun ); break; } } }
Then we need to change the appearance of the ship (on the wreckage) and cancel the handling of collisions:
for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next ) { for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next ) { if ( Point.distance( asteroid.position.position, spaceship.position.position ) <= asteroid.position.collisionRadius + spaceship.position.collisionRadius ) { spaceship.entity.remove( MotionControls ); spaceship.entity.remove( Motion ); spaceship.entity.remove( GunControls ); spaceship.entity.remove( Gun ); spaceship.entity.remove( Collision ); spaceship.entity.remove( Display ); spaceship.entity.add( new Display( new SpaceshipDeathView() ) ); break; } } }
Finally, we need to ensure that the wreckage of the ship is removed after some period of time. For this we need a new system and component:
public class DeathThroes { public var countdown : Number; public function DeathThroes( duration : Number ) { countdown = duration; } } public class DeathThroesNode extends Node { public var death : DeathThroes; } public class DeathThroesSystem extends ListIteratingSystem { private var creator : EntityCreator; public function DeathThroesSystem( creator : EntityCreator ) { super( DeathThroesNode, updateNode ); this.creator = creator; } private function updateNode( node : DeathThroesNode, time : Number ) : void { node.death.countdown -= time; if ( node.death.countdown <= 0 ) { creator.destroyEntity( node.entity ); } } }
We add
DeathThroesSystem to the game from the very beginning, and at the right moment it will react to the “death” of the Essence. It remains to add the
DeathThroes Component to the Essence of the spacecraft.
for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next ) { for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next ) { if ( Point.distance( asteroid.position.position, spaceship.position.position ) <= asteroid.position.collisionRadius + spaceship.position.collisionRadius ) { spaceship.entity.remove( MotionControls ); spaceship.entity.remove( Motion ); spaceship.entity.remove( GunControls ); spaceship.entity.remove( Gun ); spaceship.entity.remove( Collision ); spaceship.entity.remove( Display ); spaceship.entity.add( new Display( new SpaceshiopDeathView() ) ); spaceship.entity.add( new DeathThroes( 5 ) ); break; } } }
As a result, we get the necessary state of the spacecraft. The transition between the states itself was provided by “shuffling” the components of the spacecraft.
The specific state is encapsulated in the Component Set.
The main rule of the
Entity System Architecture is the
Entity state encapsulated in the Component Set. If you want to change the behavior of the Entity, you must change the set of Components. Changing the set of Components in turn will change the set of Systems - and the behavior of the Entity will change.
Standardized code for FSM
For ease of working with FSM, I added a corresponding set of classes to the framework -
standard state machine classes . A set of these classes will help identify new states, manage them.
FSM is an instance of the
EntityStateMachine class. When you create an instance, you give it a link to the Entity, the states of which it will manage. The
EntityStateMachine instance
itself is usually stored in some Component in the Entity, and thus any System has access to it.
var stateMachine : EntityStateMachine = new EntityStateMachine( guard );
FSM stores states, and these states can be changed by calling the
EntityStateMachine.changeState () method. Each specific state is identified by a string (name) that is associated with the state when it was created and used when calling
EntityStateMachine.changeState (stateName) .
var patrolState : EntityState = stateMachine.createState( "patrol" ); var attackState : EntityState = stateMachine.createState( "attack" );
What is the state added to the FSM for?- adds the necessary Components when the Entity enters this state;
- removes previously added components when leaving the current state.
The
EntityStateMachine.add () method sets the Component type necessary for the defined state and follows the rules indicating how to create this Component.
var patrol : Patrol = new Patrol(); patrol.path = PatrolPathFactory.getPath( node.entity.name ); patrolState.add( Patrol ).withInstance( patrol ); attackState.add( Attack );
There are four standard methods:
entityState.add (type: Class);Without any indication, FSM will create a new instance of the Component of this type, for later adding it to the Entity. And it will be repeated each time with repeated returns to this state.
entityState.add (type: Class) .withType (otherType: Class);This instruction creates a new instance of otherType each time it enters this state.
otherType must be of type
type or an extension of
typeentityState.add (type: Class) .withInstance (instance: *);This method makes it possible to use the same instance of the Component class when returning to this state.
And finally
entityState.add (type: Class) .withSingleton ();or
entityState.add (type: Class) .withSingleton (otherType: Class);will create a singleton and will use it every time it returns to this state. This is the same as using the
withInstance method, but the
withSingleton method
will not create an instance of the class until it is needed. If
otherType is not specified, a singleton of type is created. If
otherType is specified, it must be of type
type , or extend it.
Finally, you can define the Component yourself by implementing the
IComponentProvider interface
.entityState.add (type: Class) .withProvider (provider: IComponentProvider);The
IComponentProvider interface
is defined as follows.
public interface IComponentProvider { function getComponent() : *; function get identifier() : *; }
The
getComponent method returns an instance of the component. The
identifier property is used to compare two providers to ensure that they return the same Component. This is necessary in order to exclude the replacement of the Component, if two different states use the same Component.
The methods are designed to be “chained” so that you can create flexible interfaces, as you will see in the following example.
Back to examplesIf we apply these new tools for example with a space ship, the code will take the following form
var fsm : EntityStateMachine = new EntityStateMachine( spaceshipEntity ); fsm.createState( "playing" ) .add( Motion ).withInstance( new Motion( 0, 0, 0, 15 ) ) .add( MotionControls ) .withInstance( new MotionControls( Keyboard.LEFT, Keyboard.RIGHT, Keyboard.UP, 100, 3 ) ) .add( Gun ).withInstance( new Gun( 8, 0, 0.3, 2 ) ) .add( GunControls ).withInstance( new GunControls( Keyboard.SPACE ) ) .add( Collision ).withInstance( new Collision( 9 ) ) .add( Display ).withInstance( new Display( new SpaceshipView() ) ); fsm.createState( "destroyed" ) .add( DeathThroes ).withInstance( new DeathThroes( 5 ) ) .add( Display ).withInstance( new Display( new SpaceshipDeathView() ) ); var spaceshipComponent : Spaceship = new Spaceship(); spaceshipComponent.fsm = fsm; spaceshipEntity.add( spaceshipComponent ); fsm.changeState( "playing" );
As a result, the change of the current State will be simplified as much as possible:
for ( spaceship = spaceships.head; spaceship; spaceship = spaceship.next ) { for ( asteroid = asteroids.head; asteroid; asteroid = asteroid.next ) { if ( Point.distance( asteroid.position.position, spaceship.position.position ) <= asteroid.position.collisionRadius + spaceship.position.collisionRadius ) { spaceship.spaceship.fsm.changeState( "destroyed" ); break; } } }