Unity Chapter 11-16. 좀비 TPS 게임 만들기 : Enemy

Date:     Updated:

카테고리:

태그:

인프런에 있는 이제민님의 레트로의 유니티 C# 게임 프로그래밍 에센스 강의를 듣고 정리한 필기입니다. 😀
🌜 [레트로의 유니티 C# 게임 프로그래밍 에센스] 강의 들으러 가기!


Chapter 11. 좀비 TPS 게임 만들기

📜Enemy.cs

Zombie에 붙여준다.

  • 좀비 캐릭터의 생명체로서의 동작을 담당
    • LivingEntity를 상속 받아 상속 받은 생명체로서의 기본 동작 그 위에 좀비만의 동작을 구현할 것이다.
      • LivingEntity 위에서 확장만 하면 됨
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

#if UNITY_EDITOR
using UnityEditor;
#endif

public class Enemy : LivingEntity
{
    private enum State
    {
        Patrol,
        Tracking,
        AttackBegin,
        Attacking
    }

    private State state;

    private NavMeshAgent agent; // 경로계산 AI 에이전트
    private Animator animator; // 애니메이터 컴포넌트

    public Transform attackRoot;
    public Transform eyeTransform;

    private AudioSource audioPlayer; // 오디오 소스 컴포넌트
    public AudioClip hitClip; // 피격시 재생할 소리
    public AudioClip deathClip; // 사망시 재생할 소리

    private Renderer skinRenderer; // 렌더러 컴포넌트

    public float runSpeed = 10f;
    [Range(0.01f, 2f)] public float turnSmoothTime = 0.1f;
    private float turnSmoothVelocity;

    public float damage = 30f;
    public float attackRadius = 2f;
    private float attackDistance;

    public float fieldOfView = 50f;
    public float viewDistance = 10f;
    public float patrolSpeed = 3f;

    [HideInInspector] public LivingEntity targetEntity; // 추적할 대상
    public LayerMask whatIsTarget; // 추적 대상 레이어


    private RaycastHit[] hits = new RaycastHit[10];
    private List<LivingEntity> lastAttackedTargets = new List<LivingEntity>();

    private bool hasTarget => targetEntity != null && !targetEntity.dead;


#if UNITY_EDITOR

    private void OnDrawGizmosSelected()
    {
        if (attackRoot != null)
        {
            Gizmos.color = new Color(1f, 0f, 0f, 0.5f);
            Gizmos.DrawSphere(attackRoot.position, attackRadius);
        }

        var leftRayRotation = Quaternion.AngleAxis(-fieldOfView * 0.5f, Vector3.up);
        var leftRayDirection = leftRayRotation * transform.forward;
        Handles.color = new Color(1f, 1f, 1f, 0.2f);
        Handles.DrawSolidArc(eyeTransform.position, Vector3.up, leftRayDirection, fieldOfView, viewDistance);
    }

#endif

    private void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        animator = GetComponent<Animator>();
        audioPlayer = GetComponent<AudioSource>();
        skinRenderer = GetComponentInChildren<Renderer>();

        attackDistance = Vector3.Distance(transform.position,
                             new Vector3(attackRoot.position.x, transform.position.y, attackRoot.position.z)) +
                         attackRadius;

        attackDistance += agent.radius;

        agent.stoppingDistance = attackDistance;
        agent.speed = patrolSpeed;
    }

    // 적 AI의 초기 스펙을 결정하는 셋업 메서드
    public void Setup(float health, float damage,
        float runSpeed, float patrolSpeed, Color skinColor)
    {
        // 체력 설정
        this.startingHealth = health;
        this.health = health;

        // 내비메쉬 에이전트의 이동 속도 설정
        this.runSpeed = runSpeed;
        this.patrolSpeed = patrolSpeed;

        this.damage = damage;

        // 렌더러가 사용중인 머테리얼의 컬러를 변경, 외형 색이 변함
        skinRenderer.material.color = skinColor;

        agent.speed = patrolSpeed;
    }

    private void Start()
    {
        // 게임 오브젝트 활성화와 동시에 AI의 추적 루틴 시작
        StartCoroutine(UpdatePath());
    }

    private void Update()
    {
        if (dead) return;

        if (state == State.Tracking &&
            Vector3.Distance(targetEntity.transform.position, transform.position) <= attackDistance)
        {
            BeginAttack();
        }


        // 추적 대상의 존재 여부에 따라 다른 애니메이션을 재생
        animator.SetFloat("Speed", agent.desiredVelocity.magnitude);
    }

    private void FixedUpdate()
    {
        if (dead) return;


        if (state == State.AttackBegin || state == State.Attacking)
        {
            var lookRotation =
                Quaternion.LookRotation(targetEntity.transform.position - transform.position);
            var targetAngleY = lookRotation.eulerAngles.y;

            transform.eulerAngles = Vector3.up * Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngleY,
                                        ref turnSmoothVelocity, turnSmoothTime);
        }

        if (state == State.Attacking)
        {
            var direction = transform.forward;
            var deltaDistance = agent.velocity.magnitude * Time.deltaTime;

            var size = Physics.SphereCastNonAlloc(attackRoot.position, attackRadius, direction, hits, deltaDistance,
                whatIsTarget);

            for (var i = 0; i < size; i++)
            {
                var attackTargetEntity = hits[i].collider.GetComponent<LivingEntity>();

                if (attackTargetEntity != null && !lastAttackedTargets.Contains(attackTargetEntity))
                {
                    var message = new DamageMessage();
                    message.amount = damage;
                    message.damager = gameObject;

                    if(hits[i].distance <= 0f)
                    {
                        message.hitPoint = attackRoot.position;
                    }
                    else
                    {
                        message.hitPoint = hits[i].point;
                    }

                    message.hitNormal = hits[i].normal;

                    attackTargetEntity.ApplyDamage(message);

                    lastAttackedTargets.Add(attackTargetEntity);
                    break;
                }
            }
        }
    }

    // 주기적으로 추적할 대상의 위치를 찾아 경로를 갱신
    private IEnumerator UpdatePath()
    {
        // 살아있는 동안 무한 루프
        while (!dead)
        {
            if (hasTarget)
            {
                if (state == State.Patrol)
                {
                    state = State.Tracking;
                    agent.speed = runSpeed;
                }

                // 추적 대상 존재 : 경로를 갱신하고 AI 이동을 계속 진행
                agent.SetDestination(targetEntity.transform.position);
            }
            else
            {
                if (targetEntity != null) targetEntity = null;

                if (state != State.Patrol)
                {
                    state = State.Patrol;
                    agent.speed = patrolSpeed;
                }

                if (agent.remainingDistance <= 1f)
                {
                    var patrolPosition = Utility.GetRandomPointOnNavMesh(transform.position, 20f, NavMesh.AllAreas);
                    agent.SetDestination(patrolPosition);
                }

                // 20 유닛의 반지름을 가진 가상의 구를 그렸을때, 구와 겹치는 모든 콜라이더를 가져옴
                // 단, whatIsTarget 레이어를 가진 콜라이더만 가져오도록 필터링
                var colliders = Physics.OverlapSphere(eyeTransform.position, viewDistance, whatIsTarget);

                // 모든 콜라이더들을 순회하면서, 살아있는 LivingEntity 찾기
                foreach (var collider in colliders)
                {
                    if (!IsTargetOnSight(collider.transform)) continue;

                    var livingEntity = collider.GetComponent<LivingEntity>();

                    // LivingEntity 컴포넌트가 존재하며, 해당 LivingEntity가 살아있다면,
                    if (livingEntity != null && !livingEntity.dead)
                    {
                        // 추적 대상을 해당 LivingEntity로 설정
                        targetEntity = livingEntity;

                        // for문 루프 즉시 정지
                        break;
                    }
                }
            }

            // 0.05 초 주기로 처리 반복
            yield return new WaitForSeconds(0.05f);
        }
    }

    // 데미지를 입었을때 실행할 처리
    public override bool ApplyDamage(DamageMessage damageMessage)
    {
        if (!base.ApplyDamage(damageMessage)) return false;

        if (targetEntity == null)
        {
            targetEntity = damageMessage.damager.GetComponent<LivingEntity>();
        }

        EffectManager.Instance.PlayHitEffect(damageMessage.hitPoint, damageMessage.hitNormal, transform, EffectManager.EffectType.Flesh);
        audioPlayer.PlayOneShot(hitClip);

        return true;
    }

    public void BeginAttack()
    {
        state = State.AttackBegin;

        agent.isStopped = true;
        animator.SetTrigger("Attack");
    }

    public void EnableAttack()
    {
        state = State.Attacking;

        lastAttackedTargets.Clear();
    }

    public void DisableAttack()
    {
        if(hasTarget)
        {
            state = State.Tracking;
        }
        else
        {
            state = State.Patrol;
        }

        agent.isStopped = false;
    }

    private bool IsTargetOnSight(Transform target)
    {
        RaycastHit hit;

        var direction = target.position - eyeTransform.position;

        direction.y = eyeTransform.forward.y;

        if (Vector3.Angle(direction, eyeTransform.forward) > fieldOfView * 0.5f)
        {
            return false;
        }

        direction = target.position - eyeTransform.position;

        if (Physics.Raycast(eyeTransform.position, direction, out hit, viewDistance, whatIsTarget))
        {
            if (hit.transform == target) return true;
        }

        return false;
    }

    // 사망 처리
    public override void Die()
    {
        // LivingEntity의 Die()를 실행하여 기본 사망 처리 실행
        base.Die();

        // 다른 AI들을 방해하지 않도록 자신의 모든 콜라이더들을 비활성화
        GetComponent<Collider>().enabled = false;

        // AI 추적을 중지하고 내비메쉬 컴포넌트를 비활성화
        agent.enabled = false;

        // 사망 애니메이션 재생
        animator.applyRootMotion = true;
        animator.SetTrigger("Die");

        // 사망 효과음 재생
        if (deathClip != null) audioPlayer.PlayOneShot(deathClip);
    }
}


