Implementing FSM in Cocos Creator to create silky-smooth character actions

Implementing FSM in Cocos Creator to create silky-smooth character actions

Introduction: The author of this article, Huang Cong, is a college student. In the process of completing the design of working out a set of character motion control schemes. He shares his experience with us.


As a student in school, in the process of doing a graduate project some time ago, I also encountered the problem that many students will encounter: the action logic of the character is all written in Player.ts, so when a player script needs to execute multiple logics at the same time (movement control, animation playback, key management, etc.), without exception, such a situation occurred -

We first judged the key input, hoping to make our hero rotate, jump, flip and fly under WASD’s key drive. Then we changed the character’s action playback in the code block, set the moving speed, and kept setting his direction in an update…

Just thinking about all the work makes my head hurt! So I searched all kinds of information on making this easier for myself on the internet and finally figured out a set of solutions. The idea is based on the State Pattern in the game programming.

The following is the switching effect between the character movement, jumping, crouching, and jumping and slashing states that I implemented with the framework in Cocos Creator 2.4.x, and the behavior logic of the states I build in the script Player.ts.

Here is the final effect.

FSMImage

First test

Let’s start from scratch. In order to ensure a clear idea, we assume that we are now making a 2D horizontal version of the game, and we need to let the protagonist respond to our keyboard input and press the space bar to jump. This function looks easy to implement:

Player.ts

private  _jumpVelocity:  number  =  100 ;
onKeyDown(event:  any ) {
 if  (cc.macro.KEY.space == event.keyCode) {
        this .node.getComponent(Rigibody).setVerticalVelocity( this ._jumpVelocity);
    }
}

But there is a problem. There is nothing to stop an “air jump.” When the character presses the space crazily while in the air, the character will just float. A simple fix is to Player.ts add a _onGround field to it, and then:

private  _onGround: boolena =  false ;
private  _jumpVelocity:  number  =  100 ;
onKeyDown(event:  any ) {
 if  (cc.macro.KEY.space == event.keyCode) {
        if ( this ._onGround) {
            this ._onGround =  false ;
         // jump...
         }
    }
}

Understand it? At this point we haven’t implemented any other actions for the character. When the character is on the ground, I want the character to lie down when the ↓ arrow key is pressed, and stand up when released:

private  _onGround: boolena =  false ;
private  _jumpVelocity:  number  =  100 ;
onKeyDown(event:  any ) {
 if  (cc.macro.KEY.space == event.keyCode) {
        if ( this ._onGround) {
            this ._onGround =  false ;
         // if on the ground, jump up
         }
    }
    else  if  (cc.macro.KEY.down == event.keyCode) {
        if  ( this ._onGround){
            // if on the ground, lie down
         }
    }
}
onKeyUp(event :  any ) {
  if (cc.macro.KEY.down == event.keyCode) {
        // stand up
     }
}

New problems arise. Through this code, the character may jump up from the lying down state, and you can press the arrow keys to lie down in the air, which is not what we want, so we need to add a new field at this time…

private  _onGround: boolena =  false ;
private  _isDucking:  boolean  =  false ;
private  _jumpVelocity:  number  =  100 ;
onKeyDown(event:  any ) {
 if  (cc.macro.KEY.space == event.keyCode) {
        if ( this ._onGround && ! this ._isDucking) {
            this ._onGround =  false ;
         // if on the ground, not lying down, jump up
         }
    }
    else  if  (cc.macro.KEY.down == event.keyCode) {
        if  ( this._onGround){
            this ._isDucking =  true ;
            // if on the ground, lie down
         }
    }
}

onKeyUp(event:  any ) {
  if  (cc.macro.KEY.down == event.keyCode) {
        if  ( this ._isDucking ) {
            this ._isDucking =  false ;
            // stand up
         }
    }
}

But there are obvious problems with this approach. Every time we change the code, we break something that was written before. We need to add more moves - sliding attacks, jumping attacks, dodging backward, etc., but doing it this way creates a bunch of bug fixing before it’s done.

Finite State Machine (FSM)

After experiencing the above setbacks, I came to a painful conclusion: I left my laptop, got a pen and paper, and started to draw. I drew a box for each character’s actions: standing, jumping, lying down, jumping, and slashing… When the character responds to a keypress, draw an arrow that connects to the state it needs to toggle.

