로직 구현

근거리 몹의 데미지를 구현하기 위해 Collider를 하나 더 달아서 Trigger로 만들어준다.

 

GameManager에 로직을 추가한다. 아래 IEnumerator StartNextWave()는 Coroutine(코루틴)이다. 코루틴은 비동기적으로 실행되는 함수로, 특정 부분에서 일시적으로 멈추거나 다시 시작하게 한다.

Start에서 코루틴을 실행하고 GameOver가 되면 멈춘다. 소환된 몹이 0이 되면(첫 시작이거나 다 잡았거나) Wave를 최신화 해준다.

[SerializeField] private int currentWaveIndex = 0;
private int currentSpawnCount = 0;
private int waveSpawnCount = 0;
private int waveSpawnPosCount = 0;

public float spawnInterval = .5f;
public List<GameObject> enemyPrefebs = new List<GameObject>();

[SerializeField] private Transform spawnPositionsRoot;    
private List<Transform> spawnPostions = new List<Transform>();

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

    playerHealthSystem = Player.GetComponent<HealthSystem>();
    playerHealthSystem.OnDamage += UpdateHealthUI;
    playerHealthSystem.OnHeal += UpdateHealthUI;
    playerHealthSystem.OnDeath += GameOver;

    gameOverUI.SetActive(false);

    for (int i = 0; i < spawnPositionsRoot.childCount; i++)
    {
        spawnPostions.Add(spawnPositionsRoot.GetChild(i));
    }
}

private void Start()
{
    UpgradeStatInit();
    StartCoroutine("StartNextWave");
}

// 코루틴
IEnumerator StartNextWave()
{
    while(true)
    {
        if(currentSpawnCount == 0)
        {
            UpdateWaveUI();
            yield return new WaitForSeconds(2f);

            if(currentWaveIndex % 20 == 0)
            {
                // 업그레이드
            }

            if(currentWaveIndex % 10 == 0)
            {
                waveSpawnPosCount = waveSpawnPosCount + 1 > spawnPostions.Count ? waveSpawnPosCount : waveSpawnPosCount + 1;
                waveSpawnCount = 0;
            }

            if(currentWaveIndex % 5 ==0)
            {
                // 보상
            }

            if(currentWaveIndex % 3 == 0)
            {
                waveSpawnCount += 1;
            }


            for(int i =  0; i < waveSpawnPosCount;i++)
            {
                int posIdx = Random.Range(0, spawnPostions.Count);
                for(int j = 0; j <waveSpawnCount;j++)
                {
                    int prefabIdx = Random.Range(0,enemyPrefebs.Count);
                    GameObject enemy = Instantiate(enemyPrefebs[prefabIdx], spawnPostions[posIdx].position, Quaternion.identity);
                    enemy.GetComponent<HealthSystem>().OnDeath += OnEnemyDeath;
      
                    currentSpawnCount++;
                    yield return new WaitForSeconds(spawnInterval);
                }
            }

            currentWaveIndex++;
        }

        yield return null;
    }
}

private void OnEnemyDeath()
{
    currentSpawnCount--;
}

private void GameOver()
{    
    gameOverUI.SetActive(true);
    StopAllCoroutines();
}

private void UpdateWaveUI()
{
    waveText.text = (currentWaveIndex + 1).ToString();
}

 

유니티로 돌아와서 적 스폰지점을 만들고 GameManager에 알려준다.

 

 

아이템 구현

아이템 구현을 위해 스크립트를 만든다. PickupItem 스크립트로 아이템을 생성하진 않을 것이기 때문에 추상 클래스로 만든다.

아이템을 먹었을 때 삭제할 것인지, 아이템을 먹을 수 있는 레이어, 오디오를 추가한다.

[SerializeField] private bool destroyOnPickup = true;
[SerializeField] private LayerMask canBePickupBy;
[SerializeField] private AudioClip pickupSound;

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (canBePickupBy.value == (canBePickupBy.value | (1 << other.gameObject.layer)))
        {
            OnPickedUp(other.gameObject);
            if (pickupSound)
                SoundManager.PlayClip(pickupSound);

            if (destroyOnPickup)
            {
                Destroy(gameObject);
            }
        }
    }

    protected abstract void OnPickedUp(GameObject receiver);

 

실체를 구현하기 위해 상속 받는다.

아이템을 먹었을 때, 체력이 회복되거나 스텟이 올라가도록 한다.

public class PickupStatModifiers : PickupItem
{
    [SerializeField] private List<CharacterStats> statsModifier;
    protected override void OnPickedUp(GameObject receiver)
    {
        CharacterStatsHandler statsHandler = receiver.GetComponent<CharacterStatsHandler>();
        foreach (CharacterStats stat in statsModifier)
        {
            statsHandler.AddStatModifier(stat);
        }
    }
}
public class PickupHeal : PickupItem
{
    [SerializeField] int healValue = 10;
    private HealthSystem _healthSystem;

