Unity AI 개발: 유한 상태 머신 튜토리얼

게시 됨: 2022-03-11

경쟁이 치열한 게임 세계에서 개발자는 우리가 만드는 NPC(Non-Player Character)와 상호 작용하는 사람들에게 재미있는 사용자 경험을 제공하기 위해 노력합니다. 개발자는 유한 상태 기계(FSM)를 사용하여 NPC의 지능을 시뮬레이션하는 AI 솔루션을 생성함으로써 이러한 상호 작용을 제공할 수 있습니다.

AI 트렌드는 행동 트리로 옮겨갔지만 FSM은 여전히 ​​관련성이 있습니다. 그것들은 거의 모든 전자 게임에 어떤 방식으로든 통합되어 있습니다.

FSM의 해부학

FSM은 한 번에 유한한 수의 가상 상태 중 하나만 활성화될 수 있는 계산 모델입니다. FSM은 조건이나 입력에 응답하여 한 상태에서 다른 상태로 전환합니다. 핵심 구성 요소는 다음과 같습니다.

요소 설명
상태 FSM의 현재 전체 상태를 나타내는 유한한 옵션 세트 중 하나. 주어진 모든 상태에는 연관된 일련의 작업이 포함됩니다.
동작 FSM이 쿼리할 때 상태가 수행하는 작업
결정 전환이 발생할 때 설정하는 논리
이행 상태를 변경하는 과정

AI 구현의 관점에서 FSM에 초점을 맞추지만 애니메이션 상태 머신 및 일반 게임 상태와 같은 개념도 FSM 우산에 속합니다.

FSM 시각화

고전 아케이드 게임 팩맨의 예를 살펴보겠습니다. 게임의 초기 상태("추적" 상태)에서 NPC는 플레이어를 추격하고 결국에는 추격하는 다채로운 유령입니다. 유령은 플레이어가 파워 펠릿을 먹고 파워업을 경험할 때마다 회피 상태로 전환되어 유령을 먹을 수 있는 능력을 얻습니다. 이제 파란색이 된 유령은 전원이 켜진 시간이 초과될 때까지 플레이어를 피하고 유령이 추적 상태로 다시 전환되어 원래 동작과 색상이 복원됩니다.

팩맨 유령은 항상 추적 또는 회피의 두 가지 상태 중 하나에 있습니다. 당연히 우리는 두 가지 전환을 제공해야 합니다. 하나는 추적에서 회피로, 다른 하나는 회피에서 추적으로:

다이어그램: 왼쪽은 추적 상태입니다. 화살표(플레이어가 파워 펠릿을 먹었음을 나타냄)는 오른쪽의 회피 상태로 연결됩니다. 두 번째 화살표(파워 펠릿이 시간 초과되었음을 나타냄)는 왼쪽의 추적 상태로 돌아갑니다.
팩맨 고스트 상태 간의 전환

유한 상태 기계는 설계상 현재 상태를 쿼리하여 해당 상태의 결정과 조치를 쿼리합니다. 다음 다이어그램은 팩맨의 예를 나타내며 플레이어의 전원 켜짐 상태를 확인하는 결정을 보여줍니다. 파워업이 시작되면 NPC는 추적에서 회피로 전환됩니다. 파워업이 종료되면 NPC는 회피에서 추격으로 전환됩니다. 마지막으로 전원 공급 변경이 없으면 전환이 발생하지 않습니다.

주기를 나타내는 다이아몬드 모양의 다이어그램: 왼쪽에서 시작하여 해당 동작을 암시하는 추적 상태가 있습니다. 그런 다음 추적 상태는 결정이 있는 상단을 가리킵니다. 플레이어가 파워 펠릿을 먹으면 계속해서 회피 상태로 이동하고 오른쪽에서 행동을 회피합니다. 회피 상태는 맨 아래에 있는 결정을 가리킵니다. 파워 펠릿이 시간 초과되면 시작점으로 계속 돌아갑니다.
팩맨 고스트 FSM의 구성 요소

