(유니티) 상태패턴 (State Pattern)

티원·2025년 11월 18일

디자인패턴

목록 보기
12/16

상태패턴 시연 영상
FSM이란?
상태패턴?
Behavior Tree란?
구현 난이도 : FSM < 상태패턴 < BT
구조 복잡할수록 효율 : FSM < 상태패턴 < BT

싱글톤, 오브젝트 풀만큼 자주 쓰이기 때문에 잘 알아둬야 한다고 한다.

FSM 대신 쓰는 이유

FSM도 enum상태로 다른 로직을 수행한다는 점에서 비슷하지만,
FSM은 switch문이나 if-else문을 길게 늘여놓기 때문에 코드 가독성과 유지보수성이 매우 떨어진다.
그렇기때문에 각각 상태마다 Class를 만드는 상태패턴을 사용

상태패턴의 장점

  • 상태에 따라 다른 클래스 작성
  • 역할과 책임이 구분이 잘된다. 단, 코드가 길어진다 (간단한거에는 FSM으로 switch문 하는게 좋음), 책임분리

상태패턴의 단점

  • 상태가 많을수록 클래스와 객체가 많아져서 복잡해진다

핵심

  1. 인터페이스를 만들어서 모든 상태가 가지고 있을 메서드 지정
  2. EnemyStateController 와 같이 State 상태를 관리할 클래스를 하나 만든다.
  3. Enemy에게 Patrol, Follow라는 상태가 있다면 해당 Class를 2개 만들어줌
  4. 만약 이동 + 점프를 해야할 경우 Interface로 상태 2개를 만들어서 상태를 둘다 가지고 있으면 될듯
  5. EnemyStateController 에서 상태를 판단해서 변경하는 방식이 좋아보임. 각자 상태들도 판단해서 다른 상태로 변환해도 된다.

아래 코드들은 코드 1, 2는 사실 이해를 위해 남겨둠. 추후 상태패턴에 익숙해지면 1,2번은 지우자

코드 1 (이해를 위한 최소 예제)

//오직 “Idle → Move” 두 상태만 가진 가장 단순한 상태패턴
//IState.cs파일
public interface IState
{
    void Enter();
    void Update();
    void Exit();
}
//IdleState.cs파일
public class IdleState : IState
{
    public void Enter() => Debug.Log("Idle 시작");
    public void Update() => Debug.Log("가만히 있는중…");
    public void Exit()  => Debug.Log("Idle 종료");
}
//MoveState.cs파일
public class MoveState : IState
{
    public void Enter() => Debug.Log("Move 시작");
    public void Update() => Debug.Log("움직이는중…");
    public void Exit()  => Debug.Log("Move 종료");
}
//StateMachine.cs파일
public class StateMachine
{
    private IState _state;

    public void ChangeState(IState newState)
    {
        _state?.Exit();
        _state = newState;
        _state.Enter();
    }

    public void Update() => _state?.Update();
}



//Test.cs파일
public class Test : MonoBehaviour
{
    StateMachine fsm = new StateMachine();
    IdleState idle = new IdleState();
    MoveState move = new MoveState();

    void Start() => fsm.ChangeState(idle);

    void Update()
    {
        fsm.Update();

        if (Input.GetKeyDown(KeyCode.Space))
            fsm.ChangeState(move);
    }
}

코드 2 (플레이어 이동, 점프)

코드1에서 살짝 실제 사례 섞은 것
1. 플레이어 컨트롤러 코드

[RequireComponent(typeof(Rigidbody))]
public class PlayerMovement : MonoBehaviour
{
    [Header("Movement")]
    public float moveSpeed = 5f;
    public float jumpForce = 6f;

    public Vector2 MoveInput { get; private set; }
    public bool JumpPressed { get; set; }

    private Rigidbody rb;

    private IPlayerState currentState;

    void Start()
    {
        rb = GetComponent<Rigidbody>();

        // 초기 상태를 new로 생성
        ChangeState(new IdleState(this));

        // 입력 연결
        InputSystem.actions["Move"].performed += ctx => MoveInput = ctx.ReadValue<Vector2>();
        InputSystem.actions["Move"].canceled += ctx => MoveInput = Vector2.zero;
        InputSystem.actions["Jump"].started += ctx => JumpPressed = true;
    }

    void Update()
    {
        currentState?.Update();
    }

    public void ChangeState(IPlayerState newState)
    {
        currentState?.Exit();
        currentState = newState;
        currentState.Enter();
    }

