유튜브 <골드메탈> 영상 시청 및 따라 만들기 - 3D 쿼터뷰 액션게임


추가 및 수정한 스크립트

Player.cs ::

1."Item" 태그 오브젝트 충돌시  처리 추가

2."Waepon" 태그 오브젝트 충돌시 처리 추가

3.각종 아이템 갯수 변수 및 아이템 최대갯수 변수 추가

4.Player가 장착하는 무기 배열변수 및 활성화된 무기 bool 변수 추가

5.무기 Swap 기능 근접 무기 공격기능 추가

6.수류탄 공전 연출 추가

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

public class Player : MonoBehaviour
{
    [SerializeField] float speed; //캐릭터의 속도
    [SerializeField] float jumpPower; //캐릭터의 점프파워

    [SerializeField] GameObject[] weapons; // 플레이어가 무기를 들고있을때 활성화 해주는 오브젝트 배열
    [SerializeField] bool[] hasWeapons; // 플레이어가 해당 무기를 끼고있는것츨 체크하는 배열

    //플레이어의 아이팀 소지 갯수 변수
    public int ammo;
    public int coin;
    public int health;
    public int hasGrenades;
    [SerializeField] GameObject[] grenades; //수류탄들을 저장할 배열 변수, 공전 물체를 컨트롤하기 위해 배열로 생성

    //아이템 소지 갯수 최대 변수
    public int maxammo;
    public int maxcoin;
    public int maxhealth;
    public int maxhasGrenades;

    float hAxis; //수평선 축 value값
    float vAxis; //수직선 축 value값

    bool wDown = false; //Input Manager에서 추가한 Walk(Left Shift)의 키 유무 체크
    bool jDown = false; //점프 키 유무 체크
    bool iDown = false; //아이템,무기 획득 키 유무 체크 - 상호작용
    bool fDown = false; //공격 키 유무 체크

    bool isJump = false; //현재 점프 중인지 체크 유무
    bool isDodge = false; //현재 회피 중인지 체크 유무
    bool isSwap = false; //현재 무기 교체 중인지 체크유무 -> 교체할때 다른 액션이 간섭 못하게 하기위해
    bool isFireReady = true; //현재 공격이 가능한 상태를 체크 유무
                      
    //FPS 게임 시 1, 2,3 을누르면 무기 스왑하는 기능으로 설계
    bool sDown1 = false;
    bool sDown2 = false;
    bool sDown3 = false;

    Vector3 moveVec; // hAxis와 vAxis 값을 받아와 3차원 벡터값(방향벡터) 변수
    // 회피시 캐릭터 방향전환을 막기위해 생성
    Vector3 dodgeVec; //회피할때 적용되는 방향벡터
    Animator anim;
    Rigidbody rigid;

    GameObject nearGameObject; // 무기에 근접했을때, 무기 오브젝트를 저장할 변수 
    //GameObject equipWeapon; // 자료형 GameObject -> Weapon으로 변경
    Weapon equipWeapon;  // 현재 장착하고 있는 무기 오브젝트를 저장할 변수 

    //처음 초기값을 -1로 , 무기 첫데이터가 0번 Index이기 때문
    int equipWeaponIndex = -1; // 현재 장착하고 있는 무기의 Index 변수
    float fireDelay; //공격딜레이 변수

    private void Awake()
    {
        rigid = GetComponent<Rigidbody>();
        anim = GetComponentInChildren<Animator>();
    }

    private void Update()
    {
        GetInput();
        Move();
        Turn();
        Jump();
        Attack();
        Dodge();
        Interation();
        Swap();
    }

    private void GetInput()
    {
        //해당 프로젝트 InputManager를 이용하여 만듬 

        //GetAxisisRaw() : Axis 값을 정수로 반환하는 함수
        //GetButton() : 꾹 누르고 있을때만 호출
        hAxis = Input.GetAxisRaw("Horizontal");
        vAxis = Input.GetAxisRaw("Vertical");
        wDown = Input.GetButton("Walk");
        jDown = Input.GetButtonDown("Jump");
        fDown = Input.GetButtonDown("Fire1");
        iDown = Input.GetButtonDown("Interation");
        sDown1 = Input.GetButtonDown("Swap1");
        sDown2 = Input.GetButtonDown("Swap2");
        sDown3 = Input.GetButtonDown("Swap3");
    }

