[ Survival Shooter 3 ] 사용자 Player Object 설계하기

이제 Player에 대해서 알아보자. 해당 프로젝트가 무지 단순한 게임이기 때문에, 동적인 Object는 Player와 Enemy 두가지 밖에 없다. 이 파트를 보고나면 게임의 대부분은 이해했다고 볼 수 있다. 먼저 Player Object의 구조를 알아보겠다.




이것 저것 많은것 같지만 Player Object의 하위에 총구 위치를 따로 뺀 GunBarrelEnd Object가 포함되 있을 뿐이다.  각종 컴포넌트에 대해서는 About Unity 카테고리에서 공부하도록 하고 여기서는 Scripts 위주로 살펴보자

Player Object에는 총 3개의 script가 있고 이전의 Enemy와 상당히 유사한 구조다.
PlayerShooting을 제일 마지막에 알아보기로 하고, Player의 이동을 담당하는 Movement 먼저 살펴보도록 하자.

using UnityEngine;

public class PlayerMovement : MonoBehaviour
{
    public float speed = 6f;
    Vector3 movement;
    Animator anim;
    Rigidbody playerRigidbody;
    int floorMask;
    float camRayLength = 100f;

    void Awake()
    {
        floorMask = LayerMask.GetMask("Floor");
        anim = GetComponent<Animator>();
        playerRigidbody = GetComponent<Rigidbody>();
    }

    void FixedUpdate()
    {
        float h = Input.GetAxisRaw("Horizontal");
        float v = Input.GetAxisRaw("Vertical");

        Move(h, v);
        Turning();
        Animating(h, v);

    }

    void Move(float h, float v)
    {
        movement.Set(h, 0f, v);
        movement = movement.normalized * speed * Time.deltaTime;
        playerRigidbody.MovePosition(transform.position+movement);
    }

    void Turning()
    {
        Ray camRay = Camera.main.ScreenPointToRay(Input.mousePosition);
        RaycastHit floorHit;
        if(Physics.Raycast(camRay, out floorHit, camRayLength, floorMask))
        {
            Vector3 playerToMouse = floorHit.point - transform.position;
            playerToMouse.y = 0f;

            Quaternion newRotation = Quaternion.LookRotation(playerToMouse);
            playerRigidbody.MoveRotation(newRotation);
        }
    }
   
    void Animating(float h,float v)
    {
        bool walking = h != 0f || v != 0f;
        anim.SetBool("IsWalking", walking);
    }
}


먼저 FixedUpdate는 Update 메서드와 다르게 Fixed time step에 설정된 값을 기준으로 일정한 간격으로 호출된다. 따라서 Frame 기반의 Update 대신에 Rigidbody가 적용된 Object에 사용하기 적합하다.

내용을 보면, float 형 변수 h, v 에 Input.GetAxisRaw 메서드를 통해 변수를  입력받고 있다.
이는 무엇이냐 유니티 메뉴에서 Edit - ProjectSetting - Input 에 가보면 직관적으로 알수있다.

Input 메뉴를 클릭하면 InputManager 가 열린다. 키보드 입력에 관한 값을 지정하는데 쓰이는데, 여기서 사용하는 Horizontal 은 Positive Button left, Negative Button right 로 화살표를 의미하며, 양의버튼을 누르면 0.1 이 음의버튼을 누르면 -0.1이 입력된다. 더 자세한내용은 About Unity를 참고하기로 하자


즉, h, v 변수에 키보드 입력값을 저장하는 것이다. 이제 Move(h,v) 메서드와 Turing(); Animating(h,v) 를 FixedUpdate() 주기로 호출하는것이 PlayerMovement의 전부이다.

캐릭터의 이동을 담당하는 void Move(float h,float v) 메서드에 대해서 알아보자.
movement.Set(h,0f,v) 로 Vector3 의 x,y,z 값을 설정해주고
movement = movement.normalize*speed*Time.deltaTime 으로 이동속도를 지정하고 있다.
여기서 벡터 정규화 함수인 normalize 에 대해서 알아보자

