Unity 숙련 - 1

이준호·2023년 12월 8일
0
post-custom-banner

📌 Unity 게임 개발 숙련



📌 1. 스텟 만들기 ( 스크립터블 오브젝트 )

➔ 핵심 내용

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

  • 스크립터블 오브젝트는 Unity에서 데이터를 저장하고 관리하는 유연한 데이터 컨테이너 이다.

  • 게임에서 재사용 가능한 데이터 또는 설정을 저장하는 데 사용된다.

  • 코드와 데이터를 분리하여 코드를 더 깔끔하고 관리하기 쉽게 만든다.

  • 하나의 스크립터블 오브젝트를 여거 게임 오브젝트에서 참조하거나 재사용할 수 있다.

  • Unity에디터와 통합되어 인스펙터 창에서 직업 수정하고 관리할 수 있다.











➔ 캐릭터 스텟 만들기

캐릭터 고유의 스텟을 만들고 데이터를 처리

만들기

Attack So ( SO : Scriptable Object )

  • ScriptableObject는 이렇게 만든다고 바로 써지는게 아니다. Component를 사용할 때도 Class를 만들고 Component화 시켜야지 사용이 가능한 것 처럼

  • ScriptableObject도 실제로 하나의 데이터로 꺼내놔야 사용이 가능한데 밑에 처럼 CreatAssetMenu로 Menu에다 추가해줘야 한다.

[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;
}

RangedAttackData

[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;
}

CharacterStats

public enum StatsChangeType
{
    Add, // 더하기
    Multiple, // 곱하기
    Override // 덮어쓰기
}

[Serializable]
public class CharacterStats
{
    public StatsChangeType statsChangeType;
    [Range(1, 100)] public int maxHealth;
    [Range(1f, 20f)] public float speed;

    // 공격 데이터
    // 클래스로 만들었을 때는 객체마다 다 자신만의 저장공간을 가지게 된다. 그러면 똑같은 몬스터가 100마리 1000마리가 되면
    // 이 공격 데이터에 대한 데이터도 똑같이 할당을 해줘야한다.
    // 그래서 스크립터블 오브젝트를 한번 써보려 한다. 스크립터블 오브젝트란? : 하나의 데이터 컨테이너를 만들어 두고 얘를 계속
    // 공유해서 쓰는 것이다. 모든 오브젝트를. 100개든 1000개든 그래서 메모리나 접근 자체가 간편하다
    public AttackSO attackSO;
    
}

CharacterStatsHandler

public class CharacterStatsHandler : MonoBehaviour
{
    [SerializeField] private CharacterStats baseStats;
    public CharacterStats CurrentStates { 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);
        }

        // base를 복제해서 current에 넣는 작업
        CurrentStates = new CharacterStats { attackSO = attackSO };
        //TODO
        CurrentStates.statsChangeType = baseStats.statsChangeType;
        CurrentStates.maxHealth = baseStats.maxHealth;
        CurrentStates.speed = baseStats.speed;
    }
}






수정

TopDownMovement 수정

--------------------- 생략 ---------------------
private CharacterStatsHandler _stats;	// 추가
--------------------- 생략 ---------------------
private void Awake()
{
    _controller = GetComponent<TopDownCharacterController>();
    _stats = GetComponent<CharacterStatsHandler>();	 // 추가
    _rigidbody = GetComponent<Rigidbody2D>();
}
--------------------- 생략 ---------------------
private void ApplyMovment(Vector2 direction)
{
    //direction = direction * 5; // 삭제
    direction = direction * _stats.CurrentStates.speed;	// 추가

    _rigidbody.velocity = direction;
}

TopDownCharacterController 수정

--------------------- 생략 ---------------------
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; }	// 추가

protected virtual void Awake()	// 추가
{
    Stats = GetComponent<CharacterStatsHandler>();	// 추가
}

--------------------- 생략 ---------------------

private void HandleAttackDelay()
{
    if (Stats.CurrentStates.attackSO == null)	// 추가
        return;

		// if(_timeSinceLastAttack <= 0.2f)    // 삭제
    if(_timeSinceLastAttack <= Stats.CurrentStates.attackSO.delay)
    {
        _timeSinceLastAttack += Time.deltaTime;
    }
    
    // if(IsAttacking && _timeSinceLastAttack > 0.2f)	// 삭제
    if(IsAttacking && _timeSinceLastAttack > Stats.CurrentStates.attackSO.delay)	// 추가
    {
        _timeSinceLastAttack = 0;
        CallAttackEvent();
    }
}