    private void Move()
    {
        //정규화를 하는 이유? : 대각선 이동시 빨라지기 때문에 크기(속도)를 1로 만들어서 속도를 똑같이 만들어줘야한다.
        //쉽게 말하면 방향값이 1로 보정되는 벡터라고 생각하면 된다.
        //왜 빨라짐? : hAxis = 1 , vAxis = 1 인경우 대각성 이동시 피타고라스의 법칙으로 root(1^2+1^2) = root(2) = 1.141... 때문이다.
        moveVec = new Vector3(hAxis, 0, vAxis).normalized;

        //무기 교체시 or 무기 공격시 움직이지 못하게 설정
        if (isSwap || !isFireReady) moveVec = Vector3.zero;

        //회피 동작 중인경우 회피 방향벡터가 대입되어 방향전환을 못하게 막기 위한 코드
        if (isDodge) moveVec = dodgeVec;

        //transform 이동은 가끔 물리충돌을 무시하는 경우가 발생 함
        //Rigidbody 컴포넌트에서 CollisonDetection을 Continuos로 변경 
        //Cpu를 더 먹어 최적화는 떨어지지만 정확도는 올라가기 때문 

        if (!wDown) transform.position += moveVec * speed * Time.deltaTime;
        else transform.position += moveVec * speed * 0.3f * Time.deltaTime;

        //SetXXXX("애니메이션 이름" , bool 변수) 를 넣어 조건문처럼 사용 할 수 있다.
        anim.SetBool("isRun", moveVec != Vector3.zero);
        anim.SetBool("isWalk", wDown);
    }

    private void Turn()
    {
        //지정된 벡터를 향해서 회전시켜주는 함수 - 3D기능
        //현재 플레이어 위치에 방향벡터를 더해서 우리가 나아가는 쪽으로 오브젝트를 회전한다는 뜻
        transform.LookAt(transform.position + moveVec);
    }

    //점프키와 회피키가 같지만 이동키가 눌리고 있을때는 회피, 이동 안할때는 점프를 하게된다.
    private void Jump()
    {
        //방향벡터가 0 이기에 이동 하지 않으므로 점프
        if(jDown && moveVec == Vector3.zero && !isJump && !isDodge && !isSwap) //액션 도중에 다른 액션이 실행되지 않도록 조건 추가
        {
            //Addforce : rigidbody에 포함된 메서드이며 해당 오브젝트에 힘을 가해주는 함수
            rigid.AddForce(Vector3.up * jumpPower,ForceMode.Impulse); // Impulse : 힘을 주는 종류 중 하나로, 즉발로 힘을 준다는 의미
            anim.SetBool("isJump",true);
            anim.SetTrigger("doJump");
            isJump = true;
        }
    }

    private void Attack()
    {
        if (equipWeapon == null) //무기가 있을떄만 실행되도록 체크
        {
            return;
        }

        fireDelay += Time.deltaTime;
        isFireReady = equipWeapon.rate < fireDelay; //무기에 있는 공격속도 보다 현재 딜레이가 크면 공격 가능상태로 변경

        //공격키가 눌렸고 (fDonw) , 공격 준비 상태(isFireReady)고 점프 액션을 제외한 액션이 동작안할때 조건
        if(fDown && isFireReady && !isSwap && !isDodge)
        {
            equipWeapon.Use(); //장착하고 있는 무기의 Weapon Use함수 호출
            anim.SetTrigger("doSwing");
            fireDelay = 0; //공격을 했기에 공격딜레이 초기화
        }
    }

    private void Dodge()
    {
        //방향벡터가 0 이 아니므로 이동 하는 상태이기 때문에 회피
        if (jDown && moveVec != Vector3.zero && !isJump && !isDodge && !isSwap) //액션 도중에 다른 액션이 실행되지 않도록 조건 추가
        {
            dodgeVec = moveVec;  //회피시 현재 이동벡터를 회피벡터에 대입한뒤 Move()함수에서 대신 사용됨
            speed *= 2;
            anim.SetTrigger("doDodge");
            isDodge = true;

            Invoke("DodgeOut",0.4f);
        }
    }

    private void DodgeOut()//회피가 끝났을때
    {
        speed *= 0.5f;
        isDodge = false;
    }

