Coding Feature.

[Unity 3D] Vampire Survivors 같은 게임 만들기 #2 플레이어 공격 쿨다운 타임, States, 애니메이션, Enemy 체력 바 구현 본문

Toy Project/Bust'em, Igor! [Unity3D]

[Unity 3D] Vampire Survivors 같은 게임 만들기 #2 플레이어 공격 쿨다운 타임, States, 애니메이션, Enemy 체력 바 구현

codingfeature 2024. 2. 5. 17:41

 

우선 플레이어와 Enemy의 State를 구현하기로 했습니다.

 

플레이어의 State는 enum 형으로 총 3 가지 상태를 구현했습니다.

    enum State {
        idle,
        moving,
        attacking
    }

    State state;

 

그리고 위 state 변수를 바꾸어 주는 함수 HandleState를 아래와 같이 구현했습니다.

    void HandleState()
    {
        if (targetEnemy != null)
        { 
            if(Vector3.Distance(targetEnemy.transform.position, transform.position) <= attackRange)
            {
                state = State.attacking;
            }
            else
            {
                state = State.moving;
            }
        }
        else
        {
            state = State.idle;
        }
    }

 

만약 targetEnemy가 존재하는 경우에는, 공격 범위(attackRange) 내에 있는 경우 attacking, 없는 경우 moving 으로 설정되도록 했습니다. 만약 targetEnemy가 없는 경우에는 idle 상태가 됩니다.

 

Skeleton 도 똑같은 방식으로 구현했습니다.

    void HandleState()
    {
        if(player != null)
        {
            if (Vector3.Distance(player.transform.position, transform.position) <= attackRange)
            {
                state = State.attacking;
            }
            else
            {
                state = State.moving;
            }
        }
        else
        {
            state = State.idle;
        }
 
    }

 

 

그 다음 플레이어가 적과 가까이 있을 때 쿨다운 타임이 있는 공격을 하도록 구현하기도 했습니다.

 

쿨다운이 있는 공격이란, 일정 시간이 지나면 적을 공격하게 하는 매커니즘을 의미합니다.

 

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

public class PlayerScript : MonoBehaviour
{
	..
    public float damage;
    public float attackTime;
    float attackTimer;
    ..
    GameObject targetEnemy;

	..

    private void Start()
    {
        attackTimer = attackTime;
		..
    }

    private void Update()
    {
    	..
        if (attackTimer > 0)
        {
            attackTimer -= Time.deltaTime;
        }

        if(attackTimer <= 0)
        {
            if (state == State.attacking) {
                AttackEnemy(targetEnemy);
                attackTimer = attackTime;
            }
        }
    }
..

    void AttackEnemy(GameObject enemy)
    {
        enemy.GetComponent<SkeletonScript>().hp -= damage;
        if(enemy.GetComponent<SkeletonScript>().hp <= 0)
        {
            Destroy(enemy);
            EnemyManager.Instance.RemoveSkeletonFromList(enemy);
        }
    }
..
}

 

 

attackTimer와 attackTime 변수는 모두 float 형 변수입니다.

attackTime에는 쿨다운 타임을 지정할 수 있습니다. 그리고 attackTimer는 실제로 게임 시스템 내부에서 쿨다운 타임을 체크하는 용도로 사용됩니다.

 

Update 함수에서 attackTimer가 0보다 큰 경우 Time.deltaTime 만큼의 값을 빼줌으로써 각 프레임 당 시간이 흐르는 것을 구현합니다. 그리고 attackTimer가 0보다 작은 경우에는 쿨다운 타임이 끝났음을 의미하므로 바로 공격하도록 했습니다.

 

AttackEnemy 함수를 호출하면 다시 attackTimer를 attackTime으로 지정해주어 리셋시킵니다.

 

플레이어의 공격은 위와 같이 구현했지만 적의 공격은 따로 쿨다운 타임을 두지 않고 그냥 플레이어가 근처에 있는 경우 연속적으로 체력이 닳도록 구현했습니다.

 

Vampire Survivors에서도 같은 방식으로 적의 공격이 구현되었습니다.

 

// 플레이어 스크립트에서..
public void AttackPlayer(float damage)
    {
        hp -= damage;
    
    }
    
// Skeleton 스크립트에서..

..
        else if(state == State.attacking)
        {
            player.GetComponent<PlayerScript>().AttackPlayer(damage);
        }

 

위 처럼 AttackPlayer 함수는 플레이어 스크립트에 구현이 되어 있고 이 함수를 Skeleton 스크립트의 FixedUpdate 함수에서 호출시켜줌으로써 지속적인 데미지 입힘을 구현하였습니다.

 

 

그 다음 적의 체력이 가시화될 수 있도록 체력바 UI를 구현하겠습니다.

 

Skeleton 게임 오브젝트의 Child 게임 오브젝트로 Canvas/Slider, 즉 슬라이더 UI 게임 오브젝트를 생성하였습니다.

 

그 다음 다음과 같이 체력 바를 스크립트로 구현하였습니다.

 

public class SkeletonScript : MonoBehaviour
{

    public float hp;

    public GameObject hpBar;
    Slider hpBarSlider;

    private void Start()
    {
        hpBarSlider = hpBar.GetComponent<Slider>();

        hpBarSlider.minValue = 0;
        hpBarSlider.maxValue = hp;
    }