벡터A 와 벡터B 를 더하면 벡터C의 크기는 A나 B보다 커지게 되고, 이를 유니티에 대입해서 생각해보면 left , up 을 따로 누를때보다 left+up 을 누르면 캐릭터 이동속도가 더 빨라지게 된다. 이것이 movement 벡터가 정규화가 필요한 이유다. 정규화에 대해서는 수학 카테고리에서 살펴보도록 하자.
playerRigidbody.MovePosition(transform.position+movement) 의미는 직관적으로 이해가 된다. Rigidbody 컴포넌트를 통해서 오브젝트를 이동시키고 있으며, transform.position+movement 로 이동정보가 담긴 벡터를 transform 컴포넌트의 position 에 더함으로써 이동이 이루어진다. Rigidbody를 통해 Object를 이동시킬 경우 인스펙터에서 설정한 보간설정을 준수하여 오브젝트 이동이 일어난다. 따라서 보간설정을 적절히 해놨다면 더 부드러운 이동을 연출한다. 또한 FixedUpdate에서 강체를 계속 이동시키려면 이 메서드를 무조건적으로 사용해줘야 한다.

Turning() 에 관해서는
[ Project A / UNITY Tutorial / Survival Shooter  1 ] Raycast 를 활용해서 카메라 부터 마우스 방향 검출하여 플레이어 캐릭터 회전하기
https://teayounggamedevlearn.blogspot.kr/2018/03/project-unity-tutorial-survival-shooter.html
를 참고하도록 하자.

Animation(float h,float v)는 극히 간단하다.
bool walking = h != 0f || v != 0f; 즉 h와 v값이 하나라도 0이 아니면 walking은 true를 반환한다. 즉, 사용자가 방향키를 눌렀느냐, 마느냐를 나타내는 것이다.
anim.SetBool("IsWalking",walking); 메서드, 등록된 에니메이터의 IsWalking 파라미터를 walking 값으로 변경한다. 한마디로 Animator에 지금 움직이고 있는건지.. 아닌건지를 전달하는 메서드이다. 참고로 Player의 Animator는 다음과 같다.

Script 에서 Animator 관련 메서드를 호출할 곳은 Player가 Die 상태일 때 라는 것을 추측할수있다.

using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using UnityEngine.SceneManagement;


public class PlayerHealth : MonoBehaviour
{
    public int startingHealth = 100;
    public int currentHealth;
    public Slider healthSlider;
    public Image damageImage;
    public AudioClip deathClip;
    public float flashSpeed = 5f;
    public Color flashColour = new Color(1f, 0f, 0f, 0.1f);


    Animator anim;
    AudioSource playerAudio;
    PlayerMovement playerMovement;
    PlayerShooting playerShooting;
    bool isDead;
    bool damaged;


    void Awake ()
    {
        anim = GetComponent <Animator> ();
        playerAudio = GetComponent <AudioSource> ();
        playerMovement = GetComponent <PlayerMovement> ();
        playerShooting = GetComponentInChildren <PlayerShooting> ();
        currentHealth = startingHealth;
    }


    void Update ()
    {
        if(damaged)
        {
            damageImage.color = flashColour;
        }
        else
        {
            damageImage.color = Color.Lerp (damageImage.color, Color.clear, flashSpeed * Time.deltaTime);
        }
        damaged = false;
    }


    public void TakeDamage (int amount)
    {
        damaged = true;

        currentHealth -= amount;

        healthSlider.value = currentHealth;

        playerAudio.Play ();

        if(currentHealth <= 0 && !isDead)
        {
            Death ();
        }
    }


    void Death ()
    {
        isDead = true;

        playerShooting.DisableEffects ();

        anim.SetTrigger ("Die");

        playerAudio.clip = deathClip;
        playerAudio.Play ();

        playerMovement.enabled = false;
        playerShooting.enabled = false;
    }


    public void RestartLevel ()
    {
        SceneManager.LoadScene (0);
    }
}