    private void SwapOut()//교체가 끝났을때
    {
        isSwap = false;
    }

    private void Swap() //무기 교체
    {
        //무기 스왑키를 눌렀을때
        //1.무기를 같은 무기로 교체 안되게 함
        //2.획득하지 않은 무기는 교체를 못하게 하기위해
        //이 두가지 동작을 막기위한 로직 추가 
        if(sDown1 && (!hasWeapons[0] || equipWeaponIndex == 0))
        {
            return;
        }
        if (sDown2 && (!hasWeapons[1] || equipWeaponIndex == 1))
        {
            return;
        }
        if (sDown3 && (!hasWeapons[2] || equipWeaponIndex == 2))
        {
            return;
        }

        //무기 인덱스를 초기화 하고 키 누르는것을 확인하고 인덱스 값 변경
        int weaponIndex = -1;
        if (sDown1) weaponIndex = 0;
        if (sDown2) weaponIndex = 1;
        if (sDown3) weaponIndex = 2;

        //액션중에는 무기 변경이 안되게 조건문을 설정
        if ((sDown1 || sDown2 || sDown3) && !isJump && !isDodge)
        {
            if (equipWeapon != null) //빈손이 아닌경우에만
                equipWeapon.gameObject.SetActive(false); //빈손이 아닌경우 장착 무기 비활성화

            equipWeaponIndex = weaponIndex;
            equipWeapon = weapons[weaponIndex].GetComponent<Weapon>(); // 장착할 무기 저장
            equipWeapon.gameObject.SetActive(true); //장착 무기 활성화

            anim.SetTrigger("doSwap");

            isSwap = true;
            Invoke("SwapOut", 0.4f);//애니메이션이 안보여서 지연시간을 줌 
        }
    }

