5. 상태 패턴 with Unity

sonohoshi·2021년 1월 13일
5

State Pattern

상태 패턴은 주로 오브젝트가 특정 조건에 따라 행동이 달라지거나, 혹은 상태에 따라 다른 행동을 할 때, 즉 오브젝트의 상태를 정의할 수 있으며 그에 따라 동작이 달라질 때 이용하는 디자인 패턴이다. 게임 개발에서 특히 알아보기 쉽게 배울 수 있는데, 몬스터가 맵을 방황하는 상태, 플레이어를 쫓아오는 상태 등으로 나뉘어진다고 말하면 이해가 쉬울 것 같다. 실제로 몬스터 등을 구현할 때 이용할 수 있다.

본문에서는 상태 패턴을 응용한 것 중 하나인 FSM, Finite State Machine (유한 상태 기계)에 대해 알아볼 것이다.

FSM 이란?

서론에서도 이야기 했듯이, 영문으로는 Finite State Machine, 우리말로는 유한 상태 기계라 한다. 이는 상태 패턴에서 파생된 것 중 하나인데, 하나의 오브젝트는 동시에 여러 상태를 가지지 않고 하나의 상태만 가지고 있는 것을 말한다. 예시를 들어보겠다.

일반적인 상태 패턴

각 객체는 상태를 '동시에' 여러개 가질 수 있다.
만약 플레이어 객체가 있다면, "전투 중" 상태와 함께 "건강함" 상태를 가질 수 있는 것이다.
FSM

각 객체는 상태를 한 시점에 여러개를 가질 수 없다.
만약 플레이어 객체가 있다면, "탐험 중" 상태와 "전투 중" 상태를 함께 가질 수 없다는 것이다.

이정도의 예시로 이해가 됐길 바란다.

장점

  1. 각 객체가 상태에 따라 어떻게 작동하는지 알기 쉽게 구현을 할 수 있다.
    코드만 봐도 어떤 상태에서는 어떤 동작을 하는지 쉽게 알 수 있기 때문에, 개발하는 입장에서 관리가 상당히 쉽다.

  2. 불필요한 조건문을 줄일 수 있다.
    보통 유한 상태 기계를 구현할 때, 하나의 메소드를 만들고 그 상태에 따라 해당 메소드를 오버라이드한 것을 구현 후 이용하게 된다. 예시를 들어보겠다.

휴대폰의 화면이 켜져있을 때, 전원 버튼을 누르면 화면이 꺼진다.
휴대폰의 화면이 꺼져있을 때, 전원 버튼을 누르면 화면이 켜진다.

여기서 "전원 버튼을 누르는 작업" 을 하나의 메소드로 정의한다.
virtual void PushButton() { }
그리고 각 상태마다 PushButton() 메소드를 오버라이드 한다.

// ScreenTurnedOnState class...
override void PushButton() 
{
    TurnOffScreen();
    state = new ScreenTurnedOffState();
}

// ScreenTurnedOffState class...
override void PushButton() 
{
    TurnOnScreen();
    state = new ScreenTurnedOnState();
}

이런 식으로 구현하는 것이다.

단점

  1. 유한 상태 머신은 상태를 단 하나만 갖기 때문에, 여러 상태를 가져야 하는 경우에는 알맞지 않다.
    ex) 전투 중, 여러 버프나 디버프를 받고 있을 경우

  2. 상태가 너무 많으면 각 상태를 바꾸는 코드를 적는데에도 조건문이 많이 들어가게 될 수 있다.

구현은 어떻게?

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class State<T>
{
    public Action<T> action;
    public abstract State<T> InputHandle(T t);

    public State()
    {
        action = Enter;
    }

    public virtual void Enter(T t)
    {
        action = Update;
    }

    public virtual void Update(T t)
    {

    }
    
    public virtual void Exit(T t)
    {

    }
}

본 코드는 19년도 말에 필자가 처음으로 프로그래머 직군으로 참가한 게임잼, 2019 인디게임위크엔드에서 같은 팀에서 개발을 한 이영현 팀원의 코드를 이용한 것이다. 꼭 이렇게 구현을 해야한다는 것은 아니지만, 이 템플릿을 이용하니 그냥 박치기를 하는 것보다 개발이 훨씬 편했다. 그래서 필자가 보고 공부한 코드를 여러분에게도 설명을 해보려 한다.

먼저, abstract 클래스로 템플릿을 만들었다. 본 클래스를 상속받아 각 클래스의 상태를 정의하면 되는데, 여기서 Update 정도는 우리 모두가 알고있는 그 메소드니까 넘기고 설명하겠다.

생성자에서는 먼저 Enter 메소드의 동작을 action에 넣어주고, Enter가 실행된 후에는 action에 Update의 동작을 넣어준다.

이는 한 상태에 진입했을 때, Enter를 가장 먼저 실행시킨 뒤 그 후부터는 Update에 있는 동작을 하라는 의미로 작성된 것 같다.
Exit 메소드 또한, 다른 상태로 변경되는 이벤트가 발생할 때 실행시키면 되는 메소드이다.