시작하기 앞서

UnityEngine.AI

using UnityEngine.AI
  • 네비게이션 시스템을 사용하기 위해선 UnityEngine.AI 을 인클루딩 해줘야 한다.


전처리기

#if UNITY_EDITOR
using UnityEditor;
#endif

UnityEditor 의 기능들을 사용할 때는 using UnityEditor만 해주면 안되고 반드시 전처리기 #if UNITY_EDITOR, #endif 안에 넣어서 선언해주어야 한다.

  • 오직 유니티 에디터에서만 UnityEditor 네임스페이스를 사용하겠다고 선언.
    • 유니티 에디터 내에서만 동작할 뿐 UnityEditor 네임스페이스가 빌드 되지는 않는다.
      • 게임 개발이 완성된 후 나중에 윈도우용, 맥OS, 안드로이드 등등 이런 다양한 플랫폼으로서 빌드 할 때 이 부분은 빌드에서 빠지게 된다. 유니티 에디터에서만 되니까!
    • 오로지 유니티 에디터에서만 좀비가 플레이어를 인식할 수 있는 영역을 시각적으로 보여주기 위해서 이에 대한 기능을 제공하는 UnityEditor 네임스페이스를 사용하되 오직 유니티 에디터에서만 사용!

전처리기 👉 특정 상황에 따라 스크립트를 컴파일할지 말지 결정할 수 있다.

  • 유니티는 여러 플랫폼을 빌드할 수 있다. iOS, 안드로이드, 윈도우, 맥OS 등등..
    • 플랫폼에 따른 각각의 코드들을 만들 때 특정 플랫폼에만 컴파일 되는 전처리기를 만들 수 있다.
    • 예를들어 iOS 전처리기 부분은 게임 개발이 완성된 후 iOS로 빌드될 때만 포함된다. 안드로이드로 빌드 될 때는 이 부분의 코드가 빌드에 포함되지 않는다.
    #if UNITY_EDITOR 
      Debug.Log("Unity Editor");  // 유니티 에디터에서만 나오는 로그
    #endif
    
    #if UNITY_IOS
      Debug.Log("Iphone");   // iOS 에서만 나오는 로그. iOS로 빌드할 때만 빌드 된다.
    #endif

    #if UNITY_STANDALONE_OSX
    Debug.Log("Stand Alone OSX");   // 맥 OS 에서만 나오는 로그. 맥 OS로 빌드할 때만 빌드 된다.
    #endif

    #if UNITY_STANDALONE_WIN
      Debug.Log("Stand Alone Windows");  // 윈도우에서만 나오는 로그. 윈도우로 빌드할 때만 빌드 된다.
    #endif


멤버 변수/프로퍼티

LivingEntity를 상속 받았으므로 LivingEntity의 멤버 변수들도 가지고 있다는 것 잊지 말기!

    private enum State  // 좀비 상태
    {
        Patrol,      // 돌아다니는 상태
        Tracking,    // 플레이어를 추격하는 상태
        AttackBegin, // 공격 시작
        Attacking    // 공격
    }

    private State state;  // 좀비 상태

    private NavMeshAgent agent; // NavMeshAgent 경로계산 AI 에이전트
    private Animator animator; // 좀비 애니메이션을 표현할 애니메이터 컴포넌트

    public Transform attackRoot;  
    public Transform eyeTransform;

    private AudioSource audioPlayer; // 오디오 소스 컴포넌트. 소리 재생기
    public AudioClip hitClip; // 피격시 재생할 소리
    public AudioClip deathClip; // 사망시 재생할 소리

    private Renderer skinRenderer; // 렌더러 컴포넌트

    public float runSpeed = 10f;  // 좀비 이동 속도
    [Range(0.01f, 2f)] public float turnSmoothTime = 0.1f;  // 좀비가 방향을 스무스하게 회전할 때 사용할 지연시간. smoothDamp 에 사용할것. 
    private float turnSmoothVelocity; // smoothDamp 에 사용할것. 스무스하게 회전하는 실시간 변화량

    public float damage = 30f;  // 공격령
    public float attackRadius = 2f; // 공격 반경(반지름)
    private float attackDistance; // 공격을 시도하는 거리

    public float fieldOfView = 50f;  // 좀비의 시야 각
    public float viewDistance = 10f; // 좀비가 볼 수 있는 거리
    public float patrolSpeed = 3f; // 좀비가 돌아다니는 거리(Patrol 상태일 때)

    [HideInInspector] public LivingEntity targetEntity; // 추적할 대상. 
    public LayerMask whatIsTarget; // 추적 대상 레이어


    private RaycastHit[] hits = new RaycastHit[10];
    private List<LivingEntity> lastAttackedTargets = new List<LivingEntity>();

    private bool hasTarget => targetEntity != null && !targetEntity.dead;
  • 필기 안한건 주석 참고
  • attackRoot
    • Transform
    • 좀비 오브젝트가 공격을 하는 Pivot 포인트.
    • attackRoot을 중심으로 반지름을 지정해서 이 반경 내에 있는 플레이어가 공격을 당하도록 할 것이다.
  • eyeTransform
    • Transform
    • 시야의 기준점. ‘눈의 위치’가 될 어떤 게임 오브젝트의 Trnasform
    • eyeTransform을 기준으로 어떤 영역을 지정해서 플레이어나 적 AI를 감지할 수 있게 할 것이다.
  • skinRenderer
    • Renderer
    • 좀비의 피부색에 따라서 공격력을 다르게 해줄 것
      • 그때 사용할 피부색!
  • targetEntity
    • LivingEntity
    • 좀비가 추적할 대상.
    • 플레이어 캐릭터 오브젝트가 이 곳에 할당 될 것!
    • LivingEntity 타입이라면 어떤 것이든지 이 곳에 할당 가능.
    • [HideInInspector] 라서 유니티 인스펙터 창에선 보이지 않음. public인데도 불구하고!
      • 코드로 할당 할 것이라서 숨겼다.
  • whatIsTarget
    • LayerMask
    • 적을 감지할 때 사용할 레이어 필터
  • hits
    • 10 사이즈의 RaycastHit 배열이다.
      • 배열을 사용한 이유
        • 좀비의 공격을 범위 기반의 공격으로 구현할 것이라서 범위 기반으로 하면 여러개의 Ray 충돌 지점이 생기기 때문.
  • lastAttackedTargets
    • LivingEntity 타입의 원소들이 들어가 리스트
      • 공격을 시작할 때마다 초기화 될 리스트
      • 공격 도중에 직전 프레임까지 공격이 적용된 대상들을 모아둘 리스트
        • 공격은 시간을 들여서 진행 되는데, 공격이 똑같은 대상에게 두번 이상 적용되지 않도록 하기 위하여 이 리스트에 포함된 오브젝트들은 공격 대상에서 제외할 것이다.
          • 공격이 끝나고 나면 리스트를 비움
  • hasTarget
    • 추적할 대상이 존재하는지의 여부
    • 람다 함수로 정의된 프로퍼티
      • targetEntity != null && !targetEntity.dead
        • 추적할 상대방이 존재하고 추적할 상대방이 죽은 상태가 아니라면