PlayerInputController 수정

// private void Awake() // 삭제
protected override void Awake()	// 추가
{
    base.Awake();
    _camera = Camera.main;
}






설정

SO 데이터 만들기

  • ScriptableObjects/Datas 폴더 -> Ranged 데이터 생성

  • Player_RangedAttackData 이름 변경

  • 다음과 같이 데이터를 설정

Character Stats Handler 설정

  • Player - CharacterStatsHandler 추가

  • AttackSO -> Player_RangedAttackData 설정














📌 투사체 구현하기

➔ 핵심 내용

레이어 비트 연산

  • 레이어 비트 연산은 Unity에서 게임 오브젝트의 레이어를 빠르게 검사하고 조작하는 방법

  • 비트 연산은 AND, OR, XOR, NOT 등의 연산을 이용해 레이어 마스크를 조작

  • 이 방법은 물리적 충돌, 레이캐스팅, 카메라 렌더링 등을 제어하는 데 사용된다.

  • 각 게임 오브젝트는 고유한 레이어에 배치될 수 있으며, 이 레이어는 비트 필드로 표현된다.

  • 예를 들어, levelCollisionLayer.value == (levelCollisionLayer.value | (1 << other.gameObject.layer))
    코드에서는 게임 오브젝트의 레이어가 levelCollisionLayer에 있는지를 확인한다.
    여기서 1 << other.gameObject.layerother.gameObject.layer에 해당하는 비트를 황성화 시키는 것이고,
    levelCollisionLayer.value | (1 << other.gameObject.layer)는 그 비트가
    levelCollisionLayer.value에 이미 설정되어 있는지 확인하는 것이다.

쿼터니언과 벡터의 곱셈

  • 먼저 벡터 v를 쿼터니언으로 변환한다. 이때 v = (x, y, z)인 벡터는 (0, x, y, z)라는 쿼터니언 으로 변환된다.

  • 이렇게 변환된 벡터 쿼터니언을 원래의 쿼터니언 q와 그 켤례 쿼터니언 q와의 사이에 두고 곱셈을 수핸한다. 즉, 결과는 qvq형태가 된다.

  • 이 결과로 나온 쿼터니언의 x, y, z요소는 원래 벡터 v가 쿼터니언 q에 의해 회전한 후의 좌표를 나타낸다.

Unity에서는 Quaternion과 Vector3의 곱셈을 직접 지원하며, 이는 내부적으로 위에서 설명한 과정을 거쳐 수행된다.

싱글턴 패턴

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

  • 싱글턴 패턴은 클래스의 인스턴스가 하나만 존재하도록 보장하는 디자인 패턴이다.

  • 싱글턴 객체는 전역적으로 접근 가능한 글로벌 접근점을 제공한다.

  • 싱글턴 패턴은 전역 변수를 사용하지 않고도 객체 간 데이터를 공유하거나, 전역적인 상태를 관리하거나, 특정 서비스를 애플리케이션 전체에서 사용할 수 있게 해준다.

  • 싱글턴 패턴은 잘못 사용하면 코드의 결합도를 높이고 테스트와 유지 보수를 어렵게 할 수 있으므로 주의해서 사용해야 한다.

  • 싱글턴 객체는 프로그램의 수명주기 동안 계속 존재하므로, 메모리 관리에 신경써야 한다.






➔ 투사체 구현

Layer

  • 아무 오브젝트의 Layer ➔ Add Layer 클릭

  • Layer 추가

Player Layer

  • Player의 Layer ➔ Player 변경

  • 다음과 같은 확인 창에서 Yes, Change Children 클릭

Tilemap Layer

  • 빈 오브젝트 추가 ➔ level 이름 변경

  • Grid를 하위 오브젝트로 넣기

  • Level의 Layer ➔ Level 변경

  • Yes, Change Children 클릭

수정

TopDownCharacterController

