XR 플밍 - 9. UnityEngine2D 인터렉티브 프로그래밍 - 상태패턴 (5/23)

이형원·2025년 5월 23일
0

XR플밍

목록 보기
82/215
post-thumbnail

1. 상태패턴에 관하여

1.1 상태패턴이란?

상태 패턴은 객체의 내부 상태가 변경될 때 해당 객체가 그의 행동을 변경할 수 있도록 하는 행동 디자인 패턴이다. 객체가 행동을 변경할 때 마치 객체가 클래스를 변경한 것처럼 보일 수도 있다.

게임에서 해당 디자인 패턴을 어떻게 사용할까? 가장 단적인 예시로 이미 여러 번 사용한 애니메이터도 상태 패턴의 일종이라 할 수 있다.

플레이어는 특정 조건에 따라 상태가 계속 변화하며, 해당 상태에 따른 애니메이션을 출력하면서 움직이거나, 공격하거나 하는 등의 행동을 한다. 이런 상태를 마치 클래스 단위처럼 나눈 것처럼 하여, 매 상태에 따라 행동을 변경하도록 하는 패턴이라 할 수 있다.

1.2 상태패턴을 사용해야 하는 이유

지금까지 만들었던 PlayerController 스크립트를 보자.

지금은 걷기와 점프 정도밖에 구현되지 않은 상태이지만, Update와 FixedUpdate는 앞으로 행동이 추가되면 추가될수록 내용이 복잡해질 것이다.

이와 같이 변경해야 하는 상태가 누적되면 누적될수록 코드의 복잡성이 커지고, 디버깅이 어려워진다. 따라서 이 상태라는 것과 행동 단위를 나누어 관리하게 된다면 유지보수에도 훨씬 좋을 것이다.

1.3 FSM(Finite State Machines - 유한 상태 기계)

단순한 AI 구조에서 사용할 수 있다.

조작이 없을 때에는 대기, 방향키만 눌렀을 때는 걷기, Shift를 누른 채로 방향키를 눌렀을 때에는 달리기를 하는 등의 구성을 할 수 있을 것이다.
다만 이와 같은 방식은 상태의 개수가 적으면 사용할 수 있는 방식이겠으나, 상태가 많아지면 많아질수록 복잡해질 것이다.

1.4 HFSM(Hierachical Finite State Machines - 계층적 유한 상태 기계)

앞선 FSM의 복잡한 구조를 개선한 버전이다. 기본적인 아이디어는 FSM의 각 상태를 항목별로 분류하여 그룹으로 만드는 방식이다.

1.5 행동트리 - BT(Behaviour Tree)

행동트리는 엄연히 따지자면 상태 패턴이라기 보다는 행동에 대한 정의를 나타낸 트리를 말한다.
고도화된 AI를 구성하는 데 사용하며 전이를 담당하는 노드에서 우선순위를 판단한 뒤에 상태를 변경하는 방식으로 운영된다.

2. 상태 패턴 구현 - 플레이어

상태패턴을 구현할 방법은 다음과 같다.

상태 패턴만을 따로 정리하기 위해 모든 상태를 관리할 수 있는 부모 클래스를 만들어보자.

  • BaseState abstract 클래스
public abstract class BaseState
{
    public bool HasPhysics;

    // 상태 시작
    public abstract void Enter();

    // 해당 상태에서의 동작
    public abstract void Update();

    // 필요 시 사용
    public virtual void FixedUpdate() { }

    // 상태 끝
    public abstract void Exit();
}

public enum EState
{
    Idle, Walk, Jump
}

이와 같이 작성한 후, 해당 BaseState의 상태 변화를 반영할 StateMachine을 만들어보자.
상태를 저장할 방식으로 Dictionary로 저장하는 방식을 택했다.

  • StateMachine
using System.Collections.Generic;

public class StateMachine
{
    public Dictionary<EState, BaseState> stateDic;
    public BaseState CurState;

    public StateMachine()
    {
        stateDic = new Dictionary<EState, BaseState>();
    }

    public void ChangeState(BaseState changedState)
    {
        if (CurState == changedState) return;

        CurState.Exit();    // 바뀌기 전 상태 종료
        CurState = changedState;
        CurState.Enter();   // 바뀐 상태 진입
    }

    public void Update() => CurState.Update();

