EasyCastleUNITY

Hero SHooter 개발일지 2 본문

유니티 심화

Hero SHooter 개발일지 2

EasyCastleT 2023. 9. 3. 20:05

https://easycastleunity.tistory.com/137

 

HeroShooter 개발일지1

https://easycastleunity.tistory.com/135 HeroShooter 중간과정 정리 zombero 게임을 레퍼런스한 프로젝트 이 글을 쓰는 시점, Tutorial - Stage1- Stage2는 종료 (어떤 스테이지인지는 밑에 영상 참고) Stage3를 고치는 중

easycastleunity.tistory.com

이어서 작성한다. 

미흡한 점

1. 플레이어 움직임, new Input System을 활용하여 이동 

여태까지는 GetAxisRaw(옛날 Input System)를 활용하여 움직이고 있었다.

이렇게 하지 말고 새로 생긴 input system을 사용하여 플레이어 이동 제어를 해보기로 했다. 

이렇게 Action을 만들었다.

여기서 중요한 것은 조이스틱을 통해, 플레이어가 이동하려면 위에 사진의 Left Stick이 중요하다. 

또한 이 Left Stick을 통해 제어하려면 UI의 조이스틱에 On Screen Stick 컴포넌트를 부착해야 한다.

또한 위의 사진처럼 Control Path를 연결해주어야 한다. 

제어 대상인 플레이어는 아래의 컴포넌트를 가지고 있다.

Behavior는 c# event

Behavior에 따라 구현하는 방법은 많지만, 나는 c# event를 활용하여 제어하고 있다. 

아래는 플레이어 제어 스크립트 이다.

