[ Survival Shooter 2 ] Enemy Object 설계하기

예제 프로젝트는 간단한 Isometric View 슈팅 게임으로 나중에 비슷한 게임을 만들 때 참고용으로 사용하기 위해 특히 Enemy Object에 들어간 Components 와 Scripts 를 분석해보자. 이것을 기본으로 필요한 기능만 확장하면 좋은 Object 설계에 도움이 될 것이다.
각 컴포넌트에 대한 분석은 Unity 태그에서 따로 업로드했고, 이 글에서는 Scripts 위주로 살펴 보도록 하자.


public class EnemyMovement : MonoBehaviour
    {
        Transform player;               // Reference to the player's position.
        PlayerHealth playerHealth;      // Reference to the player's health.
        EnemyHealth enemyHealth;        // Reference to this enemy's health.
        UnityEngine.AI.NavMeshAgent nav;               // Reference to the nav mesh agent.


        void Awake ()
        {
            // Set up the references.
            player = GameObject.FindGameObjectWithTag ("Player").transform;
            playerHealth = player.GetComponent <PlayerHealth> ();
            enemyHealth = GetComponent <EnemyHealth> ();
            nav = GetComponent <UnityEngine.AI.NavMeshAgent> ();
        }


        void Update ()
        {
            // If the enemy and the player have health left...
            if(enemyHealth.currentHealth > 0 && playerHealth.currentHealth > 0)
            {
                // ... set the destination of the nav mesh agent to the player.
                nav.SetDestination (player.position);
            }
            // Otherwise...
            else
            {
                // ... disable the nav mesh agent.
                nav.enabled = false;
            }
        }

    }

먼저 EnemyMovement Scripts 를 분석해보자. Enemy는 별도의 사용자 입력이 필요하지 않으며, Nav Mesh Agent Component 가 Enemy Object의 이동을 지시한다. Nav Mesh Agent 는 about Unity 카테고리 에서 따로 기술하도록 하겟으며, 지금은 해당 Script 에서 쓰인 메서드만 알아보자.
먼저 NavMeshAgent는 UnityEngine.AI 에 선언되어 있으므로 변수 선언시 별로도 import 하지 않았기 때문에 위와 같이 클래스명 경로를 모두 써주어야 한다.

GetComponent<T> 메서드의 원형은 public Component GetComponent(Type type); 
해당 오브젝트에서 type 컴포넌트를 받아오는데 사용된다. 그렇다면 같은 컴포넌트가 복수개 있다면 어떤 방식으로 접근되는지 알아보자.
 
하나의 Game Object에 두개의 같은 컴포넌트를 추가하고 프로퍼티를 다르게 한 후
Getcomponent 와 GetComponents 의 차이점을 알아봤다.

결과 Getcomponent는 같은 컴포넌트가 복수개 존재 시 인스펙터 상의 맨위의 컴포넌트만 받아오며, 제대로 두 컴포넌트를 모두 접근하려면 Getcomponents 메서드를 사용해야한다.
Getcomponents 메서드는 T 컴포넌트를 모두 찾아 배열로 반환하는 메서드이다.


여러가지 이유에 의해서 Player 게임 오브젝트를 스크립트 내에서 접근 할 수 있어야 한다. 어떤 게임이든 Enemy의 행동은 Player의 상태에 의해서 결정되기 때문이다. 사용된 방법은 아래와 같다.
public static GameObject[] FindGameObjectsWithTag(string tag); 
이름에서 알 수있듯 이 메서드는 태그가 tag인 게임오브젝트를 모두 찾아 배열로 반환한다. 여기서는 단수버전의 FindGameObejctWithTag 가 사용되었을 뿐이다.

이제 Update메서드 호출 주기 마다 Enemy의 행동을 결정하는 부분이다
먼저 playerHealth는 player의 남은 체력 정보를 담고있는 스크립트이다. 
if 조건문의 내용은 이해하기 쉽다. 당연히 Enemy 자신의 체력이 0보다 크고, Player의 체력이 보다 커야 player에 접근해서 무언가 행동을 취하는 것이 의미가 있다.