In this way, a finite state machine is established, and its characteristics are:

  • Has a collection of all possible states of the character. Here, the states are standing, lying down, jumping, and double jumping.

  • A state machine can only be in one state at a time. Characters cannot be standing and lying down simultaneously, which is one of the reasons for using FSM.

  • All key input will be sent to the state machine. Here is the pressing and popping of different keys.

  • Each state has a series of state transitions, transition conditions, and inputs related to another state. When in this state, the input satisfies the condition of another state and the state of the state machine switches to the state of the target.

This is the core thinking of a state machine: state, input, transition.

Enumeration and Branching

Coming back to my computer to analyze the problems with the previous code. First, it untimely bundles a whole bunch of bool variables: _onGround and _isDucking seems impossible for these variables to be both true and false at the same time, so what we need is an enum. Something like this:

enum  State {
 STATE_IDLE,
 STATE_JUMPING,
 STATE_DUCKING,
 STATE_DIVING
};

In this way, we don’t need a bunch of fields. We only need to make corresponding judgments according to the enumeration:

onKeyDown(event:  any ) {
    switch (_state) {
        case  State.STATE_IDLE:
            if (cc.macro.KEY.space == event.keyCode){
                _state = STATE_JUMPING;
                // jump...
             }
            else  if  (cc.macro .KEY.down == event.keyCode) {
                _state = STATE_DUCKING;
                // lying down...
             }
            break ;
           
        case  State.STATE_JUMPING:
            if  (cc.macro.KEY.down == event.keyCode) {
                _state = STATE_DIVING;
                // jump cut...
             }
            break ;
           
        case  State.STATE_DUCKING:
            //...
            break ;
    }

It seems to have changed slightly, but it’s a significant improvement over the previous code. We distinguish in conditional branches, which group the logic that runs in a state.

This is the simplest state machine implementation, but the real problem is not that simple. Our character also has a button charge, and when released, a special attack is performed. There is no way the current code is clearly qualified for such a job.

Remember the state machine flowchart I just drew? Each state box gave me some inspiration, so I started to try to design the state machine with object-oriented thinking.

State mode

Even though switch could accomplish these needs, it would still be rugged and cumbersome if not modified. So I decided to go with the idea from the game programming pattern that allows us to use simple interfaces to do complex logical work with the same goal as always: high cohesion and low coupling.

State interface

Encapsulate the state as a base class to control the behavior related to a state, and let the state remember the role information it is attached to.

The purpose of this is clear: To make each state have the same type and commonality to manage it centrally.

/**State base class, providing the logical interface of the state */
export  default  class  StateBase {
    protected  _role: Player |  null  =  null ;
    constructor ( player: Player ) {
        this ._role = player;
    }
    //start----- -------Virtual method-----------
    /**Called when entering this state */
     onEnter() { }
   
    /**A method that will be called every frame in this state */
     onUpdate (dt:  any ) { }
   
    /**Keyboard input events monitored by this state */
     onKeyDown(event:  any ) { }
   
    /**Keyboard up events monitored by this state */
     onKeyUp(event:  any ) { }
   
    /* * Called when leaving the state */
     onExit() { }
    //end-------------virtual method------------
 }

Write a class for each state

For each state, we define a class that implements the interface.

Its methods define the behavior of the character in this state. In other words, change switch, case and move them into the state class.

export  default  class  Player_Idle  extends  StateBase {
    onEnter():  void  { }
    onExit():  void  { }
    onUpdate(dt:  any ):  void  { }
    onKeyDown(event:  any ):  void  {
        switch  (event.keyCode) {
            case  cc. macro.KEY.space:
                // jump state
                break ;
            case  cc.macro.KEY.down:
                // lie down state
                break ;
        }
    }
    onKeyUp(event:  any ):  void  { }
}

Note that here the Idle state logic that was originally written in Player.ts has been removed and put into the player_Idle.ts class. This is very clear - only the logic we need to determine is present in this state.

image

State delegation

Next, rebuild the original logic in the role, abandon the huge switch, and store the currently executing state through a variable.

export  default  class  Player {
    protected  _state: StateBase |  null  =  null ;  //The current state of the role
 constructor () {
        onInit();
    }
 onInit() {
        this .schedule( this .onUpdate);
    }

 onKeyDown(event:  any ) {
        this ._state.onKeyDown(event);
    }
 onKeyUp(event:  any ) {
        this ._state.onKeyUp (event);
    }
 onUpdate(dt) {
        this ._state.onUpdate(dt);
    }
}

To “change the state,” we need to make _state point a different StateBase object, thus implementing the entirety of the state pattern.

Where will the state exist?

Another little detail: As mentioned above, to “change state,” we need _state point to be a new state object, but where does this object come from?

We know that a character has multiple states belonging to it, and these states cannot be stored in memory as free states. We must manage all the states of this character in some way. We may do this: find harmless people and animals position, add a static class that stores all the state of the player:

export  class  PlayerStates  {
    static  idle: IdleState;
    static  jumping: JumpingState;
    static  ducking: DuckingState;
    static  diving: DivingState;
    //...
 }

This way, the player can switch states:

export  default  class  Player_Idle  extends  StateBase  {
    onEnter():  void  { }
    onExit():  void  { }
    onUpdate(dt: any):  void  { }
    onKeyDown(event: any):  void  {
        switch  (event.keyCode) {
            case  cc. macro.KEY.space:
                // Jumping state
                this ._role._state = PlayerStates.JumpingState;
                break ;
            case  cc.macro.KEY.down:
                // Lying down state
                this ._role._state = PlayerStates.DuckingState;
                break ;
        }
    }
    onKeyUp(event: any):  void  { }
}

Is there a problem? No problem. But now that the optimization has reached this point, I am unwilling to do more because it’s still a highly coupled implementation. Such an implementation means that each character needs a separate class to hold the state ensemble, which is cumbersome when multiple characters and multiple actions are in a game.

So is there a breakthrough? Of course, in a container! It solves the coupling problem and retains all the flexibility of the previous method. It only needs to register a state in the container.

protected _mapStates: Map<string, StateBase> = new Map();   // Role status collection

Modularize existing code

Now to sort out what we have implemented:

  • Multiple states inherit from a state base class and implement the same interface.

  • The variable of the current state of the role is defined in the role class _state.

  • Use a container _mapStates to store a collection of states for a character.

I think the function is almost perfect. It aggregates the variables related to the processing state into one class, completely empties the role class, and at the same time, like a general manager, implements additions, deletions, and changes to the state class, and draws a frame diagram for easy understanding. .

animator.ts

/**Animator class, used to manage the state of a single character */
export  default  class  Animator  {
    protected _mapStates:  Map <string, StateBase> =  new  Map ();    //Character state collection
     protected _state: StateBase |  null  =  null ;                   //Character current state
    /**
     * Registration state
     * @param key state name
     * @param state state object
     * @returns  
     */
     regState(key: string,  state : StateBase):  void  {
        if  ( ''  === key) {
            cc.error( 'The key of state is empty');
            return ;
        }
        if  ( null  == state) {
            cc.error( 'Target state is null' );
            return ;
        }
        if  ( this ._mapStates.has(key))
            return ;
        this ._mapStates.set(key, state );
    }
    /**
     * delete state
     *  @param  key state name
     *  @returns
     */
     delState(key: string):  void  {
        if  ( ''  === key) {
            cc.error( 'The key of state is empty ');
            return ;
        }
        this ._mapStates.delete(key);
    }
    /**
     * switch state
     *  @param  key state name
     *  @returns
     */
     switchState(key: string) {
        if  ( ''  === key) {
            cc .error( 'The key of state is empty.' );
            return ;
        }
        if  ( this ._state) {
            if  ( this ._state ==  this ._mapStates.get(key))
                return ;
            this ._state.onExit();
        }
        this ._state =  this ._mapStates.get(key);
        if  ( this ._state)
            this ._state.onEnter();
        else
             cc.warn( `Animator error: state ' ${key} ' not found.` );
    }
    /**Get all states in the state machine*/
     getStates():  Map <string, StateBase> {
        return  this ._mapStates;
    }
    /**Get the current state */
     getCurrentState(): StateBase {
        return  this ._state;
    }
    /**Current state update function*/
     onUpdate(dt: any) {
        if  (! this ._state) {
            return;
        }
        if  (! this ._state.onUpdate) {
            cc.warn( 'Animator onUpdate: state has not update function.' );
            return ;
        }
        this ._state.onUpdate(dt);
    }
}

Next, we only need to define an Animator class, and register the state we need to it, and then continue to execute the previous logic code:

Player.ts

export  default  class  Player  {
 private _animator: Animator|  null  =  null ;
   
    onInit() {
        // state machine registration
        this ._animator =  new  Animator();
        if  ( this ._animator) {
            this ._animator.regState( 'Idle' ,  new  IdleState( this ));
            this ._animator.regState( 'Jumping' ,  new  JumpingState( this ));
            this ._animator.regState( 'Ducking' ,  new  DuckingState(this ));
            this ._animator.regState( 'Diving' ,  new  DivingState( this ));
        }
        // key response event binding
         cc.systemEvent.on(cc.SystemEvent.EventType.KEY_DOWN,  this .onKeyDown,  this );
        cc.systemEvent.on(cc.SystemEvent.EventType.KEY_UP,  this .onKeyUp,  this );
       
        this .schedule( this .onUpdate);
    }
    onEnter(params?: any) { }
    onUpdate(dt: any) {
        this ._animator .onUpdate(dt);
    }
    onKeyDown(event: any) {
        let  state = this ._animator.getCurrentState();
        if  (state) {
            state.onKeyDown(event);
        }
    }
    onKeyUp(event: any) {
        let  state =  this ._animator.getCurrentState();
        if  (state) {
            state.onKeyUp(event );
        }
    }
}

Of course, you can choose to do some extended work so that the state machine is also managed:

AnimatorManager.ts

/**AnimatorManager */
export  default  class  AnimatorManager  {
    //Single example
     private  static  _instance: AnimatorManager |  null  =  null ;
    public  static  instance(): AnimatorManager {
        if  (! this ._instance) {
            this ._instance =  new  AnimatorManager ();
        }
        return  this ._instance;
    }
    private _mapAnimators:  Map <string, Animator> =  new  Map <string, Animator>();

    /**
     * Get the animation machine, if it does not exist, create a new one and return
     * @param key Animator name
     * @returns Animator
     */
     getAnimator(key: string): Animator |  null  {
        if  ( ""  == key) {
            cc.error( "AnimatorManager error: The key of Animator is empty" ) ;
        }
        let  anim: Animator |  null  =  null ;
        if  (! this ._mapAnimators.has(key)) {
            anim =  new  Animator();
            this ._mapAnimators.set(key, anim);
        }
        else  {
            anim =  this ._mapAnimators .get(key);
        }
        return  anim;
    }

    /**
     * delete animator
     *  @param  key animator name
     */
     delAnimator(key: string) {
        this ._mapAnimators.delete(key);
    }

    /** clear animator */
     clearAnimator() {
        this ._mapAnimators.clear();
    }

    /**Animator state update */
     onUpdate(dt: any) {
        this ._mapAnimators.forEach( ( value: Animator, key: string ) =>  {
            value.onUpdate(dt);
        });
    }
}

In this way, the new operations of the role class are centralized in the management class, and there is no need for new in Player.ts:

// state machine registration
this ._animator = AnimatorManager.instance().getAnimator( "player" );
if  ( this ._animator) {
    this ._animator.regState( 'Idle' ,  new  IdleState( this ));
    this ._animator. regState( 'Jumping' ,  new  JumpingState( this ));
    this ._animator.regState( 'Ducking' ,  new  DuckingState( this ));
    this ._animator.regState( 'Diving' ,  new  DivingState( this ));
}

Finished product

The final character state switching effect is achieved by the following code, which is clean and tidy:

Note: this.getController() is the module that controls the movement and has nothing to do with the system

Even though state machines have these common extensions, they suffer from some limitations.

This is just a record of my solution, which means you can critique it all you want. You are welcome to go to the Chinese forum to communicate on any better solutions you have!

2 Likes

Please give me the source code of this tutorial. Tks

Hello @thuydt2506 The code for this article is referenced above in code blocks. I’m not sure if we have a complete project to provide.

@thuydt2506 You can go to the Chinese forum and ask if they can supply. Hope this helps you out.