FSM and PlayerController

MedorpondMedorpond
6 min read

FSM (Finite State Machine)

As the player and also enemies have many different states and all states will have different situation afterward, I decided to use FSM to control each unit’s state and reaction accordingly.

IState

Before getting stared with FSM, I made an Interface for each state.

public interface IState<T> where T : Enum
{
    T Type { get; }
    IEnumerable<T> PermittedStates { get; }

    void OnEnter();
    void OnExit();
    void Tick();
    public virtual bool CanTransitionTo(T target)
    {
        return PermittedStates.Contains(target);
    }
}

Every states that implement IState will provide given Methods.

  • OnEnter(): Execute when applied

  • OnExit(): Execute when removed

  • Tick(): Can be called on Host’s Update method

  • CanTransitionTo(T target): Tells that transmitting to given state is possible

StateMachine

And for general FSM function, I made parent class of FSM.

public class StateMachine<T> where T : Enum
{
    private readonly Dictionary<T, IState<T>> states = new();
    protected IState<T> currentState;

    public void RegisterState(IState<T> state)
    {
        states[state.Type] = state;
    }

    public bool TryTransition(T target)
    {
        if (!states.TryGetValue(target, out var next)) return false;

        if (currentState == null || currentState.CanTransitionTo(target))
        {
            currentState?.OnExit();
            currentState = next;
            currentState.OnEnter();
            return true;
        }

        return false;
    }

    public void Tick() => currentState?.Tick();
    public T CurrentType => currentState.Type;
}

StateMachine has Dictionary with enum as key and IState object as value, so that each IState object can be called fast enough.

RegisterState method registeres given IState object.

TryTransition method will try transition (Obviously); and return result accordingly. Each state’s OnExit and OnEnter methods will be executed also.

Tick() method will provide host an accesspoint to calling currentState object’s Tick() method.

And host can see the current state with CurrentType field.

PlayerStates.cs

public enum PlayerState
{
    Idle, Move, Jump, Fall, Attack, Climb, Parry, Defend, Dodge, Hit
}

Under PlayerStates.cs, we have possible playerstates in Enum PlayerState.

public class IdleState : IState<PlayerState>
{
    public PlayerState Type => PlayerState.Idle;

    public IEnumerable<PlayerState> PermittedStates => new HashSet<PlayerState>
    {
        PlayerState.Move,
        PlayerState.Jump,
        PlayerState.Parry,
        PlayerState.Dodge,
        PlayerState.Defend,
        PlayerState.Climb,
        PlayerState.Attack,
        PlayerState.Hit,
        PlayerState.Fall
    };

    public void OnEnter() { }
    public void OnExit() { }
    public void Tick() { }
}

{...} // and there's other States.

and I defined each state as class implementing IState<PlayerState>.

Each States has different PermittedStates HasSet, based on how I’d like my unit to act.

I will add animation control and other features on each methods accordingly, but for now I left them empty.

PlayerFMS

public class PlayerFMS : StateMachine<PlayerState>
{
    public PlayerFMS()
    {
        RegisterState(new IdleState());
        RegisterState(new MoveState());
        RegisterState(new JumpState());
        RegisterState(new FallState());
        RegisterState(new AttackState());
        RegisterState(new ClimbState());
        RegisterState(new ParryState());
        RegisterState(new DefendState());
        RegisterState(new DodgeState());
        RegisterState(new HitState());

        TryTransition(PlayerState.Idle);
    }
}

So basically every function necessary is in StateMachine templet, all that PlayerFMS needs to do is fill the states dictionary and set starting state needed.

PlayerController

previously, I was making every module with Monobehaviour Script and was messy.

As I decided to use FSM, I needed one master class to control player and act as one and only component that external game system can communicate.

PlayerController - overview

public class PlayerController : MonoBehaviour
{
    #region playerStates
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float jumpPower = 8f;

    private Rigidbody2D rbody;
    #endregion

    #region playerFlags
    bool isGrounded;
        // TODO: Change this field to Actual "Isgrounded() Logic"
    #endregion

    #region playerModules
    private PlayerFMS fsm;
    private PlayerMove moveModule;
    private PlayerJump jumpModule;
    #endregion

    #region playerInputs
    private Vector2 moveInput;
    public Vector2 MoveInput => moveInput;


    #endregion

    void Awake()
    {
        fsm = new PlayerFMS();
        moveModule = new PlayerMove();
        jumpModule = new PlayerJump();

        rbody = GetComponent<Rigidbody2D>();
    }

