Unity 입문 TopDown Shooting - 투사체 구현하기

Amberjack·2024년 1월 19일
0

Unity

목록 보기
13/44

🧮 레이어 비트 연산

  • 레이어 비트 연산은 Unity에서 게임 오브젝트의 레이어를 빠르게 검사하고 조작하는 방법!
  • 비트 연산은 AND, OR, XOR, NOT 등의 연산을 이용해 레이어 마스크를 조작한다.
    레이어 마스크 : 레이어 기반 작업을 단순화 하는 기능으로, 특정 레이어에서 대상 작업을 필터링할 수 있도록 해준다.
  • 물리적 충돌, 레이 캐스팅, 카메라 렌더링 등을 제어하는 데 사용된다.
  • 각 게임 오브젝트는 고유한 레이어에 배치될 수 있으며, 이 레이어는 비트 필드로 표현된다. (32 비트)

레이어와 레이어마스크 : https://docs.unity3d.com/kr/2022.3/Manual/layers-and-layermasks.html

😣 쿼터니언과 벡터의 곱셈

  1. 먼저 벡터 v를 쿼터니언으로 변환한다. ex) v = (x, y, z)인 벡터는 (0, x, y, z)라는 쿼터니언으로 변환된다.
  2. 이렇게 변환된 벡터 쿼터니언을 원래의 쿼터니언 q와 그 켤레 쿼터니언 q와의 사이에 두고 곱셈을 수행한다. 즉, qvq 형태가 된다.
  3. 이 결과로 나온 쿼터니언의 x, y, z 요소는 원래 벡터 v가 쿼터니언 q에 의해 회전한 후의 좌표를 나타낸다.

유니티에서는 쿼터니언과 Vector3의 곱셈을 지원한다.

🧑‍💼 싱글톤 패턴

싱클톤 패턴은 소프트웨어 디자인 패턴 중 하나로, 특정 클래스의 인스턴스가 하나만 존재하도록 보장하고, 이를 전역적으로 접근할 수 있는 글로벌 접근점을 제공하는 패턴이다.

  • 싱글톤 패턴은 클래스의 인스턴스가 하나만 존재하도록 보장하는 디자인 패턴이다.
  • 싱글톤 객체는 전역적으로 접근 가능한 글로벌 접근점을 제공한다.
  • 싱글톤 패턴은 전역 변수를 사용하지 않고도 각 객체 간 데이터를 공유하거나, 전역적인 상태를 관리하거나, 특정 서비스를 애플리케이션 전체에서 사용할 수 있게 해준다.
  • 싱글톤 패턴은 잘못 사용하면 코드의 결합도를 높이고 테스트와 유지 보수를 어렵게 할 수 있으므로 주의해야 한다.
  • 싱글톤 객체는 프로그램의 생명 주기 동안 계속 존재하므로, 메모리 관리에 신경써야 한다.

⌨️ TopDownCharacterController 수정하기

우리가 다 작성하지 않은 OnAttackEvent를 수정해보자!

// TopDownCharacterController.cs 수정
... // ... : 이전 코드 생략

public event Action<AttackSO> OnAttackEvent;
...

private void HandleAttackDelay()
{
    // 공격 정보가 없으면 공격을 하지 않음.
    if(Stats.currentStats.attackSO == null)
    {
        return;
    }
    
    // 공격 딜레이
    if(_timeSinceLastAttack <= Stats.currentStats.attackSO.delay)
    {
        _timeSinceLastAttack += Time.deltaTime;
    }
    // 공격 딜레이가 초과되었고 공격을 했다면
    
    if (IsAttacking && _timeSinceLastAttack > Stats.currentStats.attackSO.delay)
    {
        // 공격 딜레이 초기화.
        _timeSinceLastAttack = 0;
        CallAttackEvent(Stats.currentStats.attackSO);
    }
}
...

public void CallAttackEvent(AttackSO attackSO)
{
    OnAttackEvent?.Invoke(attackSO);
}

OnAttackEvent에 AttackSO 타입을 추가해주고, 추가된 AttackSO 타입에 맞춰 기존의 OnAttackEvent 코드들을 수정해주자.

이제 OnAttackEvent에 대한 콜과 Attack에 대한 데미지를 전달하는 것까지 완료했다. 이제 이 이벤트를 들을 곳들을 작성하자.

⌨️ TopDownShooting.cs

void Start()
{
    _controller.OnAttackEvent += OnShoot;
    _controller.OnLookEvent += OnAim;
}

private void OnShoot(AttackSO attackSO)
{
    CreateProjectile();
}

이후, OnShoot에서 화살을 여러 개 쏠 수 있도록 코드를 작성하자.

