Unity 입문 TopDown Shooting - 캐릭터 스탯 만들기

Amberjack·2024년 1월 19일
0

Unity

목록 보기
12/44

📕 스크립터블 오브젝트(Scriptable Object)

  • 스크립터블 오브젝트는 Unity에서 데이터를 저장하고 관리하는 유연한 데이터 컨테이너.
  • 게임에서 재사용 가능한 데이터 또는 설정을 저장하는 데 사용된다.
  • 코드와 데이터를 분리하여 코드를 더 깔끔하고 관리하기 쉽게 만들어 준다!
  • 하나의 스크립터블 오브젝트를 여러 게임 오브젝트에서 참조하거나 재사용할 수 있다.
  • Unity 인스펙터 창에서 직접 수정하고 관리할 수 있다.

🫅 캐릭터 스탯 만들기

▪️ CharacterStats.cs 만들기

Entities 폴더 밑에 CharacterStats.cs를 생성한다.

public class CharacterStats
{

}

MonoBehavior를 지웠기 때문에(상속 받지 않기 때문에) Unity 엔진에서 제공하는 메시지들을 사용할 수 없다.

이후, 캐릭터의 스탯을 변경할 때 사용할 enum 타입들을 선언하자.

// 캐릭터의 스탯이 변경될 때, 변경 타입에 맞춰 switch문을 통해 제어할 수 있다.
public enum StatsChangeType
{
    Add,
    Multiple,
    Override,
}

public class CharacterStats
{

}

이제 캐릭터의 기초 스탯을 지정해보자.

public class CharacterStats
{
    public StatsChangeType statsChangeType;
    [Range(1, 100)] public int maxHealth;	// Range(x, y) : 범위를 지정해준다.
    [Range(1f, 20f)] public float speed;

    // 공격 데이터
}

현재 최대 체력과 속도를 선언해주었다. 문제는, 공격 데이터를 지정해주기 위해 단순하게 공격 데이터를 넣어줄 수도 있지만, 그럴 경우 모든 캐릭터와 모든 적을 구현할 때 문제가 발생한다. 모든 객체마다 공격 데이터를 위한 변수를 선언하면 메모리적으로 낭비가 심해지기 때문이다. 이를 위해서 스크립터블 오브젝트를 사용해보자!

🎯 공격 데이터

Assets 밑에 ScriptableObject 라는 폴더를 만들자. 그 아래에 Scripts라는 폴더를 추가로 생성하고, 생성된 Scripts 폴더 아래에 AttackSO.cs를 생성하자. → SO는 Scriptable Object의 줄임말!

// AttackSO.cs
public class AttackSO : ScriptableObject
{

}

스크립터블 오브젝트를 사용하기 위해 ScriptableObject를 상속 시켜준다.

이후, 인스펙터 창에서 데이터를 쉽게 확인하기 위한 Hearder를 추가하고, 해당 Header에 맞는 데이터들을 추가한다.

public class AttackSO : ScriptableObject
{
    [Header("Attack Info")]
    public float size;
    public float delay;
    public float power;
    public float speed;
    public LayerMask target;

    [Header("Knock Back Info")]
    public bool isOnKnockback;
    public float knockbackPower;
    public float knockbackTime;
}

이후, 이 스크립터블 오브젝트를 실제로 사용하기 위해서는 데이터로 꺼내놓아야 한다.

[CreateAssetMenu(fileName = "DefaultAttackData", menuName = "TopDownController/Attacks/Default", order = 0)]

public class AttackSO : ScriptableObject
{
    [Header("Attack Info")]
    public float size;
    public float delay;
    public float power;
    public float speed;
    public LayerMask target;

    [Header("Knock Back Info")]
    public bool isOnKnockback;
    public float knockbackPower;
    public float knockbackTime;
}

에셋 메뉴를 새로 생성했기 때문에 Create에서 우리가 만든 TopDownController → Attack을 확인할 수 있다.

🏹 원거리 공격 데이터

우리 캐릭터가 활을 쏘는 원거리 캐릭터이기 때문에, 원거리 공격을 위한 공격 데이터가 필요하다.
ScriptableObject → Scripts → RangedAttackData.cs 를 생성하자.

