Unity

[Unity] 캐릭터 스탯 만들기

박도치 2023. 12. 7. 21:45

캐릭터 스탯을 만드는 과정이다. 자주 쓰이는 패턴이니 잘 기록해두고 어떤 방식으로 흘러가는지 알아보자. 사용될 것은 ScriptableObject 사용 및 Class Serializable 이다.

 

1. ScriptableObject

스크립터블 오브젝트는 유니티에서 제공하는 대량의 데이터를 사용할 수 있는 컨테이너이다. 스크립터블 오브젝트를 사용하면 값의 사본이 생성되는 것을 방지하여 메모리 사용을 줄이며 특히 프리팹을 사용하는 프로젝트에서 유용하다고 한다.

변경되지 않는 데이터를 사용하는 프리팹의 데이터를 일반 변수로 구현할 경우 인스턴스화 할때마다 프리팹에 이데이터에 대한 자체 사본이 생성되는데, 스크립터블 오브젝트를 사용하면 메모리에 스크립터블 오브젝트의 데이터 사본만을 저장하고 이를 참조하는 방식으로 사용한다고 한다.

 

 

2. 생성

 

먼저 캐릭터 스탯을 선언해주는 CharacterStats 클래스를 생성한다.

 

* CharacterStats.cs

public enum StatsChageType
{
    Add,
    Multiple,
    Override,
}

// inspector창에서 할당하기 위해서 사용, 현재 클래스는 Unity가 어떤 클래스인지 모르기 때문에
// characterStatsHandler 에서 [SerializeField] private CharacterStats baseStats; 를 해도
// inspector창에 나타나지 않는다. 그래서 [Serializable] 을 클래스에 해줘야 Unity에서 사용할 수 있다.
[Serializable]
public class CharacterStats
{
    public StatsChageType statsChageType;
    [Range(1, 100)] public int maxHealth;
    [Range(1f, 20f)] public float speed;

    // 공격 데이터 Scriptable Object사용 -> 하나의 데이터를 컨테이너에 만들어두고 이를 모두가 공유해서 사용한다.
    public AttackSO attackSo;

}

 

클래스 내의 AttackSO 에서 ScriptObject를 상속받아 사용할 예정이다. 

 

클래스 위의 [Serializable]은 주석을 장황하게 써놨지만 요약하자면 클래스를 유니티에 등록하여 inspector창에서도 나올 수 있게끔 해주는 역할이다.

 

그리고 AttackSO클래스를 받아야 하니 스크립트를 생성하고, ScriptObject를 상속받는다.

 

* AttackSO.cs

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

// 컴포넌트 화 시킴 fileName 파일 이름, 선택될 메뉴 menuName, 순서 order;
[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;

}

 

CreateAssetMenu는 이후 유니티에서 우측클릭하여 create에서 만들 수 있는 커스텀 컴포넌트이다. 

 

그리고 변수 위에  Header는 분류해주는 역할로 보면된다. 

 

AttackSO를 상속받는 클래스 RangedAttackData까지 생성해준다.

 

 

* RangedAttackData.cs

 

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


// 컴포넌트 화 시킴 fileName 파일 이름, 선택될 메뉴 menuName, 순서 order;
[CreateAssetMenu(fileName = "RangedAttackData", menuName = "TopDownController/Attacks/Ranged", order = 1)]
public class RangedAttackData : AttackSO
{

    [Header("Ranged Attack Data")]
    public string bulletNameTag;
    public float duration;
    public float spread;
    public int numberofProjectilesPerShot;
    public float multipleProjectilesAngel;
    public Color projectileColor;
 
}

 

 

생성하고 나면 유니티에서는 이런식으로 나온다.

 

 

Assets에서 우클릭하여 Create를 보니 아까 CreateAssetMenu 에서 menuName을 정한 그대로 태그가 나타난다. 

Ranged를 눌러서 생성하면 정해준 filename이 나타나고, 이는 이름을 원하는대로 바꿔줄 수 있다.

 

 

파일이름 앞에 Player_ 를 붙여줌

 

이렇게 AttackSO를 상속받은 RangedAttackData 클래스에서 생성한 내용들이 이런식으로 나오기에 원하는데로 커스텀해준다.

 

이제는 이를 적용시켜줄 CharaterStatsHandler 스크립트를 생성해준다.

 

아까는 CharaterStats 에서 Monobehaviour 상속을 떼고 플레이어가 필요한 스탯들을 설정해줬다면, 이를 불러와서 설정해줄 수 있는 Handler를 따로 만들어주는 것이다.

 

* CharacterStatsHandler.cs

 

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

// 아이템 사용 등 스탯 처리를 하는 곳에서 많이 쓰이는 동작임
public class CharacterStatsHandler : MonoBehaviour
{
    [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)
        {
            // baseStats 에 있는 녀석을 메모리상으로 하나 더 복제를 한다.
            // 이유는 자유롭게 수정하며 사용하려고 하기 때문이다.
            attackSo = Instantiate(baseStats.attackSo);
        }

