정체불명의 모모

[유니티C# 스크립팅 마스터하기] - 7챕터/AI 본문

프로그래밍(C#) /유니티공부

[유니티C# 스크립팅 마스터하기] - 7챕터/AI

정체불명의 모모 2022. 9. 7. 16:44

이번 장에서는 유한 상태 머신(FSM) 과 같은 AI 개념 고유의 영역을 포함해서 

내비게이션 메시(navigation mesh), 시야 등의 내용이 나옵니다.


게임에서의 인공지능

: AI 란 사람의 생각을 수학적인 모델로 만들거나 마음속으로 무슨 생각을 하는지 알아내는 것이 아닙니다.

 인공지능은 특정 조건하에서 우리가 기대하는 대로 캐릭터가 행동하도록 동작을 만드는 것일 뿐입니다.


ㅁ 예제 프로젝트 내용 : 미로 환경에서 1인칭 예제 게임 만들어 봅니다.

플레이어는 적을 공격할 수 있고, 적은 플레이어를 공격 할 수 있습니다.

적 캐릭터는 주변 환경을 탐색하며 플레이어를 찾아 다니고, 플레이어를 발견한 경우 쫒아가서 공격 합니다.

예제 프로젝트 씬

내비게이션 메시 굽기

: 내비게이션 메시는 바닥으로 분류된 레벨의 수평면을 유니티가 걸을 수 있는 곳으로 인식할 수 있도록 자동으로 생성하는 보이지 않는 메시

  애셋입니다.

  내비게이션 메시 자체에 AI가 포함된 것은 아니고 걷게 만드는 기능이 있는것도 아닙니다.

  내비게이션 메시는 AI 유닛으로 하여금 필요한 경우 방해물을 피해 가도록 경로를 계산할 수 있게 해주는 모든 필요한 데이터를 포함하는

  수학적 모델이라고 할 수 있습니다.

Window > AI > Navigation > Navigation Mesh 탭(인스펙터)

bake를 누르게 되면 이렇게 내비게이션 메시가 구워져 ai가 움직일 수 있는 경로 생성 됩니다.

 

☐ 내비게이션 메시를 구울 때 주의해야 할 사항들

1.  Radius 설정을 기본값에서 조정을 해야 합니다.

    간다히 말해, 이 설정 값은 캐릭터 발 주위로 가상의 원에 대한 정의를 설정하는 것 입니다.

    이 원은 걷기 에이전트(agent)의 대략적인 크기를 가리키는 것 입니다.

    너무 작은 경우 메시를 생성하는 데에 긴 시간이 걸리며 에이전트가 걸을 때 벽을 뚫고 지나갈 수 있습니다.

    그리고 반지름이 너무 크면 내비게이션 메시가 좁은 영역에서 깨지게 되어, 에이전트가 틈새를 지나갈 수 없게 되므로 좋지 않습니다.

 

2. 내비게이션 메시가 실제의 메시 바닥보다 위쪽으로 솟거나 튀어나와 보일 수 있습니다.

    이런 경우 'Advance' 그룹에서 'Height Inaccuracy(높이 부정확도) %' 설정을 주면 됩니다.
    값을 변경 후에는 Bake 버튼을 눌러 줘야 반영 됩니다.
    (유니티 2021.3.1f1 version에서는 'Manual Voxel Size'(수동 3D 화소 크기)로 조정)


  미로 사이를 자유롭게 넘나 들수 있는 순간 이동 영역 만들기

: 아래와 같이 쪼개진 내비게이션 메시 사이를 연결해 AI 가 올바른 경로를 계산할 수 있게 만들기 위해

  '오프-메시 링크(off-mesh link)를 사용할 수 있습니다.

방법 :  밟고 올라섰을 때 순간이동 발판처럼 동작할 메시를 레벨에 새로 추가 합니다.

각각의 순간 이동 오브젝트에 '오프-메시 링크' 컴포넌트 트랜스폼을 할당하면, 두 순간이동기 사이에서 경로를 만들 수 있도록

연결을 맺게 됩니다. 

연결이 맺어지면 에디터에서 Navigation 패널이 열려 활성화된 상태일 때, 아래 그림 처럼 씬 뷰포트에 연결을 나타내는 화살표가 그려 집니다.


NPC 에이전트 만들기

: 레벨에 플레이어와 상호작용 가능한 AI 에이전트를 만들어 봅시다.(책에서 제공된 리소스 캐릭터 이용)

  캐릭터에 'Component > Navigation > Nav Mesh Agent' 메뉴를 선택해 오브젝트에 'NavMeshAgent' 컴포넌트를 추가 합니다.
  이렇게 하면 오브젝트가 내비게이션 메시와 함께 동작해 지시하는 시점에 경로를 찾아 움직일 수 있게 됩니다.

 

  • Radius : 회피 반경 입니다. 장애물과 다른 에이전트가 통과해서는 안되는 에이전트의 '개인 공간' 입니다.
  • Height :  장애물 등을 통과하기 위한 에이전트의 높이
  • Stopping Distance : 플레이어가 멈출 때까지 얼마나 목적지에 가깝게 다가갈 수 있는지를 결정 하는 값입니다.

 

   이제 Rigidbody 컴포넌트를 추가하고, Is Kinematic 체크박스를 활성화 시킵니다.
   이렇게 하면 오브젝트가 트리거(trigger)체적 안에 들어가게 되고, 물리 시스템의 구성 요소로서 물리 이벤트를

   일으키고 받을 수 있게 됩니다.

  이번엔 BoxCollider컴포넌트를 오브젝트에 추가하고 Is Trigger 체크 박스를 활성화해 이 오브젝트를 트리거 체적으로 변환 시킵니다.

  이렇게 하면  물리적인 오브젝트들이 이 체적에 막히는 대신, 통과해 지나갈 수 있도록 만들어 줍니다.
  (이 오브젝트는 AI의 시야 혹은 가시 영역과 비슷한 용도로 사용할 것 입니다. )


메카님에서의 유한 상태 머신

: 메카님은 유니티의 애니메이션 시스템의 이름 입니다.

  이 애니메이션 시스템은 FSM 패턴을 사용하여 만들어 졌습니다.

 

ㅁ FSM 개념에 대해 알아 봅시다.

: FSM은 유한한 개수의 상태를 정의하는 것으로 부터 시작해서 이런 상태들이 다른 상태에 어떻게 논리적으로

  연결 되는지 관리하는 역할을 합니다.

  FSM은 하나의 상태가 다른 상태로 언제 어떻게 전환되는지를 결정 합니다.

 

유니티 메카님 애니메이션 그래프

- Window > Animation > Animator. / 우클릭 > create > Animator Controller

: 메카님 애니메이터 그래프는 메시에 적용 가능한 모든 종류의 애니메이션 상태를 정의 합니다.

  아래 그림 처럼 LoopTime 과 Loop Pose 체크박스를  선택하면 모든 애니메이션을 반복 가능하게 만들 수 있습니다.

기본적인 Animator

   필요한 상태를 그래프에 하나씩 애니메이션 상태들을 추가 합니다.

   아직 상태 간에 서로 연결되지 않고 분리되어 있습니다.

   구체적으로 말해, 하나의 상태를 다른 상태로 움직이도록 조건을 조절하는 로직이 없는 상태 입니다.

      이 문제를 해결하기 위해 메카님 창 우하단 구석의 Parameters 박스를 이용해서 다섯 개의 새로운 트리거를 생성합니다.

  트리거 변수는 매번 true로 만들어지면 유니티가 자동으로 false로 재설정 하는 특별한 boolean 형식을 따르는데, 상태 변경과

  같이 한 번만 초기화되는 동작을 가능케 합니다.

파라미터를 설정해 준 후 Make Transition(우 클릭)을 통해 서로 연결 상태로 만들어 줍니다.

이제 그래프에 캐릭터 오브젝트를 위한 완성된 애니메이션 상태 머신(FSM)이 정의 되었습니다.

그리고 캐릭터 오브젝트에 project 패널에서 Animator 컴포넌트를 추가하여 넣어줍니다.

animator 컴포넌트 추가후 controller 만든것을 넣음

하나의 상태가 하나의 코루틴에 대응되도록 FSM에 존재하는 각각의 상태를 별개의 코루틴으로 작성 합니다.
 이 코루틴은 해당 상태가 지속되는 동안 무한히 반복 실행되면서 적이 이 상태일 때 해야 할 동작을 정의해 줍니다.
상태 머신의 주요 역할은 특정 조건이 충족될 때 적합한 상태를 선택해 시작되도록 하는 것 입니다.

Idle 상태 만들기

: 플레이어가 보이지 않는 동안에 이 상태가 무한히 반복되어 다른 상태로 전환되지 않습니다.

  하지만 Idle 상태는 일시적인 상태로서 idle 애니메이션은 한 번만 재생된 후 완료 되었을 때 알려줄 수 있어야 합니다.

  이러한 재생 알림을 구현하기 위해 애니메이션 이벤트를 사용할 수 있습니다.

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

public class EnemyAIAnimation : MonoBehaviour
{
    // Start is called before the first frame update

    private Animator _animator;
    private Transform _playerTransform;
    private NavMeshAgent _navMeshAgent;
    private BoxCollider _boxCollider;
    public AI_STATE CurrentState;
    
    public enum AI_STATE
    {
        IDLE = 0,
        ATTACK = 1,
        PATROL,
        CHASE
    };
    
    private void Awake()
    {
        _animator = GetComponent<Animator>();
        _navMeshAgent = GetComponent<NavMeshAgent>();
        _playerTransform = GameObject.FindGameObjectWithTag("Player").transform;
        _boxCollider = GetComponent<BoxCollider>();
        CurrentState = AI_STATE.IDLE;  
    }

    // 오브젝트가 idle 상태일 때 이 코루틴이 실행 됩니다.
    public IEnumerator State_Idel()
    {
        // 현재 상태를 설정합니다.
        CurrentState = AI_STATE.IDLE;
        
        // 메카님에서 idle 상태를 활성화 시킨다.
        _animator.SetTrigger((int)AI_STATE.IDLE);
        
        // 내비게이션 메시 에이전트 이동을 멈춘다.
        _navMeshAgent.Stop();
        
        // idle 상태에 있는 동안 무한히 반복한다.
        while (CurrentState == AI_STATE.IDLE)
        {
        	if(CanSeePlayer)
	        {
          	    // 플레이어를 볼 수 있으면, 공격 거리에 도달하기 위해 추격한다.
          	    StartCoroutine(State_Chase());
          	    yield break;
             }
        }
        // 다음 프레임 까지 기다린다.
        yield return null;
    }

    public IEnumerator State_Chase()
    {
        // 미리 만들어 둠
    }
}

ㅁ 애니메이션 이벤트

: 막대 바를 클릭하면 이벤트 지점을 생성 할 수 있는데, 함수 이름을 지정 하면 해당 지점에서 지정한 함수가 자동으로 호출 됩니다.

    // idle 애니메이션이 완료될 때 호출되는 이벤트
    public void OnIdleAnimCompleted()
    {
        // 활성화된 Idle 상태를 멈춘다.
        StopAllCoroutines();
        StartCoroutine(State_Patrol());
    }

 

Patrol 상태 만들기

: Patrol 상태에서 적은 주변 환경을 돌아다니면서 플레이어를 찾습니다.

  

[ 조건 ]

1. 임의의 위치가 반드시 선택 되어야 합니다.

   방법 : Scene 탭에서 Waypoin로 태그된 아무런 일을 하지 않는 일련의 중간 기점들(빈 오브젝트) 만들어 NavMesh 바닥에 위치를 표시 합니다.

   이런 중간 기점들은 Patrol 상태에서 적이 이동하는 동안 지나갈 수 있는 위치를 나타내는 것 입니다.

 

    Patrol 상태에서 목적지를 선택 하도록 구현하려면, Awake 함수에서 나중에 사용할 씬의 중간 기점들의 리스트를 받아두도록 합니다.

 

   private void Awake()
    {
      ...
      
        // =============================== 추가된 코드 내용 ==================================
        // 중간 지점을 표시하는 모든 게임오브젝트를 찾습니다.
        GameObject[] wayPoint = GameObject.FindGameObjectsWithTag("Waypoint");
        
        // LINQ를 이용해서 중간 기점들의 트랜스폼 컴포넌트를 선택 합니다. ( using System.Linq 추가 )
        _wayPoints = (from obj in wayPoint select obj.transform).ToArray();
        // =============================== 추가된 코드 내용 ==================================
    }
    
       // 오브젝트가 patrol 상태일 때 이 코루틴이 실행된다.
    public IEnumerator State_Patrol()
    {
        // 현재 상태를 설정 한다.
        CurrentState = AI_STATE.PATROL;
        
        // Patrol 상태를 설정 한다.
        _animator.SetTrigger((int)CurrentState);
        
        // 임의의 중간 기점을 뽑는다.
        Transform RandomDest = _wayPoints[Random.Range(0, _wayPoints.Length)];
        
        // 목적지로 이동 합니다.
        _navMeshAgent.SetDestination(RandomDest.position);
        
        // patrol 상태에 있는 동안 무한히 반복 합니다.
        while(CurrentState == AI_STATE.PATROL)
        {
            // 플레이어를 볼 수 있는지 검사 합니다.
            if (CanSeePalyer)
            {
                // 플레이어를 볼 수 있으면, 공격 거리에 도달 할 수 있게 추격합니다.
                StartCoroutine(State_Chase());
                yield break;
            }
            
            // 목적지에 도달 했는지 검사합니다.
            if (Vector3.Distance(_playerTransform.position, RandomDest.position) <= DistEps)
            {
                // 목적지에 도착 했으면 Idle 상태로 변경 합니다.
                StartCoroutine(State_Idel());
                yield break;
            }
            
            yield break;
        }
        yield return null;
    }

 

2. 플레이어 가시성 검사가 반드시 수행 되어야 한다는 것 입니다.

    방법 : Update 함수에서 매 프레임 플레이어가 적에게 보이는지를 판단 할 것 입니다.

    검사하는 부분은 플레이어가 적에 붙은 박스 충돌체 안에 있는지에 대해 판단 합니다.

    이 충돌체는 적의 시야나 범위를 나타내는것 입니다.

    private void Update()
    {
        // 플레이어를 볼 수 없다고 가정 합니다.
        CanSeePalyer = false;
        
        // 플레이어가 경계 내에 없는 경우 빠져 나갑니다.
        if (!_boxCollider.bounds.Contains(_playerTransform.position))
            return;
        
        // 플레이어가 경계 내에 있는 경우 시야를 업데이트 한다.
        CanSeePalyer = HaveLinesightToPlayer(_playerTransform);
    }

    // 현재 플레이어가 보이는지를 반환하는 함수
    private bool HaveLinesightToPlayer(Transform playerTrans)
    {
        // 적의 시선과 플레이어 간의 각도를 구한다.
        float Angle =
            Mathf.Abs(Vector3.Angle(_transform.forward, (playerTrans.position - _transform.position).normalized));
        
        // 각도가 시야 보다 큰 경우 플레이어를 볼 수 없다.
        if (Angle > FieldOfView) return false;
        
        // 레이캐스트를 이용해 검사한다 - 플레이어가 반대쪽 벽에 있는 것이 아닌지 검사
        if (Physics.Linecast(_transform.position, playerTrans.position, SightMask))
            return false;
        
        // 플레이어를 볼 수 있다.
        return true;
    }
Chase 상태 만들기

: 플레이어가 적에게 보이지만 공격 거리 내에 있지 않은 경우, 적은 플레이어를 공격하기 위해 달리게 됩니다.

  이 상태는 Chase 상태로서, 적은 플레이어를 향해 적대적인 의도를 품고 달려 갑니다.

 

  ㅁ Chase 상태에서 빠져 나가는 조건

    1. 공격 거리에 도달하게 되면 적은 Chase 상태에서 Attack 상태로 전환 됩니다.

    2. 반대로 플레이어가 시야에서 사라지는 경우, 적은 한동안 추격하다가 일정 시간이 지난 후 플레이어가 보이지 않는

         경우 추격을 포기 합니다.

  public IEnumerator State_Chase()
    {
        // 현재 상태를 설정 합니다.
        CurrentState = AI_STATE.CHASE;
        
        // Chase 상태를 설정 합니다.
        _animator.SetTrigger((int)CurrentState);
        
        // Chase 상태에 있는 동안 무한히 반복합니다.
        while (CurrentState == AI_STATE.CHASE)
        {
            // 플레이어의 목적지를 설정 합니다.
            _navMeshAgent.SetDestination(_playerTransform.position);

            // 플레이어에 대한 시야를 잃었을 때 시간 초과 시점까지 계속 추격 합니다.
            if (!CanSeePalyer)
            {
                // 시간 초과 계산 시점
                float ElapsedTime = 0.0f;

                // 계속 추격 합니다.
                while (true)
                {
                    // 시간을 증가 시킵니다.
                    ElapsedTime += Time.deltaTime;

                    //플레이어의 목적지를 설정 합니다.
                    _navMeshAgent.SetDestination(_playerTransform.position);

                    // 다음 프레임까지 기다린다.
                    yield return null;

                    // 시간이 초과 되었는가
                    if (ElapsedTime >= ChaseTimeOut)
                    {
                        // 여전히 플레이어가 보이지 않는다면 idle 상태로 전환한다.
                        if (!CanSeePalyer)
                        {
                            // idle로 전환 한다. - 여전히 플레이어가 보이지 않음
                            StartCoroutine(State_Idel());
                            yield break;
                        }
                        else break; // 다시 플레이어가 보이므로 추격을 계속한다.
                    }
                }
            }

            // 플레이어에게 도달 했다면 공격한다.
            if (Vector3.Distance(_transform.position, _playerTransform.position) <= DistEps)
            {
                // 도달했으므로 공격합니다.
                StartCoroutine(State_Attack());
                yield break;
            }
            // 다음 프레임까지 기다립니다.
            yield return null;
        }
    }
Attack 상태 만들기

: Attack 상태에서 적은 플레이어가 보이는 동안 끊임없이 공격 합니다.

 public IEnumerator State_Attack()
    {
        // 현재 상태를 설정 합니다.
        CurrentState = AI_STATE.ATTACK;
        
        // attack 상태를 설정 합니다.
        _animator.SetTrigger((int)CurrentState);
        
        // 내비게이션 메시 에이전트의 이동을 멈춥니다.
        _navMeshAgent.isStopped = true;
        
        // 공격 주기를 정하는 타이머를 설정 합니다.
        float ElapsedTime = 0f;
        
        // attack 상태에 있는 동안 무한히 반복 합니다.
        while (CurrentState == AI_STATE.ATTACK)
        {
            // 타이머를 업데이트 한다.
            ElapsedTime += Time.deltaTime;

            // 플레이어가 공격 범위를 벗어나거나 사라졌는지 검사해서 추격을 시작한다.
            if (!CanSeePalyer || Vector3.Distance(_transform.position, _playerTransform.position) > DistEps)
            {
                // chase 상태로 전환합니다.
                StartCoroutine(State_Chase());
                yield break;
            }

            // 공격 주기를 검사합니다.
            if (ElapsedTime >= AttackDelay)
            {
                // 타이머를 재설정 합니다.
                ElapsedTime = 0f;

                // 공격을 시작 합니다.
                if (_playerTransform != null)
                {
                    _playerTransform.SendMessage("ChangeHealth", -AttackDamage, SendMessageOptions.DontRequireReceiver);
                }
            }
            
            // 다음 프레임까지 기다린다.
            yield return null;
        }
    }

전체 코드 

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using System.Linq;
using Unity.VisualScripting;
using Random = UnityEngine.Random;

public class EnemyAIAnimation : MonoBehaviour
{
    private Animator _animator;
    private Transform _transform;
    private Transform _playerTransform;
    private NavMeshAgent _navMeshAgent;
    private BoxCollider _boxCollider;
    public AI_STATE CurrentState;
    private Transform[] _wayPoints;
    public bool CanSeePalyer;
    public LayerMask SightMask;
    public float ChaseTimeOut = 2f;
    public float DistEps = 1f;
    public float FieldOfView = 30f; // 시야 각
    public float AttackDelay = 1f;
    public float AttackDamage = 10f;
    
    public enum AI_STATE
    {
        IDLE = 0,
        ATTACK = 1,
        PATROL,
        CHASE
    };
    
    private void Awake()
    {
        _animator = GetComponent<Animator>();
        _transform = GetComponent<Transform>();
        _navMeshAgent = GetComponent<NavMeshAgent>();
        _playerTransform = GameObject.FindGameObjectWithTag("Player").transform;
        _boxCollider = GetComponent<BoxCollider>();
        CurrentState = AI_STATE.IDLE;
        CanSeePalyer = false;
        
        // =============================== 추가된 코드 내용 ==================================
        // 중간 지점을 표시하는 모든 게임오브젝트를 찾습니다.
        GameObject[] wayPoint = GameObject.FindGameObjectsWithTag("Waypoint");
        
        // LINQ를 이용해서 중간 기점들의 트랜스폼 컴포넌트를 선택 합니다. ( using System.Linq 추가 )
        _wayPoints = (from obj in wayPoint select obj.transform).ToArray();
        // =============================== 추가된 코드 내용 ==================================
    }

    private void Update()
    {
        // 플레이어를 볼 수 없다고 가정 합니다.
        CanSeePalyer = false;
        
        // 플레이어가 경계 내에 없는 경우 빠져 나갑니다.
        if (!_boxCollider.bounds.Contains(_playerTransform.position))
            return;
        
        // 플레이어가 경계 내에 있는 경우 시야를 업데이트 한다.
        CanSeePalyer = HaveLinesightToPlayer(_playerTransform);
    }

    // 현재 플레이어가 보이는지를 반환하는 함수
    private bool HaveLinesightToPlayer(Transform playerTrans)
    {
        // 적의 시선과 플레이어 간의 각도를 구한다.
        float Angle =
            Mathf.Abs(Vector3.Angle(_transform.forward, (playerTrans.position - _transform.position).normalized));
        
        // 각도가 시야 보다 큰 경우 플레이어를 볼 수 없다.
        if (Angle > FieldOfView) return false;
        
        // 레이캐스트를 이용해 검사한다 - 플레이어가 반대쪽 벽에 있는 것이 아닌지 검사
        if (Physics.Linecast(_transform.position, playerTrans.position, SightMask))
            return false;
        
        // 플레이어를 볼 수 있다.
        return true;
    }
 
    // 오브젝트가 idle 상태일 때 이 코루틴이 실행 됩니다.
    public IEnumerator State_Idel()
    {
        // 현재 상태를 설정합니다.
        CurrentState = AI_STATE.IDLE;
        
        // 메카님에서 idle 상태를 활성화 시킨다.
        _animator.SetTrigger((int)AI_STATE.IDLE);
        
        // 내비게이션 메시 에이전트 이동을 멈춘다.
        _navMeshAgent.Stop();
        
        // idle 상태에 있는 동안 무한히 반복한다.
        while (CurrentState == AI_STATE.IDLE)
        {
            // 플레이어를 볼 수 있으면, 공격 거리에 도달하기 위해 추격한다.
            StartCoroutine(State_Chase());
            yield break;
        }
        // 다음 프레임 까지 기다린다.
        yield return null;
    }

    
    //
    public IEnumerator State_Chase()
    {
        // 현재 상태를 설정 합니다.
        CurrentState = AI_STATE.CHASE;
        
        // Chase 상태를 설정 합니다.
        _animator.SetTrigger((int)CurrentState);
        
        // Chase 상태에 있는 동안 무한히 반복합니다.
        while (CurrentState == AI_STATE.CHASE)
        {
            // 플레이어의 목적지를 설정 합니다.
            _navMeshAgent.SetDestination(_playerTransform.position);

            // 플레이어에 대한 시야를 잃었을 때 시간 초과 시점까지 계속 추격 합니다.
            if (!CanSeePalyer)
            {
                // 시간 초과 계산 시점
                float ElapsedTime = 0.0f;

                // 계속 추격 합니다.
                while (true)
                {
                    // 시간을 증가 시킵니다.
                    ElapsedTime += Time.deltaTime;

                    //플레이어의 목적지를 설정 합니다.
                    _navMeshAgent.SetDestination(_playerTransform.position);

                    // 다음 프레임까지 기다린다.
                    yield return null;

                    // 시간이 초과 되었는가
                    if (ElapsedTime >= ChaseTimeOut)
                    {
                        // 여전히 플레이어가 보이지 않는다면 idle 상태로 전환한다.
                        if (!CanSeePalyer)
                        {
                            // idle로 전환 한다. - 여전히 플레이어가 보이지 않음
                            StartCoroutine(State_Idel());
                            yield break;
                        }
                        else break; // 다시 플레이어가 보이므로 추격을 계속한다.
                    }
                }
            }

            // 플레이어에게 도달 했다면 공격한다.
            if (Vector3.Distance(_transform.position, _playerTransform.position) <= DistEps)
            {
                // 도달했으므로 공격합니다.
                StartCoroutine(State_Attack());
                yield break;
            }
            // 다음 프레임까지 기다립니다.
            yield return null;
        }
    }

    // 오브젝트가 attack 상태일 때 이 코루틴이 실행 된다.
    public IEnumerator State_Attack()
    {
        // 현재 상태를 설정 합니다.
        CurrentState = AI_STATE.ATTACK;
        
        // attack 상태를 설정 합니다.
        _animator.SetTrigger((int)CurrentState);
        
        // 내비게이션 메시 에이전트의 이동을 멈춥니다.
        _navMeshAgent.isStopped = true;
        
        // 공격 주기를 정하는 타이머를 설정 합니다.
        float ElapsedTime = 0f;
        
        // attack 상태에 있는 동안 무한히 반복 합니다.
        while (CurrentState == AI_STATE.ATTACK)
        {
            // 타이머를 업데이트 한다.
            ElapsedTime += Time.deltaTime;

            // 플레이어가 공격 범위를 벗어나거나 사라졌는지 검사해서 추격을 시작한다.
            if (!CanSeePalyer || Vector3.Distance(_transform.position, _playerTransform.position) > DistEps)
            {
                // chase 상태로 전환합니다.
                StartCoroutine(State_Chase());
                yield break;
            }

            // 공격 주기를 검사합니다.
            if (ElapsedTime >= AttackDelay)
            {
                // 타이머를 재설정 합니다.
                ElapsedTime = 0f;

                // 공격을 시작 합니다.
                if (_playerTransform != null)
                {
                    _playerTransform.SendMessage("ChangeHealth", -AttackDamage, SendMessageOptions.DontRequireReceiver);
                }
            }
            
            // 다음 프레임까지 기다린다.
            yield return null;
        }
    }
    
    // 오브젝트가 patrol 상태일 때 이 코루틴이 실행된다.
    public IEnumerator State_Patrol()
    {
        // 현재 상태를 설정 한다.
        CurrentState = AI_STATE.PATROL;
        
        // Patrol 상태를 설정 한다.
        _animator.SetTrigger((int)CurrentState);
        
        // 임의의 중간 기점을 뽑는다.
        Transform RandomDest = _wayPoints[Random.Range(0, _wayPoints.Length)];
        
        // 목적지로 이동 합니다.
        _navMeshAgent.SetDestination(RandomDest.position);
        
        // patrol 상태에 있는 동안 무한히 반복 합니다.
        while(CurrentState == AI_STATE.PATROL)
        {
            // 플레이어를 볼 수 있는지 검사 합니다.
            if (CanSeePalyer)
            {
                // 플레이어를 볼 수 있으면, 공격 거리에 도달 할 수 있게 추격합니다.
                StartCoroutine(State_Chase());
                yield break;
            }
            
            // 목적지에 도달 했는지 검사합니다.
            if (Vector3.Distance(_playerTransform.position, RandomDest.position) <= DistEps)
            {
                // 목적지에 도착 했으면 Idle 상태로 변경 합니다.
                StartCoroutine(State_Idel());
                yield break;
            }
            
            yield break;
        }
        yield return null;
    }
    
    // idle 애니메이션이 완료될 때 호출되는 이벤트
    public void OnIdleAnimCompleted()
    {
        // 활성화된 Idle 상태를 멈춘다.
        StopAllCoroutines();
        StartCoroutine(State_Patrol());
    }
}
Comments