PlayerController

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerController : MonoBehaviour
{
    private enum eAnimationType
    {
        Idle,Run,Shoot
    }
    [SerializeField] private float moveSpeed = 1.0f; //플레이어 이동 속도

    [SerializeField] private Transform target; //튜토리얼에서의 특정위치
    [SerializeField] private float radius = 1.0f; //플레이어가 자기 주변을 인식하는 반경
    [SerializeField] private float rayOffsetY = 0.5f; //레이 오프셋

    Vector3 moveDir;
    private GameObject portal;
    private MonsterController targetMonster;

    public System.Action onPortal; //튜토리얼 포탈
    public System.Action onTarget; //튜토리얼 타겟

    private float h = 0;
    private float v = 0;

    private Animator anim;

    private PlayerInput playerInput;
    private InputActionMap mainActionMap;
    private InputAction moveAction;
    //애니메이션 파라미터 해쉬값 추출
    private readonly int hashState = Animator.StringToHash("State");

    public float H
    {
        get { return h; }
    }
    public float V
    {
        get { return v; }
    }
    public GameObject Portal
    {
        set { this.portal = value; Debug.Log("설정완료"); }
    }

    public MonsterController TargetMonster
    {
        get { return this.targetMonster; }
    }
    //게임오브젝트와 거리를 저장, 범위에 들어오는 경우에 사전에 저장
    Dictionary<GameObject, float> dic = new Dictionary<GameObject, float>();
    // Start is called before the first frame update
    void Start()
    {
        this.anim = this.GetComponent<Animator>();

        this.playerInput = this.GetComponent<PlayerInput>();
        this.mainActionMap = this.playerInput.actions.FindActionMap("PlayerActions");

        this.moveAction = this.mainActionMap.FindAction("Move");

        this.moveAction.performed += (context) => {
            Debug.Log("performed");
            Vector2 dir = context.ReadValue<Vector2>();
            this.h = dir.x; //메인에 넘기기 위함 
            this.v = dir.y;
            this.moveDir = new Vector3(dir.x, 0, dir.y);
        };

        this.moveAction.canceled += (context) =>
        {
            Debug.Log("canceled");
            this.h = 0; //메인에 넘기기 위함 
            this.v = 0;
            this.moveDir = Vector3.zero;
        };
    }

    // Update is called once per frame
    void Update()
    {
        this.Move();
        this.OnTarget();
        this.OnPortal();
        this.SearchMonster();
        
    }
    private void Move()
    {
        if(this.moveDir != Vector3.zero)
        {
            this.transform.rotation = Quaternion.LookRotation(this.moveDir);
            this.transform.Translate(Vector3.forward * Time.deltaTime * this.moveSpeed);
        }
        if(this.h == 0 && this.v == 0)
        {
            if(this.targetMonster != null)
            {
                this.transform.LookAt(this.targetMonster.transform.position); //공격 할 때만 바라보도록 수정
                this.anim.SetInteger(this.hashState, (int)eAnimationType.Shoot);
            }
            else
            {
                this.anim.SetInteger(this.hashState, (int)eAnimationType.Idle);
            }
        }
        else
        {
            //이동 애니메이션
            this.anim.SetInteger(this.hashState, (int)eAnimationType.Run);
        }
    }
    //튜토리얼에서 특정 위치 가면 실행되는 메서드
    private void OnTarget()
    {
        if(this.target != null)
        {
            float distance = Vector3.Distance(this.transform.position, target.position);
            //Debug.LogFormat("distance:{0}", distance);
            if (distance < 1.6f) //목표위치에 도착
            {
                this.onTarget(); //대리자 호출
            }
        }
    }
    //튜토리얼에서 포탈의 위치에 가면 실행되는 메서드 
    private void OnPortal()
    {
        if(this.portal != null)
        {
            float distance = Vector3.Distance(this.transform.position,
                this.portal.transform.position);
            if(distance < 4.8f)
            {
                //다음 씬으로 이동
                this.onPortal();
            }
        }
    }
    //주변에 있는 몬스터를 검색하여 선택하는 메서드
    private void SearchMonster()
    {
        int monsterLayerMask = 1 << LayerMask.NameToLayer("Monster");
        Collider[] targetColls = Physics.OverlapSphere(this.transform.position, radius, monsterLayerMask);
        this.dic.Clear(); //사전에 있는 정보 초기화

        //for문을 돌며, 해당 정보를 사전에 저장, 키값은 GameObject
        for(int i=0; i<targetColls.Length; i++)
        {
            Collider col = targetColls[i];
            float distance = Vector3.Distance(col.transform.position, this.transform.position);
            this.dic.Add(col.gameObject, distance);
        }
        //공격 사거리 내에 몬스터가 존재한다면
        if(this.dic.Count > 0)
        {
            float nearestDistance = this.dic.Values.Min(); //값 중에 가장 작은 것을 저장
            //시퀸스의 첫번째 요소를 반환하거나, 시퀸스에 요소가 없으면 기본값을 반환
            //여기서는 값, 즉 거리가, nearestDistance와 같은 요소를 가져와서, 그 요소의 키 값
            //즉, 게임오브젝트를 받아온다. 
            GameObject target = this.dic.FirstOrDefault(x => x.Value == nearestDistance).Key;
            Vector3 dir = target.transform.position - this.transform.position; // 플레이어에서의 타겟의 방향
            Ray ray = new Ray(this.transform.position + (Vector3.up * this.rayOffsetY), dir); //레이 생성
            Debug.DrawRay(ray.origin, ray.direction * 1000f, Color.red); //레이를 씬에서 그려봄
            int layerMask = 1 << LayerMask.NameToLayer("Monster") |
                            1 << LayerMask.NameToLayer("Wall") |
                            1 << LayerMask.NameToLayer("Tree");
            RaycastHit hit; 
            if(Physics.Raycast(ray, out hit, 1000f, layerMask)) //몬스터, 벽 , 나무에 관한 정보를 검출
            {
                //Debug.Log(hit.collider.tag);

                if (hit.collider.CompareTag("Monster"))
                {
                    if(this.targetMonster != null)
                    {
                        this.targetMonster.HideIndicator();
                    }
                    MonsterController monster = hit.collider.gameObject.GetComponent<MonsterController>();
                    monster.ShowIndicator();
                    this.targetMonster = monster;
                }
                else
                {
                    MonsterController[] monsters = GameObject.FindObjectsOfType<MonsterController>();
                    //Debug.LogFormat("몬스터의 수:{0}",monsters.Count());

                    foreach(MonsterController monster in monsters)
                    {
                        monster.HideIndicator();
                    }
                    this.targetMonster = null;
                }
            }
        }

    }


    private void OnDrawGizmos()
    {
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(this.transform.position, radius);
    }
}