    protected override void OnPickedUp(GameObject receiver)
    {
        _healthSystem = receiver.GetComponent<HealthSystem>();
        _healthSystem.ChangeHealth(healValue);
    }
}

 

GameManager에 5번째마다 아이템이 나오도록 추가한다.

public List<GameObject> rewards = new List<GameObject>();

// 생략
if (currentWaveIndex % 5 == 0)
{
    CreateReward();
}

void CreateReward()
{
    int idx = Random.Range(0, rewards.Count);
    int posIdx = Random.Range(0, spawnPostions.Count);

    GameObject obj = rewards[idx];
    Instantiate(obj, spawnPostions[posIdx].position, Quaternion.identity);
}

 

 

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

<Unity> 3D 게임 개발 심화 - RPG FSM(1)  (2) 2023.12.22
<Unity> UI  (0) 2023.12.14
<Unity> 숙련 - 2D 게임 개발(7)  (1) 2023.12.12
<Unity> 숙련 - 2D 게임 개발(6)  (1) 2023.12.12
ToString() 메서드  (1) 2023.12.11

파티클 생성

플레이어 하위에 Effects - Particle System을 생성한다.

 

적당히 꾸며준다.

  • Duration : 얼마나 실행할 것인가
  • Looping : 반복 실행
  • Start Lifetime : 생존 시간
  • Start Speed : 시작 속도
  • Start Size : 파티클 사이즈
  • Simulation Space : 어느 곳에서 애니메이션화할지(오브젝트, 월드, 커스텀 등)

Emission

  • Rate over Time : 시간 당 방출량

Shape

  • Shape : 방출 모양
  • Radius : 뿜어져 나오는 범위

Limit Velocity over Lifetime

  • Speed : 파티클 속도
  • - Dampen : 감소 속도, 저항

Color over Lifetime : 생존 동안의 색상 변화Size over Lifetime : 생존 동안의 크기 변화

Collision

  • Dampen : 감소 속도, 저항
  • Bounce : 탄성

Renderer

  • Material : 재질
  • Order in Layer : 레이어 순서

 

스크립트를 만들어 플레이어가 움직일 때마다 생성되도록 애니메이터가 있는 공간에 달아준다.

[SerializeField] private bool createDustOnWalk = true;
    [SerializeField] private ParticleSystem dustParticleSystem;

    public void CreateDustParticles()
    {
        if (createDustOnWalk)
        {
            dustParticleSystem.Stop();
            dustParticleSystem.Play();
        }
    }

 

동작할 때마다 파티클을 달아줄 애니메이션을 선택하여 Animation Event를 달아준다. 애니메이션 이벤트는 같이 설정되어있는 곳에 애니메이션이 있어야 호출할 수 있다.

 

투사체 소멸 효과도 만들어준다. 파티클을 만들어서 달아준 뒤, ProjectileManager에 스크립트를 추가한다.

투사체가 적이나 벽에 닿았을 때, 파티클이 터지는 코드인데 이 부분은 솔직히 이해 못해서 다시 봐야할 것 같다.

public void CreateImpactParticlesAtPostion(Vector3 position, RangedAttackData attackData)
{
    _impactParticleSystem.transform.position = position;
    ParticleSystem.EmissionModule em = _impactParticleSystem.emission;
    em.SetBurst(0, new ParticleSystem.Burst(0, Mathf.Ceil(attackData.size * 5)));
    ParticleSystem.MainModule mainModule = _impactParticleSystem.main;
    mainModule.startSpeedMultiplier = attackData.size * 10f;
    _impactParticleSystem.Play();
}

 

원거리 투사체 스크립트 부분도 수정한다.

private void DestroyProjectile(Vector3 position, bool createFx)
{
    if (createFx)
    {
        _projectileManager.CreateImpactParticlesAtPostion(position, _attackData);
    }
    gameObject.SetActive(false);
}

 

 

사운드 컨트롤

사운드 매니저 스크립트를 만든다.

싱글톤화 시킨 후, 사운드 이펙트에 맞춘 볼륨, 이펙트 볼륨, 배경음악 볼륨과 오브젝트 풀, 오디오소스와 실제로 오디오를 가지고 있는 오디오클립을 준비한다.

public static SoundManager instance;

    [SerializeField][Range(0f, 1f)] private float soundEffectVolume;
    [SerializeField][Range(0f, 1f)] private float soundEffectPitchVariance;
    [SerializeField][Range(0f, 1f)] private float musicVolume;
    private ObjectPool objectPool;

    private AudioSource musicAudioSource;
    public AudioClip musicClip;

    private void Awake()
    {
        instance = this;
        musicAudioSource = GetComponent<AudioSource>();
        musicAudioSource.volume = musicVolume;
        musicAudioSource.loop = true;

        objectPool = GetComponent<ObjectPool>();
    }

    private void Start()
    {
        ChangeBackGroundMusic(musicClip);
    }

    public static void ChangeBackGroundMusic(AudioClip music)
    {
        instance.musicAudioSource.Stop();
        instance.musicAudioSource.clip = music;
        instance.musicAudioSource.Play();
    }

    public static void PlayClip(AudioClip clip)
    {
        GameObject obj = instance.objectPool.SpawnFromPool("SoundSource");
        obj.SetActive(true);
        SoundSource soundSource = obj.GetComponent<SoundSource>();
        soundSource.Play(clip, instance.soundEffectVolume, instance.soundEffectPitchVariance);
    }

 