	// 모든 상태에 있는 조건이 아니므로, 물리적 변화가 있을 때에만 반영하도록 함.
    public void FixedUpdate()
    {
    	if(CurState.HasPhysics)
        CurState.FixedUpdate();
    }
}

이와 같이 만든 후, 기존에 사용했던 PlayerController 대신 새로운 Player 코드를 작성할 것이다.
기존에 사용했던 변수를 그대로 사용하고, 대신 상태 변화가 일어나는 부분만 새로 작성할 것이다.

  • Player
using UnityEngine;

public class Player : MonoBehaviour
{
    [SerializeField] public float moveSpeed;
    [SerializeField] public float jumpPow;

    public StateMachine stateMachine;

    public Animator animator;
    public Rigidbody2D rigid;

    public float inputX;
    public bool isJumped;
    public bool isLanded;

    public readonly int IDLE_HASH = Animator.StringToHash("Idle");
    public readonly int WALK_HASH = Animator.StringToHash("Walk");
    public readonly int JUMP_HASH = Animator.StringToHash("Jump");

    private void Start()
    {
        animator = GetComponent<Animator>();
        rigid = GetComponent<Rigidbody2D>();
        StateMachineInit();
    }

    private void StateMachineInit()
    {
        stateMachine = new StateMachine();
        stateMachine.stateDic.Add(EState.Idle, new Player_Idle(this));
        stateMachine.stateDic.Add(EState.Walk, new Player_Walk(this));
        stateMachine.stateDic.Add(EState.Jump, new Player_Jump(this));
        stateMachine.CurState = stateMachine.stateDic[EState.Idle];
    }

    private void Update()
    {
        inputX = Input.GetAxis("Horizontal");
        isJumped = Input.GetKeyDown(KeyCode.Space);
        stateMachine.Update();
    }

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

    public void OnCollisionEnter2D(Collision2D collision)
    {
        if(collision.gameObject.CompareTag("Ground"))
        {
            isLanded = true;
        }
    }
}

Player는 플레이어에게 부착할 컴포넌트이고, 해당 상태를 조정하는 PlayerState에 Player의 상태 변화 조건을 넣을 것이다.
PlayerState는 BaseState를 상속하는 자식 클래스이다.

  • PlayerState
using UnityEngine;

public class PlayerState : BaseState
{
    protected Player player;

    public PlayerState(Player _player)
    {
        player = _player;
    }
    public override void Enter() { }

    public override void Update()
    {
    	// 점프의 경우 Idle -> Jump / Walk -> Jump 모두 가능해야 함 따라서 부모 클래스에 반영
        if (player.isJumped && player.isLanded)
            player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Jump]);
    }

    public override void Exit() { }    
}

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.rigid.velocity = Vector3.zero;
    }

    public override void Update()
    {
        base.Update();
        if(Mathf.Abs(player.inputX) > 0.1f)
        {
            player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Walk]);
        }
    }

    public override void Exit() { }
}

public class Player_Walk : PlayerState
{
    public Player_Walk(Player _player) : base (_player)
    {
        HasPhysics = true;        
    }
    public override void Enter()
    {
        player.animator.Play(player.WALK_HASH);
    }

    public override void Update()
    {
        base.Update();
        if(Mathf.Abs(player.inputX) < 0.1f)
        {
            player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Idle]);
        }

        if (player.inputX < 0)
        {
            player.transform.rotation = Quaternion.Euler(0, 180, 0);
        }
        else
        {
            player.transform.rotation = Quaternion.Euler(0, 0, 0);
        }
    }

    public override void FixedUpdate()
    {
        player.rigid.velocity = new Vector2(player.inputX * player.moveSpeed, player.rigid.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.rigid.AddForce(Vector2.up * player.jumpPow, ForceMode2D.Impulse);
        player.isLanded = false;
        player.isJumped = false;
    }

    public override void Update()
    {
        if (player.isLanded)
            player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Idle]);

        if (player.inputX < 0)
        {
            player.transform.rotation = Quaternion.Euler(0, 180, 0);
        }
        else if (player.inputX > 0)
        {
            player.transform.rotation = Quaternion.Euler(0, 0, 0);
        }
    }

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

PlayerState는 BaseState를 상속하는 자식클래스이고, 그 아래의 자식 클래스로 Player_Idle / Player_Walk / Player_Jump 등의 자식 클래스를 또 만들었다.

이와 같이 만든 후 컴포넌트를 반영해보자.

