처음 캐릭터의 AI를 만들 때 가장 어려운 부분이 구조를 설계하는 부분입니다. AI를 제작하기 위한 여러가지 패턴들이 존재합니다. 그중 가장 기본이 되는 FSM(유한 상태 기계)를 알아보겠습니다. 알아보기 전에 중요성부터 간략히 설명하자면, 상태 패턴을 모르고 캐릭터를 제어하게 되면 많은 if문과 bool형을 선언하여 캐릭터의 상태제어를 합니다. 이렇게 제어를 하다 보면 유연성이 떨어져서 복잡한 행동을 하는 AI를 만드는데 문제가 되며, 여러가지 버그를 맞닥뜨리기 좋은 환경이 되어버립니다. 그렇기에 가장 기본이 되는 FSM을 아셔야 다른 상태 패턴을 공부할 때도 도움이 되며, 가장 많이 쓰이기도 합니다.
[FSM은 무엇인가?]
Finite State Machine의 약자로 이벤트에 반응하여 상태를 변경하게 됩니다. 가장 중요한 점은 각 상태를 하나의 객체로 보는 것 입니다. 그럼 여기서 객체로 본다는 것이 어떤 것인지 궁금하실 텐데 코드를 보며 설명 하겠습니다. 먼저 FSM을 사용하지 않았을 경우 코드를 보여드리겠습니다.
using UnityEngine;
public class PlayerController : MonoBehaviour
{
private bool isMoving = false;
private bool isJumping = false;
private bool isAttacking = false;
private void Update()
{
// 이동 처리
if (!isMoving && Input.GetKeyDown(KeyCode.W))
{
isMoving = true;
isJumping = false;
isAttacking = false;
Debug.Log("이동 시작");
}
// 점프 처리
if (!isJumping && Input.GetKeyDown(KeyCode.Space))
{
isMoving = false;
isJumping = true;
isAttacking = false;
Debug.Log("점프 시작");
}
// 공격 처리
if (!isAttacking && Input.GetKeyDown(KeyCode.Mouse0))
{
isMoving = false;
isJumping = false;
isAttacking = true;
Debug.Log("공격 시작");
}
}
}
상태 하나가 늘어 날 때 마다 bool형이 늘어나게 되며, AI를 구현 하다 보면 상태가 변하는 순간에 어떠한 처리를 할 때가 많은데 그 순간도 불분명하여 여러 어려움에 부딪히게 되는 코드입니다. 만약 이동을 시작한 순간에 이동 애니메이션을 재생하고 싶은데 위 코드에서는 이동을 시작한 순간이 언제 인지 파악하기가 어렵습니다. 이제 간단한 FSM을 사용한 코드를 보여드리겠습니다.
using UnityEngine;
public enum PlayerState
{
Idle,
Move,
Jump,
Attack
}
public class PlayerFSM : MonoBehaviour
{
private PlayerState currentState = PlayerState.Idle;
private void Update()
{
// 각 상태에 따른 동작을 처리합니다.
switch (currentState)
{
case PlayerState.Idle:
// Idle 상태에서 수행할 동작을 처리합니다.
if (Input.GetKeyDown(KeyCode.W))
{
ChangeState(PlayerState.Move);
}
else if (Input.GetKeyDown(KeyCode.Space))
{
ChangeState(PlayerState.Jump);
}
else if (Input.GetKeyDown(KeyCode.Mouse0))
{
ChangeState(PlayerState.Attack);
}
break;
case PlayerState.Move:
// Move 상태에서 수행할 동작을 처리합니다.
if (Input.GetKeyDown(KeyCode.Space))
{
ChangeState(PlayerState.Jump);
}
else if (Input.GetKeyDown(KeyCode.Mouse0))
{
ChangeState(PlayerState.Attack);
}
break;
case PlayerState.Jump:
// Jump 상태에서 수행할 동작을 처리합니다.
if (Input.GetKeyDown(KeyCode.W))
{
ChangeState(PlayerState.Move);
}
else if (Input.GetKeyDown(KeyCode.Mouse0))
{
ChangeState(PlayerState.Attack);
}
break;
case PlayerState.Attack:
// Attack 상태에서 수행할 동작을 처리합니다.
if (Input.GetKeyDown(KeyCode.W))
{
ChangeState(PlayerState.Move);
}
else if (Input.GetKeyDown(KeyCode.Space))
{
ChangeState(PlayerState.Jump);
}
break;
}
}
// 상태 변경 함수
private void ChangeState(PlayerState newState)
{
currentState = newState;
Debug.Log("상태 변경: " + newState);
}
}
가장 큰 차이점은 상태가 객체화 되어 현재 상태라는 개념이 적용되어 상태가 변경되는 시점이 추가되었습니다. 객체화라는 말이 어렵다고 느껴진다면 상태를 실체화 했다고 생각하셔도 됩니다.
각각의 상태는 전이 가능한 상태가 존재하게 되고 전이 될 때 ChangeStage 메서드를 통하여 변경 됩니다.
여기서 핵심은 상태는 하나만 존재하게 된다는 것입니다. 즉 Idle 상태와 Attack 상태가 한시점에 존재할 수 없다는 뜻입니다.
여기까지 읽으셨다면 FSM이 무엇인지 어느정도 감이 잡히셨을 겁니다. 하지만 장점과 단점을 알고 사용하셔야 구조가 잘 잡힌 AI를 만들 수 있기에 장단점을 알아 보겠습니다.
[장점]
- 각 상태의 전이 시점이 명확하기에 유연한 대처가 가능해 집니다.
- 디버깅이 쉬워 집니다. 각 상태 전이 시점이 파악 되기 때문에 정확히 어느 시점에 오류가 났는지도 확인이 쉽고 마음만 먹으면 에디터를 사용하여 시각화도 가능합니다.
- 현재 상태가 존재하기에 각 캐릭터의 상태를 다른 클래스에서 확인하기가 쉽습니다. 만약 적 캐릭터가 죽어서 쓰러져 있는 경우 공격을 하지 않겠다고 했을 때 적 캐릭터의 현재 상태만 확인하면 됩니다.
- 캐릭터의 행동을 손쉽게 제어가 가능합니다. FSM 특징은 상태에서 상태로 전이를 자유롭게 조작할 수 있습니다. 가령 공격상태에서는 이동 상태전이가 가능하고 점프는 불가능하게 코드를 작성했다면 의도된 AI를 손쉽게 만들 수 있습니다.
[단점]
- 상태가 많아지면 각 상태의 전이를 관리해줘야 하기 때문에 코드가 복잡해 질 수 있습니다.
- 현재 상태는 하나만 존재하므로 병렬 상태처리가 어렵습니다. 예를들어서 공격하면서 점프와 같이 두 상태가 동시에 존재해야하는 경우가 있습니다.
- 고 지능 AI를 만드는 것에 한계가 있습니다. 만약 hp가 일정 수치 이하로 줄어들었을 때 도주하는 AI를 만들고 싶다고 했을 때 그에 관련한 상태의 전이를 만들어 줘야 합니다. 이러한 상황들을 여러 개 만들고 싶다고 한다면 그만큼 관리 해줘야 한다는 문제가 있습니다.
- 많은 상태 전이는 오버헤드가 발생할 확률이 있습니다.
- 상태의 전이가 복잡한 경우 디버깅이 어려워 버그해결에 어려움을 겪을 수 있습니다.
위와 같은 단점으로 인해서 FSM의 단점을 보완하여 사용하기도 하며, 복잡한 AI를 만들 경우 BT(Behavior Tree)를 사용하기도 합니다. 자신의 프로젝트에서 FSM이 사용한지 적합한지를 판단하려면 단점을 보완하는 방법도 알아야 합니다.
[단점을 보완하는 방법]
병렬 상태가 어렵다는 단점을 어느정도 보완이 가능합니다. 예시로 들었던 공격과 점프는 캐릭터의 상체는 공격을 수행하고 하체는 점프를 수행 한다는 특성을 가집니다. 그렇기에 상태 머신을 캐릭터당 2개를 가져 상체가 하는 일과 하체가 하는 일을 분리하여 구현하면 됩니다.
다른 방법으로는 공격하면서 점프를 하나의 상태로 취급하면 됩니다. 예시코드를 작성해 보겠습니다.
using UnityEngine;
public enum MainState
{
Idle,
AttackAndJump
}
public class MainFSM : MonoBehaviour
{
private MainState currentState = MainState.Idle;
private AttackState attackState;
private JumpState jumpState;
private void Start()
{
attackState = gameObject.AddComponent<AttackState>();
jumpState = gameObject.AddComponent<JumpState>();
}
private void Update()
{
// 메인 FSM의 상태에 따른 동작 처리
switch (currentState)
{
case MainState.Idle:
// 키보드 입력 등을 통해 상태 변경
if (Input.GetKeyDown(KeyCode.Alpha1))
{
ChangeState(MainState.AttackAndJump);
}
break;
case MainState.AttackAndJump:
// AttackState와 JumpState를 병렬로 업데이트
attackState.Update();
jumpState.Update();
// 키보드 입력 등을 통해 상태 변경
if (Input.GetKeyDown(KeyCode.Alpha2))
{
ChangeState(MainState.Idle);
}
break;
}
}
private void ChangeState(MainState newState)
{
currentState = newState;
Debug.Log("상태 변경: " + newState);
}
}
공격하면서 점프를 AttackAndJump라는 것으로 정의를 하여 구현면 되는 것 입니다.
저는 보통 두 번째 방법을 선호합니다. 이유는 상체와 하체를 나눠서 구현하다 보면 예외처리를 하는데 어려움이 있고, 공격과 점프라는 특수한 상태는 몇가지 없기에 그렇습니다.
이제 가장 큰 단점인 상태의 전이가 복잡해 지면 관리가 어렵고, 그렇기에 고 지능 AI를 만들기 어렵다는 단점이 남았습니다. 사실 이점은 마땅한 해결방안이 없습니다. 그렇기에 다른 상태 패턴을 사용하는 것이 지혜로운 방법입니다.
여기서 잘 생각해 보셔야 할 것이 자신의 프로젝트가 정말 고지능 AI가 필요할까? 입니다. 많은 게임의 경우 공격, 기본상태, 죽음, 스킬 사용 등의 간단한 상태가 존재하는 것이 대부분 입니다. 제가 말하는 고지능 AI란 엘더스크롤, 심즈같이 캐릭터의 상태 전이가 몇 십 몇 백개가 존재하며 마치 인간처럼 행동하는 AI를 말하는 것입니다.
경험 상 복잡한 AI는 자신의 통제를 벗어난다는 뜻과도 같습니다. 즉, 예측하기 어렵다는 뜻입니다. 예측하기 하는 것은 의도된 재미를 도출하기 어렵다는 뜻도 됩니다. 그렇기에 복잡한 상태를 다루는 BT(Behavior Tree)와 같은 패턴이 꼭 필요한지 생각하는 것이 정말 중요합니다.
[마무리]
더 수준 높은 지식을 요구하는 상태 패턴이 항상 좋은 것은 아닙니다. 그렇기에 배우기 쉽고 다루기 쉬운 FSM이 상용화된 게임에서 가장 많이 사용되는 이유기도 합니다. 이 글을 접하시는 분께서 처음 상태패턴을 접하신다면 먼저 FSM을 구현해 보는 것을 추천 드립니다. 그 이후 BT, Goap같은 상태 패턴을 익히시는 것이 효율적인 공부 방법이라 생각됩니다.