사실 추상 클래스만 보고는 누가 알아보겠는가, 예시 코드와 함께 보도록 하겠다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public partial class Slime : Enemy
{
    private State<Slime> slimeState;

    private void Awake()
    {
        slimeState = new IdleState();
    }

    protected override void Update()
    {
        base.Update();
        State<Slime> nowState = slimeState.InputHandle(this);
        slimeState.action(this);

        if (!nowState.Equals(slimeState))
        {
            slimeState = nowState;
        }
    }
}

여긴 Slime 클래스의 작동 부분을 partial class로 정의한 것이다.
쉽게 설명하면, 먼저 슬라임의 상태가 IdleState로 시작하고, 현재 상태의 Update 메소드를 매 프레임마다 호출해줘서 각 상태에 따라 다른 Update 메소드가 실행되도록 만든 것이다.

아래 코드는 Slime 클래스의 inner class로 각 State들을 구현 해 놓은 것이다.

public partial class Slime : Enemy
{
    public class IdleState : State<Slime>
    {
        public override State<Slime> InputHandle(Slime t)
        {
            // IdleState에서 DeadState로 변화하는 이벤트이다!
            if(t.isDead)
                return new DeadState();

            // 접근 반경 내에 있는 모든 오브젝트를 받아온다...

            foreach (var col in colliders)
            {
                if (col.CompareTag("Player"))
                {
                    // IdleState에서 ChaseState로 변화하는 이벤트이다!
                    t.target = col.transform;
                    return new ChaseState();
                }
            }
            return this;
        }

        public override void Update(Slime t)
        {
            base.Update(t);
            // 점프를 하며 맵을 뛰어다닌다...
        }
    }

    public class ChaseState : State<Slime>
    {

        public override State<Slime> InputHandle(Slime t)
        {
            if(t.isDead)
                return new DeadState();

            return this;
        }

        public override void Update(Slime t)
        {
            base.Update(t);
            // 플레이어 방향으로 점프하며 뛰어간다...
        }
    }

    public class DeadState : State<Slime>
    {
        public override State<Slime> InputHandle(Slime t)
        {
            base.Update(t);
            return this;
        }

        public override void Enter(Slime t)
        {
            base.Enter(t);
            // 죽고 난 후 애니메이션을 실행시킨다...
        }
    }
}

본 코드는 위에서 언급한 2019 인디게임위크엔드에서 위에서 정의한 추상 클래스를 상속받아 내가 직접 개발했던 몬스터 '슬라임' 의 코드이다. 읽으면서 우리가 알 필요가 없는 부분은 많이 쳐냈다.

추상 클래스에서 만들어 뒀던 Enter, InputHandle, Update 등을 이용해서 각 상태 별로 메소드의 동작을 다르게 만들어주었다.

먼저, InputHandle 메소드의 역할을 설명하겠다.
이 메소드는 현재 이용중인 객체를 받아온 후, 조건에 맞는 해당 객체의 상태를 반환한다. 각 State마다 InputHandle의 내용을 달리 해, 상태가 바뀌는 조건들을 각각 구현해 두었다. 예를 들자면, 위 코드에서 슬라임이 현재 죽은 상태라면 DeadState를 내놓는 것이라고 보면 되겠다.

if (!nowState.Equals(slimeState))
{
    slimeState = nowState;
}

이 코드가 왜 있는지 의문을 가질 분이 계실 것 같아서 설명드리자면, Enter의 실행 조건 때문에 그렇다. Enter는 상태가 바뀔 때 처음에만 action에서 대입되어 실행되기 때문이다.

InputHandle을 이용해 현재 상태를 받아온 뒤에는 해당 상태의 action을 슬라임 객체에서 호출해준다. 이 경우, action는 현재 상태에 따라 다른 동작을 할 것이다.
여기서 Action<T> 이게 뭔지 모르는 분들이 계실까봐 설명드리자면, 델리게이트의 개념에 대해 먼저 알아야 한다. 쉽게 이야기하면 메소드를 담는 변수라고 할 수 있겠다. 아무튼 action에 각 상태마다 다른 동작을 저장해뒀다.
상태가 처음 바뀌었을 때는 Enter를 실행하고, 그 다음부터는 Update를 실행하도록 저장되어있다.

위와 같은 방식으로 각 상태별로 동작들을 저장해두면, 조건에 따라 자동으로 객체의 상태를 선택- 동작할 것이다.

마치며

게임 개발을 하다보면 생각보다 많이 쓰일 수 있는 패턴 중 하나인 상태 패턴에 대해 적어봤다. FSM은 실제로도 정말 많이 쓰이는 패턴 중 하나고, 우리가 쓰는 유니티 2D 애니메이션에서도 기본적으로 FSM을 채택하고 있다. 디자인 패턴을 처음 접할 때 이 패턴을 접하게 된다면, 아마 큰 충격을 받을 수 있을 것 같다.

이번 또한 부족한 글 읽어주셔서 감사하고, 언제나 피드백/질문은 환영하고 있습니다. 다음에는 더 나은 퀄리티의 글로 찾아뵙겠습니다.

고등학생 게임 개발자 김선민이었습니다.

profile
22년 기준 글을 작성하지 않고 있습니다. 해당 블로그의 글은 학생 시절 공부하다 적은 내용이며 잘못된 정보가 있을 수 있음을 알려드립니다.

1개의 댓글

comment-user-thumbnail
2022년 5월 19일

잘 보고 있습니다. 감사합니다.

답글 달기