PlayerController를 사용하지 않고 꺼도, 똑같이 작동하는 것을 확인할 수 있다.

3. 상태 패턴 구현 - 몬스터

몬스터의 경우에도 똑같이 상태 패턴을 적용해보자. Monster도 똑같이 Monster, MonsterState를 추가로 만들어주면 된다.

  • Monster
using UnityEngine;

public class Monster : MonoBehaviour
{
    [SerializeField] public int moveSpeed;
    [SerializeField] public MonsterTrigger monsterTrigger;

    public StateMachine stateMachine;

    public Animator animator;
    public Rigidbody2D rigid;

    public bool isLanded;
    public bool isWalking = true;
    public int moveDir = 1;

    public readonly int IDLE_HASH = Animator.StringToHash("Idle");
    public readonly int WALK_HASH = Animator.StringToHash("Walk");

    private void Start()
    {
        animator = GetComponent<Animator>();
        rigid = GetComponent<Rigidbody2D>();
        StateMachineInit();
    }

    private void StateMachineInit()
    {
        stateMachine = new StateMachine();
        stateMachine.stateDic.Add(EState.Idle, new Monster_Idle(this));
        stateMachine.stateDic.Add(EState.Walk, new Monster_Walk(this));
        stateMachine.CurState = stateMachine.stateDic[EState.Idle];
    }

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

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

    private void OnCollisionStay2D(Collision2D collision)
    {
        if(collision.gameObject.CompareTag("Ground"))
        {
            isLanded = true;
        }
    }

    private void OnCollisionExit2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Ground"))
        {
            isLanded = false;
        }
    }
}
  • MonsterState
using UnityEngine;

public class MonsterState : BaseState
{
    protected Monster monster;

    public MonsterState(Monster _monster)
    {
        monster = _monster;
    }

    public override void Enter() { }

    public override void Update() { }

    public override void Exit() { }
}

public class Monster_Idle : MonsterState
{
    public Monster_Idle(Monster _monster) : base(_monster)
    {
        HasPhysics = false;
    }

    public override void Enter()
    {
        monster.animator.Play(monster.IDLE_HASH);
        monster.rigid.velocity = Vector2.zero;
    }

    public override void Update()
    {
        base.Update();
        if(monster.monsterTrigger.IsWalkable) monster.isWalking = true;
        if(monster.isWalking)
        {
            monster.stateMachine.ChangeState(monster.stateMachine.stateDic[EState.Walk]);
        }
    }

    public override void Exit() { }
}

public class Monster_Walk : MonsterState
{
    public Monster_Walk(Monster _monster) : base(_monster)
    {
        HasPhysics = true;
    }

    public override void Enter()
    {
        monster.animator.Play(monster.WALK_HASH);
    }

    public override void Update()
    {
        base.Update();
        if(!monster.monsterTrigger.IsWalkable && monster.isLanded)
        {
            monster.moveDir = -monster.moveDir;
            monster.transform.rotation = monster.moveDir > 0 ? Quaternion.Euler(0, 0, 0) : Quaternion.Euler(0, 180, 0);
        }
    }

    public override void FixedUpdate()
    {
        monster.isWalking = true;
        monster.rigid.velocity = new Vector2(-monster.moveSpeed * monster.moveDir, monster.rigid.velocity.y);
    }
}

이전에 구현한 몬스터와 다르게 코드에서 살짝 달라진 부분이 있는데 바로 이 부분이다.

// Monster 중

private void OnCollisionStay2D(Collision2D collision)
    {
        if(collision.gameObject.CompareTag("Ground"))
        {
            isLanded = true;
        }
    }

    private void OnCollisionExit2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Ground"))
        {
            isLanded = false;
        }
    }

이 부분을 추가한 이유는 아무래도 이런 버그를 발견했기 때문이었다.

(점프모션 이상은 애니메이션 길이 때문에 발생한 것 같다. 조금 조절해보자.)

이와 같이 몬스터가 바닥 밑으로 떨어지거나 공중에 떴을 경우 이상하게 작동할 가능성이 있어서 바닥을 탐지 가능하도록 붙어 있는 상태인지를 확인하기로 했다.

해당 내용을 반영하고 나면 아래와 같이 작동한다.

  • 참고자료

상태패턴
https://neulsang-day.tistory.com/30

profile
게임 만들러 코딩 공부중

0개의 댓글