    private void Update()
    {
        hpBar.transform.position = Camera.main.WorldToScreenPoint(transform.position);
        hpBarSlider.value = hp;
    }
..

 

hpBarSlider의 최소, 최댓값을 Start 함수에서 설정하고, Update 함수에서 체력바의 위치를 적의 위치에 보이도록 지정했습니다. 이때 WorldToScreenPoint 함수를 사용해서 World 좌표를 스크린의 좌표로 변경시킵니다.

 

 

 

그리고 플레이어의 애니메이션을 추가했습니다.

 

 

Animator 컨트롤러를 위와 같이 설정하고 각 Transition을 플레이어의 State에 맞게 바뀌도록 했습니다.

이때 Transition에 사용되는 parameter는 integer 형식으로 "State"입니다.

 

animator.SetInteger("State", (int)state);

 

위 코드를 플레이어 스크립트의 Update 함수에 넣어줬습니다.

 

 

 

지금까지 구현된 플레이어의 전체 Script는 다음과 같습니다.

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

public class PlayerScript : MonoBehaviour
{
    public float damage;
    public float speed;
    public float hp;
    public float attackTime;
    public float attackRange;
    float attackTimer;

    Vector3 moveDirection;
    GameObject targetEnemy;

    Animator animator;

    enum State {
        idle,
        moving,
        attacking
    }

    State state;

    private void Start()
    {
        attackTimer = attackTime;
        state = State.idle;

        animator = gameObject.GetComponent<Animator>();
    }

    private void FixedUpdate()
    {
        targetEnemy = FindNearestEnemy(); // 최적화를 위해 Update 함수 대신 다른 곳에서 특정 상황 되면 호출되도록 구현 필요.

        HandleState();

        moveDirection = (targetEnemy.transform.position - transform.position).normalized;
        transform.rotation = Quaternion.LookRotation(moveDirection, Vector3.up);

        if (state == State.moving)
        {
            transform.position = new Vector3(transform.position.x + moveDirection.x * speed, transform.position.y, transform.position.z + moveDirection.z * speed);
        }
        
    }

    private void Update()
    {
        UIManager.Instance.SetAttackText(attackTimer);
        UIManager.Instance.SetHPText(hp);

        animator.SetInteger("State", (int)state);

        if (attackTimer > 0)
        {
            attackTimer -= Time.deltaTime;
        }

        if(attackTimer <= 0)
        {
            if (state == State.attacking) {
                AttackEnemy(targetEnemy);
                attackTimer = attackTime;
            }
        }
    }

    GameObject FindNearestEnemy()
    {
        GameObject nearestEnemy = null;
        float minimumDistance = 9999f;
        foreach (GameObject enemy in EnemyManager.Instance.skeletons)
        {
            float tempDistance = (transform.position - enemy.transform.position).magnitude;
            if(tempDistance < minimumDistance)
            {
                minimumDistance = tempDistance;
                nearestEnemy = enemy;
            }
        }

        if (nearestEnemy != null)
            return nearestEnemy;
        
        return null;
    }

    void AttackEnemy(GameObject enemy)
    {
        enemy.GetComponent<SkeletonScript>().hp -= damage;
        if(enemy.GetComponent<SkeletonScript>().hp <= 0)
        {
            Destroy(enemy);
            EnemyManager.Instance.RemoveSkeletonFromList(enemy);
        }
    }

    public void AttackPlayer(float damage)
    {
        hp -= damage;
    }

    void HandleState()
    {
        if (targetEnemy != null)
        { 
            if(Vector3.Distance(targetEnemy.transform.position, transform.position) <= attackRange)
            {
                state = State.attacking;
            }
            else
            {
                state = State.moving;
            }
        }
        else
        {
            state = State.idle;
        }
    }
}

 

그리고 Skeleton의 스크립트는 다음과 같습니다.

 

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

public class SkeletonScript : MonoBehaviour
{
    public float damage;
    public float hp;
    public float speed;
    public float attackRange;

    GameObject player;
    bool isPlayerNear;

    public GameObject hpBar;
    Slider hpBarSlider;

    // State 관련 구현 필요할 수도.

    Vector3 moveDirection;

    enum State
    {
        idle,
        moving,
        attacking
    }

    State state;

    private void Start()
    {
        isPlayerNear = false;
        hpBarSlider = hpBar.GetComponent<Slider>();
        player = GameManager.Instance.PlayerGameObject;

        hpBarSlider.minValue = 0;
        hpBarSlider.maxValue = hp;

        state = State.idle;
    }

    private void Update()
    {
        HandleState();

        hpBar.transform.position = Camera.main.WorldToScreenPoint(transform.position);
        hpBarSlider.value = hp;
    }

    private void FixedUpdate()
    {

        moveDirection = (player.transform.position - transform.position).normalized;
        transform.rotation = Quaternion.LookRotation(moveDirection, Vector3.up);

        if (state == State.moving)
            transform.position = new Vector3(transform.position.x + moveDirection.x * speed, transform.position.y, transform.position.z + moveDirection.z * speed);
        else if(state == State.attacking)
        {
            player.GetComponent<PlayerScript>().AttackPlayer(damage);
        }
    }

    void HandleState()
    {
        if(player != null)
        {
            if (Vector3.Distance(player.transform.position, transform.position) <= attackRange)
            {
                state = State.attacking;
            }
            else
            {
                state = State.moving;
            }
        }
        else
        {
            state = State.idle;
        }
 
    }
}