    void Update()
    {
        isGrounded = rbody.linearVelocityY <= 0.01 && rbody.linearVelocityY >= -0.01;
    }

    void OnEnable()
    {
        SubscribeEvents();
    }

    void OnDisable()
    {
        ReleaseEvents();
    }

    void FixedUpdate()
    {
        checkFall();
        UpdateMovemnetByState();
    }

    private void SubscribeEvents()
    {
        EventManager.Subscribe<MoveInputEvent>(OnMoveInput);
        EventManager.Subscribe<JumpInputEvent>(OnJumpInput);
    }

    private void ReleaseEvents()
    {
        EventManager.Unsubscribe<MoveInputEvent>(OnMoveInput);
        EventManager.Unsubscribe<JumpInputEvent>(OnJumpInput);
    }
    {...}
}

PlayerController acts as spine of the whole system. It takes every input, manage every playerStates, manages every component necessary like RigidBody2D and all modules and initialize them.

I linked basic move and jump/fall with fsm, and added some flags to handle each action properly.

  • Every Input is handled with InputActionAsset and EventManager(Event Bus) that I made before starting devlog. someday I will cover this part too.

Move

private void OnMoveInput(MoveInputEvent e)
    {
        Vector2 dir = e.Dir;
        PlayerState nextState = (dir == Vector2.zero) ? PlayerState.Idle : PlayerState.Move;

        if (isGrounded && fsm.TryTransition(nextState))
        {
            moveInput = dir;
            //TODO: Apply Animation
            Debug.Log($"State changed to {nextState}, moveInput = {dir}");
        }
    }

If player press any button triggers Move action, the OnMoveInput callback method will kick in.

if there’s any input value, it will try transition to “Move”.
else, it will try transition to “Idle”.

and it will set moveInput field which decides character’s linearVelocityX, with input value.

As I intended to make my character unable to move while Jumping/Falling, I made sure to use “isGrounded” flag to permit actual move only if the character is on the ground.

private void UpdateMovementByState()
    {
        if (fsm.CurrentType != PlayerState.Move && moveInput != Vector2.zero)
        {
            moveInput = Vector2.zero;
        }

        if(isGrounded)
        {
            moveModule.Move(rbody, moveInput, moveSpeed);    
        }
    }

and UpdateMovementByState() is the actual method which called every fixed frame.

it does two things:

  • Make sure Input is Ignored while character is not in “Move” State.

  • Only call actual move login when character is on the ground - if not, the character will lose it’s velocity mid-air, or will have ghostinput and slide after landing.

Jump / Fall

private void OnJumpInput(JumpInputEvent e)
    {
        bool stateChanged = fsm.TryTransition(PlayerState.Jump);
        if (stateChanged)
        {
            jumpModule.Jump(rbody, jumpPower);
            Debug.Log($"State changed to Jump");
            //TODO: Apply Animation
        }
    }

Jump is rather simple; if player triggers jump, it try transition to “Jump” State and call actual Jump() logic from module.

I could use IsGrounded flag also to make sure the condition, but for now, FSM is forbidding any state that should not allow jumping while in to jump, so It’s no big deal for now; but I will add that condition afteraward.

private void checkFall()
    {
        if (fsm.CurrentType != PlayerState.Fall && rbody.linearVelocityY < -0.001)
        {
            bool stateChanged = fsm.TryTransition(PlayerState.Fall);
            if (stateChanged)
            {
                //TODO: Animation 적용
                Debug.Log($"State changed to Fall");
            }
        }
        else if (fsm.CurrentType == PlayerState.Fall && rbody.linearVelocityY >= -0.001)
        {
            bool stateChanged = fsm.TryTransition(PlayerState.Idle);
            if (stateChanged)
            {
                //TODO: Animation 적용
                Debug.Log($"State changed to Idle from Fall");
            }
        }
    }

checkFall is called every fixedframe, and check rather character is falling or not.

first if state check Y velocity if player is not falling, and make player’s state “Fall” if is. by checking player’s current state first, I can avoid unnecessary transiton repeated.

second state does the same in opposite condition, shifting player state to “Idle”.

Result

FSM Works well and Shifts its’ state according to the situation.

Character move as intended.

Next Task

  • Add more Modules to PlayerController

  • Make Combo stack and combo attack

0
Subscribe to my newsletter

Read articles from Medorpond directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Medorpond
Medorpond