각 씬들의 메인에서 h와 v의 값을 통해 총알을 발사하게 하기 위해 위의 코드처럼 작성했다. 

조이스틱으로 제어

이렇게 새로운 Input System으로 제어하면서 생긴 문제도 있는데, 바로 조이스틱이 고정되었다는 것이다. 

원래는 사용자가 클릭한 곳으로 조이스틱이 이동을 하는데 이 기능을 구현하며 제거 되고 말았다. 

이 문제는 조금 더 고민해보고 해결해보도록 하겠다. 

 

2. 몬스터 움직임

저번 포스트에서 몬스터가 플레이어를 추격하는 기능을 작성했는데, 

결국 SetDestination을 활용하는 것이 좋다는 결론이 나왔었다. 

그래서 이 SetDestination을 활용하여 코드를 작성하였다. 

 

몬스터는 공통된 MonsterController를 활용하지만, 고정된 경우와, 움직이는 경우로 나누어진다.

그래서 enum을 통해 inspector에서 설정하는 것으로 몬스터의 타입을 결정했다.

몬스터의 타입 결정하는 코드
인스펙터에서 몬스터의 타입 결정

Monster를 제어하는 스크립트는 다음과 같다.

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

public class MonsterController : MonoBehaviour
{
    //몬스터 애니메이션 enum
    private enum eMonsterState 
    {
        Idle,Hit,Die
    }

    private enum eMosterType
    {
        Fixed,Move
    }
    [SerializeField] private int hp = 5; //몬스터 hp
    public System.Action onDie; //죽으면 알려주는 대리자 
    [SerializeField] private GameObject indicator; //선택된 거 표시
    [SerializeField] private Transform target; // 플레이어 

    [SerializeField]
    private eMosterType monsterType; //몬스터의 타입 
    private Animator anim;
    private NavMeshAgent agent;
    [SerializeField]
    private float speed = 1.0f;
    [SerializeField]
    private float damping = 10.0f;

    private int count = 0;
    private void Awake()
    {
        this.anim = this.GetComponent<Animator>();
        this.agent = GetComponent<NavMeshAgent>();
        if(this.agent !=null) this.agent.updateRotation = false;
    }

    private void Start()
    {

    }
    private void FixedUpdate()
    {
        //움직이는 타입일 때만 Move 함수 실행
        if(this.monsterType == eMosterType.Move) this.Move();
    }