확장성

FSM은 우리가 모듈식 AI를 구축할 수 있게 해줍니다. 예를 들어, 단 하나의 새로운 행동으로 우리는 새로운 행동을 가진 NPC를 만들 수 있습니다. 따라서 우리는 새로운 행동(파워 펠릿 먹기)을 팩맨 유령 중 하나에게 돌릴 수 있으며 플레이어를 피하면서 파워 펠릿을 먹을 수 있는 능력을 부여합니다. 이 동작을 지원하기 위해 기존 작업, 결정 및 전환을 재사용할 수 있습니다.

고유 NPC를 개발하는 데 필요한 리소스가 최소화되기 때문에 여러 고유 NPC의 진화하는 프로젝트 요구 사항을 충족할 수 있는 좋은 위치에 있습니다. 반면에 너무 많은 수의 상태와 전환은 스파게티 상태 시스템 에 얽힐 수 있습니다. FSM은 연결이 너무 많아 디버그 및 유지 관리가 어렵습니다.

Unity에서 FSM 구현

Unity에서 유한 상태 머신을 구현하는 방법을 보여주기 위해 간단한 스텔스 게임을 만들어 보겠습니다. 우리의 아키텍처는 ScriptableObject 를 통합할 것인데, 이는 우리가 그것을 재현할 필요가 없도록 애플리케이션 전체에서 정보를 저장하고 공유할 수 있는 데이터 컨테이너입니다. ScriptableObject 는 작업 호출 및 결정 쿼리와 같은 제한된 처리가 가능합니다. Unity의 공식 문서 외에도 Scriptable Objects가 포함된 이전 Game Architecture에 대해 자세히 알아보려면 훌륭한 리소스로 남아 있습니다.

이 초기 컴파일 준비 프로젝트에 AI를 추가하기 전에 제안된 아키텍처를 고려하십시오.

다이어그램: 서로 연결되는 7개의 상자, 왼쪽/위부터 표시 순서대로 설명: BaseStateMachine이라고 표시된 상자에는 + CurrentState: BaseState가 포함됩니다. BaseStateMachine은 양방향 화살표를 사용하여 BaseState에 연결합니다. BaseState라는 상자에는 + Execute(BaseStateMachine): void가 포함됩니다. BaseState는 양방향 화살표를 사용하여 BaseStateMachine에 연결합니다. State 및 RemainInState의 단방향 화살표가 BaseState에 연결됩니다. State라고 표시된 상자에는 + Execute(BaseStateMachine): void, + Actions: List<Action> 및 + Transition: List<Transition>이 포함됩니다. State는 단방향 화살표로 BaseState에 연결하고, 단방향 화살표로 "1"로 표시된 Action에, 단방향 화살표로 "1"로 Transition에 연결합니다. RemainInState라는 상자에는 + Execute(BaseStateMachine): void가 포함됩니다. RemainInState는 단방향 화살표로 BaseState에 연결합니다. Action이라고 표시된 상자에는 + Execute(BaseStateMachine): void가 포함됩니다. State에서 "1"로 표시된 단방향 화살표가 Action에 연결됩니다. Transition이라고 표시된 상자에는 + Decide(BaseStateMachine): void, + TransitionDecision: Decision, + TrueState: BaseState 및 + FalseState: BaseState가 포함됩니다. 전환은 단방향 화살표로 결정에 연결됩니다. 상태에서 "1"로 표시된 단방향 화살표가 전환에 연결됩니다. Decision이라는 레이블이 붙은 상자에는 + Decide(BaseStateMachine): bool이 포함됩니다.
제안된 FSM 아키텍처

샘플 게임에서 적(파란색 캡슐로 표시되는 NPC)이 순찰합니다. 적이 플레이어(회색 캡슐로 표시)를 보면 플레이어를 따라가기 시작합니다.