--------------------- 생략 ---------------------
// public event Action OnAttackEvent; 삭제
public event Action<AttackSO> OnAttackEvent;

--------------------- 생략 ---------------------
if(IsAttacking && _timeSinceLastAttack > Stats.CurrentStates.attackSO.delay)
{
    _timeSinceLastAttack = 0;
    // CallAttackEvent(); 삭제
    CallAttackEvent(Stats.CurrentStates.attackSO); // 추가
}

--------------------- 생략 ---------------------
// public void CallAttackEvent() 삭제
public void CallAttackEvent(AttackSO attackSO) // 추가
{
    // OnAttackEvent?.Invoke(); 삭제
    OnAttackEvent?.Invoke(attackSO); // 추가
}

TopDownShooting

private ProjectileManager _projectileManager;
// public GameObject testPrefab; 삭제

--------------------- 생략 ---------------------
void Start()
{
    _projectileManager = ProjectileManager.instance; // 추가
    _contoller.OnAttackEvent += OnShoot;
    _contoller.OnLookEvent += OnAim;
}

--------------------- 생략 ---------------------
private void OnShoot()
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);
    }
}

private void CreateProjectile(RangedAttackData rangedAttackData, float angle) // 추가
{
    _projectileManager.ShootBullet(
		    projectileSpawnPosition.position,
		    RotateVector2(_aimDirection,angle),
		    rangedAttackData
		    );
}

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

만들기

ProjectileManager

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

    public static ProjectileManager instance;

    [SerializeField] private GameObject testObj;

    private void Awake()
    {
        instance = this;
    }

    void Start()
    {
        
    }

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

        obj.transform.position = startPosition;
        RangedAttackController attackController = obj.GetComponent<RangedAttackController>();
        attackController.InitializeAttack(direction, attackData, this);

        obj.SetActive(true);
    }
}

ProjectileManager 오브젝트

  • 빈 오브젝트 생성

  • Arrow 프리팹 연결

RangedAttackController

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 fxOnDestory = true;

    private void Awake()
    {
        _spriteRenderer = GetComponentInChildren<SpriteRenderer>(); // GetComponentInChildren : 나를 포함해서 하위까지 찾는다.
        _rigidbody = GetComponent<Rigidbody2D>();
        _trailRenderer = GetComponent<TrailRenderer>();
    }

    #region 이동처리
    private void Update()
    {
        if (!_isReady)
        {
            return;
        }

        _currentDuration += Time.deltaTime;

        if (_currentDuration > _attackData.duration)
        {
            DestoryProjectile(transform.position, false);
        }

        _rigidbody.velocity = _direction * _attackData.speed;
    }
    #endregion

    private void OnTriggerEnter2D(Collider2D collision)
    {
        // 비트연산 1 << collision.gameObject.layer : collision.gameObject.layer 를 왼쪽으로 하나 밀고
        // levelCollisionLayer.value와 | 연산 하나라도 1이면 1
        if (levelCollisionLayer.value == (levelCollisionLayer.value | (1 << collision.gameObject.layer)))
        {
            // ClosestPoint : 가장 가까운 position , _direction * .2f : 벽에 부딪힌 데서 조금 안쪽으로 오게 하는 연산
            DestoryProjectile(collision.ClosestPoint(transform.position) - _direction * .2f, fxOnDestory);
        }
    }

    #region 초기화
    // 초기화(Initialize) 해주는 메소드
    public void InitializeAttack(Vector2 direction, RangedAttackData attackData, ProjectileManager projectileManager)
    {
        _projectileManager = projectileManager;
        _attackData = attackData;
        _direction = direction;

        UpdateProjectileSprite()
;        _trailRenderer.Clear();
        _currentDuration = 0;
        _spriteRenderer.color = attackData.projectileColor;

        // transform의 우측을 direction으로 맞춰준다. 그러면 direction방향으로 날라갈것 이기에 그 방향을 중심으로 회전을 시키면 된다.
        transform.right = _direction;

        _isReady = true;
    }

    // _attackData에 들어있는 size를 맞춰서 projectile의 크기를 증가시키거나 감소시킨다.
    // 같은 애들이 쓰더라도 데이터가 뭐로 되어있느냐에 따라서 크기가 달라지게 쓸 수 있다.
    private void UpdateProjectileSprite()
    {
        transform.localScale = Vector3.one * _attackData.size;
    }
    #endregion

    #region 삭제
    void DestoryProjectile(Vector3 position, bool createFx)
    {
        if(createFx)
        {

        }
        gameObject.SetActive(false);
    }
    #endregion
}