    private void Interation() //아이팀과 무기를 상호작용하는 하는 함수 (조작키  : E)
    {   
        //상호작용키가 눌리고(idown) ,무기오브젝트가 비어있지 않고(nearGameObject) , 액션중에 동작이 안되게하는 조건
        if(iDown && nearGameObject != null && !isJump && !isDodge)
        {
            if(nearGameObject.gameObject.CompareTag("Weapon"))
            {
                //nearGameObject 의 Item 컴포넌트를 가져와서 저장
                Item item = nearGameObject.GetComponent<Item>();
                int weaponIndex = item.value; //해당 아이템의 Value값을 호출
                hasWeapons[weaponIndex] = true; //bool 배열에 해당 무기를 장착했다고 알림

                Destroy(nearGameObject); //무기를 획득했기에 해ㅔ당 오브젝트를 파괴 -> Player내에서 파괴한게아니라 WorldSpace에서의 파괴
            }
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        //충돌처리 함수로 점프 후 착지 확인
        if(collision.gameObject.CompareTag("Floor"))
        {
            anim.SetBool("isJump", false);
            isJump = false;
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        if(other.gameObject.CompareTag("Item"))
        {
            //충돌된 오브젝트의 Item 컴포넌트를 호출
            Item item = other.GetComponent<Item>();

            //호출한 Item의 value값을 호출하여 조건문 진행
            switch (item.type)
            {
                case Item.Type.Ammo:
                    ammo += item.value;
                    if(ammo > maxammo) ammo = maxammo;

                    break;

                case Item.Type.Coin:
                    coin += item.value;
                    if (coin > maxcoin) coin = maxcoin;
   
                    break;

                case Item.Type.Grenade:
                
                    hasGrenades += item.value;
                    if (hasGrenades > maxhasGrenades) hasGrenades = maxhasGrenades;

                    //Player가 몇개의 수류탄을 가지고 잇는지 연출 설계
                    //Player 주위로 수류탄이 공전하는 공전물체 생성으로 갯수 파악
                    grenades[hasGrenades - 1].SetActive(true);

                    break;

                case Item.Type.Heart:
                    health += item.value;
                    if (health > maxhealth) health = maxhealth;
          
                    break;
            }
            //사용된 아이템 오브젝트는 파괴
            Destroy(other.gameObject);

        }

   
    }

    private void OnTriggerStay(Collider other)
    {
        //Player가 무기에 근접했다는것을 체크
        if(other.gameObject.CompareTag("Weapon"))
        {
            //근접한 무기 오브젝트를 저장
            nearGameObject = other.gameObject;
        }
    }

    private void OnTriggerExit(Collider other)
    {
        //Player가 무기에서 벗어났다는것을 체크
        if (other.gameObject.CompareTag("Weapon"))
        {
            //벗어낫기에 오브젝트를 null로 초기화 
            nearGameObject = null;
        }
    }
}

 

Item.cs :: 드랍되있는 아이템들은 전부 Item.cs를 따른다. 

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

public class Item : MonoBehaviour
{
    //열거형 변수 enum
    public enum Type
    {
        Ammo, Coin, Grenade, Heart, Weapon
    };

    public Type type; //아이템 종류 변수
    public int value; //아이템 값을 저장할 변수 ex)회복량,총알량,무기의 코드번호 등등..

    void Update()
    {
        //Rotate(오일러 각도) : 오브젝트를 해당 각도(방향벡터)로 회전시키는 함수 
       transform.Rotate(Vector3.up * 10 * Time.deltaTime);
    }
}

 

Orbit.cs :: 물체를 타겟 주위로 공전시키는 스크립트

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

//수류탄이 공전하는 스크립트
public class Orbit : MonoBehaviour
{
    public Transform target;
    public float orbitSpeed;
    Vector3 offset;

    private void Start()
    {
        offset = transform.position - target.position;
    }

    void Update()
    {
        transform.position = target.position + offset;

        //타겟주위를 회전하는 함수 , 단점 : 목표(target)이 움직이면 일그러져서 회전 반경이 이상하게 됨
        //이 단점을 대처하기위해 Offset 변수 설정
        transform.RotateAround(target.position,
                               Vector3.up,
                               orbitSpeed * Time.deltaTime);

        //RotateAround() 후의 위치를 가지고 목표와의 거리를 유지
        offset = transform.position - target.position;
    }
}

 

Weanpon.cs :: 근접무기만 구현되있는 상태이며, 이 Weapon 스크립트는 드랍된 오브젝트가 아닌 Player 오브젝트에 자식으로 있는 무기 오브젝트들만 가지고 있다. 

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

public class Weapon : MonoBehaviour
{
    public enum Type { Melee, Range } //근거리, 원거리
    public Type type;//무기타입
    public int damage;//공격력
    public float rate;//공격속도
    public BoxCollider meleeArea; //근접 공격범위 콜라이더
    public TrailRenderer trailRenderer; //근정 공격시 연출 변수


    //Player에서 호출할것이기에 public으로 접근한정자 설정
    public void Use()
    {
        if(type == Type.Melee)
        {
            //똑같은 코루틴을 동작할때 Stop으로 멈추게하고 동작 해야만한다.(설계상 그렇다는거지 이걸 노리고 할수도 있음)
            //비동기로 돌아가기때문에 Start로 하면 코루틴이 2개 돌아가 로직이 꼬여버릴 수 있다.
            StopCoroutine(Swing());
            StartCoroutine(Swing());
        }
    }

    //열거형 함수 클래스 :: IEnumerator
    IEnumerator Swing()
    {
        //yield 결과를 전달하는 키웓 :: yield
        yield return new WaitForSeconds(0.1f);
        //근접공격 콜라이더와 트레일렌더러를 활성화 시킴
        meleeArea.enabled = true;
        trailRenderer.enabled = true;

        //근접공격을 하고 콜라이더를 바로 꺼버리면 공격이 안될 수도있으니
        //지연시간을 줘서 공격 콜라이더의 수명을 늘리기
        yield return new WaitForSeconds(rate);
        meleeArea.enabled = false;

        //근접공격의 연출을 바로 종료하지않고 지연시간을 줘서 기다리게 하고 종료
        yield return new WaitForSeconds(0.1f);
        trailRenderer.enabled = false;
    }
}

유니티 기능 정리

1.드랍된 오브젝트(Player 몸에 장착되는 오브젝트)의 콜라이더 관리

Player 가 획득시 손에 장착되는 망치(무기) 오브젝트

 

1-1.빨간상자 의 Collider는 땅위에 서있어야 되기때문에 추가

RigidBody를 적용시켜 중력을 받게 해야됨

 

1-2.파란상자의 Collider는 Player가 충돌시 Player의 스크립트 안에 미리 무기를 저장시키는 콜라이더이다.

무기를 완전하게 획득시 해당 오브젝트의 Item 컴포넌트를 호출한다.

public class Player : MonoBehaviour
{
    [SerializeField] GameObject[] weapons; // 플레이어가 무기를 들고있을때 활성화 해주는 오브젝트 배열
    [SerializeField] bool[] hasWeapons; // 플레이어가 해당 무기를 끼고있는것츨 체크하는 배열

   GameObject nearGameObject; // 무기에 근접했을때, 무기 오브젝트를 저장할 변수 
 	 
    private void Interation() //무기(item)와 상호작용하는 하는 함수 (조작키  : E)
    {   
        //상호작용키가 눌리고(idown) ,무기오브젝트가 비어있지 않고(nearGameObject) , 액션중에 동작이 안되게하는 조건
        if(iDown && nearGameObject != null && !isJump && !isDodge)
        {
            if(nearGameObject.gameObject.CompareTag("Weapon"))
            {
                //nearGameObject 의 Item 컴포넌트를 가져와서 저장
                Item item = nearGameObject.GetComponent<Item>();
                int weaponIndex = item.value; //해당 아이템의 Value값을 호출
                hasWeapons[weaponIndex] = true; //bool 배열에 해당 무기를 장착했다고 알림

                Destroy(nearGameObject); //무기를 획득했기에 해당 오브젝트를 파괴
                //Player내에서 파괴한게아니라 WorldSpace에서의 파괴
            }
        }
    }
     
     
   private void OnTriggerStay(Collider other)
    {
        //Player가 무기에 근접했다는것을 체크
        if(other.gameObject.CompareTag("Weapon"))
        {
            //근접한 무기 오브젝트를 저장
            nearGameObject = other.gameObject;
        }
    }

    private void OnTriggerExit(Collider other)
    {
        //Player가 무기에서 벗어났다는것을 체크
        if (other.gameObject.CompareTag("Weapon"))
        {
            //벗어낫기에 오브젝트를 null로 초기화 
            nearGameObject = null;
        }
    }
}

Player.cs에서 해당되는 부분의 스크립트만 가져왔다.

 

OnTriggerStay/Exit 함수로 근처에 있는 무기(Item)오브젝트를 저장 및 초기화를 하고있다.

그리고 무기 오브젝트에 다가가서 <E>키를 누르면 Interation()함수가 호출되어 해당 오브젝트(무기)를 Player 오브젝트가 가지고 있다고 판단을 한다.

 

2.무기 오브젝트를 획득할때 스크립트로 무기를 넣어 오브젝트를 생성해줘야함?

물론, 그렇게 해도 된다. 하지만, 이번 프로젝트에서는 다른 방법으로 처리를 하였다.

 

우선 무기오브젝트를 장착할 때, 어떡해 하는지를 메모하겠다.

Player의 오른팔에 무기를 장착하기 위해 RightHand(자식) 으로 빈 오브젝트를 넣어 관리 할거다.

보통 3D게임에서 무기를 관리할떄 이 방법을 많이 사용하는것 같다. 

우선 오른손의 무기 잡는 위치를 조정해줘야한다. 

자식으로 실린더 오브젝트를 만들고 위치를 손 안으로 집어넣어서 위치 조정

손 안으로 실린더를 집어넣어 위치를 잡아주고 실린더는 빈오브젝트 처럼 보여야 되기 땜누에 Renderer 및 Collider를 비활성화 제거를 해준다.

실린더 오브젝트 -> Weapon Point 로 변경하여 무기가 위치 되는 되는곳으로 잡아준다.

해당 오브젝트 자식으로 무기 오브젝트들을 전부 자식으로 넣은뒤 비 활성화 해주고, 이 오브젝트들을 최상위 부모 클래스인 Player의 Inspector를 통해 Weapons 배열에 삽입해준다.

무기를 획득하여 Inspector에 체크가 되어있다.

1번과 2번을 통해 만들면 위 그림 처럼 완성된다. 

 

 

3.공전하는 물체 오브젝트 만들기 및 유니티 에디터에 표시하기

해당 프로젝트에서 수류탄이라는 아이템을 획득시 UI 표현이 아닌 Player 캐릭터 주위로 돌리는것으로 판단하기로 했다.

수류탄 회전 위치를 표시하기 위해 아이콘을 설정하면 이렇게 표시된다.

//수류탄이 공전하는 스크립트
public class Orbit : MonoBehaviour
{
    public Transform target;
    public float orbitSpeed;
    Vector3 offset;

    private void Start()
    {
        offset = transform.position - target.position;
    }

    void Update()
    {
        transform.position = target.position + offset;

        //타겟주위를 회전하는 함수 , 단점 : 목표(target)이 움직이면 일그러져서 회전 반경이 이상하게 됨
        //이 단점을 대처하기위해 Offset 변수 설정
        transform.RotateAround(target.position,
                               Vector3.up,
                               orbitSpeed * Time.deltaTime);

        //RotateAround() 후의 위치를 가지고 목표와의 거리를 유지
        offset = transform.position - target.position;
    }
}

 

Orbit.cs는 RotateAround() 함수를 통해서 Player 오브젝트 주위로 y 축을 회전 시키는 스크립트다.

하지만 RotateAround()를 사용하게되면 target 오브젝트가 이동시 회전 반경이 일그러지기 때문에 따로 보정이 필요하다.

 

Orbit.cs를 가지고 있는 transform.position 과 target.position의 차이를 구하고 회전 반경을 구할때 다시 offset를 더해서 계속 보정하는 로직을 가지고있다. 

 

또한 Player에서 수류탄을 획득한 갯수만큼 보여주게 하는 로직이 필요한데

public class Player : MonoBehaviour
{
	 public int hasGrenades;
 	[SerializeField] GameObject[] grenades; //수류탄들을 저장할 배열 변수, 공전 물체를 컨트롤하기 위해 배열로 생성
 
	 private void OnTriggerEnter(Collider other)
 	{
         if(other.gameObject.CompareTag("Item"))
         {
             //충돌된 오브젝트의 Item 컴포넌트를 호출
             Item item = other.GetComponent<Item>();

             //호출한 Item의 value값을 호출하여 조건문 진행
             switch (item.type)
             {
                 case Item.Type.Grenade:

                     hasGrenades += item.value;
                     if (hasGrenades > maxhasGrenades) hasGrenades = maxhasGrenades;

                     //Player가 몇개의 수류탄을 가지고 잇는지 연출 설계
                     //Player 주위로 수류탄이 공전하는 공전물체 생성으로 갯수 파악
                     grenades[hasGrenades - 1].SetActive(true);

                     break;
             }
             //사용된 아이템 오브젝트는 파괴
             Destroy(other.gameObject);

         }
 	 }

}

OnTriggerEnter() 아이템을 획득 할 때마다 해당 오브젝트의 수류탄 Mesh를 활성화 시켜주는 로직이다. 

 

4.TrailRenderer 컴포넌트의 기능

TrailRenderer는 오브젝트의 잔상을 그려주는 기능이다.

player의 자식 오브젝트(근접무기)의 자식 오브젝트에 Effect로 추가하여 관리한다.

 

TrailRenderer 기능 설명

 

5.Particle System 기능 설명

여기서 설명하는 Particle System 기능은 이동시에 파티클을 생성하는 기능을 설정하는 방법이다.

 

(좌)Simulation Space - Local 일때, 파티클이 부모오브젝트를 따라온다. (우)Simulation Space - World 일때, 파티클이 World Space 기준으로 따라온다.

 

6.Floor의 Material 재질 타일링 하는법

해당 오브젝트에 적용되는 Material 설정을 건들여야 한다.

Floor의 텍스쳐가 타일링 되어있다.

 


2024-09-05(목)

https://www.youtube.com/watch?v=u2DLOay5oO8&t=738s

: 아이템 로직 및 프리팹 저장  (12:20초까지 수강)

https://www.youtube.com/watch?v=APS9OY_p6wo

: 드랍 무기 획득 및 무기교체(FPS 무기의 1,2,3)

https://www.youtube.com/watch?v=esGkgvm9eSg

: 아이템 먹기 & 공전물체 만들기(획득한 수류탄 갯수 파악 겸 제작)

https://www.youtube.com/watch?v=Zfoyagdz1y0   

: 코루틴으로 근접공격 구현 및 공격 범위 , 잔상연출 추가 

 

 

+ Recent posts