private void OnShoot(AttackSO attackSO)
{
	// AttackSO를 RangedAttackData로 형변환하여 사용하기
	RangedAttackData rangedAttackData = attackSO as RangedAttackData;
    
	float projectilesAngleSpace = rangedAttackData.multipleProjectilesAngel;
	int numberofProjectilesPerShot = rangedAttackData.numberofProjectilesPerShot;

	// 발사 각도를 조정하는 코드이다.
	float minAngle = -(numberofProjectilesPerShot / 2f) * projectilesAngleSpace + 0.5f * rangedAttackData.multipleProjectilesAngel;


    for(int i = 0; i < 5; i++)
    {

        CreateProjectile();
    }
}


이렇게 발사각을 조정하도록 작성하는 코드이다.

다시 마저 추가하자.

private void OnShoot(AttackSO attackSO)
{
    RangedAttackData rangedAttackData = attackSO as RangedAttackData;

    float projectilesAngleSpace = rangedAttackData.multipleProjectilesAngel;
    int numberofProjectilesPerShot = rangedAttackData.numberofProjectilesPerShot;

    float minAngle = -(numberofProjectilesPerShot / 2f) * projectilesAngleSpace + 0.5f * rangedAttackData.multipleProjectilesAngel;

    for (int i = 0; i < numberofProjectilesPerShot; i++)
    {
        // 실제로 발사해야 하는 각도 + 어느 정도의 랜덤 각도
        float angle = minAngle + projectilesAngleSpace * i;
        float randomSpread = Random.Range(-rangedAttackData.spread, rangedAttackData.spread);
        angle += randomSpread;

        CreateProjectile(rangedAttackData, angle);
    }
}

화살이 발사될 각도를 랜덤으로 생성해서 CreateProjectile()에 공격 데이터와 발사 각도를 넘겨준다.

이제 CreateProjectile()에서 발사체를 관리할 스크립트를 작성하자.

⌨️ ProjectileManager.cs

유니티로 돌아와서 Scripts 밑에 Global 이라는 폴더를 생성한다. 그리고 ProjectileManager.cs를 생성한다.

ProjectileManager는 Manager 급이기 때문에 싱글톤화를 시켜 코드를 작성해보자.

주의! 싱글톤을 너무 남발하는 것은 코드에 오히려 악영향을 끼친다.

튜터님이 추천하는 제일 좋은 방법은 GameManager를 만들어 싱글톤화 시킨 뒤, GameManager에서 나머지 Manager들을 가지고 있는 것을 추천한다고 함!!!!!!!!!!

간단하게 싱글톤 시켜주기

public class ProjectileManager : MonoBehaviour
{
    [SerializeField] private ParticleSystem _impactParticleSystem;

    public static ProjectileManager instance;

    public void Awake()
    {
        instance = this;
    }

    void Start()
    {
        
    }
}

이후 발사를 책임질 함수를 선언해둔다.

public class ProjectileManager : MonoBehaviour
{
    [SerializeField] private ParticleSystem _impactParticleSystem;

    public static ProjectileManager instance;

    public void Awake()
    {
        instance = this;
    }

    void Start()
    {
        
    }

    public void ShootBullet(Vector2 startPosition, Vector2 direction, RangedAttackData attackData)
    {
        // TODO
    }
}

이제 발사체를 발사하는 TopDownShooting 코드를 수정하자.

public class TopDownShooting : MonoBehaviour
{
    private ProjectileManager _projectileManager;       // ProjectileManager 캐싱
    private TopDownCharacterController _controller;

    [SerializeField] private Transform projectileSpawnPosition;
    private Vector2 _aimDirection = Vector2.right;      // Vector2.right; 벡터를 계속 새로 생성하지 말고 기본값 Vector2.right를 재사용하기 위한 코드

    private void Awake()
    {
        _controller = GetComponent<TopDownCharacterController>();
    }

    void Start()
    {
        // 싱글톤 인스턴스를 Awake() 단계에서 선언하기 때문에 그보다 늦는 Start()에서 받아준다.
        _projectileManager = ProjectileManager.instance;
        _controller.OnAttackEvent += OnShoot;
        _controller.OnLookEvent += OnAim;
    }

    private void OnAim(Vector2 newAimDireciton)
    {
        _aimDirection = newAimDireciton;
    }

