디자인 패턴 - 7. State Pattern

땡구의 개발일지·2025년 6월 5일
post-thumbnail

상태 패턴
코드 예시


개요

  • FSM과 비슷한 개념이다. 아니, 게임에 한해서는 거의 동일한 개념이다

게임 구현에서 자주 사용되는 디자인 패턴으로, 객체가 가진 상태를 관리하고, 각 상태에 따른 행동을 미리 지정할 수 있다

현업에서의 경우

현업에서는 코드의 재사용성과 유지보수성을 높이기 위해 객체지향적인 설계를 지향하고 있다. 상태패턴은 어떤 한 객체의 상태를 직관적으로 선언하고 제어어할 수 있다. 게임 프로그래밍에서는 필수 디자인 패턴이다

플레이어 캐릭터가 'Idle','Move','Jump','Attack' 등의 상태를 가졌을 때 객체지향적인 설계가 고려되지 않으면 어떻게 스크립팅 되는지 보자

public enum StateList
{
   Idle, Move, Jump, Attack
}

public class PlayerState : MonoBehaviour
{
   private int _direction;
   [SerializeField] private StateList CurrentState; 

   private void Update() 
   {
     // 키 입력 에 따른 각 상태 변화
     
   }

   private void Idle()
   {
     // 기능 로직
     // 애니메이션 변화 로직
     // 예외처리 등
   }

   private void Move()
   {
     // 기능 로직
     // 애니메이션 변화 로직
     // 예외처리 등
   }

   private void Jump()
   {
     // 기능 로직
     // 애니메이션 변화 로직
     // 예외처리 등
   }

   private void Attack()
   {
     // 기능 로직
     // 애니메이션 변화 로직
     // 예외처리 등
   }
}
  • 상태패턴을 이렇게 짜면 안된다. 조건이나 예외 처리를 해야하는 것들이 너무 많아진다

  • 상태가 하나씩 추가될 때마다 하나의 클래스에 각 기능구현과 더불어 각 상황에 대한 예외처리까지 생각해야 하므로, 결과적으로 버그나 오류 발생 확률을 높이게 된다. 그리고 State를 플레이어 뿐만 아니라 플레이어와 상태와 동작이 동일한 몬스터 객체에도 적용해야 할 경우, 몬스터 클래스 내부에서 다시 새롭게 개별 동작을 정의해야 한다는 단점이 있다.

HFSM

  • 록맨 X4의 조작같은 경우에는 상태 패턴으로 만들기는 힘들다. 애니메이터의 FSM처럼 만들어야 할까? 예를들어, 대쉬중이면서 점프 중이면 대쉬점프 상태, 대쉬점프 상태에서 발사면 대쉬점프발사 상태가 된다. 이동상태, 이동발사상태, 이동대쉬상태 등 너무 많은 상태가 만들어진다. 그러면 유지보수가 힘들어진다. 이런식으로 중첩된 상태들 같은 경우, 큰 상태 하나에 작은 상태들을 가지는 형태하이라키 FSM(HFSM)으로 만들어야 한다
    피격, 발사도 하나의 상태여야 한다

Behaviour Tree 구조

  • BT 구조 개요
  • Behaviour Tree를 알아보자
  • Tree 자료구조를 통해 구현하는 상태 패턴이라고 볼 수 있다
  • Root 노드가 상태를 결정하는 방식이라서 상태들 간의 연결 상태를 체크할 필요가 없다
  • 다만, 상태에서 다른 상태로 연결되는 부분에 새로운 상태가 필요한(예를들어 애니메이션) 경우 유용하지 않은 방법
  • 적의 AI로 많이 쓰인다

상태 패턴

  • 플레이어를 상태 패턴으로 구현해본다
  • 전체적인 구조는 위와 같이 될 것이다

상태 패턴은 각 개별적인 상태를 하나의 클래스, 객체로 지정해 놓고 각 상태에 따른 동작을 미리 정의하는 것이 핵심. 실습을 통해 상태패턴을 구현하고, 캐릭터에 적용해 보겠습니다.

먼저 BaseState를 만든다

Base State

  • 플레이어, 적과 공유하는, 기본적인 상태의 정보들을 가진다