AttackSO를 상속 시켜주고, 똑같이 CreateAssetMenu를 해준다. 대신 원거리 공격에 대한 데이터이기 때문에 fileName과 menuName을 변경해준다.

[CreateAssetMenu(fileName = "RangedAttackData", menuName = "TopDownController/Attacks/Ranged", order = 1)]

public class RangedAttackData : AttackSO
{

}

이제 RangedAttackData를 작성하자.

[CreateAssetMenu(fileName = "RangedAttackData", menuName = "TopDownController/Attacks/Ranged", order = 1)]

public class RangedAttackData : AttackSO
{
    [Header("RangedAttackData")]
    public string bulletNameTag;
    public float duration;
    public float spread;
    public int numberofProjectilesPershot;      // 한번에 쏠 양
    public float multipleProjectilesAngel;        // 쏠 때의 각도
    public Color projectileColor;                   // 투사체 색깔
}

📕 스크립터블 오브젝트 사용하기

ScriptableObject 폴더 밑에 Datas 라는 폴더를 추가로 생성하자.

이제 Datas 폴더 밑에 플레이어의 원거리 공격 데이터 Player_RangedAttackData를 생성해보자.

우리가 CreateAssetMenu를 해주었기 때문에 TopDownController → Attack → Ranged를 통해 RangedAttackData.cs 파일을 생성할 수 있다!!! RangedAttackData.cs를 생성하고 이름을 Player_RangedAttackData로 변경하자.

인스펙터를 통해 공격 데이터를 확인할 수 있는 모습!!! ▼

이제 공격 데이터를 넣어주자.

Size(공격의 크기) : 1
Delay(공격의 딜레이) : 0.15초
Power(공격 데미지) : 2
Speed(공격 속도) : 10

Is On Knockback : true
Knockback Power : 5
Knockback Time : 0.1

Bullet Name Tag : Arrow
Duration : 5초
Spread(탄 퍼짐) : 4
Numberof Projectiles Per Shot(한 번에 발사할 화살 수) : 1
Multiple Projectiles Angle : 7도
Color : FFFFFF

이제 이 데이터를 통해 모든 플레이어의 원거리 공격에 적용을 해줄 수 있다.

하지만 모든 플레이어의 공격에 적용이 되기 때문에 데이터가 변경되면 모든 플레이어의 데이터 또한 변경되게 된다. 때문에 이를 우회하는 코드를 작성할 필요가 있다!

다시 CharacterStats.cs 로 돌아와 코드를 추가하자.

// 캐릭터의 스탯이 변경될 때, 변경 타입에 맞춰 switch문을 통해 제어할 수 있다.
public enum StatsChangeType
{
    Add,
    Multiple,
    Override,
}

// CharacterStats를 CharacterStatsHandler.cs에서 SerializeField를 통해 Inspector에서 출력하려 하므로
// 클래스를 SerializeField해주기 위해 Serializable을 추가해준다.
[Serializable]
public class CharacterStats
{
    public StatsChangeType statsChangeType;
    [Range(1, 100)] public int maxHealth;
    [Range(1f, 20f)] public float speed;

    // 공격 데이터
    public AttackSO attackSO;
}

AttackSO를 선언해준 뒤에, 유니티에서 Entities 밑에 CharacterStatsHandler.cs 를 추가하자!

public class CharacterStatsHandler : MonoBehaviour
{
	// CharacterStats가 클래스이기 때문에, SerializeField를 사용하기 위해서는 [Serializable] 선언이 필요하다.
    [SerializeField] private CharacterStats baseStats;
    public CharacterStats currentStats { get; private set; }

    // 수정될 캐릭터 스탯. 아이템 장착을 통한 캐릭터 스탯 변화와 같은 코드 처리에 사용된다.
    public List<CharacterStats> statsModifiers = new List<CharacterStats>();

    private void Awake()
    {
        UpdateCharacterStats();
    }