멤버 함수

private void OnDrawGizmosSelected()

Zombie 오브젝트의 시야와 공격 범위를 유디터 에디터 내에서만, 씬 상에서만 그리는 역할을 한다.

#if UNITY_EDITOR

    private void OnDrawGizmosSelected()
    {
        if (attackRoot != null)
        {
            Gizmos.color = new Color(1f, 0f, 0f, 0.5f);
            Gizmos.DrawSphere(attackRoot.position, attackRadius);
        }
        
        var leftRayRotation = Quaternion.AngleAxis(-fieldOfView * 0.5f, Vector3.up);
        var leftRayDirection = leftRayRotation * transform.forward;
        Handles.color = new Color(1f, 1f, 1f, 0.2f);
        Handles.DrawSolidArc(eyeTransform.position, Vector3.up, leftRayDirection, fieldOfView, viewDistance);
    }

#endif
  • 전처리기 #if #endif로 묶여있다.
    • UNITY_EDITOR 유니티 에디터 내에서만 이 코드가 존재하게 되며 최종적인 실제 게임 빌드에서는 빠지게 된다.
  • OnDrawGizmosSelected() 는 이벤트 함수다.
    • OnDrawGizmos() 👉 이 함수가 소속된 스크립트가 컴포넌트로서 붙은 오브젝트가 Scene 화면 상에서 항상 보이도록 하는 이벤트 함수.
    • OnDrawGizmosSeleced() 👉 이 함수가 소속된 스크립트가 컴포넌트로서 붙은 오브젝트가 선택됐을때만 보이도록 하는 이벤트 함수.
    • 씬상에 존재하기는 하나 아직 보이지는 않는 그런 오브젝트들을 기즈모를 통해 보여줌으로서 위치 조정을 쉽게 해주는 등등 개발자 편의를 돕는다.
  • Zombie의 빨간색 공격 반경 그리기 🔈 씬 상에서만 그려진다.
    • if (attackRoot != null)
      • attackRoot 공격의 기준점이 존재할 때만 공격 반경을 그릴 수 있으므로.
      • 기즈모의 색깔을 빨간색으로 변경해준다.
        • 레드를 1.0f 100 % 로.
      • 공격 반경을 구로 그려준다.
        • 중심점 attackRoot.position
        • 반지름 attackRadius
  • Zombie의 빨간색 시야 범위 그리기 🔈 씬 상에서만 그려진다.
    • 피자 모양의 ‘호’(Arc) 형태로 그릴 것이다.
    • image
      • 갈색 피자모양이 좀비 시야각을 표현한 것이다. 이것을 씬상에서 그릴 것!
        • 만약 fieldOfView 즉, 좀비의 시야각이 60 도라면 -fieldOfView * 0.5f은 -30 도가 되므로 transform.forward 축을 기준으로 왼쪽으로 30도 만큼 회전한 상태를 시작점으로 해서 오른쪽으로 60도만큼 원을 그려(피자모양) 시야각을 표현할 것이다.
    • leftRayRotation 왼쪽 끝지점으로 향하는 회전값(쿼터니언)
      • Vector3.up 위쪽 방향(y축)을 중심으로 하여 fieldOfView * 0.5f 각도만큼 왼쪽으로 - 회전시킨 것 (최종적으로 fieldOfView * 0.5f) 을 쿼터니언으로 표현한 것.
      • 필기 그림 상에서 노란 각도 부분
    • leftRayDirection 왼쪽 끝지점으로 향하는 방향
      • leftRayRotation * transform.forward
        • 위에서 구한 쿼터니언인 leftRayRotation 만큼 앞쪽 방향으로 회전한 처리가 된다.
        • leftRayRotation으로부터 lefRayDirection을 구함!
        • 필기 그림 상에서 노란 각도를 forward(z축)를 기준으로 한 과정
    • 이렇게 피자모양의 호(Arc)를 그릴 수 있는 기능은 Gizmos에는 없어서 여러가지 그리기 요소가 담겨있는 unityEditorHandles 클래스의 함수 DrawSolidArc 를 통해 그릴 것이다.
      • 색깔은 조금 투명한 하얀색 new Color(1f, 1f, 1f, 0.2f);
      • 필기 그림 상에서 핫핑크 색 (leftRayDirection)을 시작점 으로 오른쪽으로 fieldOfview 각도(노란색의 2 배) 만큼의 부채꼴 Arc를 그린다.
      • Handles.DrawSolidArc(eyeTransform.position, Vector3.up, leftRayDirection, fieldOfView, viewDistance);
        • 시야 각이 그려지는 위치인 eyeTransform.position 에서 그려지며
        • Vector3.up 축을 기준으로 회전한 부채꼴
        • leftRayDirection 을 부채꼴 시작점으로 (from)
        • 오른쪽으로 fieldOfview 각도만큼 회전한
        • 중심으로부터 호가 그려지는 길이는, 즉 반지름이 viewDistance (좀비가 볼 수 있는 거리)인 Arc를 그린다.
Eye, AttackRoot 추가
  • 빈 게임 오브젝트인 EyeZombie의 자식으로 추가해준다.
    • 좀비의 눈의 위치가 될 것이다.
    • image
    • Trnasform 값은 (0, 1.6, 0.12) 로 해준다.
    • 📜Enemy.cs 의 `eyeTransform` 슬롯에 드래그 앤 드롭 해준다.
  • 빈 게임 오브젝트인 Attack RootZombie의 자식으로 추가해준다.
    • 좀비의 공격 반경이 된다.
    • image
      • 좀비의 앞쪽에 위치한 것을 볼 수 있다. 좀비와 이 Attack Root 거리가 공격 반경 구의 반지름이 될 거이다.
    • Trnasform 값은 (0, 1.2, 0.5) 로 해준다.
    • 📜Enemy.cs 의 `attackRoot` 슬롯에 드래그 앤 드롭 해준다.
  • image
    • Zombie를 클릭했을 때 Scene 상에서 다음과 같이 빨간 공격 반경 구와 시야 범위가 Arc로 그려지는 것을 확인할 수 있다.


private void Awake()

필요한 다른 컴포넌트들을 가져와서 멤버 변수에 할당

    private void Awake()
    {
        // 컴포넌트들 가져오기
        agent = GetComponent<NavMeshAgent>(); // Zombie의 NavMeshAgent 컴포넌트 가져오기
        animator = GetComponent<Animator>(); // Zombie의 Animator 컴포넌트 가져오기
        audioPlayer = GetComponent<AudioSource>(); // Zombie의 AudioSource 컴포넌트 가져오기
        skinRenderer = GetComponentInChildren<Renderer>();  // Zombie의 자식 오브젝트들 중에서 Rederer 컴포넌트를 가진 오브젝트를 Reneerer 타입으로 가져오기

        // 
        attackDistance = Vector3.Distance(transform.position, new Vector3(attackRoot.position.x, transform.position.y, attackRoot.position.z)) + attackRadius;

        attackDistance += agent.radius;

        agent.stoppingDistance = attackDistance;
        agent.speed = patrolSpeed;
    }
  • skinRenderer
    • Zombie의 자식 오브젝트들 중에서 Rederer 컴포넌트를 가진 오브젝트를 Reneerer 타입으로 가져오기
    • 좀비의 색상(Renderer)는 바로 Zombie의 컴포넌트로 추가되는게 아니라 Zombie의 자식 오브젝트인 LowManSkinned Mesh Renderer로 붙어 있기 때문에 GetComponentInChildren 함수로 가져오는 것이다.
  • attackDistance 공격을 시도하는 거리를 나타내는 멤버 변수. 플레이어와 적 사이의 거리가 attackDistance보다 작거나 같다면 적이 플레이어에게 공격을 시도.
    • image
    • transform.position 적(좀비인 자기 자신)의 위치로부터
    • 공격 반경 중심점이 되는 attackRoot 사이의 거리에다가
      • 적의 Trnasform은 적의 발을 기준으로 하기 때문에 attackRoottransform.position보다 더 높이 위치한다.
      • 수평 방향으로만 따질거라서 바로 transform.positionattackRoot 사이의 거리를 바로 구하는게 아니라 x, z 위치값은 attackRoot를 따르고 y 위치값은 transform.position와 일치한 벡터를 더해준다.
    • 이 거리에 공격 반경(attackRadius)을 더해주면 attackDistance 완성
  • stoppingDistance
    • 👉 NavMeshAgent의 변수로 AI Agent가 도착지로부터 이 stoppingDistance 값의 거리내에 들면 속도를 감속하여 서서히 멈춘다. 즉 AI가 목표를 추적하다가 목표 위치에 가까워졌을시 정지하는 근접 거리.
    • 좀비가 플레이어를 막 추적하다가 공격 사정거리(attackDistance) 안에 들면 플레이어를 공격해야 한다. 이 때 플레이어에게 공격 행동을 하기 위해선 좀비가 멈춰야 한다. 좀비가 멈추고 공격을 해야 함. 따라서 stoppingDistanceattackDistance로 초기화 해둠.
  • speed
    • 👉 NavMeshAgent의 변수로 AI Agent가 목표(플레이어)를 추적하는 속도
    • 처음엔 순찰 속도로 초기화 agent.speed = patrolSpeed