    private void Move()
    {
        if(this.target != null)
        {
            this.agent.SetDestination(this.target.position);
            if (this.agent.remainingDistance >= this.agent.stoppingDistance) //추적
            {
                //이동방향
                Vector3 dir = agent.desiredVelocity;
                DrawArrow.ForDebug(this.transform.position, dir.normalized, 0, Color.green, ArrowType.Solid); //dir 그리기 
                                                                                                              //회전
                Quaternion rot = Quaternion.LookRotation(dir.normalized);
                this.transform.rotation = Quaternion.Slerp(this.transform.rotation, rot, Time.deltaTime * 10.0f);
                //Debug.LogFormat("velocity:{0}", this.agent.velocity);
            }
            else //공격
            {
                this.agent.velocity = Vector3.zero;
            }
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (collision.collider.CompareTag("Bullet"))
        {
            this.hp--;
            this.anim.SetInteger("MonsterState", 1); //hit //아직 미흡
            if(this.hp <= 0)
            {
                this.Die();
            }
        }
    }

    public void Die()
    {
        StartCoroutine(this.CoDie());
        //Destroy(this.gameObject);
    }

    private IEnumerator CoDie()
    {
        this.anim.SetInteger("MonsterState", 2); //die
        yield return new WaitForSeconds(2.0f);
        this.onDie();
    }

    public void HideIndicator()
    {
        //Debug.Log("선택 마크 숨기기!");
        this.indicator.SetActive(false);
    }

    public void ShowIndicator()
    {
        //Debug.Log("선택 마크 보여주기!");
        this.indicator.SetActive(true);
    }
}

몬스터의 타입이 Fixed인 경우 

움직이지 않는다.

몬스터의 타입이 Move인 경우 

움직이면서 쫓아온다. 장애물이 있으면 장애물을 피하면서 추적

이렇게 몬스터가 플레이어를 추격하는 기능은 완료되었다. 

2. 오브젝트 풀링

현재는 총알을 필요한 순간의 복제하고 몬스터와 부딫치면 삭제된다. 

계속 동적으로 총알을 생성하고 삭제하는 것은 성능저하로 이어질 수 있기에 

미리 만들어두고 사용하는 오브젝트 풀링을 해보려고 한다. 

씬이 넘어가도 계속 가지고 있어야 하기에, DontDestroyOnLoad를 사용하려고 한다. 

 

오브젝트 미리 생성

실행하면 생기는 Bullet 오브젝트들
수정 전의 Bullet Generator

수정 전에는 일정시간마다 총알을 생성하여 발사하고 있었다.

수정 후 BulletGenerator

수정 후에는 Pool에서 가져와서 발사하는 방식으로 변경되었다. 

그런데 이 후에 문제가 생겼다. 

처음 발사 될 때는 괜찮은데 그 이후에 다시 불리는 경우, 총알이 회전하듯 움직이는 문제가 발견되었다. 

Bullet 각각을 제어하는 스크립트

그래서 확인해보니, 현재의 BulletController는 각각의 local을 기준으로 앞으로 날아가도록 되어있는데, 

몬스터와 부딫치는 순간 회전되면서, 이 후에 날아가는 총알이 이상해지는 것이었다. 

그래서 다시 pool로 돌아갈 때, 오브젝트(총알)의 회전을 초기화하기로 하였다. 

하지만, 예상했던 문제가 아니었는지 여전히 결과는 같았다. 

 

이 다음에는, Translate를 Addforce 함수로 바꾸어 봤지만, 문제 해결로 이어지지는 않았다. 

 

다시 한번, 분석해보니 OnCollisionEnter를 통해 충돌처리를 하다보니, pool에 돌아가기 전에

충돌하면서 별도의 회전이 들어가게 되면서 위에 처럼 회전하는 총알이 발사되게 된것이었다. 

 

그래서 OnCollisionEnter 대신에 OnTriggerEnter를 사용하여 문제를 해결하였다. 

하지만 또다른 문제가 생기게 되었다.

바로 밑에 보이는 것처럼 총알이 발사되는 문제가 생겼다. 

처음에는 잘 발사되지만, 다른 몬스터를 향해 발사할 때는 아까 다른 몬스터를 향해 발사했을때와 

같은 방향으로 발사가 된 것이다. 

문제는 바로 유니티 라이프 사이클에 있었다. 

https://easycastleunity.tistory.com/110

 

유니티 라이프 사이클

https://docs.unity3d.com/kr/2021.3/Manual/ExecutionOrder.html 이벤트 함수의 실행 순서 - Unity 매뉴얼 Unity 스크립트를 실행하면 사전에 지정한 순서대로 여러 개의 이벤트 함수가 실행됩니다. 이 페이지에서는

easycastleunity.tistory.com

위에 코드 사진을 보면, 총알이 Addforce를 받는 것은 Start에서 처리하고 있는 것을 알 수 있다. 

그래서, 총알이 처음에 발사될 때는, 제대로 발사가 되었지만 

이 후에 pool로 돌아간 다음에는, 발사될 때, 처음 발사한 방향으로 날아가도록 설정이 된 것이다. 

그래서 나는 AddForce를 OnEnable로 옮겨서 시험해보기로 하였다. 

OnEnable은 활성화 될 때마다 불리는 함수이기에 적절하다고 생각했다. 

이 후 실행을 해보면, NullRefereceException오류가 발생하는 것을 볼 수 있다. 

BulletController의 23번째줄은 위에 사진에서 보이는 AddForce 부분이다. 

이 오류가 생긴 이유 또한 유니티 라이프 사이클에 있다. 

유니티 라이프 사이클 앞 부분

위에 사진을 보면, 호출되는 순서는 Awake -> OnEnable -> Start 순이다. 

근데 현재 코드를 보면, Start에서 리지드바디를 저장하고 있는데, 

이러면 호출순서상, AddForce는 아직 존재하지도 않는 리지드바디를 호출하고 있다는 것을 알 수 있다. 

따라서 Start가 아닌 Awake에서 리지드바디를 저장하기로 했다. 

Awake에서 저장하도록 바꾸고 실행한 결과

NullReferenceException 오류가 사라진 것을 볼 수 있다. 

 

하지만 AddForce로 구현하니 자잘한 오류가 많아서 다시 Translate를 활용하기로 하였다. 

비록 위에서 한 것이 결과적으로는 무용지물이 되었지만,

유니티 라이프 사이클에 대해 다시 한번 공부 할 수 있는 기회가 되었다.

최종 BulletController 스크립트

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

public class BulletController : MonoBehaviour
{
    [SerializeField]
    private float moveSpeed = 5.0f;
    