AI.NavMeshAgent 의 SetDestination 함수의 원형은 아래와 같다.
public bool SetDestination(Vector3 target);
target의 position 정보를 받아 위치를 추적을 결정하는 NavMeshAgent의 메서드인데. update 주기마다 player의 position 정보를 최신화 하는 것을 의미한다.

만약 Enemy의 체력이 0이하 이면 Enemy 자신이 기능을 정지한 것이고, Player의 체력이 0이하면 더이상 추척할 필요가 없으므로 NavMeshAgent 컴포넌트의 사용을 중지한다.

이제 우리는 EnemyMovement Script 의 내용을 이해하고 이를 응용해서 더 재밌게 행동하는 Enemy를 설계할 때 사용할 수 있다.

EnemyHealth Script 의 내용은 다음과 같다.
using UnityEngine;

namespace CompleteProject
{
    public class EnemyHealth : MonoBehaviour
    {
        public int startingHealth = 100;            // The amount of health the enemy starts the game with.
        public int currentHealth;                   // The current health the enemy has.
        public float sinkSpeed = 2.5f;              // The speed at which the enemy sinks through the floor when dead.
        public int scoreValue = 10;                 // The amount added to the player's score when the enemy dies.
        public AudioClip deathClip;                 // The sound to play when the enemy dies.


        Animator anim;                              // Reference to the animator.
        AudioSource enemyAudio;                     // Reference to the audio source.
        ParticleSystem hitParticles;                // Reference to the particle system that plays when the enemy is damaged.
        CapsuleCollider capsuleCollider;            // Reference to the capsule collider.
        bool isDead;                                // Whether the enemy is dead.
        bool isSinking;                             // Whether the enemy has started sinking through the floor.


        void Awake ()
        {
            // Setting up the references.
            anim = GetComponent <Animator> ();
            enemyAudio = GetComponent <AudioSource> ();
            hitParticles = GetComponentInChildren <ParticleSystem> ();
            capsuleCollider = GetComponent <CapsuleCollider> ();

            // Setting the current health when the enemy first spawns.
            currentHealth = startingHealth;
        }


        void Update ()
        {
            // If the enemy should be sinking...
            if(isSinking)
            {
                // ... move the enemy down by the sinkSpeed per second.
                transform.Translate (-Vector3.up * sinkSpeed * Time.deltaTime);
            }
        }


        public void TakeDamage (int amount, Vector3 hitPoint)
        {
            // If the enemy is dead...
            if(isDead)
                // ... no need to take damage so exit the function.
                return;

            // Play the hurt sound effect.
            enemyAudio.Play ();

            // Reduce the current health by the amount of damage sustained.
            currentHealth -= amount;
           
            // Set the position of the particle system to where the hit was sustained.
            hitParticles.transform.position = hitPoint;

            // And play the particles.
            hitParticles.Play();

            // If the current health is less than or equal to zero...
            if(currentHealth <= 0)
            {
                // ... the enemy is dead.
                Death ();
            }
        }


        void Death ()
        {
            // The enemy is dead.
            isDead = true;

            // Turn the collider into a trigger so shots can pass through it.
            capsuleCollider.isTrigger = true;

            // Tell the animator that the enemy is dead.
            anim.SetTrigger ("Dead");

            // Change the audio clip of the audio source to the death clip and play it (this will stop the hurt clip playing).
            enemyAudio.clip = deathClip;
            enemyAudio.Play ();
        }


