본문 바로가기

IT/유니티

15. 유니티 교육 (Sebastian Lague 강의-3DShooter #2)

1. Damage System


플레이어, 적, 총, 총알등을 만들었다.

총으로 총알을 쏘았고, 그 총알이 적들과 충돌체크를 하기도 했다.

그럼 그다음은 무엇일까?

그렇다 바로 피해 즉 데이지 시스템을 적용해보도록 하자.


베토벤의 재능을 시기한 살리에르의 기분처럼 세바스찬의 강의를 들으면 한없이 작아지는 스스로를 느낄수 있다.

RPG겜도 액션겜도 만들어 보았는데, 역시나 인디이고 혼자만들어서 그런지, 

만드는데만 급급했고, 답답함을 느꼈는데, 세바스찬의 강의를 들으면서 뭔가 뻥뚤린 기분을 느낄수 있었다.




적이나 플레이어는 무생물이기 보다는 생물에 가깝다. 

게임이라는 세계에서는!

예전에 DB설계했을대 배웠던게 많이 생각난다. 

비슷한 속성을 가진 이들을 묶는것이 어쩌면 객체지향의 기본이라고 할수 있을것이다.

프로그래밍적으로 생각하기이전에 상식적으로 묶을수 있는 것들은 묶어놓고 시작해야한다.


결국 플레이어와 적은 둘다 생물체라고 가정하고 프로그래밍을 한다면 아마도 다음과 같은 클래스가 필요할것이다.


LivingEntity.cs

using UnityEngine;

using System.Collections;


public class LivingEntitiy : MonoBehaviour, IDamagable {


    public float startingHealth;

    protected float health;

    protected bool dead;


    // Use this for initialization

    protected virtual void Start()

    {

        health = startingHealth;

    }


    public void TakeHit(float damage, RaycastHit hit)

    {

        health -= damage;


        if (health <=0 && !dead)

        {

            Die();

        }

    }


    protected void Die()

    {

        dead = true;

        GameObject.Destroy(gameObject);

    }


}


위의 코드는 생명체 코드이다.

이 생명체는 체력이 있고, 피해, 죽음이라는 함수를 가지고 있다.

그렇다 생명체이므로 피해와 죽음을 가진다.


이 생명체클래스를 상속받아서 플레이어와 에너미를 만든다면

특별히 플레이어와 에너미에서 따른 코드를 작성하지 않아도 이 생명체의 특징적인 함수와 변수는 가져갈수 있다.


다만 신경써야 할부분은 다음과 같다.

protected virtual void Start()


상속받아서 새로 만든 클래스에도 그 클래스의 start함수가 있을것인데,

만약 virtual로 만들지 않는다면, 둘중하나는 실행되지 않을 것이다. 그렇기때문에 위와 같이 가상함수로 해준다.

상속받은 클래스는 이 가상함수를 override로 사용할수 있다. 


플레이어와 적의 start함수를 다음과 같이 수정한다. 


protected override void Start () {

        base.Start();

        .......

}

이렇게 하면 부모의 start를 실행하고 그위에 자신의 start를 실행시킬수 있다.


그럼 우선 데미지를 받는 시스템을 구성해 놓았다. 

이 데이지를 언제 발생할까?

바로 총알이 적에게 맞을때이다.

물론 플레이어가 맞을때도 같다.


그럼 총알소스를 조금 수정하자.


void OnHitObject(RaycastHit hit)

    {

        IDamagable damageableObject = hit.collider.GetComponent<IDamagable>();

        if (damageableObject != null)

        {

            damageableObject.TakeHit(damage, hit);

        }

        GameObject.Destroy(gameObject);

    }

충돌했다면, 충돌한 객체에서 IDamagable를 컴포넌트를 찾아서 

IDamagable이 있다면 그것은 생명체를 상속받은 객체임으로 takeHit를 실행한다.




2. Spawn System


총에 맞으면 적들이 죽는다.

이제 게임을 만들수 있는 가장 기본적인 구조를 가지게 되었다.

그럼 간단하게, 웨이브를 만들어 보자.


우선 빈객체를 만들어서 화면에 놓는다. 위치는 걍 가운데! 

웨이브는 결국 레벨를 나눈다는 의미를 가진다.

각 웨이브마다 난이도를 달리하기 위해서 만드는것인데 이렇때 사용하면 좋은 방법이 있다.


우선 코드를 보자.


Spawner.cs

using UnityEngine;

using System.Collections;


public class Spawner : MonoBehaviour {


    public Wave[] waves;

    public Enemy enemy;


    Wave currentWave;

    int currnetWaveNumber;


    int enemyRemainingToSpawn;

    int enemyRemainingAlive;

    float nextSpawnTime;


    [System.Serializable]

    public class Wave

    {

        public int enemyCount;

        public float timeBetweenSpawns;

    }

    

    void Start()

    {

        NextWave();

    }

   

// Update is called once per frame

void Update () {

   if(enemyRemainingToSpawn > 0  && Time.time > nextSpawnTime)

        {

            enemyRemainingToSpawn--;

            nextSpawnTime = Time.time + currentWave.timeBetweenSpawns;


            Enemy spawnedEnemy = Instantiate(enemy, Vector3.zero, Quaternion.identity) as Enemy;

            spawnedEnemy.OnDeath += OnEnemyDeath;

        }

}


    void OnEnemyDeath()

    {

        enemyRemainingAlive--;


        if (enemyRemainingAlive == 0)

        {

            NextWave();

        }

    }


    void NextWave()

    {

        currnetWaveNumber++;


        print("Wave: " + currnetWaveNumber);

        if(currnetWaveNumber -1 < waves.Length)

        {

            currentWave = waves[currnetWaveNumber - 1];

            enemyRemainingToSpawn = currentWave.enemyCount;

            enemyRemainingAlive = enemyRemainingToSpawn;

        }

    }

}




  [System.Serializable]

public class Wave

위와 같이 시리얼화 해놓으면, 인스펙터에서 조정하기 편하다.

이 웨이브 클래스안에 들어있는 정보는 스폰몬스터 수와 스폰시간이다.

결국 이 두정보를 이용해서 난이도 조절을 하는 것이다. 


public Wave[] waves;

 public Enemy enemy;

 public으로 선언된 배월웨이브는 인스펙터에서 그 배열의 길이를 입력할수 있고,

에너미는 prefab해 놓은 것을 드래그 해놓으면 된다. 


spawnedEnemy.OnDeath += OnEnemyDeath;

그리고 스폰에서 가장중요한것은 위의 event함수 설정이다.

쩝 어쩌면 당연할수도 있는 건데, 내가 잘못하니 볼때마다 중요하게 느껴진다.


자! 생각해보자

스폰시스템의 입장에서 적들이 몇명 죽었는지는 아주 중요한 정보이다.

그 정보를 토대로 현재 웨이브에서 다음 웨이브로 넘어갈수 있다.


하지만 생명체입장에서는 스폰시스템을 알 필요가 있는가?

없다.없어야 한다. 만약 있다면, 이 생명체는 영원히 이 스폰시스템과 함께 다녀야 할것이다.


그러기때문에 생명체 클래스에

public event System.Action OnDeath;를 만들고

만약 OnDeath가 null이 아닐때 OnDeath함수를 부르게 하면된다.

만약 없다면 그냥 아무것도 하지 않으면 된다.


이렇게 하므로써, 생명체객체는 스폰시스템과는 별개로 혼자 독립적으로

다른 말로 하면 재사용이 가능해지는 소스가 된다.


spawnedEnemy.OnDeath += OnEnemyDeath;

이 코드의 의미는 결국 생성된 에너미에 스폰시스템의 OnEnemyDeath함수를 연결시켜줌으로써

적이 죽을때에 스폰시스템에 그 죽음의 정보를 전달하도록 하고 있다.

캬~ 멋지고 이쁜코드다.


누군가 더 잘하는 사람이 보면 더 좋은 코드가 있다고 말하겠지만,

나에겐 지금 이 코드가 너무 멋지게 보인다. 

땡스 세바스찬!!




3. Enemy Attack


이제 웨이브도 나오고, 총맞으면 적도 죽고, 겜다됐다.

근데 그냥 죽이기만 하면 재미가 있는가?

그렇다! 적도 공격을 해야지 재미있을 것이다.


그럼 적은 플레이어를 어떻게 공격할까?

현재까지 구현되어 있는 것은 nav mesh agent를 이용해서 플레이어를 따라다니게는 해놓았다.

비록 막 겹치지만!

아무튼 플레이어를 따라와서 일정거리 즉 공격범위안으로 들어오면 공격을 하게끔 만들어 보려고 한다.


이번에는 enemy.cs를 많이 수정했다. 한번 같이 보자.


enemy.cs

using UnityEngine;

using System.Collections;


[RequireComponent (typeof(NavMeshAgent))]

public class Enemy : LivingEntitiy{


    //공격을 하고 있을때 체이싱을 하면 안되므로, 상태를 enum으로 정의해놓는다.

    public enum State { Idle, Chasing, Attacking};

    //정의한 상태를 저장한다.

    State currentState;


    NavMeshAgent pathfinder;

    Transform target;

    Material skinMaterial;


    //공격할때 색깔을 바꾸어 주기 위해서

    Color originalColour;


    //공격범위

    float attackDistanceThreshold = .5f;

    //공격속도

    float timeBetweenAttacks = 1;


    float nextAttackTime;


    //적과 플레이어 콜리젼의 반지름

    float myCollisionRadius;

    float targetCollisionRadius;



// Use this for initialization

protected override void Start () {

        //추상함수이므로 부모의 것도 실행시켜준다.

        base.Start();


        pathfinder = GetComponent<NavMeshAgent>();


        //현재색깔을 저장해놓고 나중에 사용한다.

        skinMaterial = GetComponent<Renderer>().material;

        originalColour = skinMaterial.color;


        //생성되면 추적모드로 설정해놓는다.

        currentState = State.Chasing;


        target = GameObject.FindGameObjectWithTag("Player").transform;


        //자신과 타겟의 충돌콜리전의 반지름을 구해놓는다.

        myCollisionRadius = GetComponent<CapsuleCollider>().radius;

        targetCollisionRadius = target.GetComponent<CapsuleCollider>().radius;


        //추적을 위해서 길찾기 쓰레드를 실행시킨다.

        StartCoroutine(UpdatePath());

}

// Update is called once per frame

void Update () {

        

        //업데이트는 자동적으로 실행된다. 매번 들어가지만, 만약 타겟이 공격범위안에 들어왔다면

        //nextAttackTime까지는 들어가지 않는다. 

        if (Time.time > nextAttackTime)

        {

            //vecter3D.distance도 주 벡터사이의 거리를 구할수 있지만, 코스트가 높기때문에

            //다음같이 처리하면 좋다. 

            //목표벡터에 현재벡터를 빼고, sqrMagnitude를하면 거리가 나오며,

            //공격범위를 제곱한 값으로 비교한다.

            //매번 자신과 타겟의 거리를 구한다.

            float sqrDstToTarget = (target.position - transform.position).sqrMagnitude;


            //그 거리가 공격범위보다 가까우면 공격한다.

            //한데 너무 가까운데서 공격을 하면 타겟과  겹쳐버리기 때문에, 조금더 거리를 둔다.

            if (sqrDstToTarget < Mathf.Pow(attackDistanceThreshold + myCollisionRadius + targetCollisionRadius, 2))

            {

                nextAttackTime = Time.time + timeBetweenAttacks;

                StartCoroutine(Attack());

            }

        }

        


}


    IEnumerator Attack()

    {

        //공격중일때는 상태를 공격으로 바꾸어 놓는다.

        currentState = State.Attacking;

        //길찾기기능은 비활성화 시켜놓는다.

        pathfinder.enabled = false;


        //공격할때 현재위치

        Vector3 originalPosition = transform.position;

        //공격 방향을 정하고

        Vector3 dirToTarget = (target.position - transform.position).normalized;

        //공격할때 이동할 위치. 현재위치에서 방향벡터에 원하는 거리를 곱한것을 뺀다. 

        Vector3 attackPosition = target.position - dirToTarget * (myCollisionRadius);

         

        float attackSpeed = 3;

        float percent = 0;


        //공격할때는 빨갱이

        skinMaterial.color = Color.red;


        //요기 겁나 멋지다.

        //퍼센트로 1이될때까지 while문을 도는데

        //interpolation을 이용해서 움직임을 제어한다.

        //인터넷에서는 그냥 자연스럽게 움직임을 제어할때 사용한다고 하지만,

        //세바스찬은 그래프처럼 자연스러운 공격속도? 모션?을 제어했다.

        while (percent <= 1)

        {

            percent += Time.deltaTime * attackSpeed;

            //아래의 식은 결국 천천히 움직이다가 가운데서 가장 빠르게 그리고 느리게...

            float interpolation = (-Mathf.Pow(percent, 2) + percent) * 4;

            //lerp함수를 이용해서 자연스러운 공격 모션을 제어

            transform.position = Vector3.Lerp(originalPosition, attackPosition, interpolation);

            yield return null;

        }


        //공격이 끝나고 나면 색깔을 원래색으로 돌려놓고, 상태도 추적상태로 해놓는다.

        skinMaterial.color = originalColour;

        currentState = State.Chasing;

        pathfinder.enabled = true;

    }


    IEnumerator UpdatePath()

    {

        float refreshRate = .25f;

        while (target != null)

        {

            if (currentState == State.Chasing)

            {

                //적이 플레이어와 겹쳐지지 않기 위해서 근처까지만 간다.

                Vector3 dirToTarget = (target.position - transform.position).normalized;

                Vector3 targetPosition = target.position - dirToTarget * (myCollisionRadius + targetCollisionRadius + attackDistanceThreshold/2);


                //마이너 버그의 원인이 될수있다.

                //총알에 맞아서 죽었는데 아직 한번더 루프를 돌수도 있기때문에 조건문을 걸어준다.

                if (!dead)

                {

                    pathfinder.SetDestination(targetPosition);

                }

            }

            

            yield return new WaitForSeconds(refreshRate);

        }

    }

}


주석으로 설명을 해놓았다.

그리고 interpolation식의 그래프는 아래와 같다.

쩝 수학이 정말 중요하다.

지금이라도 늦지 않았다고 생각하지만,

늦은건 늦은거고

늦었으니까 하려고 해도 머리가 받아주지 않는다. 흑흑





4. Minor Bug Fix For Bullet


현재 총알에 약간 마이너한 버그가 있기때문에 수정 보완하였다.

소스코드는 다음과 같고, 주석으로 설명해 놓았다.


using UnityEngine;

using System.Collections;


public class Bullet : MonoBehaviour {


    public LayerMask collisionMask;

    float speed = 10;

    float damage = 1;


    //일정시간이 지나면 총알이 사라지게 하기 위한 변수

    float lifeTime = 3;

    //빠른 적들과의 충돌체크를 위한 변수(밑에 설명)

    float skinWidth = .1f;


    void Start()

    { 

        //일정시간이 흐르면 없앴다.

        Destroy(gameObject, lifeTime);


        //레이캐스트는 현재 위치와 오브젝트간의 거리를 의미하는 것이기때문에

        //만약에 오브젝트 안에서 총알이 생성된다면, 충돌체크를 하지 못할것이다.

        //그렇기 때문에 만약 총알이 생성되었는데

        //그 생성장소가 에너미 객체의 안에서 생성되었때를 위한 코드

        //총알이 생성될때 모든 콜리전 객체들과 충돌체크를 해보고 그 결과를 반환하다.

        Collider[] initialCollisions = Physics.OverlapSphere(transform.position, .1f, collisionMask);

        if (initialCollisions.Length > 0)

        {

            OnHitObject(initialCollisions[0]);

        }

    }


    public void SetSpeed(float newSpeed)

    {

        speed = newSpeed;

    }


// Update is called once per frame

void Update () {

        float moveDistance = speed * Time.deltaTime;

        checkCollisions(moveDistance);

        transform.Translate(Vector3.forward * Time.deltaTime * speed);

}


    void checkCollisions(float moveDistance)

    {

        Ray ray = new Ray(transform.position, transform.forward);

        RaycastHit hit;


        //여기서 skinWidth의 역활은 객체가 빠르게 움직일대 레이캐스트로 체크 못한다.

        //생각해보자 총알이 레이캐스트를 쐈을대에는 떨어져 있던 객체가 다음 프레임에 

        //총알이 움직이기 전에 객체가 먼저 움직이고 총알이 이미 들어가버렸다면

        //총알의 프레임에 레이캐스트를 쏘아도 충돌체크를 하지 못한다.

        //그렇기 때문에 skinWidth를 미리 넣어두고 체크함으로써 체크할수 있다.

        //하지만 너무빠른 경우에는 안될수도 있으니 빠른 객체는 skinWidth를 늘려준다.

        if (Physics.Raycast(ray, out hit, moveDistance + skinWidth, collisionMask, QueryTriggerInteraction.Collide))

        {

            OnHitObject(hit);

        }

    }


    void OnHitObject(RaycastHit hit)

    {

        IDamagable damageableObject = hit.collider.GetComponent<IDamagable>();

        if (damageableObject != null)

        {

            damageableObject.TakeHit(damage, hit);

        }

        GameObject.Destroy(gameObject);

    }



    //인자에 따라서 OnHitObject를 두개 만들다. 

    //레이캐스트를 인자로 가진것과 collider를 가진것을 구분해서 사용한다.

    void OnHitObject(Collider c)

    {

        IDamagable damageableObject = c.GetComponent<IDamagable>();

        if (damageableObject != null)

        {

            damageableObject.TakeDamage(damage);

        }

        GameObject.Destroy(gameObject);

    }

}