유니티2D 입문 정리 12 - 적 구현

woollim·2024년 11월 9일
0

0. 핵심내용

○ FindGameObjectWithTag

  • "FindGameObjectWithTag"는 지정된 태그와 일치하는 첫 번째 활성 GameObject를 반환합니다. 이 메서드는 특정 태그를 가진 오브젝트를 빠르게 찾을 수 있도록 돕습니다.
  • 태그를 사용하면 씬 내에서 특정 유형의 오브젝트를 쉽게 찾을 수 있으며, 코드에서 게임 오브젝트를 참조할 때 유용합니다.
  • 그러나 "FindGameObjectWithTag"는 매우 비싼 연산입니다. 즉, 이 메서드를 사용하면 CPU를 많이 사용하게 됩니다. 따라서 이 메서드를 매 프레임마다 호출하면 게임 성능에 심각한 영향을 미칠 수 있습니다.
  • 따라서 일반적으로는 이 메서드를 Start나 Awake와 같은 초기화 메서드에서 한 번만 호출하는 것이 권장됩니다. 오브젝트를 찾은 후에는 참조를 저장하고 나중에 재사용합니다.
  • 게임 오브젝트가 많은 큰 씬에서는 태그 대신 레이어나 다른 메서드를 사용하는 것이 더 효율적일 수 있습니다.

○ Physics.Raycast 또는 Physics2D.Raycast

(2D Raycast와 3D Raycast는 파라미터는 조금 다르지만 그 원리가 같다.)

  • Raycasting은 콜라이더와 교차하는지를 감지하는 데 사용되는 기술입니다. 이는 레이저 포인터나 총알을 쏘는 효과를 만들거나, 플레이어의 시야를 계산하는 등 다양한 방식으로 사용됩니다.
  • Unity에서는 Physics.Raycast 또는 Physics2D.Raycast를 사용하여 Raycast를 수행할 수 있습니다. 이들 메서드는 시작점, 방향, 최대 거리, 그리고 선택적으로 레이어 마스크를 매개변수로 받습니다.
  • Raycast는 hit 정보를 반환합니다. 이 정보에는 충돌한 객체, 충돌 지점, 충돌 지점의 정규화된 벡터 등이 포함됩니다.
  • Raycast는 남발하는 경우 상당한 비용이 들 수 있으므로, 호출 시점 및 대상을 최적화하는 것이 중요합니다.
  • Raycast는 비주얼 디버깅을 위해 Debug.DrawRay와 함께 사용할 수 있습니다. 이를 통해 Scene 뷰에서 Raycast의 경로를 시각적으로 확인할 수 있습니다.


1. 게임의 심판, GameManager 만들기

○ Character 오브젝트 수정

  • Character → Tag → Player 수정


○ GameManager 만들기

  • 게임 매니저는 싱글톤 패턴을 활용하여, 게임 전체를 관리하는 심판 같은 역할로 두겠습니다.
  • 게임 어디에서든 접근할 수 있으니, 전역 변수처럼 쓰인다고 말하기도 합니다.
  • 플레이어가 가지고 있던 오브젝트 풀도 GameManager안에 넣도록 하겠습니다.
  • 스크립트 작성
using UnityEngine;

public class GameManager : MonoBehaviour
{
    public static GameManager Instance;

    public Transform Player { get; private set; }
    public ObjectPool ObjectPool { get; private set; }
    [SerializeField] private string playerTag = "Player";

    private void Awake()
    {
        Instance = this;
        Player = GameObject.FindGameObjectWithTag(playerTag).transform;

        ObjectPool = GetComponent<ObjectPool>();
    }
}

  • GameManager 오브젝트에 생성 및 GameManager 컴포넌트 추가
  • GameManager 오브젝트에 ObjectPool 컴포넌트 추가


○ TopDownShooting 수정하기

using UnityEngine;

public class TopDownShooting : MonoBehaviour
{
    private TopDownController contoller;
    private Vector2 aimDirection = Vector2.right;
		// ObjectPool 삭제
    [SerializeField] private Transform projectileSpawnPosition;

    private void Awake()
    {
        contoller = GetComponent<TopDownController>();
	      // ObjectPool 삭제
    }

---- 생략 ----

    private void CreateProjectile(RangedAttackSO RangedAttackSO, float angle)
    {
        // 오브젝트 풀을 활용한 생성으로 변경
        GameObject obj = GameManager.Instance.ObjectPool.SpawnFromPool(RangedAttackSO.bulletNameTag);
        
        // 발사체 기본 세팅
        obj.transform.position = projectileSpawnPosition.position;
        ProjectileController attackController = obj.GetComponent<ProjectileController>();
        attackController.InitializeAttack(RotateVector2(aimDirection, angle), RangedAttackSO);

        obj.SetActive(true);
    }