다이어그램: 서로 연결되는 5개의 상자, 왼쪽/위부터 표시 순서대로 설명: 순찰이라고 표시된 상자는 단방향 화살표로 시야에 있는 IF 플레이어라고 표시된 상자에 연결하고 순찰 작업이라고 표시된 상자는 "상태"라는 레이블이 지정된 단방향 화살표입니다. IF 플레이어라는 레이블이 붙은 상자가 상자 바로 아래에 추가 e레이블 "결정"이 있는 시야에 있습니다. IF 플레이어가 시야에 있음이라고 표시된 상자는 단방향 화살표로 추적이라고 표시된 상자에 연결됩니다. 순찰이라고 표시된 상자의 단방향 화살표는 IF 플레이어가 시야에 있는 상자에 연결됩니다. Chase라고 표시된 상자는 "state"라고 표시된 단방향 화살표를 사용하여 Chase Action이라고 표시된 상자에 연결합니다. IF 플레이어가 시야에 있는 상자의 단방향 화살표가 체이스라는 상자에 연결됩니다. Patrol이라고 표시된 상자의 단방향 화살표 화살표는 Patrol Action이라고 표시된 상자에 연결됩니다. Chase라고 표시된 상자의 단방향 화살표 화살표는 Chase Action이라고 표시된 상자에 연결됩니다.
샘플 스텔스 게임 FSM의 핵심 구성 요소

팩맨과 달리 게임의 적은 플레이어를 따라오면 기본 상태("순찰")로 돌아가지 않습니다.

클래스 생성

먼저 클래스를 생성해 보겠습니다. 새 scripts 폴더에서 제안된 모든 아키텍처 빌딩 블록을 C# 스크립트로 추가합니다.

BaseStateMachine 클래스 구현

BaseStateMachine 클래스는 AI 지원 NPC에 액세스하기 위해 추가할 유일한 MonoBehavior 입니다. 간단하게 하기 위해 BaseStateMachine 은 기본적으로 사용됩니다. 그러나 원하는 경우 추가 매개변수와 추가 구성요소에 대한 참조를 저장하는 상속된 사용자 정의 FSM을 추가할 수 있습니다. BaseState 클래스를 추가할 때까지 코드는 제대로 컴파일되지 않습니다. 이 클래스는 자습서의 뒷부분에서 할 것입니다.

BaseStateMachine 의 코드는 현재 상태를 참조하고 실행하여 작업을 수행하고 전환이 필요한지 확인합니다.

 using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private void Awake() { CurrentState = _initialState; } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } } }

BaseState 클래스 구현

우리의 상태는 ScriptableObject 에서 파생된 BaseState 유형입니다. BaseState 에는 Execute 라는 단일 메서드가 포함되어 있으며 BaseStateMachine 을 인수로 사용하고 이 메서드에 작업 및 전환을 전달합니다. BaseState 는 다음과 같습니다.

 using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } }

StateRemainInState 클래스 구현

이제 BaseState 에서 두 개의 클래스를 파생시킵니다. 먼저 작업 및 전환에 대한 참조를 저장하고 두 개의 목록(하나는 작업, 다른 하나는 전환)을 포함하고 작업 및 전환에 대한 기본 Execute 을 재정의하고 호출하는 State 클래스가 있습니다.

 using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/State")] public sealed class State : BaseState { public List<FSMAction> Action = new List<FSMAction>(); public List<Transition> Transitions = new List<Transition>(); public override void Execute(BaseStateMachine machine) { foreach (var action in Action) action.Execute(machine); foreach(var transition in Transitions) transition.Execute(machine); } } }

두 번째로, FSM에 전환을 수행하지 않아야 할 때를 알려주는 RemainInState 클래스가 있습니다.

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } }

이러한 클래스는 FSMAction , DecisionTransition 클래스를 추가할 때까지 컴파일되지 않습니다.

FSMAction 클래스 구현