public void Setup(float health, float damage, float runSpeed, float patrolSpeed, Color skinColor)

Zombie가 생성될 때, Zombie의 스펙(체력, 공격력, 뛰는 속도, 정찰 속도, 색깔)을 결정해준다.

  • 인수는 나중에 스포너에서 결정할 것이다.
  • 이 함수의 역할은 들어온 인수로 Zombie의 스펙을 설정하는 역할만 수행.
    // 적 AI의 초기 스펙을 결정하는 셋업 메서드
    public void Setup(float health, float damage,
        float runSpeed, float patrolSpeed, Color skinColor)
    {
        // 체력 설정
        this.startingHealth = health; // 초기 시작 체력
        this.health = health;  // 체력

        // 내비메쉬 에이전트의 이동 속도 설정
        this.runSpeed = runSpeed;
        this.patrolSpeed = patrolSpeed;

        this.damage = damage;  // 공격력

        // 렌더러가 사용중인 머테리얼의 컬러를 변경, 외형 색이 변함
        skinRenderer.material.color = skinColor;

        agent.speed = patrolSpeed; // 위에서 변경된 patrolSpeed로 다시 적용
    }


private void Start()

    private void Start()
    {
        // 게임 오브젝트 활성화와 동시에 AI의 추적 루틴 시작
        StartCoroutine(UpdatePath());
    }
  • 게임이 시작되자마자 private IEnumerator UpdatePath() 함수를 코루틴으로 실행시키고 있다.


private void Update()

매 프레임마다 추적 대상과의 거리를 따져서 공격을 실행할지 검사하고 현재 상태에 따라 재생할 애니메이션을 결정한다.

    private void Update()
    {
        if (dead) return;

        // 추적 대상과의 거리를 따져서 공격을 실행할지 검사
        if (state == State.Tracking &&
            Vector3.Distance(targetEntity.transform.position, transform.position) <= attackDistance)
        {
            BeginAttack();
        }


        // 추적 대상의 존재 여부에 따라 다른 애니메이션을 재생
        animator.SetFloat("Speed", agent.desiredVelocity.magnitude);
    }
  • Zombie가 죽었다면 Update 함수 종료
  • 매 프레임마다 추적 대상과의 거리를 따져서 공격을 실행할지 검사
    • 추적 상태 + 추적 대상과 나 사이의 거리가 공격 사정거리 이하라면
      • 👉 공격하기 ! BeginAttack() 공격을 시작하는 메소드
        • 총과 다르게 직접 공격을 하는 모션은 시작과 끝이 존재하기 때문에 공격을 시작하는 메소드, 끝내는 메소드 따로 지정해야 한다.
  • 매 프레임마다 현재 상태에 따라 재생할 애니메이션을 결정
    • 애니메이션에 넘길 속도 파라미터(“Speed”) 값으로는 agent.desiredVelocity.magnitude
      • NavMeshAgent(좀비)의 desiredVelocity은 음 목적지로 향하는 목표 속도를 나타낸다. 실제 속도는 아님! 현재 속도로 설정하고 싶은 원하는 속도 값. desiredVelocity 속도로 움직이게 하고 싶지만 실제론 관성이나 어떤 장애물에 의해 실제 속도와는 차이가 날 수 있다.
        • 예를 들어 우리가 원하는 속도가 50인데 현재 캐릭터가 장애물에 막혀 제자리에서 뛰고 있다면 속도는 실제로는 0 이다.
        • 이동하려고 하는 속도가 아니라 실제 이동 속도를 적용하고 싶다면 그냥 agent.velocity
      • 그 속도의 magnitude 스칼라 크기.


private void FixedUpdate()

  • Fixedupdate()
    • Update()처럼 매번 반복 실행되나 프레임에 기반하지 않고 어떤 고정적이고 동일한 시간에 기반하여 실행된다.
      • 기본적으로 0.02초마다 실행됨.
    • 프레임 환경에 상관없이 무조건 실행 횟수를 지킨다.
    private void FixedUpdate()
    {
        if (dead) return;


        if (state == State.AttackBegin || state == State.Attacking)
        {
            var lookRotation =
                Quaternion.LookRotation(targetEntity.transform.position - transform.position);
            var targetAngleY = lookRotation.eulerAngles.y;

            transform.eulerAngles = Vector3.up * Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngleY,
                                        ref turnSmoothVelocity, turnSmoothTime);
        }

        if (state == State.Attacking)
        {
            var direction = transform.forward;
            var deltaDistance = agent.velocity.magnitude * Time.deltaTime;

            var size = Physics.SphereCastNonAlloc(attackRoot.position, attackRadius, direction, hits, deltaDistance,
                whatIsTarget);

            for (var i = 0; i < size; i++)
            {
                var attackTargetEntity = hits[i].collider.GetComponent<LivingEntity>();

                if (attackTargetEntity != null && !lastAttackedTargets.Contains(attackTargetEntity))
                {
                    var message = new DamageMessage();
                    message.amount = damage;  // 공격양
                    message.damager = gameObject;  // 공격을 가하는 사람은 좀비 자기 자신

                    // 공격이 들어간 지점
                    if(hits[i].distance <= 0f)
                    {
                        message.hitPoint = attackRoot.position;
                    }
                    else
                    {
                        message.hitPoint = hits[i].point;
                    }

                    // 공격이 들어가는 방향
                    message.hitNormal = hits[i].normal;

                    attackTargetEntity.ApplyDamage(message);

                    // 이미 공격을 가한 상대방이라는 뜻에서
                    lastAttackedTargets.Add(attackTargetEntity);
                    
                    break;  // 공격 대상을 찾았으니 for문 종료
                }
            }
        }
    }