    private void OnShoot(AttackSO attackSO)
    {
        RangedAttackData rangedAttackData = attackSO as RangedAttackData;

        float projectilesAngleSpace = rangedAttackData.multipleProjectilesAngel;
        int numberofProjectilesPerShot = rangedAttackData.numberofProjectilesPerShot;

        float minAngle = -(numberofProjectilesPerShot / 2f) * projectilesAngleSpace + 0.5f * rangedAttackData.multipleProjectilesAngel;

        for (int i = 0; i < numberofProjectilesPerShot; i++)
        {
            // 실제로 발사해야 하는 각도 + 어느 정도의 랜덤 각도
            float angle = minAngle + projectilesAngleSpace * i;
            float randomSpread = UnityEngine.Random.Range(-rangedAttackData.spread, rangedAttackData.spread);
            angle += randomSpread;

            CreateProjectile(rangedAttackData, angle);
        }
    }

    private void CreateProjectile(RangedAttackData rangedAttackData, float angle)
    {
        // _projectileManager.ShootBullet(발사 위치, 발사 회전 각, 공격 정보)
        _projectileManager.ShootBullet(
            projectileSpawnPosition.position,
            RotateVector2(_aimDirection, angle),
            rangedAttackData);
    }

    private static Vector2 RotateVector2(Vector2 v, float degree)
    {
        return Quaternion.Euler(0, 0, degree) * v;      // 쿼터니언과 벡터의 곱. -> 사실상 '벡터 v를 이 각도로 회전시켜라.'와 같은 내용이다.
    }
}

다시 ProjectileManager에서 ShootBullet을 적고, 화살이 날아가는 것을 구현하자!

public class ProjectileManager : MonoBehaviour
{
    [SerializeField] private ParticleSystem _impactParticleSystem;

    public static ProjectileManager instance;

    [SerializeField] private GameObject testObject;

    public void Awake()
    {
        instance = this;
    }

    void Start()
    {
        
    }

    public void ShootBullet(Vector2 startPosition, Vector2 direction, RangedAttackData attackData)
    {
        GameObject obj = Instantiate(testObject);

        obj.transform.position = startPosition;
        // 발사되는 코드
    }
}

🏹 화살 날라가기!!

유니티로 돌아와서 프리팹에 있는 Arrow를 더블 클릭!

그러면 프리팹을 수정할 수 있게 된다.

Arrow에 Box Collider 2D를 추가하고 아래와 같이 수정하자.

그리고 화살이 움직여야 하기 때문에 Rigidbody 2D도 추가해준다. Gravity Scale은 0으로 변경!

그 후에는 Trail Renderer를 추가해준다.

추가를 해주면 현재 Trail Renderer의 현재 Shape에 따라 선이 생기는 것을 확인할 수 있다.

그리고 Play를 해보면 선을 따라 화살이 움직이는 것을 확인할 수 있다.

우리는 Shape을 Line으로 설정한 후 진행한다.

이후 Trail Renderer의 Width를 화살에 맞춰 수정해준다. Edit Key → 값 변경(0.1 정도?)

그후, 끝부분에 Add Key를 통해 0으로 떨어트린다.

그러면 Trail의 뒷부분이 점점 사라지는 것을 확인할 수 있다.

이제 세부 사항을 수정하자. Color는 임의로 수정해도 상관 없음!

화살에 꼬리가 생긴 모습! (주황색 테두리는 현재 Arrow가 선택되어 있음을 알리는 것이기 때문에 무시해도 좋다)

⌨️ RangedAttackController.cs

이제 원거리 공격을 컨트롤 해보자. Controllers 폴더 밑에 RangedAttackController.cs를 생성한다.

public class RangedAttackController : MonoBehaviour
{
    [SerializeField] private LayerMask levelCollisionLayer;

    private RangedAttackData _attackData;
    private float _CurrentDuration;

    private Vector2 _direction;
    private bool _isReady;

    private Rigidbody2D _rigidbody;
    private SpriteRenderer _spriteRenderer;
    private TrailRenderer _trailRenderer;
    private ProjectileManager _projectileManager;

    public bool fxOnDestroy = true;

    private void Awake()
    {
        _spriteRenderer = GetComponentInChildren<SpriteRenderer>();     // 자신과 자식 오브젝트까지 포함해서 GetComponent가 수행된다.
        _rigidbody = GetComponent<Rigidbody2D>();
        _trailRenderer = GetComponent<TrailRenderer>();
    }

    private void Update()
    {
        if(!_isReady)
        {
            return;
        }

        ////////////////////////////////////////////////////////////////////
        // 발사체 이동 처리
        _CurrentDuration += Time.deltaTime;     // 발사체가 현재 날아가는 중일 때, 날아가는 시간을 저장. -> 발사체의 지속 시간

        if(_CurrentDuration > _attackData.duration)     // 발사체의 지속 시간이 attackData에 있는 시간보다 커질 경우
        {
            DestroyProjectile(transform.position, false);   // 발사체 삭제
        }

        _rigidbody.velocity = _direction * _attackData.speed;
        /////////////////////////////////////////////////////////////////////
    }

