투사체 구현

오브젝트 풀(Object Pool)은 오브젝트를 미리 생성해 놓은 뒤, 삭제하거나 소멸시키지 않고 재사용 하는 것이다. 생성이나 소멸을 최소화하여 메모리를 효율적으로 관리할 수 있다.

빈번히 생성되고 사라지는 총알이나 파티클 같은 사운드 등을 재사용하기 위해 자주 사용되고 있다.

 

오브젝트 풀 스크립트를 만들어서 ProjectileManager에 달아준다.

지정된 개수를 넘기면 마지막에 사용한 것을 또 사용한다.

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

public List<Pool> pools;
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;

    GameObject obj = poolDictionary[tag].Dequeue();
    poolDictionary[tag].Enqueue(obj);

    return obj;
}

 

투사체를 달고 20개를 미리 풀링하도록 한다.

 

이제 ProjectileManager에서 임시로 만들어둔 테스트 오브젝트를 지우고 오브젝트 풀을 불러온다.

[SerializeField] private ParticleSystem _impactParticleSystem;

private ObjectPool objectPool;

public static ProjectileManager instance;

private void Awake()
{
    instance = this;
}

private void Start()
{
    objectPool = GetComponent<ObjectPool>();
}

public void ShootBullet(Vector2 startPosition, Vector2 direction, RangedAttackData attackData)
{
    GameObject obj = objectPool.SpawnFromPool(attackData.bulletNameTag);

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

    obj.SetActive(true);
}

 

투사체의 Bullet Name Tag를 확인하면 Arrow로 tag가 걸려있다.

 

Arrow라고 하는 Key값이 동일한 것을 사용할 수 있게 만들어준다.

 

현재 20개를 미리 생성했지만 1개씩 날아가고 있기 때문에 계속 반환되며 끝까지 날아간다. 20개를 한 번에 쏘도록 수정하면 투사체가 다시 삭제되며 처음부터 날아간다.

 

애니메이션

애니메이션 스크립트와 컨트롤러 스크립트를 만든다.

컨트롤러는 같은 곳에 애니메이터는 하위에 만든다.

public class TopDownAnimation : MonoBehaviour
{
    protected Animator animator;
    protected TopDownCharacterController controller;

    protected virtual void Awake()
    {
        animator = GetComponentInChildren<Animator>();
        controller = GetComponent<TopDownCharacterController>();
    }
}

 

애니메이션을 바로 실행하지 않고 트랜지션으로 연결 구조를 만들기 위해 컨트롤러에 애니메이션을 상속시킨다.

Animation을 통해 StringToHash로 값을 변환하는데, StringToHash는 특정 문자열을 일정한 공식에 의해 숫자, Hash 값으로 변환하는 것이다. Animator 안에서 키값을 string으로 제공했을 때, string을 비교하는 연산이 일어나는데 그 비용이 높기 때문에 Hash값으로 변환하여 비교하는 것이다.

 

public class TopDownAnimationController : TopDownAnimations
{
    private static readonly int IsWalking = Animator.StringToHash("IsWalking");
    private static readonly int Attack = Animator.StringToHash("Attack");
    private static readonly int IsHit = Animator.StringToHash("IsHit");

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

    private void Start()
    {
        controller.OnAttackEvent += Attacking;
        controller.OnMoveEvent += Move;
    }

    private void Move(Vector2 obj)
    {
        animator.SetBool(IsWalking, obj.magnitude > .5f);
    }