// MonoBehaviour를 상속받지 않는다
// 여기서는 따로 구현을 하지 않는다
// 다른 상태들은 이 클래스를 상속받는다
// 추상화 클래스로 만들고, 상태들이 기능을 각자 구현한다

// 플레이어, 적 모두 BaseState를 상속받는다
public abstract class BaseState
{
    // FixedUpdate를 사용하기 원한다면 이것을 true로 가진다
    public bool HasPhysics;

    // 1. 상태가 시작 될 때
    // 2. 해당 상태에서 동작을 담당
    // 3. 상태가 끝날 때

    // 1. 상태가 시작 될 때
    public abstract void Enter();
    // 2. 해당 상태에서 동작을 담당
    public abstract void Update();
    // 2-1. 사용하지 않는 상태들도 있기 때문에 가상 함수로 선언
    public virtual void FixedUpdate() { }
    // 3. 상태가 끝날 때
    public abstract void Exit();
}

// 상태를 나타내는 열거형
public enum EState
{
    Idle, Walk, Jump
}

Player State

  • BaseState를 상속받아서 플레이어 전용 상태 패턴을 구현한다
// BaseState를 상속받는다
// 플레이어의 상태들은 이 클래스를 상속받는다
// AnyState와 같은 기능을 이 클래스에서 담당한다
public class PlayerState : BaseState
{
    // 플레이어 정보를 가져와서 상태 판단
    protected Player player;
    public PlayerState(Player player)
    {
        this.player = player;
    }

    // 모두 오버라이드 했으므로 하위 클래스들은 원하는 기능만 구현하면 된다
    public override void Enter() { }
    public override void Update()
    {
        if (player.isJump && player.isGround)
        {
            player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Jump]);
        }
    }
    public override void Exit() { }
}

// 플레이어의 상태들을 클래스로 작성한다
// PlayerState를 상속받는다
public class Player_Idle : PlayerState
{
    public Player_Idle(Player player) : base(player)
    {
        HasPhysics = false;
    }
    public override void Enter()
    {
        player.animator.Play(player.IDLE_HASH);
        // 속도 초기화
        player.rig.velocity = Vector2.zero;
    }
    public override void Update()
    {
        // 점프 판단을 위해 상위 Update도 수행한다
        base.Update();

        // 딱 0으로 떨어지게 구하는 것은 피한다(업계 불문율)
        // Mathf.Abs(player.inputX)>0.05f
        if (player.inputX != 0)
        {
            player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Run]);
        }
        // 점프는 Idle 상태에서도, Walk 상태에서도 넘어갈 수 있으므로 AnyState(상위 상태 = PlayerState)에서 넘어가는 걸로 한다
    }
    public override void Exit() { }
}

public class Player_Run : PlayerState
{
    public Player_Run(Player player) : base(player)
    {
        HasPhysics = true;
    }

    public override void Enter()
    {
        player.animator.Play(player.RUN_HASH);
    }

    public override void Update()
    {
        base.Update();
        // 입력값이 없을 경우, Idle로 전이
        if (Mathf.Abs(player.inputX) < 0.05f)
        {
            player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Idle]);
        }

        // 왼쪽, 오른쪽 방향 전환(스프라이트, 카메라)
        if (player.inputX < 0)
        {
            player.spriteRenderer.flipX = true;
            player.cinemachine.m_TrackedObjectOffset = new Vector3(-2, 2, 0);
        }

        else if (player.inputX > 0)
        {
            player.spriteRenderer.flipX = false;
            player.cinemachine.m_TrackedObjectOffset = new Vector3(2, 2, 0);
        }
    }

    public override void FixedUpdate()
    {
        player.rig.velocity = new Vector2(player.inputX * player.moveSpeed, player.rig.velocity.y);
    }

    public override void Exit() { }
}

public class Player_Jump : PlayerState
{
    public Player_Jump(Player player) : base(player)
    {
        HasPhysics = true;
    }

    public override void Enter()
    {
        player.animator.Play(player.JUMP_HASH);
        player.rig.AddForce(Vector2.up * player.jumpPower, ForceMode2D.Impulse);
        // isGround는 Player 인스턴스의 OnCollision 부분에서 true로 바뀐다
        player.isGround = false;
        // 점프키 입력이 있을경우 true로 들어온다
        player.isJump = false;
    }