제안된 FSM 아키텍처 다이어그램에서 기본 FSMAction 클래스는 "Action"이라는 레이블이 지정됩니다. 그러나 기본 FSMAction 클래스를 만들고 FSMAction 이라는 이름을 사용합니다( Action 은 이미 .NET System 네임스페이스에서 사용 중이므로).

ScriptableObjectFSMAction 은 함수를 독립적으로 처리할 수 없으므로 추상 클래스로 정의합니다. 개발이 진행됨에 따라 둘 이상의 주에 서비스를 제공하기 위해 단일 작업이 필요할 수 있습니다. 다행히도 FSMAction 을 원하는 만큼 FSM의 많은 상태와 연결할 수 있습니다.

FSMAction 추상 클래스는 다음과 같습니다.

 using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } }

DecisionTransition 클래스 구현

FSM을 마치기 위해 두 개의 클래스를 더 정의합니다. 첫째, 다른 모든 결정이 사용자 정의 동작을 정의하는 추상 클래스인 Decision 이 있습니다.

 using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } }

두 번째 클래스인 Transition 에는 Decision 객체와 두 가지 상태가 포함됩니다.

  • Decision 이 true를 반환하는 경우 전환할 상태입니다.
  • Decision 이 false를 생성하는 경우 전환할 또 다른 상태입니다.

다음과 같이 보입니다.

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Transition")] public sealed class Transition : ScriptableObject { public Decision Decision; public BaseState TrueState; public BaseState FalseState; public void Execute(BaseStateMachine stateMachine) { if(Decision.Decide(stateMachine) && !(TrueState is RemainInState)) stateMachine.CurrentState = TrueState; else if(!(FalseState is RemainInState)) stateMachine.CurrentState = FalseState; } } }

지금까지 구축한 모든 것은 오류 없이 컴파일되어야 합니다. 문제가 발생하면 Unity 에디터 버전을 확인하세요. 최신 버전이 아닌 경우 오류가 발생할 수 있습니다. 모든 파일이 원래 프로젝트 폴더에서 제대로 복제되었는지 확인하고 공개적으로 액세스된 모든 변수가 비공개로 선언되지 않았는지 확인합니다.

사용자 지정 작업 및 결정 만들기

이제 무거운 작업이 완료되었으므로 새 scripts 폴더에서 사용자 지정 작업 및 결정을 구현할 준비가 되었습니다.

PatrolChase 클래스 구현

샘플 스텔스 게임 FSM 다이어그램의 핵심 구성 요소를 분석할 때 NPC가 두 가지 상태 중 하나에 있을 수 있음을 알 수 있습니다.

  1. 순찰 상태 — 상태와 관련된 항목은 다음과 같습니다.
    • 원 액션: NPC가 전 세계의 무작위 순찰 지점을 방문합니다.
    • 하나의 전환: NPC는 플레이어가 시야에 있는지 확인하고, 그렇다면 추적 상태로 전환합니다.
    • 한 가지 결정: NPC는 플레이어가 시야에 있는지 확인합니다.
  2. 체이스 상태 — 상태와 관련된 항목은 다음과 같습니다.
    • 원 액션: NPC가 플레이어를 쫓습니다.

나중에 논의하겠지만 Unity의 GUI를 통해 기존 전환 구현을 재사용할 수 있습니다. 이렇게 하면 두 가지 작업( PatrolActionChaseAction )과 코딩할 결정이 남습니다.

순찰 상태 작업(기본 FSMAction 에서 파생됨)은 Execute 메서드를 재정의하여 두 가지 구성 요소를 가져옵니다.

  1. 순찰 지점을 추적하는 PatrolPoints .
  2. NavMeshAgent , 3D 공간에서의 탐색을 위한 Unity의 구현입니다.

그런 다음 재정의는 AI 에이전트가 대상에 도달했는지 확인하고 그렇다면 다음 대상으로 이동합니다. 다음과 같이 보입니다.

 using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Patrol")] public class PatrolAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var patrolPoints = stateMachine.GetComponent<PatrolPoints>(); if (patrolPoints.HasReached(navMeshAgent)) navMeshAgent.SetDestination(patrolPoints.GetNext().position); } } }

