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
}
}
}