    private void Attacking(AttackSO obj)
    {
        animator.SetTrigger(Attack);
    }

 

애니메이션을 추가해서 idle과 연결한 뒤, Parameters에서 코드에 추가한 Bool과 Trigger를 추가한다.

 

Attack Layer를 추가하고 Create State - Empty로 Default값을 Empty로 만든다.

 

Attack이 들어오면 Trigger가 켜지도록 한다.

 

 

적 구현

적을 컨트롤하기 위한 스크립트를 만든다.

적 컨트롤러에 캐릭터 컨트롤러를 상속받는다. 적이 플레이어를 찾을 때, Update에서 매 프레임마다 찾기에는 메모리에 부담이 되므로 GameManager를 통해 찾도록 한다.

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

 

FindGameObjectWithTag()는 괄호 안의 태그가 붙어있는 오브젝트를 찾아준다. Find 함수들은 성능에 영향을 많이 끼치고 비용이 많이 들기 때문에 웬만하면 지양하는 것이 좋다.

public class GameManager : MonoBehaviour
{
    public static GameManager instance;

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

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

 

근거리 공격몹을 만들기 위해 플레이어와 닿을 때를 처리하는 스크립트를 만든다.

public class TopDownContactController : 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)
    {
    	// 이동 및 회전 처리
        float rotZ = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
        characterRenderer.flipX = Mathf.Abs(rotZ) > 90f;
    }
}

 

유니티로 돌아와서 적에 Rigidbody와 콜라이더, 그리고 필요한 스크립트들을 달아준다.

 

근접 공격을 위해 디폴트값으로 공격 데이터를 만든다.

 

적 또한 플레이어와 같은 방법으로 애니메이션을 만들어준다.

 

이번엔 원거리 공격몹을 만들기 위해 스크립트를 만든다. Raycast는 안보이는 물리적인 레이저를 쏴서 충돌이 일어나는지 검사하는 것으로 몹과 플레이어 사이에 막히는 지형이 있는지 확인하기 위해 사용한다.

public class TopDownRangeEnemyContreoller : TopDownEnemyController
{
    [SerializeField] private float followRange = 15f;
    [SerializeField] private float shootRange = 10f;

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

        float distance = DistanceToTarget();
        Vector2 direction = DirectionToTarget();

        IsAttacking = false;
        if (distance <= followRange)
        {
            if (distance <= shootRange)
            {
                int layerMaskTarget = Stats.CurrentStates.attackSO.target;
                RaycastHit2D hit = Physics2D.Raycast(transform.position, direction, 11f, (1 << LayerMask.NameToLayer("Level")) | layerMaskTarget);

                if (hit.collider != null && layerMaskTarget == (layerMaskTarget | (1 << hit.collider.gameObject.layer)))
                {
                    CallLookEvent(direction);
                    CallMoveEvent(Vector2.zero);
                    IsAttacking = true;
                }
                else
                {
                    CallMoveEvent(direction);
                }
            }
            else
            {
                CallMoveEvent(direction);
            }
        }
        else
        {
            CallMoveEvent(direction);
        }
    }
}

 

 

넉백과 데미지 피격

넉백은 이동에 관련된 것이므로 Movement에서 처리한다.

deltaTime은 Update에서 사용하는데 FixedUpdate와 구동 범위가 다르므로 fixedDeltaTime을 사용한다. 넉백을 걸면 시간과 힘을 주고 그 값을 저장해놨다가 넉백해야할 때 direction이 모두 깎여나갈 때까지 자동으로 넉백을 진행한다.

private Vector2 _knockback = Vector2.zero;
private float knockbackDuration = 0.0f;

private void FixedUpdate()
{
    ApplyMovment(_movementDirection);
    if(knockbackDuration > 0.0f)
    {
        knockbackDuration -= Time.fixedDeltaTime;
    }
}

public void ApplyKnockback(Transform other, float power, float duration)
{
    knockbackDuration = duration;
    _knockback = -(other.position - transform.position).normalized * power;
}

private void ApplyMovment(Vector2 direction)
{
    direction = direction * _stats.CurrentStates.speed;

    if(knockbackDuration > 0.0f)
    {
        direction += _knockback;
    }
    _rigidbody.velocity = direction;
}

 

데미지를 입거나 죽는 것을 처리하기 위해 체력관리 스크립트를 만든다. 이 스크립트는 플레이어와 적 모두 갖는다.

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

public class HealthSystem : MonoBehaviour
{
    [SerializeField] private float healthChangeDelay = 0.5f;

    private CharacterStatsHandler _statsHandler;
    private float _timeSinceLastChange = float.MaxValue;

    public event Action OnDamage;
    public event Action OnHeal;
    public event Action OnDeath;
    public event Action OnInvincibilityEnd;

