// 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 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(); 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 } } }