        CurrentStats = new CharacterStats { attackSo = attackSo };

        // 추후에 추가적인 계산하는 공식을 만듦
        CurrentStats.statsChageType = baseStats.statsChageType;
        CurrentStats.maxHealth = baseStats.maxHealth;
        CurrentStats.speed = baseStats.speed;
    }
}

 

 

위에서 클래스 위에 [Serializable] 을 생성한 기억이 있을것이다. 이를 이용해 CharateerStats 내용들을 유니티에서 할당받아온다. 

 

아래 UpdateCharterStats()내용은 다음과 같다.

 

attackSo 복제본을 하나 더 생성하여 수정한 내용을 CharacterStats에 담아준다. 이 attackSo는 유니티에서 이전에 커스텀해준 Player_RangedAttackData를 담아줄 내용이다.

 

스크립트를 작성했다면, 유니티에가서 Player에 스크립트를 AddComponent를 해준다.

 

 

CharaterStats에 내용들을 이렇게 설정해줄 수 있고, AttackSo에 아까 만든 ScriptObject를 넣을 수 있다.

 

이제 바뀐 스탯들을 플레이어에 적용해줄 차례이다.

 

3. 적용

우리가 해줄 내용은 플레이어가 움직일 때 speed를 받아오는 것과, 발사시 delay이다.

 

먼저 speed부터 적용해주자.

 

(1) speed

 

TopDownMovement 클래스에서 Hadler를 생성하고 캐싱처리한후 값을 가져온다.

 

* TopDownMovement .cs

private TopDownCharacterController _controller;
private CharacterStatsHandler _stats;

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

private void Awake()
{
    _controller = GetComponent<TopDownCharacterController>();
    _rigidbody = GetComponent<Rigidbody2D>();
    _stats = GetComponent<CharacterStatsHandler>();
}

    private void Start()
    {
        _controller.OnMoveEvent += Move;
    }

    private void FixedUpdate()
    {
        ApplyMovement(_movementDirection);
    }

    private void Move(Vector2 direction)
    {
        // 키보드로 입력한 값을 _movementDirection 으로 이동하게 된다.
        // Why? : _controller.OnMoveEvent += Move; OnMoveEvent 를 구독하고 있기 때문에 OnMove를 받는 순간 이 동작이 이루어진다.
        _movementDirection = direction;
    }
    
    private void ApplyMovement(Vector2 direction)
    {
        direction = direction * _stats.CurrentStats.speed; //기존값: 5, _stats에서 받아옴

        _rigidbody.velocity = direction;
    }

 

 

기존에는 direction * 5 를 해줬었다면, 이제는 Handler에서 속도를 정해주고 있기 때문에 이 값을 가져와서 곱해준다.

 

baseStats 에서 받아온 speed는 CurrentStats에 전달해주기 때문에 그 값을 가져오면된다.

 

 

(2) Delay

 

이번에는 Delay값을 전달해준다. 이는 attackSo에 담겨져있기 때문에 이 값을 가져와서 전달해준다.

 

* TopDownCharacterController.cs

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

public class TopDownCharacterController : MonoBehaviour
{

    // event 외부에서는 호출하지 못하게 막는다.
    public event Action<Vector2> OnMoveEvent;
    public event Action<Vector2> OnLookEvent;
    public event Action OnAttackEvent;

    protected CharacterStatsHandler Stats { get; private set; }

    private float _timeSinceLastAttack = float.MaxValue;
    protected bool IsAttacking { get; set; }

    // Stats 가 null인 이슈가 있었음
    protected virtual void Awake()
    {
        Stats = GetComponent<CharacterStatsHandler>();
    }

    protected virtual void Update()
    {
        HandleAttackDelay();
    }

    private void HandleAttackDelay()
    {
        if(Stats.CurrentStats.attackSo == null)
        {
            // Attack 정보가 없기 때문에 공격 x
            return;
        }

        if(_timeSinceLastAttack <= Stats.CurrentStats.attackSo.delay)
        {
            _timeSinceLastAttack += Time.deltaTime;
        }
        else if (IsAttacking && _timeSinceLastAttack > Stats.CurrentStats.attackSo.delay)
        {
            _timeSinceLastAttack = 0;
            CallAttackEvent();
        }
    }

    public void CallMoveEvent(Vector2 direction)
    {
        // null이 아닐 때만 동작
        OnMoveEvent?.Invoke(direction);
    }

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

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

}

 

 

CharacterStatHandler 에서 attackSO에 들어있는 delay를 가져와서 적용한다.

 

아직 완강을 한건 아니지만 CharacterStatHandler 에서 프로퍼티로 값을 주고받으려는 것은 아마 protected로 상속을 받으려는거보면 delay 값이 유동적으로 변화할 수도 있다고 생각된다.

 

추후 공부를하면서 내용이 나온다면 다시한번 언급을하도록 하겠다.