현재 상태에 따라서 공격 범위에 겹친 상대방의 Collider를 통해 상대방을 감지하고 데미지를 주는 처리를 할 것이다.

  • Zombie가 죽었다면 FixedUpdate 함수 종료
  • 이제 막 공격을 시작하는 상태(AttackBegin)이거나 공격이 한창 이루어지는 중(Attacking)에는 나(좀비) 자신이 회전값을 타겟을 향한 방향으로 변경한다. 즉, 좀비가 공격하는 대상인 타겟을 향하여 보도록
    • lookRotation 👉 현재 Zombie가 바라보고 있는 방향
      • Quaternion.LookRotation(targetEntity.transform.position - transform.position, Vector3.up);
        • 인수로 넣은 Vector3 의 방향을 바라보게끔 회전한 값
        • 인수 👉 (타겟 위치 - 자기 자신의 위치 = 타겟을 바라보는 방향)
    • targetAngleY 👉 현재 Zombie가 바라보고 있는 방향으로 LookRotation 할 때 y 축 회전 방향.
      • targetAngleY = lookRotation.eulerAngles.y;
        • eulerAngles는 쿼터니언(lookRotation)을 오일러각으로 변환시킨다. 즉 Vector3로 변환한다.
        • x축, z축 즉 평면의 회전 값만 변하는 y 축 중심의 회전 방향만 고려할 것
    • 내 Vector3 회전 값(transform.eulerAngles)을 Vector3.up * targeAngleY로 설정해주면 되는데, 이를 스무스 하게 설정하자. transform.eulerAngles.y 값이 targetAngleY로 스무스 하게 변할 수 있도록.
      • Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngleY, ref turnSmoothVelocity, turnSmoothTime);
  • 공격이 실행되자마자 바로 공격 처리가 들어가게 하지 않고 공격 애니메이션이 끝나갈때, 즉 좀비가 공격하기 위해 팔을 뻗는 애니메이션을 충분히 다 취하고난 후에 공격 처리를 할 것이다.
    • 따라서 AttackBegin 상태에는 공격 애니메이션을 재생하고 한창 공격 애니메이션이 충분히 재생되고 난 후인 Attacking 상태일 때 공격 처리(감지하고 데미지 주기 등등)를 할 것이다.
      • if (state == State.Attacking) 공격 처리 시작
        • 1️⃣ attackRoot 중심으로 구를 그려서 해당 구에 겹치는 Collider들을 감지.
          • ⭐ 단, Physics.OverlapSphere 함수로 Collider를 감지 하지 않고 Physics.SphereCastNonAlloc 함수를 사용할 것이다.
            • Physics.OverlapSphere 함수
              • image
              • 실행과 실행 그 사이(다음 FixedUpdate())의 순간에서 좀비나 공격 대상이 너무 빠르게 움직여버려서 감지가 됐어야 맞는데 그림처럼 감지가 안되는 상황이 생길 수 있다.
              • Collider [] 배열을 리턴한다.
            • Physics.SphereCastNonAlloc 함수
              • image
              • 위와 같은 상황을 방지하고자 Physics.SphereCastNonAlloc 함수를 쓸 것이다.
                • 이 함수는 인수로 방향과 거리를 넘겨주면 구가 해당 방향과 거리로 이동한 ✨궤적✨에 겹치는 Collider가 있는지를 검사한다. 이게 바로 Cast계열 함수들의 특성
              • Collider가 들어있는 것이 아닌 Raycast에 걸린 원소들이 들어 있는 hit 배열의 크기를 리턴한다.
          • direction 👉 좀비가 보는 앞쪽 방향 transform.forward
          • deltaDistance 👉 좀비(AI agent)가 다음 FixedUpdate() 실행까지의 사잇 시간인 Time.fixedDeltaTime 동안 이동한 거리(스칼라)가 된다. (속도 크기를 곱해주면 됨!)
            • 그런데 Time.fixedDeltaTime가 아닌 Time.deltaTime을 쓴 이유는 밑에 참고
          • size 👉 Physics.SphereCastNonAlloc 함수의 리턴값으로 hit 배열의 크기. 즉, 구 궤적에 걸린 모든 RaycastHit 들이 몇개인지를 나타낸다.
            • 어차피 감지될 플레이어는 한명인데 size가 1 보다 클 수 있는 이유. 즉 RaycastHi 결과 충돌한 Collider들이 여러개일 수 있는 이유
              • 플레이어는 한명이지만 Ray 충돌 포인트는 다수가 존재할 수 있다.
              • 플레이어는 하나라도 플레이어에게 할당된 콜라이더는 여러개가 존재할 수 있으며 또한 플레이어랑 부딪치는 물체도 여러개가 존재할 수 있다.
            • Physics.SphereCastNonAlloc 함수에 대한 자세한 설명은 바로 아래에 필기함
        • 2️⃣ 감지된 것들이 모여있는 hit 배열에서 Player Character인 것(whatIsTarget 레이어에다가 LivingEntity를 가지고 있는 것)을 for문으로 순회하며 찾아 공격을 처리해준다. 찾으면 순회 끝냄 break
          • hit.size()만큼 돌면 안되고 size만큼만 돌아야 한다. 만약 충돌체들이 3 개였다면 hit 배열 크기는 10이므로 (멤버 변수 선언 때 10 크기로 선언했었다) 3 개의 충돌체와 함께 그 뒤의 7 개는 이전 프레임의 충돌체 정보를 담고 있게 된다. 따라서 10 만큼 돌면 안되고 딱 3 만큼으로 반복문을 돌아야 함!
          • 감지된 것들 중 `LivingEntity`이며 lastAttackedTargets 리스트에 소속되는 것이 아닌 경우(공격 도중에 또 공격이 들어가면 안되므로)에만 공격 처리를 해야 한다.
            • 공격량, 공격행위자(자기자신), 공격거리, 공격방향 등을 message에 설정한 후 공격을 개시한다.
            • lastAttackedTargets리스트에 공격대상을 추가해준다.
Physics의 Cast계열 함수의 특징

참고 블로그

  • 유니티에서 Collider를 찾아내는 방법은 크게 4 가지가 있다. Ray, Box, Sphere, Capsule 등등 이런 형태의 Collider 컴포넌트를 오브젝트에 달아서 OnTrigger나 OnCollision 같은 이벤트를 사용하여 해당 형태에 겹치는 Collider를 찾아내기도 한다.
    • 그러나 이런 방법의 경우 매 프레임 실행되는 것이기 때문에 순간적으로 딱 한번만 Collider를 찾아내려고 하는 경우에는 성능상 부적절할 수 있다. 매 프레임 실행되므로 적당히 모양을 유지하는 경우에는 사용하기 괜찮지만 그 순간의 겹치는 Collider를 잡아내야 하는 경우에는 힘들기 때문!
  • Physics의 Cast계열 함수 들은 움직이려는 궤적에 충돌하는지를 검사하기 때문에 위와같이 프레임간 사이에서 빠르게 변화하여 감지되지 못할 수 있는 것들도 감지할 수 있도록 해준다.
    • 종류
      • Cast
        • 👉 찾아낸 충돌체 하나만을 구조체로 반환한다. 가장 처음에 충돌한 물체만 반환한다.
      • CastAll
        • 👉 찾아낸 충돌체 전부를 리턴한다. RaycastHit [] 배열로 리턴한다.
      • CastNonAlloc
        • 👉 충돌체들을 리턴이 아닌 인수로 넘긴 RaycastHit 배열에 담아준다. 따라서 CastAll보다는 성능이 더 좋을 수 있다. 다만 찾아낸 충돌체들의 수가 인수로 넘긴 배열의 사이즈보다 적을 수도 많을 수도 있다는 것에 주의하여 사용해야 한다.
          • 직전 프레임까지 어떤 RaycastHit 정보가 있었는지를 알 수 있다.
        • 리턴은 int로 인수로 넘긴 배열이 채워진 사이즈, 즉 충돌체들의 개수를 리턴한다.
    • 움직이려고 하자마자, 즉 궤적을 그리기도 전에 바로 Collider가 걸린 상태라면 첫번째로 감지된 이 Collider의 point는 제로 포인트다.
      • 가상의 구 (SphereCastNonAlloc을 예로 들자면) 가 움직이기도 전에 처음부터 겹쳐있었던 것(hits[0])이 있다면 그 Collider의 point(충돌 위치)는 제로 포인트가 된다.
        • hits[0].point는 (0, 0, 0)
        • 따라서 이런 경우엔 distance도 0 으로 나오게 된다.
var size = Physics.SphereCastNonAlloc(attackRoot.position, attackRadius, direction, hits, deltaDistance, whatIsTarget);
  • SphereCastNonAlloc 함수
    • attackRadius 반경을 가진 구가 attackRoot.position에 위치로부터 direction 방향으로 deltaDistance 거리만큼 이동하면서 생긴 궤적(연속선상)에 겹치는 Collider들 중에서 whatIsTarget LayerMask를 가진 Collider가 있다면 그것을 hits 배열에 담고 그 배열의 크기를 리턴한다.
    • out hits 혹은 ref hits 이렇게 레퍼런스로 넘기지 않은 이유는 hits 배열 이름이 그 자체로 레퍼런스가 되기 때문이다. 따라서 그냥 hits로 인수 넘기면 됨.
      • 배열이 아니라 그냥 RaycastHit 자체를 넘기는 것이였으면 value이므로 out hit 이런식으로 넘겨야 한다.