수정

Arrow

  • BoxCollider 2D 추가

  • Rigidbody 2D 추가 ( 움직이는 쪽에 Rigidbody를 달아주는 것이 좋음 )

  • Trail Renderer 추가 ( 이펙트 )

  • RangedAttackController 추가

➔ 화살의 로직 피그마














📌 오브젝트 풀 구현 (Object Pooling)

➔ 핵심 내용

오브젝트 풀링

게임 개발에 널리 사용되는 테크닉으로, 게임의 성능을 개선하기 위해 사용된다.

  • 오브젝트 풀링은 객체를 미리 생성해 두고 필요할 때 가져다 사용하고, 사용이 끝나면 다시 풀에 반납하는 방식을 말한다.

  • 오브젝트 풀링은 생성(Instantiate)과 소멸(Destory)이라는 비용이 큰 작업을 최소화함으로써 성능을 향상시키는 데 중요한 역할을 한다.

  • 이는 특히 빈번하게 생성하고 파괴되는 객체(예 : 총알, 입자 등)에 대해 중요하며, 이런 객체들을 풀에 저장해 놓고 재사용함으로써 메모리 할당과 가비지 컬렉션에 따른 성능 저하를 방지할 수 있다.

  • 오브젝트 풀링은 적절히 사용하면 큰 성능 개선을 가져올 수 있지만, 불필요한 메모리 사용을 증가시킬 수 있으므로 사용 시에는 신중해야 한다. 오브젝트 풀의 크기를 적절히 조절하는 것이 중요하다.

➔ 오브젝트 풀 만들기

ObjectPool

public class ObjectPool : MonoBehaviour
{
    [System.Serializable]
    public struct Pool
    {
        public string tag;
        public GameObject prefab;
        public int size;
    }

    public List<Pool> pools;
    // Queue : FIFO 선입선출 방식의 데이터 구조
    public Dictionary<string, Queue<GameObject>> poolDictionary;

    private void Awake()
    {
        poolDictionary = new Dictionary<string, Queue<GameObject>>();
        foreach (var pool in pools)
        {
            Queue<GameObject> objectPool = new Queue<GameObject>();
            for (int i = 0; i < pool.size; i++)
            {
                GameObject obj = Instantiate(pool.prefab);
                obj.SetActive(false);
                objectPool.Enqueue(obj);
            }
            poolDictionary.Add(pool.tag, objectPool);
        }
    }

    public GameObject SpawnFromPool(string tag)
    {
        if (!poolDictionary.ContainsKey(tag))
            return null;

        // 맨 앞에있는 것을 빼서(Dequeue) 맨 뒤에다 넣는다(Enqueue)
        // Enqueue : 데이터를 입력하는 함수, Dequeue : 데이터를 출려하는 함수
        GameObject obj = poolDictionary[tag].Dequeue(); // Dequeue : 맨 끝에껄, 제일 먼저 들어와있던 애들 꺼낸다.(queue 선입 선출)
        poolDictionary[tag].Enqueue(obj);
        // 제한 개수를 넘어서면 제일 마지막에 사용했던 것을 또 사용한다.

        return obj;
    }
}

오브젝트 풀 적용하기

  • ProjectileManager에 Object Pool 추가

  • 다음과 같이 Object Pool 수정

ProjectileManager 수정

// [SerializeField] private GameObject testObj; 삭제
private ObjectPool objectPool; // 추가

void Start()
{
    objectPool = GetComponent<ObjectPool>(); // 추가
}

public void ShootBullet(Vector2 startPostiion, Vector2 direction, RangedAttackData attackData)
{
    // GameObject obj = Instantiate(testObj); 삭제
    GameObject obj = objectPool.SpawnFromPool(attackData.bulletNameTag); // 추가

    obj.transform.position = startPostiion;
--------------------- 생략 ---------------------
profile
No Easy Day
post-custom-banner

0개의 댓글