Source file unity-csharp/patterns/PlayerStateController.cs from the GDnD code examples. Download raw file

// GDnD wiki example
// Demonstrates: the Gang of Four State pattern for a 2D player controller. An IState interface
//   (Enter / Execute / Exit), a reusable plain C# StateMachine that raises a StateChanged event,
//   and Idle, Walk and Jump states that each decide their own transitions.
// Related pages: [[state-pattern]], [[ai-state-machine-pattern]], [[observer-pattern]], [[unity-rigidbody2d]]
// Unity 6 (6000.x). Uses Rigidbody2D.linearVelocity and the old Input Manager
//   (Input.GetAxisRaw / Input.GetButtonDown). New Unity 6 projects default to the Input System
//   package, so set Project Settings > Player > Active Input Handling to "Both" (or swap the two
//   input lines in ReadInput for an InputAction).
//
// Setup: add this component to a GameObject with a Rigidbody2D and a Collider2D. Create an empty
//   child at the feet and assign it to Ground Check, and put the level geometry on a layer that
//   is selected in Ground Layer.
 
using System;
using UnityEngine;
 
namespace GDnD.Patterns.State
{
    // Every state implements the same three-step lifecycle.
    public interface IState
    {
        void Enter();   // runs once when the machine switches into this state
        void Execute(); // runs every frame while this state is current (also called Update or Tick)
        void Exit();    // runs once just before the machine leaves this state
    }
 
    // The machine knows nothing about players, enemies or doors. It only tracks the current
    // state, runs the Exit -> Enter handshake, and tells listeners when the state changes.
    public class StateMachine
    {
        public IState CurrentState { get; private set; }
 
        // Observer pattern hook: UI, audio or animation can react without the states knowing.
        public event Action<IState> StateChanged;
 
        public void Initialize(IState startingState)
        {
            CurrentState = startingState;
            startingState.Enter();
            StateChanged?.Invoke(startingState);
        }
 
        public void TransitionTo(IState nextState)
        {
            if (nextState == null || nextState == CurrentState)
                return;
 
            CurrentState?.Exit();
            CurrentState = nextState;
            nextState.Enter();
            StateChanged?.Invoke(nextState);
        }
 
        public void Execute()
        {
            CurrentState?.Execute();
        }
    }
 
    // The context: owns the Rigidbody2D, reads input, and exposes a small API the states use.
    // It contains no switch statement; behaviour lives in the state classes below.
    [RequireComponent(typeof(Rigidbody2D))]
    public class PlayerStateController : MonoBehaviour
    {
        [SerializeField] private float moveSpeed = 6f;
        [SerializeField] private float jumpSpeed = 12f;
        [SerializeField] private Transform groundCheck;
        [SerializeField] private float groundCheckRadius = 0.1f;
        [SerializeField] private LayerMask groundLayer;
 
        private Rigidbody2D rb;
        private float targetSpeedX;
        private bool jumpRequested;
 
        public StateMachine StateMachine { get; } = new StateMachine();
 
        // One instance of each state, created once and reused (no allocation per transition).
        public IdleState Idle { get; private set; }
        public WalkState Walk { get; private set; }
        public JumpState Jump { get; private set; }
 
        // Read-only data the states use to decide transitions.
        public float MoveInput { get; private set; }
        public bool JumpPressed { get; private set; }
        public bool IsGrounded { get; private set; }
        public Vector2 Velocity => rb.linearVelocity;
        public float MoveSpeed => moveSpeed;
 
        private void Awake()
        {
            rb = GetComponent<Rigidbody2D>();
 
            Idle = new IdleState(this);
            Walk = new WalkState(this);
            Jump = new JumpState(this);
        }
 
        private void Start()
        {
            // Start rather than Awake, so listeners that subscribe in OnEnable hear the first state.
            StateMachine.Initialize(Idle);
        }
 
        private void Update()
        {
            ReadInput();
            IsGrounded = groundCheck != null &&
                         Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
 
            StateMachine.Execute();
        }
 
        private void FixedUpdate()
        {
            // States only set intentions; physics is applied here at the fixed timestep.
            Vector2 velocity = rb.linearVelocity;
            velocity.x = targetSpeedX;
 
            if (jumpRequested)
            {
                velocity.y = jumpSpeed;
                jumpRequested = false;
            }
 
            rb.linearVelocity = velocity;
        }
 
        private void ReadInput()
        {
            MoveInput = Input.GetAxisRaw("Horizontal");
            JumpPressed = Input.GetButtonDown("Jump");
        }
 
        // Small API used by the states.
        public void SetHorizontalSpeed(float speed) => targetSpeedX = speed;
        public void RequestJump() => jumpRequested = true;
 
        private void OnDrawGizmosSelected()
        {
            if (groundCheck == null)
                return;
            Gizmos.color = Color.yellow;
            Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
        }
    }
 
    public class IdleState : IState
    {
        private readonly PlayerStateController player;
 
        public IdleState(PlayerStateController player) => this.player = player;
 
        public void Enter()
        {
            player.SetHorizontalSpeed(0f);
            // e.g. animator.Play("Idle");
        }
 
        public void Execute()
        {
            // Jump pressed, or no longer grounded (pushed off a ledge). JumpState.Enter decides
            // whether to add an upward impulse.
            if (player.JumpPressed || !player.IsGrounded)
            {
                player.StateMachine.TransitionTo(player.Jump);
                return;
            }
 
            if (Mathf.Abs(player.MoveInput) > 0.01f)
                player.StateMachine.TransitionTo(player.Walk);
        }
 
        public void Exit() { }
    }
 
    public class WalkState : IState
    {
        private readonly PlayerStateController player;
 
        public WalkState(PlayerStateController player) => this.player = player;
 
        public void Enter()
        {
            // e.g. animator.Play("Walk"); start footstep loop
        }
 
        public void Execute()
        {
            player.SetHorizontalSpeed(player.MoveInput * player.MoveSpeed);
 
            if (player.JumpPressed || !player.IsGrounded)
            {
                player.StateMachine.TransitionTo(player.Jump);
                return;
            }
 
            if (Mathf.Abs(player.MoveInput) <= 0.01f)
                player.StateMachine.TransitionTo(player.Idle);
        }
 
        public void Exit()
        {
            // e.g. stop footstep loop
        }
    }
 
    // Covers both jumping and falling. It holds its own data (enterTime), which is why each
    // controller needs its own JumpState instance rather than one shared static instance.
    public class JumpState : IState
    {
        private const float MinAirTime = 0.1f; // ignore the ground check while the jump gets going
 
        private readonly PlayerStateController player;
        private float enterTime;
 
        public JumpState(PlayerStateController player) => this.player = player;
 
        public void Enter()
        {
            enterTime = Time.time;
 
            // Only push upwards if we entered from the ground; walking off a ledge just falls.
            if (player.IsGrounded)
                player.RequestJump();
 
            // e.g. animator.Play("Jump"); play jump sound
        }
 
        public void Execute()
        {
            player.SetHorizontalSpeed(player.MoveInput * player.MoveSpeed); // air control
 
            bool landed = Time.time - enterTime > MinAirTime &&
                          player.IsGrounded &&
                          player.Velocity.y <= 0.01f;
            if (!landed)
                return;
 
            if (Mathf.Abs(player.MoveInput) > 0.01f)
                player.StateMachine.TransitionTo(player.Walk);
            else
                player.StateMachine.TransitionTo(player.Idle);
        }
 
        public void Exit()
        {
            // e.g. spawn landing dust particles
        }
    }
}