PatrolPointsNavMeshAgent 구성 요소 캐싱을 고려할 수 있습니다. 캐싱을 사용하면 유한 상태 시스템의 각 쿼리에서 GetComponent 를 실행하는 성능에 영향을 미치지 않고 에이전트 간에 작업을 위해 ScriptableObject 를 공유할 수 있습니다.

분명히 하기 위해 Execute 메서드에서 구성 요소 인스턴스를 캐시할 수 없습니다. 따라서 대신 BaseStateMachine 에 사용자 지정 GetComponent 메서드를 추가합니다. 사용자 지정 GetComponent 는 인스턴스가 처음 호출될 때 인스턴스를 캐시하고 연속 호출 시 캐시된 인스턴스를 반환합니다. 참고로 다음은 캐싱을 사용하여 BaseStateMachine 을 구현한 것입니다.

 using System; using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private Dictionary<Type, Component> _cachedComponents; private void Awake() { CurrentState = _initialState; _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } public new T GetComponent<T>() where T : Component { if(_cachedComponents.ContainsKey(typeof(T))) return _cachedComponents[typeof(T)] as T; var component = base.GetComponent<T>(); if(component != null) { _cachedComponents.Add(typeof(T), component); } return component; } } }

해당 PatrolAction 과 마찬가지로 ChaseAction 클래스는 Execute 메서드를 재정의하여 PatrolPointsNavMeshAgent 구성 요소를 가져옵니다. 그러나 반대로 AI 에이전트가 목적지에 도달했는지 확인한 후 ChaseAction 클래스 작업은 목적지를 Player.position 으로 설정합니다.

 using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Chase")] public class ChaseAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var enemySightSensor = stateMachine.GetComponent<EnemySightSensor>(); navMeshAgent.SetDestination(enemySightSensor.Player.position); } } }

InLineOfSightDecision 클래스 구현

마지막 조각은 InLineOfSightDecision 클래스로, 기본 Decision 을 상속하고 EnemySightSensor 구성 요소를 가져와 플레이어가 NPC의 시야에 있는지 확인합니다.

 using Demo.Enemy; using Demo.FSM; using UnityEngine; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Decisions/In Line Of Sight")] public class InLineOfSightDecision : Decision { public override bool Decide(BaseStateMachine stateMachine) { var enemyInLineOfSight = stateMachine.GetComponent<EnemySightSensor>(); return enemyInLineOfSight.Ping(); } } }

행동을 상태에 연결하기

드디어 Enemy 에이전트에 행동을 첨부할 준비가 되었습니다. Unity 에디터의 프로젝트 창에서 생성됩니다.

PatrolChase 상태 추가

두 개의 상태를 만들고 이름을 "Patrol"과 "Chase"로 지정하겠습니다.

  • 마우스 오른쪽 버튼 클릭 > 생성 > FSM > 상태

여기에서 RemainInState 개체도 만들어 보겠습니다.

  • 마우스 오른쪽 버튼 클릭 > 생성 > FSM > 상태 유지

이제 방금 코딩한 작업을 만들 차례입니다.

  • 마우스 오른쪽 버튼 클릭 > 생성 > FSM > 작업 > 순찰
  • 우클릭 > 생성 > FSM > 액션 > 체이스

Decision 을 코딩하려면:

  • 마우스 오른쪽 버튼 클릭 > 생성 > FSM > 결정 > 시선

ChaseState 에서 PatrolState 로의 전환을 활성화하려면 먼저 전환 스크립팅 가능한 개체를 생성해 보겠습니다.

  • 마우스 오른쪽 버튼 클릭 > 생성 > FSM > 전환
  • 마음에 드는 이름을 선택하세요. 나는 Spotted Enemy라고 불렀다.

결과 인스펙터 창을 다음과 같이 채울 것입니다.