    public float CurrentHealth { get; private set; }

    public float MaxHealth => _statsHandler.CurrentStates.maxHealth;

    private void Awake()
    {
        _statsHandler = GetComponent<CharacterStatsHandler>();
    }

    private void Start()
    {
        CurrentHealth = _statsHandler.CurrentStates.maxHealth;
    }

    private void Update()
    {
        if (_timeSinceLastChange < healthChangeDelay)
        {
            _timeSinceLastChange += Time.deltaTime;
            if (_timeSinceLastChange >= healthChangeDelay)
            {
                OnInvincibilityEnd?.Invoke();
            }
        }
    }

    public bool ChangeHealth(float change)
    {
        if (change == 0 || _timeSinceLastChange < healthChangeDelay)
        {
            return false;
        }

        _timeSinceLastChange = 0f;
        CurrentHealth += change;
        CurrentHealth = CurrentHealth > MaxHealth ? MaxHealth : CurrentHealth;
        CurrentHealth = CurrentHealth < 0 ? 0 : CurrentHealth;

        if (change > 0)
        {
            OnHeal?.Invoke();
        }
        else
        {
            OnDamage?.Invoke();
        }

        if (CurrentHealth <= 0f)
        {
            CallDeath();
        }

        return true;
    }

    private void CallDeath()
    {
        OnDeath?.Invoke();
    }
}

 

애니메이션 컨트롤러에서 데미지를 받았을 때 Hit 애니메이션이 동작하도록 한다.

private HealthSystem _healthSystem;

protected override void Awake()
{
    base.Awake();
    _healthSystem = GetComponent<HealthSystem>();
}

private void Start()
{
    controller.OnAttackEvent += Attacking;
    controller.OnMoveEvent += Move;

    if (_healthSystem != null)
    {
        _healthSystem.OnDamage += Hit;
        _healthSystem.OnInvincibilityEnd += InvincibilityEnd;
    }
}

 

원거리 공격 데미지의 충돌과 HP를 처리해준다.

private void OnTriggerEnter2D(Collider2D collision)
{
    if (levelCollisionLayer.value == (levelCollisionLayer.value | (1 << collision.gameObject.layer)))
    {
        DestroyProjectile(collision.ClosestPoint(transform.position) - _direction * .2f, fxOnDestory);
    }
    else if (_attackData.target.value == (_attackData.target.value | (1 << collision.gameObject.layer)))
    {
        HealthSystem healthSystem = collision.GetComponent<HealthSystem>();
        if (healthSystem != null)
        {
            healthSystem.ChangeHealth(-_attackData.power);
            if (_attackData.isOnKnockback)
            {
                TopDownMovement movement = collision.GetComponent<TopDownMovement>();
                if (movement != null)
                {
                    movement.ApplyKnockback(transform, _attackData.knockbackPower, _attackData.knockbackTime);
                }
            }
        }
        DestroyProjectile(collision.ClosestPoint(transform.position), fxOnDestory);
    }
}

 

근거리 공격 역시 처리해준다.

private HealthSystem healthSystem;
private HealthSystem _collidingTargetHealthSystem;
private TopDownMovement _collidingMovement;

protected override void Start()
{
    base.Start();
    
    healthSystem  = GetComponent<HealthSystem>();
    healthSystem.OnDamage += OnDamage;
}

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

    if (_isCollidingWithTarget)
    {
        ApplyHealthChange();
    }

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

    CallMoveEvent(direction);
    Rotate(direction);
}

private void OnDamage()
{
    followRange = 100f;
}

private void OnTriggerEnter2D(Collider2D collision)
{
    GameObject receiver = collision.gameObject;

    if(!receiver.CompareTag(targetTag))
    {
        return;
    }

    _collidingTargetHealthSystem = receiver.GetComponent<HealthSystem>();
    if( _collidingTargetHealthSystem != null )
    {
        _isCollidingWithTarget = true;
    }

    _collidingMovement = receiver.GetComponent<TopDownMovement>();
}