Time.fixedDeltaTime
  • FixedUpdate() 함수가 실행되는 그 사이의 시간. 다음 FixedUpdate() 함수가 실행되기까지의 시간.
  • FixedUpdate() 함수는 디폴트로 1/50초인 0.02초를 주기로 실행되기 때문에 디폴트론 Time.fixedDeltaTime 값은 0.02이다.
  • FixedUpdate() 함수 안에서 사용할 때 Time.fixedDeltaTime가 아닌 그냥 Time.deltaTime 를 사용할 것을 권장한다.
    • 자동으로 Time.deltaTimeFixedUpdate() 함수 안에서 쓰이면 알아서 Time.fixedDeltaTime 으로 리턴하기 때문이다.
      • Time.deltaTime은 알아서 Update() 함수에서는 프레임간의 사잇 시간으로서 동작하고 FixedUpdate() 함수 안에서 쓰이면 알아서 고정된 프레임인 Time.fixedDeltaTime (0.02초) 으로 쓰인다.
  • Time.timeScale을 통해 시간이 흘러가는 속도를 변경한다면 Time.fixedDeltaTime도 그에 맞게 변경해줄 것을 권장한다.
    • Time.fixedDeltaTime = 0.02f * Time.timeScale
    • Time.timeScale이 2.0f이면 디폴트에 비해 두배로 시간이 빨리 흘러간다는 의미고 0.0f면 시간이 아예 흐르지 않고 정지 상태라는 것을 의미한다.


private IEnumerator UpdatePath()

코루틴 함수

좀비 AI 가 주기적으로 추적할 대상의 위치를 찾아 경로를 갱신

  • 좀비가 살아있는 동안 계속 반복한다. 무한 루프
    private IEnumerator UpdatePath()
    {
        // 살아있는 동안 무한 루프
        while (!dead)
        {
            if (hasTarget)
            {
                if (state == State.Patrol)
                {
                    state = State.Tracking;
                    agent.speed = runSpeed;
                }

                // 추적 대상 존재 : 경로를 갱신하고 AI 이동을 계속 진행
                agent.SetDestination(targetEntity.transform.position);
            }
            else
            {
                if (targetEntity != null) targetEntity = null;

                // 정찰 상태가 아니였다면 이제 다시 정찰 상태로 변경
                if (state != State.Patrol)
                {
                    state = State.Patrol;
                    agent.speed = patrolSpeed;
                }

                // 일단 시야를 통해 감지하기 전에 NavMesh 위의 어떤 임의의 지점으로 이동하게 한다.
                if (agent.remainingDistance <= 1f)
                {
                    var patrolPosition = Utility.GetRandomPointOnNavMesh(transform.position, 20f, NavMesh.AllAreas);
                    agent.SetDestination(patrolPosition);
                }

                // 20 유닛의 반지름을 가진 가상의 구를 그렸을때, 구와 겹치는 모든 콜라이더를 가져옴
                // 단, whatIsTarget 레이어를 가진 콜라이더만 가져오도록 필터링
                var colliders = Physics.OverlapSphere(eyeTransform.position, viewDistance, whatIsTarget);

                // 모든 콜라이더들을 순회하면서, 살아있는 LivingEntity 찾기
                foreach (var collider in colliders)
                {
                    if (!IsTargetOnSight(collider.transform)) continue;

                    var livingEntity = collider.GetComponent<LivingEntity>();

                    // LivingEntity 컴포넌트가 존재하며, 해당 LivingEntity가 살아있다면,
                    if (livingEntity != null && !livingEntity.dead)
                    {
                        // 추적 대상을 해당 LivingEntity로 설정
                        targetEntity = livingEntity;

                        // for문 루프 즉시 정지
                        break;
                    }
                }
            }

            // 0.05 초 시간 간격을 두면서 살아 있는 동안 무한 루프 반복 처리
            yield return new WaitForSeconds(0.05f);
        }
    }
  • while (!dead) 살아있는 동안 무한 루프로 추적 대상의 위치를 찾아 경로를 갱신한다.
    • if (hasTarget) 추적 대상이 존재한다면
      • agent.SetDestination(targetEntity.transform.position);
        • 추적 대상의 위치를 agent의 목표 위치로 삼고 그에 따라 경로를 갱신하고 AI 이동을 계속 진행
        • SetDestination
          • 👉 NavMeshAgent 컴포넌트의 함수로 목적지를 인수로 넘겨 해당 목적지로 매 프레임마다 agent로 하여금 이동하게 하는 함수다.
      • 추적 대상이 존재하는데 지금 정찰 상태였다면 if (state == State.Patrol)
        • 이제 더 이상 정찰하지 않고 추적 대상을 향해 뛰어가도록 상태를 Tracking으로 바꿔 주고
        • 속도도 runSpeed로 바꿔준다.
    • else 추적 대상이 존재하지 않는다면 (플에이어가 죽었거나 없는 상태)
      • 주변을 정찰하면서 플레이어를 찾아야 함.
        • 1️⃣ targetEntity가 null이 아닌데 hasTarget이 null인 경우는 플레이어가 사망한 상태이다. 나중에 만약 이 게임을 온라인 게임으로 확장해서 사망한 플레이어 말고 이제 좀비들이 다른 플레이어를 추적하게끔 하려고 한다면 targetEntity를 null로 변경해주어야 한다.
        • 2️⃣ 정찰 상태가 아니였다면 이제 다시 정찰 상태로 변경해주어야 한다. 추적 대상이 없어진거니까.
          • 상태와 속도를 Patrol에 맞게 변경
        • 3️⃣ 새로운 정찰 시작 위치 결정하기
          • 시야를 통해서 감지하기 전에 먼저 Zombie에게 NavMesh 위의 임의의 위치로 찍어줘서 그 위치로 이동하게끔 해준다.
            • 이 무한루프는 0.2초마다 실행이 되고 있으므로 아직 그 위치로 이동을 완료 하지도 않았는데 매번 새로운 정찰 위치를 결정하면 이상하게 보일 것이다. 따라서 새로운 정찰 위치인 patrolPosition에 거의 다다른 다음에, 즉 거의 이동을 완료할 때쯤에, 찾으려는 대상이 없으면 그제서야 새로운 정찰 위치를 설정할 수 있도록 한다.
              • if (agent.remainingDistance <= 1f)
                • 인공지능 agent가 목표지점까지의 남은 거리가 1 이하일 때만. 즉, 거의 다 왔을 때만 실행하도록.
            • var patrolPosition = Utility.GetRandomPointOnNavMesh(transform.position, 20f, NavMesh.AllAreas);
              • 📜Utility.cs에 구현되어 있는 GetRandomPointOnNavMesh 함수를 사용하여 NavMesh 위에서 어떤 위치와 반경을 기준으로 랜덤한 위치 리턴
              • 현재 위치에서 20 만큼의 반경 내의 NavMesh.AllAreas에서 랜덤한 위치 찍음.
            • agent.SetDestination(patrolPosition);
              • 그 위치로 이동시킴
        • 4️⃣ 시야를 통해 적을 감지
          • image
            • 시야 각 내에 있는 모든 Collider들을 배열에 담는다.
              • var colliders = Physics.OverlapSphere(eyeTransform.position, viewDistance, whatIsTarget);
                • 좀비의 눈의 위치 eyeTransform.position 를 기준으로 시야 사정거리 viewDistance을 구의 반경으로 삼았을 때 whatIsTarget layMask에 해당하는 Collider 들을 colliders에 리턴한다.
                  • colliders은 Collider [] 배열
            • 모든 Collider들을 순회하면서 살아있는 Living Entity 찾기.
              • 단, 시야 각 내에 있는 모든 것들에게 Zombie의 눈에서 Raycast를 쐈을 때 상대방에게 무사히 도착 했는지를 기준으로 걸러낼 것. 시야각 내에 있더라도 장애물 뒤에 있는 대상이라면 감지 할 수 없는 대상으로서 걸러내야 한다. 좀비가 볼 수 있는 것들에 대해서만 감지해야 한다
                • if (!IsTargetOnSight(collider.transform)) continue;
                  • 좀비가 볼 수 없는 대상이라면 스킵! 다음 collider 검사하러 가기.
                  • IsTargetOnSight 함수
                    • 👉 밑에서 설명 되어있는 함수.
                    • 인수로 넘긴 collider의 위치가 좀비가 볼 수 있는 위치인지를 Raycast로 검사하여 True, False로 리턴하는 함수다.
              • LivingEntity 컴포넌트가 존재하며, 해당 LivingEntity가 살아있다면
                • 추적 대상을 해당 LivingEntity로 설정
                • 찾았으니 더 이상 collider는 순회하지 않고 for문을 빠져나온다.
    • 위 과정을 0.05 초 시간 간격을 두면서 살아 있는 동안 무한 반복.
📜Utility.cs
using UnityEngine;
using UnityEngine.AI;

