상태 패턴은 주로 오브젝트가 특정 조건에 따라 행동이 달라지거나, 혹은 상태에 따라 다른 행동을 할 때, 즉 오브젝트의 상태를 정의할 수 있으며 그에 따라 동작이 달라질 때 이용하는 디자인 패턴이다. 게임 개발에서 특히 알아보기 쉽게 배울 수 있는데, 몬스터가 맵을 방황하는 상태, 플레이어를 쫓아오는 상태 등으로 나뉘어진다고 말하면 이해가 쉬울 것 같다. 실제로 몬스터 등을 구현할 때 이용할 수 있다.
본문에서는 상태 패턴을 응용한 것 중 하나인 FSM, Finite State Machine (유한 상태 기계)에 대해 알아볼 것이다.
서론에서도 이야기 했듯이, 영문으로는 Finite State Machine
, 우리말로는 유한 상태 기계라 한다. 이는 상태 패턴에서 파생된 것 중 하나인데, 하나의 오브젝트는 동시에 여러 상태를 가지지 않고
하나의 상태만 가지고 있는 것을 말한다. 예시를 들어보겠다.
일반적인 상태 패턴
각 객체는 상태를 '동시에' 여러개 가질 수 있다.
만약 플레이어 객체가 있다면, "전투 중" 상태와 함께 "건강함" 상태를 가질 수 있는 것이다.
FSM
각 객체는 상태를 한 시점에 여러개를 가질 수 없다.
만약 플레이어 객체가 있다면, "탐험 중" 상태와 "전투 중" 상태를 함께 가질 수 없다는 것이다.
이정도의 예시로 이해가 됐길 바란다.
각 객체가 상태에 따라 어떻게 작동하는지 알기 쉽게 구현을 할 수 있다.
코드만 봐도 어떤 상태에서는 어떤 동작을 하는지 쉽게 알 수 있기 때문에, 개발하는 입장에서 관리가 상당히 쉽다.
불필요한 조건문을 줄일 수 있다.
보통 유한 상태 기계를 구현할 때, 하나의 메소드를 만들고 그 상태에 따라 해당 메소드를 오버라이드한 것을 구현 후 이용하게 된다. 예시를 들어보겠다.
휴대폰의 화면이 켜져있을 때, 전원 버튼을 누르면 화면이 꺼진다.
휴대폰의 화면이 꺼져있을 때, 전원 버튼을 누르면 화면이 켜진다.
여기서 "전원 버튼을 누르는 작업" 을 하나의 메소드로 정의한다.
virtual void PushButton() { }
그리고 각 상태마다 PushButton() 메소드를 오버라이드 한다.
// ScreenTurnedOnState class...
override void PushButton()
{
TurnOffScreen();
state = new ScreenTurnedOffState();
}
// ScreenTurnedOffState class...
override void PushButton()
{
TurnOnScreen();
state = new ScreenTurnedOnState();
}
이런 식으로 구현하는 것이다.
유한 상태 머신은 상태를 단 하나만 갖기 때문에, 여러 상태를 가져야 하는 경우에는 알맞지 않다.
ex) 전투 중, 여러 버프나 디버프를 받고 있을 경우
상태가 너무 많으면 각 상태를 바꾸는 코드를 적는데에도 조건문이 많이 들어가게 될 수 있다.
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을 채택하고 있다. 디자인 패턴을 처음 접할 때 이 패턴을 접하게 된다면, 아마 큰 충격을 받을 수 있을 것 같다.
이번 또한 부족한 글 읽어주셔서 감사하고, 언제나 피드백/질문은 환영하고 있습니다. 다음에는 더 나은 퀄리티의 글로 찾아뵙겠습니다.
고등학생 게임 개발자 김선민이었습니다.
잘 보고 있습니다. 감사합니다.