4 min read

Implementing State Manager in Unity

Implementing State Manager in Unity

In this blog post, we delve into our state manager's workings. While it may not be perfect, it's efficient enough for our current needs, allowing us to focus on other crucial aspects of development. For those interested in the evolution and learning process of our state manager, check out this blog post.

Today, we're focusing on our stack-based state machine implementation and the technical details behind it. We've covered the basic concepts in a previous post, and we suggest you get familiar with it for better context.

How does it work?

Each state must implement the BaseState abstract class, to provide unique behaviours for lifecycle events like Start(), Pause(), Resume(), and Finish(). Start and Resume return an IEnumerator, enabling coroutine creation if necessary. This feature allows simple initialization as well as actions with time-dependent effects. The State Manager is responsible for invoking the appropriate methods for each managed state.

Base State class

In our case, the BaseState is a standard abstract class. The Start() and Resume() functions return an IEnumerator, allowing the state to execute over time using coroutines if necessary. We'll provide specific examples later in the post.

public abstract class BaseState
{
    public abstract IEnumerator Start();
    public abstract void Pause();
    public abstract IEnumerator Resume();

    public Action<BaseState> OnFinished;  // Used by StateManager to remove or start a new state. Not ideal but does the job.
    protected virtual void Finish() => OnFinished?.Invoke(null);  // Called by state to signal finished execution and that it should be removed from the stack.
    protected virtual void PushState(BaseState nextState) => OnFinished?.Invoke(nextState);  // State can also start another state, this will simply push new state to the top of the stack
}
BaseState abstract class

State Manager

Here is our current State Manager implementation:

public class StateManager : MonoBehaviour
{
    private readonly List<BaseState> stateList = new();
    private readonly Dictionary<BaseState, Coroutine> stateCoroutineMap = new();

    public BaseState CurrentState => stateList.Count > 0 ? stateList[^1] : null;
    public BaseState PreviousState => stateList.Count > 1 ? stateList[^2] : null;

    public void PushState(BaseState newState)
    {
        // Add the state to the list
        stateList.Add(newState);

        // Pause the state that is currently running
        if (PreviousState != null)
        {
            // Stop the coroutine if it's running
            if (stateCoroutineMap[PreviousState] != null)
            {
                StopCoroutine(stateCoroutineMap[PreviousState]);
            }
            PreviousState.Pause();
        }

        // Remove the state when it's finished and push the next state if it's not null
        newState.OnFinished = nextState =>
        {
            if (nextState != null)
            {
                PushState(nextState);
            }
            else
            {
                RemoveState(newState);
            }
        };

        // Start the new state and save its coroutine
        var startCoroutine = newState.Start();
        if (startCoroutine != null)
        {
            stateCoroutineMap[newState] = StartCoroutine(startCoroutine);
        }
        else {
            stateCoroutineMap[newState] = null;
        }
    }

    private void RemoveState(BaseState state)
    {

        // If the state is not in the list, return
        if (!stateList.Contains(state))
        {
            return;
        }

        if (state == CurrentState)
        {
            stateList.Remove(state);
            stateCoroutineMap.Remove(state);

            var newState = CurrentState;
            var resumeCoroutine = newState.Resume();
            if (resumeCoroutine != null)
            {
                stateCoroutineMap[newState] = StartCoroutine(resumeCoroutine);
            }
            else
            {
                stateCoroutineMap[newState] = null;
            }
        }
        else
        {
            stateList.Remove(state);
            stateCoroutineMap.Remove(state);
        }
    }
}
State Manager is responsible for adding new states at the top of the stack and removing existing states.

Example States

Let's explore some of our existing states in more detail.

Stunned State

To represent a stunned character, we designed a class as shown below. A noteworthy feature here is the handling of stun effect pausing and resuming - it's not about removing the stun effect, but transitioning from the running to the paused state.

public class StunnedState : BaseState
{    
    public float Duration { get; private set; }
    public float StartTime { get; private set; }

    // private readonly Character character;  // Reference to the character object

    public StunnedState(Character character, float duration)
    {
        Duration = duration;
        // this.character = character; // We do not really use it here in sample code
    }

    public override IEnumerator Start()
    {
        StartTime = Time.time;

        yield return new WaitForSeconds(Duration);
        Finish();
    }

    public override void Pause()
    {
        // Nothing really
    }

    public override IEnumerator Resume()
    {
        yield return new WaitForSeconds(Duration - (Time.time - StartTime));
        Finish();  // Signals that the state is done and should be removed
    }
}

Hero Moving State

This state represents the player's character's "default" state. It's primarily about moving and responding to inputs. An interesting aspect here is the use of coroutine and yield return new WaitForFixedUpdate() to mimic the MonoBehaviour's standard FixedUpdate() method behavior.

[Serializable]
public class HeroMovingStateStats
{
    [SerializeField]
    private float moveSpeed = 5f;
    public float MoveSpeed => moveSpeed;

    [SerializeField]
    private float maxRotationAngle = 420f;
    public float MaxRotationAngle => maxRotationAngle;
}

public abstract class HeroMovingState : BaseState
{
    // Common Components
    protected readonly CharacterController characterController;
    protected readonly PlayerInputBuffer inputBuffer;
    protected readonly HeroMovingStateStats stats;

    protected HeroMovingState(HeroCharacter character, HeroMovingStateStats stats)
    {
        characterController = character.GetComponent<CharacterController>();
        inputBuffer = character.InputBuffer;
        this.stats = stats;
    }

    protected IEnumerator Update()
    {
        while (true)
        {
            yield return new WaitForFixedUpdate();
            MoveAndRotate(inputBuffer.MoveDirection);
        }
    }

    // TODO: Add a gradual speed up and slow down on the start and end of the movement
    protected virtual void MoveAndRotate(Vector3 moveDirection)
    {
        // Move the player character controller
        characterController.SimpleMove(moveDirection * stats.MoveSpeed);

        // Rotate the player
        if (moveDirection != Vector3.zero)
        {
            Quaternion targetRotation = Quaternion.LookRotation(moveDirection);
            var transform = characterController.transform;

            if (Vector3.Angle(transform.forward, moveDirection) > 90)
            {
                transform.rotation = Quaternion.LookRotation(moveDirection);
            }
            transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, stats.MaxRotationAngle * Time.deltaTime);
        }
    }

    public override IEnumerator Start()
    {
        return Update();
    }

    public override void Pause() { }

    public override IEnumerator Resume()
    {
        return Update();
    }
}

Summary

Understanding the workings of our state manager and the different states helps streamline our development process. While our state manager may not be perfect, it's efficient, allowing us to focus on other critical aspects of development. Stay tuned for more insights and updates as we continue to evolve our tools and processes.