다음으로 PlayerHealth 를 분석하겠다 .
이전에도 그래왔지만, Awake() 메서드에서 컴포넌트를 변수에 할당하는 부분은 특이사항이 없으면 설명을 생략하겠다. 본격적으로 분석하기 전에 해당 Script에 쓰인 public 변수에 대해서 한차례 설명을 하고 가야겠다. Player HP 는 사용자에게 중요한 정보인만큼 게임의 UI와 밀접한 연관이 있다.



PlayerHealth Script의 Public 변수들 중 Health Slider / Damage Image는 UI 컴포넌트로 게임화면에서 우측하단의 HP 표시인 Health Slider와 게임 화면 전체에 덧씌워진 Damage Iamge 가 있다. Damage Image는 Player가 Enemy에게 피격시 Flash Colour (u오타) 의 색으로 화면이 번쩍이는 효과를 연출하기 위함이다. 여러 게임에서 공격을 받으면 화면이 붉게 번쩍이는 효과를 생각하면 이해하기 쉽다.
Update() 메서드에서는 if(damaged) 즉 Player가 피격 상태 이면, damageImage.color=flashColor; 부분으로 인해 화면이 붉게 바뀌게 되고,
바로 다음 Update() 에서 else{}구문으로 진입하여 붉게 바뀐 이미지를 점점 투명하게 바꾸는 것이다.
붉은색->투명한색으로 이미지를 순차적으로 바꾸는 코드는
damageImage.color = Color.Lerp (damageImage.color, Color.clear, flashSpeed * Time.deltaTime); 이다.


원형은 public static Color Lerp (Color aColor b, float t); 으로 Color a 에서 Color b 까지 t동안 색을 바꾼다고 이해하면 쉽다. *Lerp는 선형보간 이란 뜻인데 수학 카테고리에서 따로 설명하도록 하겠다.

public void TakeDamege(int amount) 메서드는 이름에서 알 수 있듯, 피격당했을때의 이벤트를 처리하는 메서드이고 public 인 것을 보아 다른 객체에서 호출 하겠다는 뜻이다. 먼저 내용은 어려운 부분은 없다 damaged 프로퍼티를 ture로 바꿔 Update 메서드에서 화면이 붉은색으로 번쩍이는 효과를 주면서, 그에 따른 효과음, 또 Player의 hp를 amout 만큼 감소시키고, hp가 0이하라면 Death() 메서드를 호출해 Player가 죽는 과정을 진행한다. Death()는 바로 밑에서 알아보도록 하고 지금은 이 TakeDamege() 메서드를 누가 호출하는지 알아보자. 사실 Survival Shooter 2 에서 볼수있듯, EnemyAttack Script 에서 호출한다.
 해당 소스의 호출 부분.
마지막으로 void Death()이다. Death() 는 게임의 끝을 알리는 메서드와도 비슷하다 주인공 Object의 각종 기능을 Disabled 로 바꾸고, animator 에도 Die 트리거가 진행되야 함을 알리는 내용이다. 많이 봣던 코드가 이해가 어렵진 않겠지만 playerShooting.DisableEffects(); 만 알아보고 가자. 사실 알아볼게 없다 그냥 playerShooting의 public 메서드이기 때문이다. 고로 playerShooting에서 알아보도록 하겠다.

using UnityEngine;

public class PlayerShooting : MonoBehaviour
{
    public int damagePerShot = 20;
    public float timeBetweenBullets = 0.15f;
    public float range = 100f;


    float timer;
    Ray shootRay = new Ray();
    RaycastHit shootHit;
    int shootableMask;
    ParticleSystem gunParticles;
    LineRenderer gunLine;
    AudioSource gunAudio;
    Light gunLight;
    float effectsDisplayTime = 0.2f;


    void Awake ()
    {
        shootableMask = LayerMask.GetMask ("Shootable");
        gunParticles = GetComponent<ParticleSystem> ();
        gunLine = GetComponent <LineRenderer> ();
        gunAudio = GetComponent<AudioSource> ();
        gunLight = GetComponent<Light> ();
    }


    void Update ()
    {
        timer += Time.deltaTime;

               if(Input.GetButton ("Fire1") && timer >= timeBetweenBullets && Time.timeScale != 0)
        {
            Shoot ();
        }

        if(timer >= timeBetweenBullets * effectsDisplayTime)
        {
            DisableEffects ();
        }
    }