    private static Vector2 RotateVector2(Vector2 v, float degree)
    {
        return Quaternion.Euler(0, 0, degree) * v;
    }
}

○ CharacterAnimationController 클래스명 변경

적에도 대응되게 하기 위해서 클래스명을 변경해요.
TopDownAnimationController로 변경합니다.

using UnityEngine;

public class TopDownAnimationController : AnimationController
{
	---- 생략 ----
}


2. 근거리 적 구현하기

○ TopDownEnemyController 만들기

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

public class TopDownEnemyController : TopDownCharacterController
{
    GameManager gameManager;
    protected Transform ClosestTarget { get; private set; }

    protected override void Awake()
    {
        base.Awake();
    }

    protected virtual void Start()
    {
        gameManager = GameManager.instance;
        ClosestTarget = gameManager.Player;
    }

    protected virtual void FixedUpdate()
    {

    }

    protected float DistanceToTarget()
    {
        return Vector3.Distance(transform.position, ClosestTarget.position);
    }

    protected Vector2 DirectionToTarget()
    {
        return (ClosestTarget.position - transform.position).normalized;
    }
}

○ TopDownContactEnemyController 만들기

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

public class TopDownContactEnemyController : TopDownEnemyController
{
    [SerializeField][Range(0f, 100f)] private float followRange;
    [SerializeField] private string targetTag = "Player";
    private bool isCollidingWithTarget;

    [SerializeField] private SpriteRenderer characterRenderer;

    protected override void Start()
    {
        base.Start();
    }

    protected override void FixedUpdate()
    {
        base.FixedUpdate();

        Vector2 direction = Vector2.zero;
        if(DistanceToTarget() < followRange)
        {
		        direction = DirectionToTarget();
        }

        CallMoveEvent(direction);
        Rotate(direction);
    }

    private void Rotate(Vector2 direction)
    {
        // TopDownAimRotation에서 했었죠? 
        // Atan2는 가로와 세로의 비율을 바탕으로 -파이~파이(-180도~180도에 대응, * Rad2Deg가 그 기능)하는 값을 나타내주는 함수였다는 것 기억하시죠?
        float rotZ = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
        characterRenderer.flipX = Mathf.Abs(rotZ) > 90f;
    }
}

○ Goblin 만들기

  • Default AttackData 생성 → Goblin_DefaultAttackData 이름 변경

  • 빈 오브젝트 생성 - Goblin 이름 변경
  • 하위에 빈 오브젝트 생성 - MainSptrite 이름 변경 → Sprite Renderer 추가 → goblin_idle_anim_f0 설정, Order in Layer 4

  • Goblin 오브젝트에 필요 컴포넌트 추가




○ Animation Clip 만들기 & Animator Controller 수정

  • 고블린 애니메이션 생성
  • Animator Controller 수정 → Base Layer
    단일 레이어로 구성, IsWalking, IsHit, Attack 파라미터를 넣어서 구성.
    HasExitTime에 체크해제, Transition Duration 0임을 확인
    • 🆘 캐릭터가 적과 부딪혔을 때 회전하고 있나요?
      Rigidbody2D의 Constraint를 확인해보세요.



3. 원거리 적 구현하기

○ TopDownRangeEnemyController 만들기

using UnityEngine;

public class TopDownRangeEnemyController : TopDownEnemyController
{
    [SerializeField] private float followRange = 15f;
    [SerializeField] private float shootRange = 10f;
    private int layerMaskLevel;
    private int layerMaskTarget;

    protected override void Start()
    {
        base.Start();
        layerMaskLevel = LayerMask.NameToLayer("Level");
        layerMaskTarget = stats.CurrentStat.attackSO.target;
    }

    protected override void FixedUpdate()
    {
        base.FixedUpdate();

        float distanceToTarget = DistanceToTarget();
        Vector2 directionToTarget = DirectionToTarget();

        UpdateEnemyState(distanceToTarget, directionToTarget);
    }

    private void UpdateEnemyState(float distance, Vector2 direction)
    {
        isAttacking = false; // 기본적으로 공격 상태를 false로 설정합니다.

        if (distance <= followRange)
        {
            CheckIfNear(distance, direction);
        }
    }

    private void CheckIfNear(float distance, Vector2 direction)
    {
        if (distance <= shootRange)
        {
            TryShootAtTarget(direction);
        }
        else
        {
            CallMoveEvent(direction); // 사정거리 밖이지만 추적 범위 내에 있을 경우, 타겟 쪽으로 이동합니다.
        }
    }