    // Update is called once per frame
    void Update()
    {
        this.transform.Translate(Vector3.forward * this.moveSpeed * Time.deltaTime);
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Monster"))
        {
            Debug.Log("<color=lime>몬스터 피격</color>");
            Debug.Log("<color=yellow>총알 풀로 되돌아감</color>");
            BulletPoolManager.instance.ReleaseBullet(this.gameObject);

        }
        else if (other.CompareTag("Wall"))
        {
            Debug.Log("<color=yellow>총알 풀로 되돌아감</color>");
            BulletPoolManager.instance.ReleaseBullet(this.gameObject);

        }
    }
}

원래 목적이던 회전하는 총알 문제 해결

AddForce로도 구현해보고 싶기에 조금 더 연구해 보겠다. 

오브젝트 풀링 코드 

BulletPoolManager -> 총알 풀을 관리하는 스크립트, 싱글톤으로 구현하였다.

                                    또한, 모든 씬에서 있어야하기에, DontDestrouOnLoad를 활용하였다. 

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

public class BulletPoolManager : MonoBehaviour
{
    //총알을 미리 생성해 저장할 리스트 
    [SerializeField]
    private List<GameObject> bulletPool = new List<GameObject>();
    //오브젝트 풀에 생성할 총알의 최대 개수
    [SerializeField] private int maxBullets = 10;
    //싱글톤 인스턴스 선언 
    public static BulletPoolManager instance = null;
    //총알 프리팹
    [SerializeField]
    private GameObject bulletPrefab;

    //------------------- 속성 ------------------
    public List<GameObject> BulletPool
    {
        get { return this.bulletPool; }
    }

    private void Awake()
    {
        if(instance == null)
        {
            instance = this;
        }
        else if(instance != null)
        {
            Destroy(this.gameObject);
        }
        DontDestroyOnLoad(this.gameObject);
    }
    // Start is called before the first frame update
    void Start()
    {
        this.CreateBulletPool();
    }