public static class Utility
{
    // NavMesh 위에서 어떤 위치와 반경을 기준으로 랜덤한 위치 리턴
    public static Vector3 GetRandomPointOnNavMesh(Vector3 center, float distance, int areaMask)  // 인수 👉 중심 위치, 반경 거리, 검색할 Area (내부적으로 int)
    {
        var randomPos = Random.insideUnitSphere * distance + center;  // center를 중점으로 하여 반지름(반경) distance 내에 랜덤한 위치 리턴. *Random.insideUnitSphere*은 반지름 1 짜리의 구 내에서 랜덤한 위치를 리턴해주는 프로퍼티다.
        
        NavMeshHit hit;  // NavMesh 샘플링의 결과를 담을 컨테이너. Raycast hit 과 비슷
        
        NavMesh.SamplePosition(randomPos, out hit, distance, areaMask);  // areaMask에 해당하는 NavMesh 중에서 randomPos로부터 distance 반경 내에서 randomPos에 *가장 가까운* 위치를 하나 찾아서 그 결과를 hit에 담음. 
        
        return hit.position;  // 샘플링 결과 위치인 hit.position 리턴
    }
    
    // 이전 포스트에서 한번 사용한적 있음! 
    public static float GetRandomNormalDistribution(float mean, float standard)
    {
        var x1 = Random.Range(0f, 1f);
        var x2 = Random.Range(0f, 1f);
        return mean + standard * (Mathf.Sqrt(-2.0f * Mathf.Log(x1)) * Mathf.Sin(2.0f * Mathf.PI * x2));
    }
}
  • 랜덤한 위치 randomPos 생성
    • var randomPos = Random.insideUnitSphere * distance + center;
      • center를 중점으로 하여 반지름(반경) distance 내에 랜덤한 위치 리턴.
      • Random.insideUnitSphere은 반지름 1 짜리의 구 내에서 랜덤한 위치를 리턴해주는 프로퍼티다.
  • NavMesh인 areaMask 이면서 + distance 내에서, randomPos에 가장 가까운 위치 생성
    • NavMesh.SamplePosition(randomPos, out hit, distance, areaMask);
      • areaMask에 해당하는 NavMesh 중에서 randomPos로부터 distance 반경 내에서 randomPos에 가장 가까운 위치를 하나 찾아서 그 결과를 hit에 담음.
  • hit.posiion이 최종 결과가 됨.
    • NavMesh 위에서 어떤 위치와 반경을 기준으로 랜덤한 위치


public override bool ApplyDamage(DamageMessage damageMessage)

📜LivingEntity.cs 의 ApplyDamage 오버라이딩

    // 좀비가 총 맞아서 데미지를 입었을때 실행할 처리
    public override bool ApplyDamage(DamageMessage damageMessage)
    {
        if (!base.ApplyDamage(damageMessage)) return false;  // 데미지 처리

        if (targetEntity == null)
        {
            targetEntity = damageMessage.damager.GetComponent<LivingEntity>();
        }

        EffectManager.Instance.PlayHitEffect(damageMessage.hitPoint, damageMessage.hitNormal, transform, EffectManager.EffectType.Flesh);  // 타격 파티클효과 실행
        audioPlayer.PlayOneShot(hitClip);  // 오디오 재생

        return true;
    }

좀비가 맞았을 때

  • if (!base.ApplyDamage(damageMessage)) return false;
    • 📜LivingEntity.cs 의 ApplyDamage을 먼저 실행하고
      • base.ApplyDamage(damageMessage)
    • 이때 성공적으로 데미지 처리가 이루어질 수 없어 False가 리턴 되었다면 📜Enemy.cs의 이 ApplyDamage 함수도 return false 한다.
    • 데미지 처리는 부모 클래스인 📜LivingEntity.cs의 ApplyDamage 함수에서 이루어진다.
      • 즉, 이 if문에서 데미지 처리가 수행된다.
      • LivingEntity의 자식인 📜PlayerHealth.cs 스크립트나 📜Enemy.cs 스크립트나, 데미지를 입는 로직은 동일하기 때문에 base.ApplyDamage(damageMessage)로 처리 하는 것이다.
        • 공격을 당하고 난 후에 수행할 행동이라던가 파티클 효과 같은 것은 좀비랑 플레이어 캐릭터가 서로 다르게 일어나므로 이 부분만 오버라이딩된 ApplyDamage 함수에 덧붙여 주면 되는 것이다.
  • if (targetEntity == null)
    • 아직 추적할 대상을 못 찾았는데 공격을 당했다면 자신할 공격한 사람을 targetEntity에 할당한다.
    • 공격 당하는 순간에 공격 한 사람을 알아보도록.
  • 파티클 효과 재생
    • 재생 위치, 방향 등등 인수로 넘겨준다.
    • 피가 튀는 효과
  • 효과음 재생


public void BeginAttack()

코드상에서(이 함수는 Update 함수 내에서 실행됨) 명시적으로 공격을 시작할 때 사용.

  • 공격 애니메이션이 시작되지만 데미지를 입히는 시점은 아님
    public void BeginAttack()
    {
        state = State.AttackBegin;

        agent.isStopped = true;
        animator.SetTrigger("Attack");
    }
  • 상태를 State.AttackBegin 으로 설정.
  • AI agent 잠시 추적을 정지.
    • agent.isStopped = true;
  • 공격 애니메이션 시작
    • animator.SetTrigger(“Attack”);


public void EnableAttack()

실제로 데미지가 들어가기 시작하는 지점 (State.Attacking 상태가 되어 FixedUpdate 함수에서 데미지 처리 실행)

  • 코드 상에서 실행되는 메소드가 아닌애니메이션 이벤트를 통해 실행할 메소드다.
    • 공격 애니메이션의 재생 중간 쯤에 실행할 것이라서
    public void EnableAttack()
    {
        state = State.Attacking;

        lastAttackedTargets.Clear();
    }
  • 상태를 State.Attacking 으로 변경.
  • 이전까지 공격한 대상들이 담겨있는 lastAttackedTargets 리스트 비움


public void DisableAttack()

공격이 끝나는 지점. 데미지를 입히는 처리가 끝났을 때.

  • 코드 상에서 실행되는 메소드가 아닌 애니메이션 이벤트를 통해 실행할 메소드다.
    • 공격 애니메이션이 얼추 끝나갈 때 실행할 것이라서
    public void DisableAttack()
    {
      if(hasTarget)
        {
            state = State.Tracking;
        }
        else
        {
            state = State.Patrol;
        }

        agent.isStopped = false;
    }
  • 타겟이 여전히 남아있다면 상태를 State.Tracking로 되돌림.
  • 타겟이 이제 없다면 상태를 State.Patrol로 되돌림.
    • 위와 같이 두개의 케이스로 나누어주지 않고 State.Tracking를 해버리면 나중에 targetEntity는 null이 되버리므로 targetEntity와의 거리를 재려는 처리를 할 때 런타임 에러가 발생한다.
  • AI Agent가 다시 움직이도록 agent.isStopped = false


애니메이션 이벤트 함수

📢 이 애니메이션이 적용되는 오브젝트에 붙어있는 또 다른 스크립트 혹은 컴포넌트에 해당 함수가 존재해야 한다.

  • ZombieAnimator 의 애니메이터 컨트롤러에 보면 상태도에 Bite라는 이름의 애니메이션 상태가 있다. Bite는 공격 애니메이션을 취하는 상태이다.
  • Bite 상태에 할당되어 있는 애니메이션 클립인 📂Assets/Animations/Zombie Clips 에 위치한 ZombieBite 라는 애니메이션 클립을 더블 클릭 해보자.
    • image
    • image
    • ZombieBite 애니메이션 클립의 재생 흐름인데 상단을 보면 중간에 하얀 네모가 두개가 있는 것을 볼 수 있다. 이것들이 하나는 EnableAttack() 함수가 실행되는, 다른 하나는 DisableAttack() 함수가 실행되는 이벤트임을 알 수 있다.
      • 무기를 휘두루는 시점에서 데미지가 들어가는 것이 아닌 얼추 휘두르고 난 애니메이션 재생 중간 시점부터 데미지가 들어가게 하기 위해 애니메이션 클립 중간 즈음에 두 함수가 실행된는 것을 볼 수 있다.
애니메이션 클립에 함수를 이벤트로 등록하는 방법

write 권한이 있는 애니메이션 클립에만 적용할 수 있다.