    public void DisableEffects ()
    {
        gunLine.enabled = false;
        gunLight.enabled = false;
    }


    void Shoot ()
    {
        timer = 0f;

        gunAudio.Play ();

        gunLight.enabled = true;

        gunParticles.Stop ();
        gunParticles.Play ();

        gunLine.enabled = true;
        gunLine.SetPosition (0, transform.position);

        shootRay.origin = transform.position;
        shootRay.direction = transform.forward;

        if(Physics.Raycast (shootRay, out shootHit, range, shootableMask))
        {
            EnemyHealth enemyHealth = shootHit.collider.GetComponent <EnemyHealth> ();
            if(enemyHealth != null)
            {
                enemyHealth.TakeDamage (damagePerShot, shootHit.point);
            }
            gunLine.SetPosition (1, shootHit.point);
        }
        else
        {
            gunLine.SetPosition (1, shootRay.origin + shootRay.direction * range);
        }
    }
}


지금까지 읽느라 너무 고생했다. 마지막으로 playerShooting Script 를 분석하고 마치도록 하겠다. PlayerShooting은 슈팅게임의 핵심기능중 하나, 총알을 발사하고 그를 처리하는 기능을 한다. 처음에는 따로 뺄려했으나, Player를 통째로 분석한다는 의미를 살리기 위해 이 글에 첨부하기로 하였다.

Awake() 에서 shootableMask = LayerMask.GetMask ("Shootable"); 라는 구문이 있다. LayerMask 란 포토샵의 그 Layer와 비슷한 개념으로 씬안의 수많은 오브젝트들을 분류하는 기능이 있다. 여기서는 Shootable LayerMask를 참조하고 있는데, Shootable Layer에는 Enemy 와 Environment 즉, Player가 발사한 총알에 Shootble  한 Object들을 모아놓은 것이다. 씬 뷰에서 이것저것 누르면서 직접 확인해보자

  
Zombunny 등 Enemy 종류의 Object들은 총알을 맞고 죽어야 하므로 당연히 Shootable이고 Environment는 총알이 부딪칠 경우 총알이 통과하면 안되고 그자리에서 없어져야 하므로 또한 Shootable Layer가 되야한다. 이쯤으로 Shootable Layer에 대한 설명을 마치겟다.
ParicleSystem, Light 같은 새로운 Component 관련은 About Unity 카테고리에서 따로 정리하도록 하겠다 .


자 Update() 함수이다.

Input.GetButton은 PlayerMovement에서 봤던 InputManager 관련 코드이다. Fire1 로 등록된 입력장치 버튼이 있을것이다. 한번 찾아보도록 하자. 발사 버튼을 눌렀을때만 총을 쏴야하기 때문에 당연한 코드이다.
 timeBetweenBullets는 Player의 공격속도가 짧으면 짧을수록 게임의 난이도가 급격히 낮아지기 때문에 공격속도를 제한하기 위한 변수이다. 바로 위에 timer+=Time.deltaTime으로 시간을 카운팅하면서 설정한 공격속도 시간보다 작을 시 에는 총알이 발사되지 않게한다.
 마지막으로 Time.timeScale != 0 일때 인데, timeScale 이란 게임 내에서 흐르는 시간의 빠르기를 의미한다. 0일경우 게임내의 시간이 일시 정지한 효과를 낼 수 있는데, 당연히 게임내의 시간이 멈춰있는데 총알이 발사되면 안된다. 따라서 Time.timeScale을 검사해주어야한다. 앞으로 소스코드를 분석하다보면 분명히 게임을 일시정지하는 기능이 있을것으로 예상된다.

다음으로 조금전에 알아보겠다고한 public void DisableEffects() 메서드가 있다.
gunLine 과 gunLight 컴포넌트를 disabled 로 설정하고 있다. 각각 LineRenderer, Light Component 를 사용하지 않는다는 의미이다. Player가 죽은 상황이므로 더이상 총을 발사할 일이 없다. 따라서 총관련 이펙트도 모두 필요없어지는데, 필요없는 컴포넌트는 disabled로 설정해서 발사번총알이 더이상 발사되지 않기 위함이다.