        public void StartSinking ()
        {
            // Find and disable the Nav Mesh Agent.
            GetComponent <UnityEngine.AI.NavMeshAgent> ().enabled = false;

            // Find the rigidbody component and make it kinematic (since we use Translate to sink the enemy).
            GetComponent <Rigidbody> ().isKinematic = true;

            // The enemy should no sink.
            isSinking = true;

            // Increase the score by the enemy's score value.
            ScoreManager.score += scoreValue;

            // After 2 seconds destory the enemy.
            Destroy (gameObject, 2f);
        }
    }

}
해당 Script는 Zombunny 자신이 체력이 0이되서 죽는것 과 Player의 공격에 대해 체력이 감소하는 내용을 담고 있다.
Enemy 들은 체력이 다해서 죽으면 쓰러지는 애니메이션과 함께 땅속으로 서서히 내려가면서 사라진다 (Sinking).
이 Script의 Update() 함수에서는 만약 해당 오브젝트가 isSinking 변수가 Ture 일 시 땅속으로 서서히 내려가는 부분을 구현한 코드이다.
 transform.Translate (-Vector3.up * sinkSpeed * Time.deltaTime);
Vector3.up 의 반대방향으로 sinkSpeed*Time.deltaTime 만큼 이동한다. 어려운점 없는 코드이다 .

그렇다면 isSinking의 값을 변경하는 메서드인 StartSinking을 살펴보자. public 메서드이므로, 다른 Script 에서 이 메서드를 호출할 수 있고, NavMeshAgent의 동작과 Rigidbody의 isKinematic 파라미터를 true 로 변경한다는 점을 보다 Enemy가 체력이 다해 죽는상황에서 호출하는 메서드라고 추측할수있다. 메서드의 마지막부분에 Destroy(gameObject,2f)는 2초뒤에 해당 오브젝트를 해제한다는 뜻으로, 이 메서드는 Enemy가 죽는 과정에서도 맨 마지막에 호출해야할 것이다.








프로젝트의 Scripts 어디를 살펴봐도 StartSinking 메서드를 호출하는 라인은 찾을수가 없다 하지만 StartSinking 메서드는 Death 애니메이션 클립이 실행되면 자동으로 호출되는데 그것은 Animation 탭의 Events 속성에서 지정할 수 있다. 나중에 자세히 알아보도록 하겠다. 다만지금은 StartSinking 메서드는 자동으로 호출된다는 점을 알아두자.


다음으로 public void TakeDamage 메서드를 살펴보자
코드가 워낙 직관적이라 이해하기 어려운 부분은 없다.
한가지 짚고 넘어갈것이 총알을 맞은 위치에 Particle System Component를 통해 Vector3 hitPoint 매개변수를 통해서 공격에 맞은 지점에 이펙트를 생성한다는 점이다. 이를 위해서
hitParticles.transform.position = hitPoint;
hitParticles.Play();
즉, hitPoint 매개변수에 공격 맞은 위치를 전달받아 Particles.Play() 메서드를 호출하고 있다. TakeDamege 메서드는 Player의 Attack에 관련이 있으므로, 해당 기능을 담당하는 Script에서 호출하기 위해 public 메서드로 정의하고 있다. 바로 다음 문서에서 Player Scripts를 분석하면서 알아보도록 하겠다.

마지막으로 Death() 메서드이다. Death메서드는 단순하게 isDead 변수를 True로 변경하고 에니메이터 파라미터에 Dead 트리거를 세팅해주고 있다. 이는 TakeDamage 메서드에서 currentHealth가 0 이하이면 호출되는 메서드 이기도 하다. capsuleCollider.isTrigger=ture는 해당 캡슐콜라이더를 트리거로 바꾸는 것인데 당연히 Enemy가 죽은 시점에서 해당 콜라이더의 물리효과를 모두 꺼주어야 Player가 시체를 통과하여 총을 쏘고 움직이고 할 것이다.

내가 판단하기에는 StartSinking 메서드에는 Sinking에 관한 정보만 몰아놓고 Score라든지 NavMeshAgent의 기능을 정지한다던디 하는것은 Dead 메서드에 옮기는게 맞지 않나 싶다.