    // 발사체 초기화
    public void InitializeAttack(Vector2 direction, RangedAttackData attackData, ProjectileManager projectileManager)
    {
        _projectileManager = projectileManager;
        _attackData = attackData;
        _direction = direction;

        _trailRenderer.Clear();     // 재사용을 하기 위한 Clear
        _CurrentDuration = 0;
        _spriteRenderer.color = attackData.projectileColor;

        transform.right = _direction;       // X축 방향을 _direction으로 맞춰주는 코드.

        _isReady = true;
    }

    // 발사체 초기화(발사체의 사이즈를 attackData에 지정된 사이즈에 맞게 변경)
    private void UpdateProjectileSprite()
    {
        transform.localScale = Vector3.one * _attackData.size;      // 발사체의 크기를 attackData에 있는 size로 변경해준다.
    }

    // 발사체 삭제
    private void DestroyProjectile(Vector3 position, bool createFx)
    {
        if (createFx)
        {
			// 추후에 작성
        }
        gameObject.SetActive(false);        // 재사용을 하기 위해 삭제가 아니라 false로 설정하기
    }
}

이 스크립트를 Arrow에 추가해주자!

이제 ProjectileManager의 ShootBullet을 마저 작성하러 가보자!

public void ShootBullet(Vector2 startPosition, Vector2 direction, RangedAttackData attackData)
{
    GameObject obj = Instantiate(testObject);

    obj.transform.position = startPosition;
    // 발사되는 코드
    RangedAttackController attackController = obj.GetComponent<RangedAttackController>();
    attackController.InitializeAttack(direction, attackData, this);
    
    obj.SetActive(true);
}

🎯 테스트하러 가보기!

이제 테스트를 하기 위해 ProjectileManager를 생성해보자.
Create Empty → GameManager를 생성한 뒤, 그 밑에 ProjectileManager를 생성하고 ProjectileManager.cs를 추가하자.


이후 test Object에 Arrow를 넣어준다.

화살이 나가는 모습!!! ▼

💥 화살이 벽에 닿으면 터지게 하기!

Layer를 설정해보자.

아무 오브젝트나 선택해서 Inspector에서 Add Layer를 해보자.

0 ~ 5번까지는 기본 할당되어 있는 layer이고, 그 다음으로 추가를 해보자.

layer는 "팀"이라고 생각하면 쉽다.

이제 layer를 적용해보자.

Player를 선택해 layer를 변경했다. 변경할 때 Children까지 바꿀거냐고 물어보는 팝업창이 나오는데 Yes를 선택하면 된다.

Level layer 설정하기

Create Empty를 해서 Level을 만들어 준 다음, Grid들을 전부 밑으로 옮긴다.

그후, layer를 Level로 변경해주면 끝!

이후에 Arrow로 와서 RangedAttackController에 선언되어 있는 Level Coliision Layer를 Level로 변경하자.

😣 Level Collision Layer 설정

벽에 화살이 부딪히면 사라지도록 코드를 작성하자.
LayerMask와 layer의 비트 연산을 활용해 화살이 부딪힌 layer가 Level과 같다면 Destroy하도록 설정하자.

//RangedAttackController.cs

// 현재 Arrow의 rigidbody2d에 is Trigger를 켜놓았기 때문에 트리거 충돌이 발생한다.
private void OnTriggerEnter2D(Collider2D collision)
{
    // LayerMask의 비트 연산.
    // 현재 Level의 layer 값은 7이다. 즉, 1 << collision.gameObject.layer를 진행하면 1을 7번 left shift 한 값이 나온다. 1 0 0 0 0 0 0 0
    // 이 때, OR 연산을 통해 내가 찾는 layer값과 비교를 한다. 만약 levelCollisioinLayer.value 도 1 0 0 0 0 0 0 0 이라면 OR를 해도 1 0 0 0 0 0 0 0 이다.
    // 이 경우, levelCollisionLayer.value 값과 같기 때문에 if문이 실행된다.
    if (levelCollisionLayer.value == (levelCollisionLayer.value | (1 << collision.gameObject.layer)))       // 만약 부딪힌 collision의 레이어 값이 Level 이라면
    {
        // 발사체 삭제.
        // collision.ClosestPoint(transform.position) - _direction * .2f : 부딪힌 지점(positioni)에서 0.2f 만큼 안쪽
        DestroyProjectile(collision.ClosestPoint(transform.position) - _direction * .2f, fxOnDestroy);
    }
}

벽에 닿으면 사라지는 모습!!! ▼

0개의 댓글