    public override void Update()
    {
        if (player.isGround)
        {
            player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Idle]);
        }
        // 왼쪽, 오른쪽 방향 전환(스프라이트, 카메라)
        // 모든 상태에서 동일한 내용이면, 최상위(PlayerState)로 올리는게 낫다
        if (player.inputX < 0)
        {
            player.spriteRenderer.flipX = true;
            player.cinemachine.m_TrackedObjectOffset = new Vector3(-5, 2, 0);
        }
        else if (player.inputX > 0)
        {
            player.spriteRenderer.flipX = false;
            player.cinemachine.m_TrackedObjectOffset = new Vector3(5, 2, 0);
        }
    }
    public override void FixedUpdate()
    {
        player.rig.velocity = new Vector2(player.inputX * player.moveSpeed, player.rig.velocity.y);
    }
}
  • 피격같이 모든 상태에서 넘어가야 하는 상태의 경우, 가장 상위인 PlayerState에서 조건을 받아서 넘어가는 걸로 한다

State Machine

  • 어떤 상태들이 존재하는 지 보관하고, 상태의 내용들을 수행한다
  • 상태를 전환하는 과정도 여기서 수행된다
// 플레이어 컨트롤러가 해당 클래스를 받도록 한다
public class StateMachine
{
    // 1. 상태를 가지고 있어야 한다. 딕셔너리로 관리
    public Dictionary<EState, BaseState> stateDic;
    // 2. 각 상태를 받아서 조건에 따라 상태를 전이(Transition) 시켜줘야 한다
    // 2-1. 현재 상태 저장
    public BaseState curState;

    // 2-2. 상태 변경 함수
    public void ChangeState(BaseState changeState)
    {
        // 변경 하는게 현재 상태이면 return
        if (curState == changeState) return;

        curState.Exit(); // 현재 상태를 벗어난다
        curState = changeState; // 다음 상태로 전이
        curState.Enter(); // 다음 상태 진입
    }
    // 3. 각 상태의 Enter, Update, Exit ... 을 실행시켜준다
    public void Update() => curState.Update();

    public void FixedUpdate()
    {
        // FixedUpdate를 사용하기 위해서 HasPhysics를 true로 바꾸면 사용할 수 있다
        if(curState.HasPhysics)
        curState.FixedUpdate();
    }
}

Player

  • 플레이어의 정보를 담은 필드를 가지는 클래스
public class Player : MonoBehaviour , IDamagable
{
    [field: Header("Set Value")]
    [field: SerializeField] public float moveSpeed { get; set; }
    [field: SerializeField] public float dashSpeed { get; set; }
    [field: SerializeField] public float jumpPower { get; set; }
    [field: SerializeField] public float fireSpeed { get; set; }
    [field: SerializeField] public float dashTime { get; set; }

    // 인스턴스 및 컴포넌트 참조
    public StateMachine stateMachine;
    public Rigidbody2D rig;
    public Animator animator;
    public SpriteRenderer spriteRenderer;
    [SerializeField] public CinemachineVirtualCamera virtualCamera;
    public CinemachineFramingTransposer cinemachine;

    //변수
    //public float MoveSpeed;
    public bool isMove;
    public bool isJump;
    public bool isGround;
    public bool isDash;
    public bool isCharging;
    public float dashDir;
    public float chargeTime;
    public float dashTimer;
    public float inputX;

    //애니메이션
    public readonly int IDLE_HASH = Animator.StringToHash("Idle");
    public readonly int RUN_HASH = Animator.StringToHash("Run");
    public readonly int JUMP_HASH = Animator.StringToHash("Jump");
    public readonly int DASH_HASH = Animator.StringToHash("Dash");
    public readonly int ATK_HASH = Animator.StringToHash("Attack");

    // 게임 시작 시 설정
    private void Start()
    {
        animator = GetComponent<Animator>();
        rig = GetComponent<Rigidbody2D>();
        spriteRenderer = GetComponent<SpriteRenderer>();
        cinemachine = virtualCamera.GetCinemachineComponent<CinemachineFramingTransposer>();
        // 스테이트 머신 인스턴스 생성
        StateMachineInit();
    }