마지막으로 Enemy가 Player를 공격 할 때 사용되는 EnemyAttack Script 이다.
using UnityEngine;
using System.Collections;

namespace CompleteProject
{
    public class EnemyAttack : MonoBehaviour
    {
        public float timeBetweenAttacks = 0.5f;     // The time in seconds between each attack.
        public int attackDamage = 10;               // The amount of health taken away per attack.


        Animator anim;                              // Reference to the animator component.
        GameObject player;                          // Reference to the player GameObject.
        PlayerHealth playerHealth;                  // Reference to the player's health.
        EnemyHealth enemyHealth;                    // Reference to this enemy's health.
        bool playerInRange;                         // Whether player is within the trigger collider and can be attacked.
        float timer;                                // Timer for counting up to the next attack.


        void Awake ()
        {
            // Setting up the references.
            player = GameObject.FindGameObjectWithTag ("Player");
            playerHealth = player.GetComponent <PlayerHealth> ();
            enemyHealth = GetComponent<EnemyHealth>();
            anim = GetComponent <Animator> ();
        }


        void OnTriggerEnter (Collider other)
        {
            // If the entering collider is the player...
            if(other.gameObject == player)
            {
                // ... the player is in range.
                playerInRange = true;
            }
        }


        void OnTriggerExit (Collider other)
        {
            // If the exiting collider is the player...
            if(other.gameObject == player)
            {
                // ... the player is no longer in range.
                playerInRange = false;
            }
        }


        void Update ()
        {
            // Add the time since Update was last called to the timer.
            timer += Time.deltaTime;

            // If the timer exceeds the time between attacks, the player is in range and this enemy is alive...
            if(timer >= timeBetweenAttacks && playerInRange && enemyHealth.currentHealth > 0)
            {
                // ... attack.
                Attack ();
            }

            // If the player has zero or less health...
            if(playerHealth.currentHealth <= 0)
            {
                // ... tell the animator the player is dead.
                anim.SetTrigger ("PlayerDead");
            }
        }


        void Attack ()
        {
            // Reset the timer.
            timer = 0f;

            // If the player has health to lose...
            if(playerHealth.currentHealth > 0)
            {
                // ... damage the player.
                playerHealth.TakeDamage (attackDamage);
            }
        }
    }

}
Enemy가 Player를 공격하기 위한 범위에 Player가 들어와 있는지 검사하고 만약 들어왔다면 Player의 Health에 관여해 공격하는 내용을 담고 있다.
OnTriggerEnter()와 OnTriggerExit() 메서드는 단순하게 Player가 범위 안에 있으면 playerInRange 파라미터를 true로 아니면 false 로 바꾼다.

Update함수로 넘어가보자
Enemy가 Attack을 실행하는 쿨타임이 있는데 Update함수에서 timer+=Time.deltaTime 해주므로써, enemy가 player를 공격한 시간 차이를 계산하고 있다.
timer >= timeBetweenAttacks && playerInRange && enemyHealth.currentHealth > 0
정리하면 타이머가 충분히 지났고, player가 범위안에 있고 자신(Enemy)의 현재체력이 0 초과이면 Attack() 메서드를 실행한다. 그리고 PlayerDead 상황 즉, Player가 죽는 상황은 오직 Enemy의 공격에 의해서만 발생하므로, Player의 currentHealth가 0 이하인지도 검사하고 있다.

마지막으로 Attack 함수이다.
playerHealth.TakeDamege(attackDamge) 라는 메서드를 호출하는데 직관적으로 Enemy자신의 공격력만큼 Player의 HP를 깍는다는 내용임을 직관적으로 이해할 수 있다.


이상으로 Enemy Object가 수행하는 Scripts에 대해서 모두 알아보았다. 해당 프로젝트를 모두 완성하고 새로운 적을 추가로 구현할때 이 내용을 잘 활용해야 겟다.