private void OnTriggerExit2D(Collider2D collision)
{
    if (!collision.CompareTag(targetTag))
    {
        return;
    }

    _isCollidingWithTarget = false;
}

private void ApplyHealthChange()
{
    AttackSO attackSO = Stats.CurrentStats.attackSO;
    bool hasBeenChanged = _collidingTargetHealthSystem.ChangeHealth(-attackSO.power);
    if(attackSO.isOnKnockback && _collidingMovement != null )
    {
        _collidingMovement.ApplyKnockback(transform, attackSO.knockbackPower, attackSO.knockbackTime);
    }
}

 

유니티로 돌아와서 몹들의 레이어를 Enemy로 바꿔주고 플레이어의 공격 데이터 타겟을 Enemy로 설정한다.

 

이번엔 죽는 처리에 대한 스크립트를 만들어준다.

OnDeath에 이벤트를 걸기 위해 HealthSystem과 Rigidbody를 가져온다. 죽었을 때, 자리에서 멈추도록 Vector3 값을 0으로 준다. 모든 컴포넌트들을 Behaviour를 상속받고 있기 때문에 Behaviour를 검색하여 죽었을 때 동작하지 않게 한다.

 

private HealthSystem _healthSystem;
    private Rigidbody2D _rigidbody;

    private void Start()
    {
        _healthSystem = GetComponent<HealthSystem>();
        _rigidbody = GetComponent<Rigidbody2D>();
        _healthSystem.OnDeath += OnDeath;
    }

    void OnDeath()
    {
        _rigidbody.velocity = Vector3.zero;

        foreach (SpriteRenderer renderer in transform.GetComponentsInChildren<SpriteRenderer>())
        {
            Color color = renderer.color;
            color.a = 0.3f;
            renderer.color = color;
        }

        foreach (Behaviour component in transform.GetComponentsInChildren<Behaviour>())
        {
            component.enabled = false;
        }

        Destroy(gameObject, 2f);
    }

 

유니티로 돌아와 몹들에 달아주면 체력이 닳았을 때 죽는다.

 

 

'부트캠프 > Study' 카테고리의 다른 글

<Unity> 숙련 - 2D 게임 개발(8)  (0) 2023.12.13
<Unity> 숙련 - 2D 게임 개발(7)  (1) 2023.12.12
ToString() 메서드  (1) 2023.12.11
<Unity> 숙련 - 2D 게임 개발(5)  (0) 2023.12.10
<Unity> 숙련 - 2D 게임 개발(4)  (0) 2023.12.08

코드카타 알고리즘 '문자열 내림차순으로 배치하기' 문제를 풀다가 ToString()에 대해 궁금증이 생겼다.

문제 내용은 아래와 같다.

 

문자열을 배열에 넣어 Array.Sort()를 이용하여 풀었는데, 문자열로 출력하는 부분에서 왜 ToString으로 배열을 전환할 수 없는지 궁금해졌다.

using System;
using System.Linq;
public class Solution {
    public string solution(string s) 
    {
        char[] arr = s.ToCharArray();
        Array.Sort(arr);
        Array.Reverse(arr);
        string answer = new string(arr);
        return answer;
    }
}

 

ToString()을 사용할 경우에 나오는 출력값은 System.Char[]이다.

C#에서 다루는 대부분은 System.Object라는 클래스를 상속받았다. 그 중 ToString()은 '이 개체가 어떤 개체인가'를 string으로 반환하는 문자열이다. Object를 상속받은 모든 클래스는 ToString()을 오버라이딩할 수 있는데, 예시로  System.Int32는 ToString()을 오버라이딩해서 정수값을 string으로 보여주라고 정의 되어있다.

즉, ToString()을 재정의하냐 안하냐에 따라 결과가 달라지는데 배열은 재정의되어 있지 않은 것이다.

쉽게 말하면 배열에서 ToString으로 문자열 변환은 안된다!

 

ToString을 이것저것 문자열 변환 할 때 사용했다가 안되는 경우가 많아서 그냥 단순히 숫자만 가능한가보다...하고 넘겼는데 상속과 오버라이딩이 관련 있었다. 더 자세한 내용은 아래에서 확인해보면 좋을 것 같다.