SoundSource는 사운드 클립을 컨트롤 하기 위해 만든 것이다. Play를 걸면 해당 클립을 설정한 볼륨과 피치로 재생시킨다. Invoke는 지연 실행으로, 노래가 끝난 2초 뒤에 끝낸다.

private AudioSource _audioSource;

    public void Play(AudioClip clip, float soundEffectVolume, float soundEffectPitchVariance)
    {
        if (_audioSource == null)
            _audioSource = GetComponent<AudioSource>();

        CancelInvoke();
        _audioSource.clip = clip;
        _audioSource.volume = soundEffectVolume;
        _audioSource.Play();
        _audioSource.pitch = 1f + Random.Range(-soundEffectPitchVariance, soundEffectPitchVariance);

        Invoke("Disable", clip.length + 2);
    }

    public void Disable()
    {
        _audioSource.Stop();
        gameObject.SetActive(false);
    }

 

유니티에서 SoundManager와 SoundSource를 만들어준 뒤, 각각 스크립트를 연결시킨다.

SoundSource에 Audio Source를 달아주고 프리팹화 시킨다. SoundManager 역시 Audio Source와 배경음악을 달아준다.

 

Shooting 소리나 데미지를 입었을 때 등 효과음을 넣어주기 위해 스크립트에 AudioClip을 부른 뒤, 해당 조건에 재생되도록 한다.

public AudioClip shootingClip;

----------------- 생략 ----------------- 
if (shootingClip)
        SoundManager.PlayClip(shootingClip);

 

 

UI

UGUI를 사용하여 UI를 구현하기 위해 Canvas를 생성하고 Scale을 Scale With Screen Size로 바꿔준 뒤, 16:9로 변경한다. Scale With Screen Size 화면이 커질수록 UI 요소도 커져서 멀티 해상도에 대응이 가능하다. 반대로 Constant Physical Size는 화면 크기와 해상도에 관계없이 UI 요소가 동일한 물리적인 크기로 유지된다.

 

UI 배치는 큰 묶음과 앵커에 신경쓰면 좋다.

앵커를 기준으로 크기와 좌표를 정한다.

현재 앵커가 중앙에 걸려있으므로 화면 사이즈가 변할 때, 중앙을 기준으로 위치가 유지된다.

 

Wave를 띄우기 위해 Canvas 하위에 빈 오브젝트를 생성해서 image를 추가하여 앵커로 위치를 조정한다.

 

생성한 캔버스를 꽉 채우기 위해서는 alt + 클릭으로 stretch를 선택하면 된다.

 

UI를 적당히 꾸며준 뒤, UI를 처리하는 스크립트를 GameManager에서 처리한다.

플레이어의 체력을 불러와서 %로 계산해준 뒤, Silder UI에 반영한다.

GameOver가 되면 GameOver UI가 실행되도록 하고, 버튼을 눌렀을 때 각각 게임 Scene화면을 다시 불러오고 어플리케이션을 종료시킨다. 예전에는 Application.LoadScene로 씬을 불러왔는데 이제는 사용되지 않기 때문에 SceneManager로 build Setting에 걸려있는 인덱스 번호로 불러온다.(Scene 이름으로 불러올 수도 있다.)

using TMPro;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

private HealthSystem playerHealthSystem;

[SerializeField] private TextMeshProUGUI waveText;
[SerializeField] private Slider hpGaugeSlider;
[SerializeField] private GameObject gameOverUI;

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

    playerHealthSystem = Player.GetComponent<HealthSystem>();
    playerHealthSystem.OnDamage += UpdateHealthUI;
    playerHealthSystem.OnHeal += UpdateHealthUI;
    playerHealthSystem.OnDeath += GameOver;

    gameOverUI.SetActive(false);
}

private void UpdateHealthUI()
{
    hpGaugeSlider.value = playerHealthSystem.CurrentHealth / playerHealthSystem.MaxHealth;
}

private void GameOver()
{
    gameOverUI.SetActive(true);    
}

private void UpdateWaveUI()
{
    // waveText.text = 
}

public void RestartGame()
{
    SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}

public void ExitGame()
{
    Application.Quit();
}

 

유니티로 돌아와서 GameManager와 버튼에 필요한 것들을 연결해준다.

 

 

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

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

투사체 구현

오브젝트 풀(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

+ Recent posts