    private void CreateBulletPool()
    {
        for(int i=0; i<maxBullets; i++)
        {
            GameObject bulletGo = Instantiate<GameObject>(this.bulletPrefab);
            bulletGo.SetActive(false);
            bulletGo.transform.SetParent(this.transform);
            bulletPool.Add(bulletGo);
        }
    }

    public GameObject GetBulletInPool()
    {
        foreach(GameObject bullet in this.bulletPool)
        {
            if(bullet.activeSelf == false)
            {
                return bullet;
            }
        }
        return null;
    }
    //원랴 위치로 돌아감 
    public void ReleaseBullet(GameObject bulletGo)
    {
        bulletGo.transform.rotation = Quaternion.identity; //회전 초기화
        bulletGo.SetActive(false);
        bulletGo.transform.SetParent(this.transform);
    }
    // Update is called once per frame
    void Update()
    {
        
    }
}

BulletGenerator -> 총알을 풀에서 가져와서 발사하고, 저장한 총알이 없으면,

                               그 자리에서 생성하고 Pool에 저장하도록 한다. 

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

public class BulletGenerator : MonoBehaviour
{
    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private Transform firePos;
    [SerializeField] private float intervelTime = 1.0f;

    private float elaspedTime;

    public void Shoot()
    {
        this.elaspedTime += Time.deltaTime;

        if(this.elaspedTime > intervelTime)
        {
            this.CallBullet();
            this.elaspedTime = 0;
        }

    }

    private void CallBullet()
    {
        GameObject go = BulletPoolManager.instance.GetBulletInPool();
        if (go == null) //이미 만들어둔 오브젝트를 다 사용하면 
        {
            go = Instantiate(bulletPrefab, firePos.position, firePos.rotation);
            BulletPoolManager.instance.BulletPool.Add(go);
        }
        else if (go != null)
        {
            go.transform.SetParent(null);
            go.transform.position = this.firePos.position;
            go.transform.localRotation = this.firePos.rotation;
            go.SetActive(true);
        }
    }
}

BulletController -> 총알 각각을 제어하는 스크립트, 충돌이 발생하면, 풀로 되돌아가도록 하였다.

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

public class BulletController : MonoBehaviour
{
    [SerializeField]
    private float moveSpeed = 5.0f;
    
    // Update is called once per frame
    void Update()
    {
        this.transform.Translate(Vector3.forward * this.moveSpeed * Time.deltaTime);
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Monster"))
        {
            Debug.Log("<color=lime>몬스터 피격</color>");
            Debug.Log("<color=yellow>총알 풀로 되돌아감</color>");
            BulletPoolManager.instance.ReleaseBullet(this.gameObject);

        }
        else if (other.CompareTag("Wall"))
        {
            Debug.Log("<color=yellow>총알 풀로 되돌아감</color>");
            BulletPoolManager.instance.ReleaseBullet(this.gameObject);

        }
    }
}

시연 영상

현재는 죽어도 사망하는 애니메이션이 다 실행되고 사라지는 데, 이 때도 아직 타겟으로 선택이 되어있는 상태다. 

(죽었지만, 게임 오브젝트가 남아있어 타겟으로 설정되고 있는 상황)

그래서 이 점도 조금 더 보완 해볼 생각이다. 

 

남은 미흡한 점

이번에는 플레이어 움직임과 몬스터 플레이어 추적, 오브젝트 풀링을 해보았다. 

다른 미흡한 점도 차차 해결해 나아가도록 하겠다. 

'유니티 심화' 카테고리의 다른 글

유니티 GUI 2  (0) 2023.09.05
유니티 GUI  (0) 2023.09.04
유니티 데이터 연동  (0) 2023.09.01
Hero Shooter 새로운 Input System 활용한 조이스틱 이동  (0) 2023.09.01
Hero Shooter new Input System Test  (0) 2023.08.31