https://learn.microsoft.com/ko-kr/dotnet/api/system.object.tostring?view=net-8.0

투사체 구현하기

CharacterController에서 Attack을 불러올 때 스탯을 불러오도록 한다.

 

Call이 일어나고 Attack에 대한 데미지를 전달하는 코드들을 수정한다.

 

전달 받은 코드인 OnShoot 메서드에서 Attack을 받도록 처리한다. 투사체가 부채꼴로 퍼지도록 만들어줬다.

private void OnShoot(AttackSO attackSO) 
{
    if (attackSO is not RangedAttackData rangedAttackData) return;
    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);
        CreateProjecrile(rangedAttackData, angle);
    }
}

as는 형변환으로 attackSO를 RangedAttackData 참조형식으로 바꿔주겠다는 뜻이다.

 

형변환 실패 시 예외처리를 해주는 것이 안전하기 때문에 null 처리를 해준다.

RangedAttackData rangedAttackData = attackSO as RangedAttackData;
if (rangedAttackData == null) return;

 

아니면 is 연산자를 이용하여 간결하게 처리할 수도 있다.

if (attackSO is not RangedAttackData rangedAttackData) return;

 

투사체가 공격 데이터와 각도를 받아오도록 한다.

private void CreateProjecrile(RangedAttackData rangedAttackData, float angle) 
{
    Instantiate(ArrowPrefab, projectileSpawnPosition.position, Quaternion.identity);
}

 

투사체를 관리해주는 ProjectileManager 스크립트를 만들어준다.

쉬운 접근성을 위해 싱글톤 패턴을 사용한다.

static은 정적 메모리로, 정적 메모리들끼리 공간을 공유한다. 정적 메모리 공간을 할당 받은 후, 오브젝트들이 바라보게 한다. 즉, 오브젝트들이 모두 한 공간을 바라보는 것이다.

[SerializeField] private ParticleSystem _impactParticleSystem;

public static ProjectileManager instance;

private void Awake()
{
    instance = this;
}

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

 

instance가 정적 메모리에 들어있기 때문에 클래스명으로 접근이 가능해진다. 클래스명으로 인스턴스를 접근하면 자기 자신이 참조이므로 만들어져 있는 객체에 접근할 수 있게 된다. 그러나 가장 마지막에 들어온 오브젝트만 접근이 가능하고 처리할 수 있다.

그렇기 때문에 싱글톤 패턴을 사용할 때는 단일 객체로 만들어 하나만 접근할 수 있도록 한다. 보통 매니저에 자주 사용한다.

 

Shooting 스크립트에서 싱글톤이 설정된 이후 사용하기 위해 Start에서 ProjectileManager를 접근할 수 있도록 한다.

private ProjectileManager _projectileManager;

private void Start()
{
    _projectileManager = ProjectileManager.instance;
}

 

총알을 생성해준다. 현재 캐릭터 위치에서 어느 각도로 쏴야하는지만 있고 방향이 없기 때문에 벡터로 구해준다.

private void CreateProjecrile(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;
}

Quaternion과 Vector 둘다 횡렬이기 때문에 곱하기가 가능한데 Verctor * Quaternion은 원소수가 맞지 않기 때문에 곱할 수 없고 Quaternion * Vector(Vector를 이 각도로 회전시켜라)를 해야한다.

 

다시 매니저로 돌아와서 실제로 생성되는 부분을 구현한다. 아직 Prefab을 불러오는 방법이 없기 때문에 임시로 처리한다.

[SerializeField] private GameObject testObj;

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

    obj.transform.position = startPosition;
    //TODO
}

 

 

지금까지 투사체를 정확하게 발사하는 것까지 구현했다. 이제 투사체가 혼자 날아갈 수 있도록 해주어야한다.

투사체 Prefab에 충돌처리를 위해 Box Collider와 RigidBody를 준다.

 

그리고 Trail Renderer를 추가해주는데 날아가는 투사체의 잔상이라고 보면 된다.

 

투사체를 컨트롤할 스크립트를 만들어준다.