Spotted Enemy (Transition) 화면에는 4줄이 있습니다. 스크립트 값이 "Transition"으로 설정되고 회색으로 표시됩니다. Decision의 값은 "LineOfSightDecision(시선 내)"으로 설정됩니다. True State의 값은 "ChaseState(State)"로 설정됩니다. False State의 값은 "RemainInState(Remain In State)"로 설정됩니다.
Spotted Enemy (Transition) Inspector 창 채우기

그런 다음 다음과 같이 Chase State inspector 대화 상자를 완료합니다.

추적 상태(주) 화면은 "열기"라는 레이블로 시작됩니다. "Script" 레이블 옆에 "State"가 선택되어 있습니다. "Action" 레이블 옆에 "1"이 선택되어 있습니다. "Action" 드롭다운에서 "Element 0 Chase Action(Chase Action)"이 선택됩니다. 그 뒤에 더하기 기호와 빼기 기호가 있습니다. "전환" 레이블 옆에 "0"이 선택되어 있습니다. "전환" 드롭다운에서 "목록이 비어 있습니다"가 표시됩니다. 그 뒤에 더하기 기호와 빼기 기호가 있습니다.
체이스 스테이트 인스펙터 창 채우기

다음으로 Patrol State 대화 상자를 완료합니다.

순찰 상태(상태) 화면은 "열기"라는 레이블로 시작됩니다. "Script" 레이블 옆에 "State"가 선택되어 있습니다. "Action" 레이블 옆에 "1"이 선택되어 있습니다. "Action" 드롭다운에서 "Element 0 Patrol Action(Patrol Action)"이 선택됩니다. 뒤에 플러스 및 마이너스 기호가 있습니다. "전환" 레이블 옆에 "1"이 선택되어 있습니다. "전환" 드롭다운에서 "요소 0 SpottedEnemy(전환)"가 표시됩니다. 그 뒤에 더하기 기호와 빼기 기호가 있습니다.
순찰 상태 검사기 창 작성

마지막으로 BaseStateMachine 구성 요소를 적 개체에 추가합니다. Unity 편집기의 프로젝트 창에서 SampleScene 자산을 열고 Hierarchy 패널에서 Enemy 개체를 선택한 다음 Inspector 창에서 Add Component > Base State Machine 을 선택합니다.

기본 상태 시스템(스크립트) 화면: 회색으로 표시된 "스크립트" 레이블 옆에 "BaseStateMachine"이 선택되어 회색으로 표시됩니다. "초기 상태" 레이블 옆에 "PatrolState(State)"가 선택됩니다.
기본 상태 시스템(스크립트) 구성 요소 추가

문제가 있는 경우 게임 개체가 올바르게 구성되었는지 다시 확인하십시오. 예를 들어 Enemy 개체에 PatrolPoints 스크립트 구성 요소와 개체 Point1 , Point2 등이 포함되어 있는지 확인하십시오. 이 정보는 잘못된 편집기 버전 관리로 인해 손실될 수 있습니다.

이제 샘플 게임을 할 준비가 되었으며 플레이어가 적의 시야에 들어오면 적이 플레이어를 따라오는 것을 관찰할 수 있습니다.

FSM을 사용하여 재미있고 대화형 사용자 경험 만들기

이 유한 상태 머신 자습서에서는 향후 프로젝트에서 재사용할 수 있는 고도로 모듈화된 FSM 기반 AI(및 해당 GitHub 리포지토리)를 만들었습니다. 이러한 모듈성 덕분에 우리는 항상 새로운 구성 요소를 도입하여 AI에 힘을 더할 수 있습니다.

그러나 우리의 아키텍처는 또한 그래픽 우선 FSM 디자인을 위한 길을 열어 개발자 경험을 새로운 수준의 전문성으로 끌어올릴 것입니다. 그런 다음 게임을 위한 FSM을 보다 빠르고 정확하게 만들 수 있습니다.