    // 실제 이동/점프 로직
    public void Move()
    {
        Vector3 dir = new Vector3(MoveInput.x, 0f, MoveInput.y);
        transform.position += dir * moveSpeed * Time.deltaTime;
    }

    public void Jump()
    {
        rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
        JumpPressed = false;
    }
}

2.인터페이스 + 상태 코드

public interface IPlayerState
{
    void Enter();
    void Update();
    void Exit();
}
public class IdleState : IPlayerState
{
    private PlayerMovement player;

    public IdleState(PlayerMovement player)
    {
        this.player = player;
    }

    public void Enter() { }

    public void Update()
    {
        if (player.JumpPressed)
        {
            player.ChangeState(new JumpState(player));
        }
        else if (player.MoveInput != Vector2.zero)
        {
            player.ChangeState(new MoveState(player));
        }
    }

    public void Exit() { }
}

public class MoveState : IPlayerState
{
    private PlayerMovement player;

    public MoveState(PlayerMovement player)
    {
        this.player = player;
    }

    public void Enter() { }

    public void Update()
    {
        player.Move();

        if (player.MoveInput == Vector2.zero)
        {
            player.ChangeState(new IdleState(player));
        }
        else if (player.JumpPressed)
        {
            player.ChangeState(new JumpState(player));
        }

        //걷는 로직으로 변하는 내용이 있어야 함

    }

    public void Exit() { }
}

public class JumpState : IPlayerState
{
    private PlayerMovement player;

    public JumpState(PlayerMovement player)
    {
        this.player = player;
    }

    public void Enter()
    {
        player.Jump();
    }

    public void Update()
    {
        // Jump 후 바로 Idle로
        player.ChangeState(new IdleState(player));
    }

    public void Exit() { }
}

코드 3 (실습때 한 코드)

상태마다 클래스를 지정, 상태에 따라서 다른 상태를 지정해서 계속 진행한다.
아래 코드는 상태끼리 서로 상태를 변경시키는데 EnemyStatePattern 에서 상태 변경시켜도 상관없음

  1. interface
public interface IEnemyState
{
    void Enter();
    void Exit();
    void Update();
}
  1. EnemyStatePattern
    State를 관리해주는 클래스
public class EnemyStatePattern : MonoBehaviour
{
    //따라갈 플레이어 위치 지정
    [SerializeField] private Transform _player;
    //몬스터가 지속적으로 순찰할 위치 지정
    [SerializeField] private Transform[] _patrolPoints;
    //몬스터 감지거리, 이동속도
    [SerializeField] private float _detectRange = 5f;
    [SerializeField] private float _moveSpeed = 2f;
    //현재 상태
    private IEnemyState _currentState;
    //상태들에서 접근할수있게 프로퍼티 생성(읽기전용)
    public Transform[] PatrolPoints => _patrolPoints;
    public float MoveSpeed => _moveSpeed;
    public float DetectRange => _detectRange;
    public Transform PlayerTrf => _player;
    //초기 상태 지정 -> 순찰
    private void Start()
    {
        SetState(new PatrolState(this));
    }
    //상태들마다 다른 기본 행동을 수행한다
    private void Update()
    {
        _currentState.Update();
    }
    /*
    //만약 new로 하는게 너무 메모리낭비같다. 하면 여기에 모든 패턴을 정의해두고 사용하면됨.
    ChaseState chaseState;
    PatrolState patrolState;
    
    private void Start()
    {
        _currentState = new ChaseState(this);
    }
    */
    //상태 지정
    public void SetState(IEnemyState newState)
    {
        _currentState?.Exit();
        _currentState = newState;
        _currentState.Enter();
    }
}
  1. PatrolState
    순찰 상태일때 클래스
public class PatrolState : IEnemyState
{
    private EnemyStatePattern _enemy;
    private int _currentPatrolIndex = 0;
    private Transform[] _patrolPoints;
    private float _moveSpeed;
    private float _detectRange;
    private Transform _player;
    public PatrolState(EnemyStatePattern enemy)
    {
        _enemy = enemy;
        _patrolPoints = _enemy.PatrolPoints;
        _moveSpeed = _enemy.MoveSpeed;
        _player = _enemy.PlayerTrf;
        _detectRange = _enemy.DetectRange;
    }
    public void Enter()
    {
        
    }

    public void Exit()
    {
        
    }