GetComponent는 자신이 포함되어있는 오브젝트에서만 찾기 때문에 자신을 포함한 하위까지 찾는 GetComponentInChildren을 사용한다. 자신에게 있으면 자신의 것을 사용하고 없다면 자식을 검사한다.

[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>();
        _rigidbody = GetComponent<Rigidbody2D>();
        _trailRenderer = GetComponent<TrailRenderer>();
    }

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

        _currentDuration += Time.deltaTime;

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

        _rigidbody.velocity = _direction * _attackData.speed;
    }

    public void InitializeAttack(Vector2 direction, RangedAttackData attackData, ProjectileManager projectileManager)
    {
        _projectileManager = projectileManager;
        _attackData = attackData;
        _direction = direction;

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

        transform.right = _direction;

        _isReady = true;
    }

    private void UpdateProjectilSprite()
    {
        transform.localScale = Vector3.one * _attackData.size;
    }

    private void DestroyProjectile(Vector3 position, bool createFx)
    {
        if(createFx)
        {

        }
        gameObject.SetActive(false);
    }

 

다시 매니저로 돌아와서 투사체를 날리는 것을 테스트하기 위해 구현한다.

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

 

투사체에 투사체를 컨트롤하는 스크립트를 달아주고 GameManager를 만들어 매니저 스크립트를 달아준다.

 

작동 순서는 Shooting에서 Shoot을 하면 ProjecticleManager에 투사체를 만들어달라고 요청한다. ProjecticleManager는 총알을 만들고 투사체 컨트롤러로 요청하여 초기화를 진행한 뒤, 총알을 발사한다.

 

그런데 투사체가 안보이는 오류가 있다. Player_RangedAttackData의 Projecticle Color 알파값이 0이었다.

 

최대로 바꿔준 뒤, 실행해보면 화살이 보인다.

 

현재 투사체가 Trigger 충돌로 되어있기 때문에 충돌 감지는 되지만 물리적인 충돌은 되지 않기 때문에 벽 밖으로 발사된다.

 

벽과 충돌했을 때, 투사체가 사라지도록 처리하기 위해 세 가지 레이어를 추가한다.

 

Player와 하위 오브젝트들의 레이어를 Player로 바꿔주고 Level 오브젝트를 만들어 Grid를 넣어준 뒤, Level 레이어로 바꿔준다.

 

그리고 투사체 Prefab을 컨트롤 하는 스크립트에서 레이어를 Level로 바꿔준다.

 

스크립트에서 누구와 충돌처리를 할 것인지 추가한다.

private void OnTriggerEnter2D(Collider2D collision)
    {
        if(levelCollisionLayer.value == (levelCollisionLayer.value | (1 << collision.gameObject.layer)))
        {
            DestroyProjectile(collision.ClosestPoint(transform.position) - _direction * .2f, fxOnDestory);
        }
    }

Layer 검사나 Name 검사, Tag 검사도 가능하지만 비트 연산이 빠르게 처리하기 때문에 사용한다.

'부트캠프 > Study' 카테고리의 다른 글

<Unity> 숙련 - 2D 게임 개발(6)  (1) 2023.12.12
ToString() 메서드  (1) 2023.12.11
<Unity> 숙련 - 2D 게임 개발(4)  (0) 2023.12.08
<프로젝트> 팀과제 Unity 2D 게임 - Space Survival(2)  (0) 2023.12.01
벡터(Vecter)  (0) 2023.11.29

캐릭터 스탯 만들기

캐릭터 스탯 스크립트를 만들어준다.

캐릭터 스탯은 데이터 단위로만 사용해서 클래스로 객체화 시키지 않을 것이기 때문에 MonoBehaviour를 지워도 된다.

MonoBehaviour를 지웠기 때문에 유니티에서 사용할 수 있는 Start(), Update() 등의 메서드를 사용할 수 없다.

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;

    // 공격 데이터 저장
}

공격 데이터를 저장할 때, 클래스를 기반으로 저장하면 각 객체마다 저장 공간을 가지게 되어 데이터 또한 똑같이 할당해주어야 하기 때문에 객체가 늘어나면 부담이 된다.