    // 매 프레임마다 수행
    private void Update()
    {
        // 인풋만 여기서 받도록 한다
        inputX = Input.GetAxisRaw("Horizontal");
        isJump = Input.GetKeyDown(KeyCode.C);
        // state의 업데이트로 책임 전가
        stateMachine.Update();
    }
    private void FixedUpdate()
    {
        stateMachine.FixedUpdate();
    }
    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject.layer == 6)
        {
            isGround = true;
        }
    }

    // SM 생성 및 딕셔너리 추가
    void StateMachineInit()
    {
        stateMachine = new StateMachine();
        // 여기서 this는 Player 클래스의 인스턴스다
        stateMachine.stateDic.Add(EState.Idle, new Player_Idle(this));
        stateMachine.stateDic.Add(EState.Run, new Player_Run(this));
        stateMachine.stateDic.Add(EState.Jump, new Player_Jump(this));

        // 초기 상태 설정
        stateMachine.curState = stateMachine.stateDic[EState.Idle];
    }

    public void TakeDamage(int damage)
    {

    }
}

적 구현

  • 적 또한 상태 패턴을 적용해서 구현한다
  • 기존의 트리거 2개로 구현했던 플레이어 인식범위, 숨는 범위는 레이캐스트로 대체한다

Enemy

  • Player스크립트와 동일한 기능을 수행한다
public class Enemy : MonoBehaviour
{
    [field: Header("Set Value")]
    [field: SerializeField] public float moveSpeed { get; set; }
    [field: SerializeField] public float fireSpeed { get; set; }
    [field: SerializeField] public float fireDelay { get; set; }
    [field: SerializeField] public int damage { get; set; }
    [field: SerializeField] public float searchDistance {  get; set; }
    [field: SerializeField] public float hideDistance {  get; set; }

    [Header("Set Reference")]
    [SerializeField] public EnemyBullet bulletPrefab;

    // 상태
    public bool isHiding = false;
    public bool isAttacking = false;

    // 참조
    public Rigidbody2D rig;
    public Animator animator;
    public SpriteRenderer spriteRenderer;
    public StateMachine stateMachine;

    // 지역변수
    public float timer;
    public int flip = 1;
    public float moveTimer;
    public Vector2 moveDir;

    // 애니메이션
    public readonly int IDLE_HASH = Animator.StringToHash("Idle");
    public readonly int MOVE_HASH = Animator.StringToHash("Move");
    public readonly int ATK_HASH = Animator.StringToHash("Attack");
    public readonly int HATD_HASH = Animator.StringToHash("HatDown");
    public readonly int HATU_HASH = Animator.StringToHash("HatUp");

    private void Start()
    {
        animator = GetComponent<Animator>();
        spriteRenderer = GetComponent<SpriteRenderer>();
        rig = GetComponent<Rigidbody2D>();
        StateMachineInit();
        moveDir = Vector2.left;
    }

    private void FixedUpdate()
    {
        stateMachine.FixedUpdate();
    }

    private void Update()
    {
        stateMachine.Update();
        Debug.Log($"적 현재 상태 {stateMachine.curState}");
    }

    void StateMachineInit()
    {
        stateMachine = new StateMachine();
        stateMachine.stateDic.Add(EState.Idle, new Enemy_Idle(this));
        stateMachine.stateDic.Add(EState.Move, new Enemy_Move(this));
        stateMachine.stateDic.Add(EState.Hide, new Enemy_Hide(this));
        stateMachine.stateDic.Add(EState.Attack, new Enemy_Attack(this));

        stateMachine.curState = stateMachine.stateDic[EState.Idle];
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        // TODO : 플레이어에게 데미지 넣기
        // IDamagable
    }
}

Enemy State

  • Player State와 동일한 기능을 수행한다
public class EnemyState : BaseState
{
    protected Enemy enemy;
    protected float fireDelay;
    public EnemyState(Enemy enemy)
    {
        this.enemy = enemy;
    }
    public override void Enter() { }
    public override void Update()
    {
        RaycastHit2D forwardHitInfo = Physics2D.Raycast(enemy.transform.position, enemy.moveDir, enemy.searchDistance, 1 << 7);
        if (forwardHitInfo.collider != null)
        {
            if (forwardHitInfo.distance < enemy.hideDistance)
            {
                enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Hide]);
            }
            else if (enemy.stateMachine.curState != enemy.stateMachine.stateDic[EState.Attack])
            {
                enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Attack]);
            }
        }
        if(fireDelay>0)
        {
            fireDelay -= Time.deltaTime;
        }
    }

    public override void Exit()
    {

    }
}