    public void Update()
    {
        if (_patrolPoints.Length == 0) return;

        Transform target = _patrolPoints[_currentPatrolIndex];
       _enemy.transform.position = Vector3.MoveTowards(_enemy.transform.position,
        target.position, _moveSpeed * Time.deltaTime);

        if (Vector2.Distance(_enemy.transform.position, target.position) < 0.1f)
        {
            _currentPatrolIndex = (_currentPatrolIndex + 1) % _patrolPoints.Length;
        }

        //플레이어 감지 및 상태 변경
        if(Vector2.Distance(_enemy.transform.position,_player.position) <= _detectRange)
        {
            _enemy.SetState(new ChaseState(_enemy));  //new가 불편하면?? ~~하면된다

        }
    }
}
  1. ChaseState
    추격상태일때 클래스
public class ChaseState : IEnemyState
{
    private EnemyStatePattern _enemy;
    private Transform _player;
    private float _moveSpeed;
    private float _detectRange;
    public ChaseState(EnemyStatePattern enemy)
    {
        _enemy = enemy;
        _player = enemy.PlayerTrf;
        _moveSpeed = enemy.MoveSpeed;
        _detectRange = enemy.DetectRange;
    }
    public void Enter()
    {
    }
    public void Exit()
    {
    }

    public void Update()
    {
        Vector3 direction = (_player.position - _enemy.transform.position).normalized;
        Vector3 nextPos = new Vector3(_enemy.transform.position.x + direction.x * _moveSpeed * Time.deltaTime,
            _enemy.transform.position.y);
        _enemy.transform.position = nextPos;

        
        if (Vector2.Distance(_enemy.transform.position, _player.position) > _detectRange)
        {
            _enemy.SetState(new PatrolState(_enemy));
        }
    }
}

코드 4 여러 조건이 병렬적으로 존재하는 상태패턴

hierachy state Machie이라는 명칭으로 불리는 듯. (자주 들어보지는 못했다)
만약에 이동+공격, 이동+점프+공격, 이동+점프 이런 다양한 상태들이 동시에 수행되어야하는경우
일반적인 FSM이나 상태패턴으로는 불가능
아래처럼 상태를 따로따로 나눠준다 병렬적으로
아래 코드가 상당히 길다...
개선점으로는 1. 점프 그라운드 체크 버그있어서 점프 반복됨. 일단 상태패턴만 공부하자
2. 상태들에 Class를 보면 생성자부분이랑 PlayerStateController 가 필드로 있다는 사실이 겹친다. 이거를 추상 클래스로 바꿔볼 수 있을것

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static UnityEngine.RuleTile.TilingRuleOutput;

public interface IPlayerMoveState
{
    void Enter();
    void Exit();
    void Update();
}
public interface IPlayerJumpState
{
    void Enter();
    void Exit();
    void Update();
}
//이동 초기화 상태
public class IdleStatePlayer : IPlayerMoveState
{
    PlayerStateController playerStateController;
    public IdleStatePlayer(PlayerStateController playerState)
    {
        playerStateController = playerState;
        Debug.Log("Idle상태 진입");
    }
    public void Enter(){}
    public void Exit(){}
    public void Update()
    {
        if(playerStateController.MoveX != 0)
        {
            playerStateController.SetStateMove(new RunStatePlayer(playerStateController));
        }
    }
}
//달리기 상태
public class RunStatePlayer : IPlayerMoveState
{
    PlayerStateController playerStateController;
    public RunStatePlayer(PlayerStateController state)
    {
        playerStateController = state;
        Debug.Log("Run상태 진입");
    }

    public void Enter(){}
    public void Exit(){}
    public void Update()
    {
        Vector3 movePos = new Vector3(playerStateController.MoveX, 0, 0) * Time.deltaTime;
        playerStateController.MovePlayer(movePos);

        if(playerStateController.MoveX == 0 )
        {
            playerStateController.SetStateMove(new IdleStatePlayer(playerStateController));
        }
    }
}
public class GroundedStatePlayer : IPlayerJumpState
{
    PlayerStateController playerStateController;
    public GroundedStatePlayer(PlayerStateController state)
    {
        playerStateController = state;
    }
    public void Enter(){ Debug.Log("그라운드상태로 진행"); }
    public void Exit(){}
    public void Update()
    {
        if(playerStateController.JumpPressed)
        {
            playerStateController.SetStateJump(new JumpStatePlayer(playerStateController));
        }
    }
}