3D 모델 파일 내부에 포함되어 있는 애니메이션 클립같은 경우 유니티 엔진에서 write 쓰기 권한이 없기 때문에 이런 클립들엔 함수를 이벤트로 등록할 수가 없다. 📂Assets 폴더에 별개의 애니메이션 클립으로서 독립적으로 존재하는 그런 애니메이션 클립들에만 적용이 가능하다.

  1. 애니메이션 클립을 더블 클릭해서 연다.
  2. 이벤트 함수를 추가할 시점을 선택(하얀 직선이 생긴다)하여 클릭한 후
    • image
  3. 하얀 네모 추가 버튼, 즉 이벤트 추가버튼을 눌러주면 해당 시점에 하얀 네모가 추가된다.
    • image
  4. Inspector 창을 보면 이 시점에 추가할 이벤트를 작성할 수 있는 폼이 뜬다. 해당 시점에 원하는 함수의 이름을 써주면 됨.
    • image
  • 팁! 애니메이션 클립 창의 아래에 있는 가로 스크롤바의 양 옆에 커서를 놓으면 가로 화살표가 뜨는데 이를 드래그 하여 줌 앤 줌아웃 할 수 있다. 마우스 휠 스크롤로도 가능하다.
    • image


private bool IsTargetOnSight(Transform target)

인수로 넘긴 위치가 좀비가 볼 수 있는 위치인지를 Raycast로 검사하여 True, False로 리턴하는 함수다.

    private bool IsTargetOnSight(Transform target)
    {
        RaycastHit hit;

        var direction = target.position - eyeTransform.position;

        direction.y = eyeTransform.forward.y;

        // 1️⃣ 그 광선이 시야각을 벗어나선 안되며 
        if (Vector3.Angle(direction, eyeTransform.forward) > fieldOfView * 0.5f)
        {
            return false;
        }

        direction = target.position - eyeTransform.position;

        // 2️⃣ 시야각 내에 존재 하더라도 광선이 장애물에 부딪치지 않고 목표에 잘 닿아야 함
        if (Physics.Raycast(eyeTransform.position, direction, out hit, viewDistance, whatIsTarget))
        {
            if (hit.transform == target) return true;
        }

        return false;
    }
  • hit
    • Raycast 결과 정보가 담길 컨테이너
  • direction
    • 눈의 위치(시작)로부터 타겟 위치(목표)로 향하는 방향
      • 방향 = 눈의 위치 - 타겟 위치
    • 다만, 수평적인 각도만 따질 것이므로 1️⃣ 조건을 따질 때 수직 방향은 고려하지 않기 위해서 y 값은 눈의 위치와 똑같게.
      • direction.y = eyeTransform.forward.y;
  • 좀비의 눈의 위치에서 Raycast 광선을 쐈을 때
    • 1️⃣ 그 광선이 시야각을 벗어나지 않고
      • 좀비 눈에서 타겟 위치로 향하는 방향과 눈의 앞쪽 방향의 사이각이 좀비가 볼 수 있는 시야 각의 절반 보다 크다면 볼 수 없는 타겟임.
        • image
    • 2️⃣ 광선이 장애물에 부딪치지 않고 목표에 잘 닿아야 함
      • eyeTransform.position 눈에서 direction 방향으로 광선 쏴서 viewDistance 거리 내에 whatIsTarget 레이마스크인 것과 충돌하는 것이 있다면 hit에 담는다.
        • if (hit.transform == target) return true;
          • 원래 target과 Raycast의 결과와 같다면 장애물에 부딪치는게 없었던 것이니 true 리턴
      • direction = target.position - eyeTransform.position;
        • 2️⃣ 하기전에 이렇게 다시 초기화 해준 이유는 Raycast를 실행하기 전 y방향을 원래 방향대로 되돌려놓기 위해서
  • 1️⃣2️⃣ 를 만족하지 않으면 좀비가 볼 수 없는 것이니 False 리턴
  • 둘 다 만족하면 True 리턴


public override void Die()

📜LivingEntity.cs 의 ApplyDamage 오버라이딩

    // 사망 처리
    public override void Die()
    {
        // LivingEntity의 Die()를 실행하여 기본 사망 처리 실행
        base.Die();

        // 다른 AI들을 방해하지 않도록 자신의 모든 콜라이더들을 비활성화
        GetComponent<Collider>().enabled = false;

        // AI 추적을 중지하고 내비메쉬 컴포넌트를 비활성화
        agent.enabled = false;

        // 사망 애니메이션 재생
        animator.applyRootMotion = true;
        animator.SetTrigger("Die");

        // 사망 효과음 재생
        if (deathClip != null) audioPlayer.PlayOneShot(deathClip);
    }
}
  • 📜LivingEntity.cs 의 Die()을 먼저 실행하여 생명체로서의 기본 사망 처리를 한다.
    • base.Die()
    • 기본적인 사망 처리는 부모 클래스인 📜LivingEntity.cs의 Die() 함수에서 이루어진다.
      • LivingEntity의 자식인 📜PlayerHealth.cs 스크립트나 📜Enemy.cs 스크립트나, 공통적인 생명체로서의 사망 로직은 동일하기 때문에 base.Die()로 처리 하는 것이다.
        • 사망 하고 난 후에 수행할 행동이라던가 애니메이션 같은 것은 좀비랑 플레이어 캐릭터가 서로 다르게 일어나므로 이 부분만 오버라이딩된 Die 함수에 덧붙여 주면 되는 것이다.
  • 다른 AI들을 방해하지 않도록, 예를 들어 길막하지 않도록 자신의 모든 콜라이더들을 비활성화
    • GetComponent<Collider>().enabled = false;
      • 자기 자신의 Collider 컴포넌트를 끈다.
  • AI 추적을 중지하고 내비메쉬 컴포넌트를 비활성화
    • agent.isStopped = true 하는 방법도 있는데 다만 이 방법은 본인의 추적을 멈추는 것일 뿐 비활성화된 Agent는 아니다. Nav Mesh Agent들끼리는 내비게이션 추적에 있어 서로를 장애물로 인식하고 피하고 다니기 때문에 아예 비활성화 해주는 것이다.
      • 즉, 활성화 되있다면 나중에 좀비가 많이 죽었을 때 쓸데없이 크게 돌아와야 하고 그렇기 때문 😢
      • 사망한 좀비는 밟고 다닐 수 있게끔 해주자.
  • 사망 애니메이션 재생
    • Animator 컴포넌트의 applyRootMotion를 True로 하여 루트 모션을 켜준다.
      • 사망 애니메이션 위치 같은 것을 애니메이션에 의해 통제되게 하는 것이 더 자연스럽기 때문.
      • 어차피 죽었기 때문에 코드로 통제하는것 보다는 애니메이션으로 통제하는 것이 더 자연스럽다.
    • Die 애니메이션 재생
  • 사망 효과음 재생


🔔 컴포넌트 설정

image

  • 사망, 데미지 애니메이션 클립을 위와 같이 할당해준다.
  • attackRadius 값을 0.5로 조금 줄였다!
    • image
  • 원래 targetEntity[HideInInspector] 속성이여서 슬롯이 안열리고 안보이는게 맞는데, 일단 코드 상에서 [HideInInspector]를 지워 잠시 열어두었다. 👉 이러면 좀비는 타겟이 누군지 알기 때문에 게임 시작하면 플레이어에게 그냥 바로 멀리서도 달려온다! 이것을 본 후 다시 원래대로 [HideInInspector]을 붙여 수정했다. targetEntityPlayer Character 오브젝트가 좀비의 시야각에 들어올 때 SphereCastNonAlloc 함수를 통해 감지되고 할당된다.
    • Player Character 오브젝트를 할당한다.
      • 📜LivingEntity.cs 를 상속 하는 📜PlayerHealth.cs 스크립트를 가지고 있기에 LivingEntity 타입인 targetEntity에 할당이 가능하다.
  • whatIsTarget 레이어마스크는 Player로 설정해준다.

프리팹

image

  • PlayerCharacter의 변경 사항을 모두 Overrides 하여 Apply all 해준다.
  • Zombie 또한 변경 사항을 모두 Overrides 하여 Apply all 해준다.


🌜 개인 공부 기록용 블로그입니다. 오류나 틀린 부분이 있을 경우 
언제든지 댓글 혹은 메일로 지적해주시면 감사하겠습니다! 😄

맨 위로 이동하기


Unity Lesson 1 카테고리 내 다른 글 보러가기

댓글 남기기