    private void TryShootAtTarget(Vector2 direction)
    {
        // 몬스터 위치에서 direction 방향으로 레이를 발사합니다.
        RaycastHit2D hit = Physics2D.Raycast(transform.position, direction, shootRange, GetLayerMaskForRaycast());

        // 벽에 맞은게 아니라 실제 플레이어에 맞았는지 확인합니다.
        if (IsTargetHit(hit))
        {
            PerformAttackAction(direction);
        }
        else
        {
            CallMoveEvent(direction);        }
    }

    private int GetLayerMaskForRaycast()
    {
        // "Level" 레이어와 타겟 레이어 모두를 포함하는 LayerMask를 반환합니다.
        return (1 << layerMaskLevel) | layerMaskTarget;
    }

    private bool IsTargetHit(RaycastHit2D hit)
    {
        // RaycastHit2D 결과를 바탕으로 실제 타겟을 명중했는지 확인합니다.
        return hit.collider != null && layerMaskTarget == (layerMaskTarget | (1 << hit.collider.gameObject.layer));
    }

    private void PerformAttackAction(Vector2 direction)
    {
        // 타겟을 정확히 명중했을 경우의 행동을 정의합니다.
        CallLookEvent(direction);
        CallMoveEvent(Vector2.zero); // 공격 중에는 이동을 멈춥니다.
        isAttacking = true;
    }
}

○ Orc_Shaman 만들기

  • Ranged AttackData 생성 → Orc_Shaman_RangedAttackData이름 변경

  • 빈 오브젝트 생성 - OrcShaman이름 변경
  • 하위에 빈 오브젝트 생성 - MainSptrite 이름 변경 → Sprite Renderer 추가 → orc_shaman_idle_anim_f0 설정, Order in Layer 4
  • 다음과 같이 WeaponPivot 및 하위 오브젝트를 생성

  • OrcShaman 오브젝트에 필요 컴포넌트 추가



○ Animation Clip 만들기 & Animator Controller 수정

  • Goblin 과 동일하게 설정
  • 어려우시면 00_애니메이션 파일의 패키지를 다운받아주세요!


3. 오류 수정

○ TopDownAimRotation 수정하기


샤먼의 팔이 너무 많이 회전하면서 거꾸로 들고 있는데, 이를 Y축으로 플립하여 도와줍시다.

캐릭터의 XFlip과 같은 방향이기 때문에 flip여부를 그대로 넣어주겠습니다.

    private void RotateArm(Vector2 direction)
    {
        // Atan2는 직각삼각형이 있다고 할 때 세로가 y, 가로가 x일 때 그 각도를 라디안 [-Pi,Pi]로 나타내는 함수임
        // 라디안의 -Pi는 -180도, Pi는 180도 이므로 Mathf.Rad2Deg는 약 57.29임 (180 / 3.14)
        float rotZ = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;

        // [1. 캐릭터 뒤집기]
        // 이때 각도는 오른쪽(1,0 방향)이 0도이므로,
        // -90~90도에서는 오른쪽을 바라보는 게 맞지만, -90도 미만 90도 초과라면 왼쪽을 바라보는 것임.
        characterRenderer.flipX = Mathf.Abs(rotZ) > 90f;
        // 플레이어 무기는 상하대칭이라 괜찮았지만 샤먼 무기는 상하대칭이 아니라 뒤집어줘야함.
        armRenderer.flipY = characterRenderer.flipX;
        
        // [2. 팔 돌리기]
        // 팔을 돌릴 때는 나온 각도를 그대로 적용하는데, 이때 유니티 내부에서 사용하는 쿼터니언으로 변환한다.
        // 쿼터니언으로 변형하는 방법 두 가지
        // 1) Vector3를 Quaternion으로 변환해서 넣는 방법
        //    Quaternion.Euler(x 회전, y 회전, z 회전) : 오일러 각 기준으로 값을 넣으면 쿼터니언으로 변환됨
        // 2) 프로퍼티를 통해 자동으로 변환되게 하는 방법
        //    Transform.eulerAngles을 변경
        armPivot.rotation = Quaternion.Euler(0, 0, rotZ);
        // (2번 방법으로 하면) armPivot.eulerAngles = new Vector3(0, 0, rotZ);
    }


4. 더 알아보기

○ Animator Override Controller

같은 애니메이션 구성을 가진 다른 오브젝트가 있는 경우,
애니메이션 파일만 갈아끼워서 같은 애니메이터를 활용하게 하는 컴포넌트입니다.

잡몹들의 행동들은 거의 똑같은데 그림만 바뀌는 게임에서 활용할 수 있겠죠?

0개의 댓글