상태가 하나 늘면 조건은 하나만 늘지 않는다
적 하나를 만듭니다. 평소에는 가만히 있다가, 플레이어를 보면 쫓아가고, 가까워지면 공격하고, 체력이 다하면 죽습니다.
처음에는 이렇게 시작합니다.
public class Enemy : MonoBehaviour
{
bool isChasing;
bool isAttacking;
bool isDead;
void Update()
{
if (isDead) return;
if (isAttacking) { Attack(); return; }
if (isChasing) { Chase(); return; }
LookAround();
}
}
동작합니다. 그런데 bool 세 개가 만들 수 있는 조합은 여덟 가지인데
실제로 말이 되는 것은 네 가지뿐입니다.
isDead = true 를 켜면서 isChasing = false 를 빠뜨리면
죽었는데 추격 중인 적이 생깁니다. 그리고 플래그가 넷, 다섯으로 늘어나면
조합은 열여섯, 서른둘이 됩니다. 유효한 것은 여전히 몇 개뿐인데요.
enum 으로 한 걸음
문제의 뿌리는 상태가 여러 변수에 흩어져 있다는 것입니다. 하나에 모으면 애초에 잘못된 조합이 만들어지지 않습니다.
enum State { Idle, Chase, Attack, Dead }
State state = State.Idle;
void Update()
{
switch (state)
{
case State.Idle: LookAround(); break;
case State.Chase: Chase(); break;
case State.Attack: Attack(); break;
case State.Dead: break;
}
}
변수 하나에 값 하나뿐이라 두 상태가 동시에 참일 수 없습니다. 대부분의 현장 코드가 여기까지 와 있고, 상태가 서너 개면 이걸로 충분합니다.
그런데 왜 다시 무너지나
enum 이 무너지는 것은 상태가 늘어서가 아니라 같은 enum 을 여러 곳에서
다시 검사하기 시작해서입니다.
void Update() { switch (state) { ... } } // 매 프레임 무엇을 하나
void OnPlayerSpotted() { switch (state) { ... } } // 발견했을 때 어디로 가나
void OnDamaged() { switch (state) { ... } } // 맞았을 때 어디로 가나
void OnAnimEnd() { switch (state) { ... } } // 애니메이션이 끝나면
상태를 하나 추가하면 이 switch 를 전부 찾아서 case 를 넣어야 합니다.
하나를 빠뜨리면 컴파일은 되고 그 상태에서만 아무 반응이 없습니다.
제목의 이야기가 이것입니다. 상태 하나가 늘면 고칠 곳은 하나가 아닙니다.
여기에 하나가 더 붙습니다. “이 상태에 들어올 때 딱 한 번만 할 일” 을 적을 자리가 없습니다. 추격을 시작할 때 울음소리를 한 번 내려면 결국 이렇게 됩니다.
case State.Chase:
if (!hasRoared) { Roar(); hasRoared = true; } // 플래그가 돌아왔습니다
Chase();
break;
애써 없앤 bool 이 다시 기어 들어옵니다.
상태를 객체로
switch 의 각 case 를 클래스 하나씩으로 뜯어냅니다.
그리고 들어올 때와 나갈 때를 문법으로 만들어 줍니다.
public interface IState
{
void Enter(); // 이 상태가 될 때 한 번
void Update(); // 이 상태인 동안 매 프레임
void Exit(); // 이 상태를 떠날 때 한 번
}
public class ChaseState : IState
{
readonly Enemy enemy;
public ChaseState(Enemy e) => enemy = e;
public void Enter() => enemy.Roar(); // 한 번만, 플래그 없이
public void Update() => enemy.MoveTowardPlayer();
public void Exit() => enemy.StopMoving();
}
바꾸는 쪽은 이렇게 짧아집니다.
public class StateMachine
{
public IState Current { get; private set; }
public void Change(IState next)
{
Current?.Exit();
Current = next;
Current.Enter();
}
public void Tick() => Current?.Update();
}
이제 상태를 추가하려면 클래스 파일 하나를 새로 만들면 됩니다.
Enemy 도 StateMachine 도 고치지 않습니다.
switch 를 찾아 헤맬 일도, 빠뜨릴 일도 없습니다.
전이는 누가 정하나
여기가 이 패턴의 진짜 결정입니다. 다음 상태를 누가 고르느냐입니다.
가장 흔한 답은 상태 자신입니다.
public class ChaseState : IState
{
public void Update()
{
if (enemy.DistanceToPlayer < 2f)
enemy.Machine.Change(enemy.AttackState); // 스스로 넘긴다
enemy.MoveTowardPlayer();
}
}
추격 상태만이 “언제 공격으로 넘어가는가” 를 압니다. 대기 상태는 그 조건을 몰라도 되고, 알 필요도 없습니다. 각 상태가 자기 출구만 아는 것이 이 구조의 핵심입니다.
전이 규칙을 상태 밖의 표로 빼는 방식도 있습니다. 전이가 복잡해서 한눈에 보여야 하거나, 데이터로 편집하고 싶을 때 씁니다. 대신 상태와 규칙이 두 곳으로 갈리므로 처음부터 그렇게 갈 이유는 없습니다.
유니티에서 챙길 것
상태 객체를 매 전이마다 만들지 않습니다.
// 전이할 때마다 new 하면 쓰레기가 계속 쌓입니다
machine.Change(new ChaseState(this));
// 미리 하나씩 만들어 두고 재사용합니다
void Awake()
{
idleState = new IdleState(this);
chaseState = new ChaseState(this);
attackState = new AttackState(this);
}
상태 객체에 이번 회차에만 쓰는 값이 있다면 Enter() 에서 초기화하면 됩니다.
그러면 재사용해도 문제가 없습니다.
상태는 MonoBehaviour 가 아니어도 됩니다. 순수 C# 클래스로 두면
오브젝트에 컴포넌트가 잔뜩 붙지 않고, 인스펙터도 깔끔합니다.
유니티 API 는 Enemy 참조를 통해 쓰면 됩니다.
그리고 이미 쓰고 있습니다. Animator 의 상태 머신이 정확히 이 구조입니다.
상태가 있고, 전이 조건이 화살표로 붙어 있고, 각 상태에 진입과 종료가 있습니다.
StateMachineBehaviour 를 상속하면 OnStateEnter 와 OnStateExit 를 직접 받습니다.
언제 쓰지 않는가
상태가 둘이고 앞으로도 둘이면 bool 하나가 정답입니다.
IState 인터페이스와 클래스 두 개를 만드는 것은 그 자체가 비용입니다.
기준은 이렇게 잡습니다.
상태가 셋을 넘고, 상태마다 진입이나 종료 처리가 있고, 앞으로 더 늘어날 것 같으면 객체로 뺀다.
프로토타입은 enum 과 switch 로 빠르게 만들고, 확장이 보일 때 옮기는 것이
현실적인 순서입니다. 처음부터 완성된 구조로 시작하려다 아무것도 못 만드는 것보다 낫습니다.
정리
bool플래그는 있을 수 없는 조합까지 만든다. 플래그 셋이면 여덟 가지가 생기고 넷은 오류다.enum은 상태를 한 변수에 모아 그 문제를 없앤다. 셋넷까지는 이걸로 충분하다.enum이 무너지는 것은 같은 값을 여러 곳에서 다시switch하기 때문이다.- 상태 하나를 추가하면 그
switch를 전부 고쳐야 하고, 빠뜨려도 컴파일된다. - “들어올 때 한 번만” 을 적을 자리가 없어서
bool이 다시 등장한다. - 상태를 객체로 만들면
Enter와Exit가 문법이 된다. 추가는 파일 하나다. - 전이는 보통 상태 자신이 정한다. 각 상태가 자기 출구만 알면 된다.
Exit이Enter보다 먼저다. 상태 객체는 미리 만들어 재사용한다.