대망의 Shoot() 함수를 보도록 하겟다

timer = 0f; 즉, 한번 사격했으면 타이머를 다시 0으로 돌려놔야한다.
gunAudio, gunLight, gunParticles gunLine은 발사 시의 오디오, 비주얼 이펙트를 담당하는 컴포넌트로 코드가 직관적이여서 어렵지않다. gunLine만 잠깐 보고가겠다.

gunLine을 담당하는 LineRenderer를 활설화 하고 이 것의 시점를 총구의 끝 즉, 자기 자신으로 설정한다. PlayerShooting Script 는 GunBarrelEnd Object의 컴포넌트로 들어가 있으므로, transform.position은 곳 총구의 끝 위치를 의미하게 된다.
잠시 public void SetPosition(int indexVector3 position); 에 대하여 간략하게 알아보자면 선은  시점과 종점 으로 이뤄진다. 유니티에서는 이를 인스펙터에서도 지정할 수 있지만, 코드상에도 값을 변경할수 있는데, 게임 특성상 총의 궤적 (즉, LienRenderer)은 Player의 입력에 따라 동적으로 변화하기 마련이다. 따라서 Player의 총구 시작위치로 시점을 설정해주는 기능을 하기위해 SetPosition 메서드가 사용된 것이다. 당연히 밑에는 종점을 설정해주는 코드가 있다는걸 추측할수있다. 이에 관한 자세한 내용은 About Unity 카테고리에 업로드하겠다. 정점 집합 Positions 는 인스펙터에서 설정하는데 이 프로젝트에서는 다음과 같다. 하지만 SetPosition 메서드로 값이 변경될 마당에 무슨 의미가 있겠는가? 한가지 Index의 Size를 지정하는 부분만이 의미가 있다.






shootRay는 Ray 객체로 우리는 이미 Ray 객체와는 구면이다. 직관적으로 알 수 있겠지만, Ray.origin은 시점이고 Ray.direction은 방향, 여기서는 transform.forward 즉 Z축 양의 방향이 되시겠다.

여기까지가 발사 버튼을 눌렀을때 총알이 발사되는 궤적과 총알의 충돌 검사를 위한 Ray 객체를 활성화 한 것이다.

밑의 if 구절은 총알이 어딘가에 맞는 이벤트를 처리하고 있다.
if(Physics.Raycast ~~) 구절은 [ Project A / UNITY Tutorial / Survival Shooter 1 ]은 이제 설명없이도 이해가 된다.

Shootable Layer 에는 Environment 와 Enemy 가 있다는 사실을 기억하자, 위 코드는 "일단" 은 맞은 대상에게서 EnemyHealth 라는 Component를 참조하라고 지시한다. 만약 총알이 맞은 대상이 Environment라면 enemyHealth는 존재하지 않으므로 당연히 NULL이 될것이다.

따라서 만약 enemyHealth =! NULL 이라면 Enemy 에 명중한 것을 의히 하므로 EnemyHealth 컴포넌트의 TakeDamage 메서드를 호출해서 이벤트를 처리한다.
이 메서드는 [ Project A / UNITY Tutorial / Survival Shooter 2 ] 에 설명이 있다.

다음으로 LineRenderer의 종점을 정해주어야 할 때이다. 종점은 당연하게도 총알이 Shootable 레이어의 무언가에 맞은 위치 이다. 따라서 코드는 다음과 같다.
 위에서 자세하게 설명했으므로, 이해가 술술 된다. 만약 총알이 Shootable Layer에 맞지 않았다면?


 미리 지정해준 충분히 긴 거리(Range) 만큼 전진해서 종점을 잡아주면 그만이다.

자 이제 우리는 [ Project A / UNITY Tutorial / Survival Shooter 1,2,3 ] 을 이해하므로, TPS 게임에서 Player 와 Enemy 의 주요 동작들을 구현할 수 있다. 너무 뿌듯하지 않을수가 없는 노릇이다.