ScriptableObjects를 사용하면 하나의 데이터 컨테이너를 공유하여 사용할 수 있어 메모리적으로 간편하고, Inspector에서 바로 컨트롤 할 수 있어 접근적으로도 간편해진다.

 

유니티에서 ScriptableObjects 폴더와 스크립트를 따로 만들어준다.

 

ScriptableObject를 상속 받고 필요한 것들을 구현한다. 디폴트 공격값을 세팅한다.

Header()는 괄호 안의 내용이 Inspector에 뜨는 변수 위에 뜨게 한다.

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

ScriptableObject를 사용하기 위해 데이터로 꺼내야하는데 메뉴로 추가시켜주면 된다.

[CreateAssetMenu(fileName ="처음 생성될 때 이름", menuName ="선택될 메뉴 이름", order = 0)]

 

원거리 공격을 메뉴에 추가하기 위해 RangedAttackData 스크립트를 만들어준다.

[CreateAssetMenu(fileName = "DefaultAttackData", 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;
}

 

유니티로 돌아와서 Create에 확인해보면 메뉴가 생겨있다.

 

Datas폴더를 만들어서 플레이어의 공격을 추가하고 값을 설정해준다.

위에서 설정한 데이터는 공용이 되는 데이터로 한 플레이어의 값을 바꾸면 모든 플레이어의 값이 바뀐다.

 

캐릭터 스탯 스크립트에 공격 데이터를 추가해준다.

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;

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

 

모든 플레이어의 값이 연동되지 않게 하기 위한 코드를 짠다.

baseStats.attackSO가 null이 아니라면 baseStats.attackSO를 자유롭게 수정하기 위해 가상으로 메모리상에 복제한다.

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

        CurrentStates = new CharacterStats { attackSO = attackSO };
        // TODO
        CurrentStates.statsChangeType = baseStats.statsChangeType;
        CurrentStates.maxHealth = baseStats.maxHealth;
        CurrentStates.speed = baseStats.speed;

    }
}

 

유니티에 돌아와서 플레이어의 스탯을 설정해준다.

 

 

TopDownMovement 스크립트로 돌아가서 스탯을 추가해서 GetComponent에 달아놓고 임의로 설정해둔 speed를 수정한다.

public class TopDownMovement : MonoBehaviour
{
    private TopDownCharactreController _controller;
    private CharacterStatsHandler _state; // 추가

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

    private void Awake() 
    {
        _controller = GetComponent<TopDownCharactreController>();
        _state = GetComponent<CharacterStatsHandler>(); // 추가
        _rigidbody = GetComponent<Rigidbody2D>();
    }

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

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

    private void Move(Vector2 direction) 
    {
        _movementDirection = direction;
    }

    private void ApplyMovment(Vector2 direction)
    {
        direction = direction * _state.CurrentStates.speed; // 수정!
        _rigidbody.velocity = direction;
    }
}

 

TopDownCharactreController 역시 임의로 적어놓은 딜레이를 수정한다.

public class TopDownCharactreController : 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; } // 추가

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

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

    private void HandleAttackDelay() 
    {
        // attack 정보가 없으면 공격하지 않는다
        if (Stats.CurrentStates.attackSO == null)
        {
            return;
        }

        if(_timeSinceLastAttack <= Stats.CurrentStates.attackSO.delay)  // 수정
        {
            _timeSinceLastAttack += Time.deltaTime;
        }

        if(IsAttacking && _timeSinceLastAttack > Stats.CurrentStates.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();
    }
}

 

PlayerInputController 스크립트에서 Awake를 override 한다.

protected override void Awake() 
{
    base.Awake();
    _camera = Camera.main;
}

 

유니티로 돌아와보면 Inspector 창에서 Delay를 조절할 수 있게 됐다.

 

 

ScriptableObject를 만드는 것까지는 이해 됐는데 모든 플레이어의 스탯을 연동되지 않게 처리하는 부분에서 슬슬 이해가 안되기 시작했다... 주말에 이론과 특강을 포함해서 강의를 한 번 더 봐야할 것 같다.

+ Recent posts