    private void UpdateCharacterStats()
    {
        AttackSO attackSO = null;
        if(baseStats.attackSO != null)
        {
            attackSO = Instantiate(baseStats.attackSO);     // 스탯을 마음껏 수정하기 위해 미리 복사 떠놓기. -> 그러나 굳이 필요 없을 수도 있을 것이라고 함!
        }

        currentStats = new CharacterStats { attackSO = attackSO };

        // 임시. 추후에 추가적인 스탯을 계산하는 코드를 작성할 곳.
        currentStats.statsChangeType = baseStats.statsChangeType;
        currentStats.maxHealth = baseStats.maxHealth;
        currentStats.speed = baseStats.speed;
    }
}

이제 유니티로 돌아가서 플레이어에게 CharacterStatsHandler를 추가해주자.

이제 아래와 같이 설정해주면 캐릭터의 기본 스탯이 설정된다!

Override를 하는 이유는 해당 스탯이 원본이기 때문에 Override를 통해 지정해주는 것이다.

이제 전에 작성해둔 TopDownMovement.cs의 코드들을 스탯에 맞게 수정해주자.

public class TopDownMovement : MonoBehaviour
{
    private TopDownCharacterController _controller;
    private CharacterStatsHandler _stats;       // 현재 지정되어 있는 스탯을 가져온다.

    private Vector2 _movementDirection = Vector2.zero;
    private Rigidbody2D _rigidbody;

    private void Awake()
    {
        _controller = GetComponent<TopDownCharacterController>(); // GetComponent : GetComponent를 통해 Inspector의 컴포넌트를 가져올 수 있다.
        _rigidbody = GetComponent<Rigidbody2D>();
        
        _stats = GetComponent<CharacterStatsHandler>();
    }

    private void Start()
    {
        // PlayerInputController에 있는 OnMove를 구독.
        _controller.OnMoveEvent += Move;
    }

    private void FixedUpdate()
    {
        // rigidbody를 사용하기 때문에 물리적인 처리가 종료된 후 호출되는 FixedUpdate()를 사용한다.
        ApplyMovement(_movementDirection);
    }

    private void Move(Vector2 direction)
    {
        // OnMove() 이벤트가 발생하면 구독되어 있던 Move()가 동작한다.
        _movementDirection = direction;
    }

    private void ApplyMovement(Vector2 direction)
    {
        direction = direction * _stats.currentStats.speed;		// 현재 스탯에서 speed를 가져온다.

        _rigidbody.velocity = direction; // velocity : 가속도
    }
}

마찬가지로 TopDownCharacterController.cs에서도 코드를 수정하자.

public class TopDownCharacterController : MonoBehaviour
{
    public event Action<Vector2> OnMoveEvent;
    public event Action<Vector2> OnLookEvent;
    public event Action OnAttackEvent;

    private float _timeSinceLastAttack = float.MaxValue;    // 마지막으로 공격했던 시간
    protected bool IsAttacking {  get; set; }

    protected CharacterStatsHandler Stats { get; private set; }     // 클래스(참조형) 이기 때문에 GetComponent를 해줘야 사용할 수 있다.

    protected virtual void Awake()
    {
    	// CharacterStatsHandler를 사용하기 위해 GetComponent 해주기
        Stats = GetComponent<CharacterStatsHandler>();
    }

    // 하위 클래스에서 상속받아서 사용할 수 있도록
    protected virtual void Update()
    {
        HandleAttackDelay();
    }

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

    public void CallMoveEvent(Vector2 direction)
    {
        OnMoveEvent?.Invoke(direction);
    }

    public void CallLookEvent(Vector2 direction)
    {
        OnLookEvent?.Invoke(direction);
    }

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

이후 해당 TopDownCharacterController.cs를 상속 받는 PlayerInputController에서 위에 선언한 Awake()를 상속받아 실행하도록 코드를 수정하자.

protected override void Awake()
{
    // 부모의 Awake()를 먼저 실행 시키기.
    // TopDownCharacterController의 Awake를 override 하기 때문에 base.Awake()를 해주지 않으면 
    // TopDownCharacterController의 Awake()가 실행되지 않는다.
    base.Awake();
    // 태그가 main인 카메라 찾아오기 -> 메인 카메라를 가져온다.
    _camera = Camera.main;
}

정상적으로 동작하는 모습! ▼

0개의 댓글