//적 아이들
public class Enemy_Idle : EnemyState
{
    private float randomTimer;
    public Enemy_Idle(Enemy enemy) : base(enemy)
    {
        HasPhysics = false;
    }
    public override void Enter()
    {
        enemy.animator.Play(enemy.IDLE_HASH,0,0);
        randomTimer = Random.Range(1, 5);
        // 속도 초기화
        enemy.rig.velocity = Vector2.zero;
    }
    public override void Update()
    {
        base.Update();
        if (randomTimer <= 0)
        {
            enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Move]);
        }
        else
        {
            randomTimer -= Time.deltaTime;
        }
    }
    public override void Exit()
    {

    }
}

//적 이동
public class Enemy_Move : EnemyState
{
    // 여기서만 적이 회전한다. flipX 및 그와 관련 로직을 여기서 구현
    public Enemy_Move(Enemy enemy) : base(enemy)
    {
        HasPhysics = true;
    }
    public override void Enter()
    {
        
        enemy.animator.Play(enemy.MOVE_HASH);
    }
    public override void Update()
    {
        base.Update();
        Vector2 rayOrigin = enemy.transform.position + new Vector3(enemy.moveDir.x * 0.5f, 0);
        RaycastHit2D downHitInfo = Physics2D.Raycast(rayOrigin, Vector2.down, 1f, 1 << 6);
        if (downHitInfo.collider == null)
        {
            enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Idle]);
            enemy.spriteRenderer.flipX = !enemy.spriteRenderer.flipX;
            enemy.moveDir *= -1;
        }
    }
    public override void FixedUpdate()
    {
        enemy.rig.velocity = new Vector2(enemy.moveSpeed * enemy.moveDir.x, 0);
    }

}

// 적 숨기
public class Enemy_Hide : EnemyState
{
    public Enemy_Hide(Enemy enemy) : base(enemy) { }
    public override void Enter()
    {
        enemy.animator.Play(enemy.HATD_HASH);
    }
    public override void Update()
    {
        RaycastHit2D forwardHitInfo = Physics2D.Raycast(enemy.transform.position, enemy.moveDir, enemy.searchDistance, 1 << 7);
        if (forwardHitInfo.collider != null)
        {
            if (forwardHitInfo.distance < enemy.hideDistance)
            {
                enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Hide]);
            }
            else
            {
                enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Attack]);
            }
        }
        else
        {
            enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Idle]);
        }
    }
    public override void Exit() { }
}

//적 공격
public class Enemy_Attack : EnemyState
{
    public Enemy_Attack(Enemy enemy) : base(enemy) { }
    public override void Enter()
    {
        if (fireDelay <= 0)
        {
            enemy.animator.Play(enemy.ATK_HASH);
            Rigidbody2D bullet = MonoBehaviour.Instantiate(enemy.bulletPrefab, enemy.transform.position, Quaternion.identity).GetComponent<Rigidbody2D>();
            bullet.velocity = enemy.moveDir * enemy.fireSpeed;
            fireDelay = enemy.fireDelay;
        }
    }
    public override void Update()
    {
        // 쿨타임마다 공격
        if (fireDelay > 0)
        {
            fireDelay -= Time.deltaTime;
        }
        else
        {
            enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Idle]);
        }

        // 전방 레이캐스트
        RaycastHit2D forwardHitInfo = Physics2D.Raycast(enemy.transform.position, enemy.moveDir, enemy.searchDistance, 1 << 7);
        if (forwardHitInfo.collider != null)
        {
            if (forwardHitInfo.distance < enemy.hideDistance)
            {
                enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Hide]);
            }
            else
            {
                enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Attack]);
            }
        }
        else
        {
            enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Idle]);
        }
    }
    public override void Exit()
    {

    }
}
profile
개발 박살내자

0개의 댓글