//점프 상태
public class JumpStatePlayer : IPlayerJumpState
{
    PlayerStateController playerStateController;

    public JumpStatePlayer( PlayerStateController state)
    {
        playerStateController = state;
    }

    public void Enter()
    {
        Debug.Log("점프 상태로 진행");
        Rigidbody2D rigidbody2D = playerStateController.GetComponent<Rigidbody2D>();
        rigidbody2D.velocity = new Vector2(0, 7);
    }
    public void Exit(){}
    public void Update()
    {
        //원래는 레이캐스트로 바닥을 체크하거나
        //캐릭터 발밑에 Collider두어서 바닥 Wall이랑 충돌했는지 체크해서 바닥 체크해야하지만. 편의상 y좌표로 계산
        if(playerStateController.IsGrounded)
        {
            //현재는 그라운드 체크가 이상하게 되서 점프가 계속되긴하는데.. 중요한건 상태가 변한다는것
            playerStateController.SetStateJump(new GroundedStatePlayer(playerStateController));
        }
    
    }
}

public class PlayerStateController : MonoBehaviour
{
    IPlayerMoveState _playerMoveState;
    IPlayerJumpState _playerJumpState;

    //입력처리를 받아서 전역으로 쓰기위해서
    public float MoveX { get; private set; }
    //public bool JumpPressed => Input.GetKeyDown(KeyCode.Space); //Updatd에서 안해주고 이렇게 해줘도 되긴하는듯
    public bool JumpPressed { get; private set; }
    public bool IsGrounded { get; private set; } 
    
    private void Start()
    {
        _playerMoveState = new IdleStatePlayer(this);
        _playerJumpState = new JumpStatePlayer(this);
    }
    void Update()
    {
        //입력 체크
        MoveX = Input.GetAxisRaw("Horizontal"); //이동체크
        JumpPressed = Input.GetKeyDown(KeyCode.Space); //점프체크
        IsGrounded = Physics2D.Raycast(transform.position, Vector2.down, 0.1f); //바닥체크

        _playerMoveState.Update();
        _playerJumpState.Update();
        
    }

    //아래 SetState를 상태별로 public하는거 개선 가능할듯(인터페이스에서 정의하거나... 추상으로 바꿔서 하거나)
    public void SetStateMove(IPlayerMoveState state)
    {
        state?.Exit();
        _playerMoveState = state;
        state.Enter();
    }
    public void SetStateJump(IPlayerJumpState state)
    {
        state?.Exit();
        _playerJumpState = state;
        state.Enter();
    }
    public void MovePlayer(Vector3 moveDir)
    {
        transform.position += moveDir;
    }
}

코드 5 코드4의 조건이 만약에 아래와같다면

(코드는 아닌데 예시)
코드4에서 이동,걷기,점프,공격 이런 상태들만 있었다면
그냥 모션과 액션을 나누는 방법이 쉬운 해결책이다
이동과 걷기는 상태가 변화하는 것이므로 똑같이 상태패턴으로 클래스 지정
점프, 공격은 1회성 동작(액션)이므로 Controller에서 Update문 Input을 확인해서 그냥 처리해준다

코드 개선점

  • (보류)각각 상태변경할때마다 Class를 new 해주는데 이거를 개선할 방법?
    상태마다 변경할 상태를 (전역 클래스)로 상태를 가지고있다가 변환해주면 될것같은데... 다만 사용했던 클래스가 new로 매번 초기화해주는게 아닌 계속 가지고있는다면 데이터 오염으로 이전값의 데이터가 예기치않게 들어가는 경우가 생길듯. 일단은 new로 쓰자
  • 코드 4번에서 점프 그라운드 체크 버그있어서 점프 반복됨. 일단 상태패턴만 공부하자
  • 코드 4번에서 상태들에 Class를 보면 생성자부분이랑 PlayerStateController 가 필드로 있다는 사실이 겹친다. 이거를 추상 클래스로 바꿔볼 수 있을것

전략패턴vs 상태패턴

코드는 비슷하지만 상태를 어디에서 전환해주는지가 다르다

  • 상태패턴 : 각자의 상태를 내부에서 전환
  • 전략패턴 : 상태를 외부에서 전환

상태패턴이 왜 쓰이면 좋을지 설명

https://gameprogrammingpatterns.com/state.html
이미 알고있는 내용들이긴한데 가끔 보면 좋을것같아 추가

상태패턴 추가 의문사항 정리

상태패턴 질문사항

0개의 댓글