본문 바로가기
게임 프로그래밍/유니티 활용

[유니티 활용] 액션 RPG 스타일의 실시간 전투 구현하기

by 레오란다 2023. 1. 14.
반응형

지금까지 캐릭터의 이동과 몬스터의 AI 및 각 상황에 맞는 애니메이션을 적용하는 방법에 대해 알아보았습니다. 

 

 

[유니티 활용] StateMachine 을 활용한 3D 몬스터 AI 구현

아래에 링크한 이전 글에서는 유니티에서 제공하는 NavMeshAgent 를 이용해 몬스터가 플레이어를 따라다니도록 했습니다. 이번 글에서는 한단계 더 나아가서 처음엔 가만히 있다가 플레이어가 근

ugames.tistory.com

 

위의 작업 내용을 토대로 액션 RPG 스타일의 실시간 근접 전투를 구현하는 방법에 대해 알아보도록 하겠습니다. 현 시점까지 구현한 내용은 위에 링크한 글로 이동하시면 아래쪽에 unitypackage 다운로드와 필수 에셋 링크가 있으니 다운로드 받아 사용하시면 됩니다.


 

플레이어 공격 만들기

게임에서 플레이어가 몬스터를 공격하는 방식은 다양하게 있지만 여기서는 이동이 멈춘 상태에서 몬스터가 공격범위 안에 있으면 자동으로 공격하는 방식으로 구현하도록 하겠습니다.

 

우선 Hero Animator 에 다음과 같이 Idle / Attack / GetHit 모션을 추가합니다. 모션을 추가하고 각 state의 이름이 아래와 동일하게 되도록 Inspector 에서 변경해 주셔야 합니다. 

모션추가
Animator 에 모션 추가

Attack02 와 GetHit 에서 IdleBattle 로 Transition 을 추가해 주세요. 별도의 Condition 설정은 하지 않습니다. 이렇게 transition 을 설정하는 이유는 공격이나 피격 후 다음 번 공격까지 전투 대기 상태가 유지되도록 하기 위해서입니다.

 

 

State Attack02 를 더블클릭하면 Inspector 창에 Animation 설정이 나타납니다. 아래로 스크롤하면 Events 항목이 있고 펼치면 아래와 같이 특정 프레임에 이벤트를 발생시키도록 설정할 수 있는 UI 가 나타납니다.

 

Animation Event 설정
공격 모션의 특정 프레임에 이벤트 추가

 

1번 위치의 슬라이더를 이용해 이벤트를 발생시킬 프레임으로 이동시키고 2번 위치의 버튼을 클릭한 다음 3번에 이벤트 이름을 입력하면 됩니다. 여기선 AnimHit 로 하겠습니다. 

 

이벤트는 이 애니메이션을 사용하는 객체에 추가된 스크립트에서 동일한 이름의 함수 형태로 만들어 놓으면 됩니다. 이 경우에는 AnimHit() 이렇게 만들면 됩니다. 그러면 애니메이션이 플레이되고 지정한 프레임에 도달했을 때 스크립트의 AnimHit() 함수가 호출되는 방식입니다.

 

 

다음으로 몬스터를 인식하기 위해 Player 객체 Sphere Collider 를 추가하고 다음과 같이 설정합니다. 반드시 IsTrigger 를 체크하셔야 합니다.

 

Player 객체의 Sphere Collider 설정
Sphere Collider 설정 값

몬스터가 Collider 와 충돌하면 Player 객체에서 해당 몬스터를 타겟으로 간주하고 조건에 따라 공격하게 됩니다.

 

 

이제 Player 의 스크립트를 다음과 같이 수정합니다.

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

public class Player : MonoBehaviour
{   
   Vector3 dir; 

   Animator anim;
   CharacterController cc;
   Monster target;
   public AudioClip footstep;

   public float speed;
   public int HP;


   // 공격 시간 간격
   public float attackDelay;
   // 다음 공격까지 남은 시간
   float remainAttackTime;

   // Start is called before the first frame update
   void Start()
   {
      anim = GetComponent<Animator>();
      cc = GetComponent<CharacterController>();
      
      HP = 10;      
      remainAttackTime = 0f;      
   }

   // Update is called once per frame
   void Update()
   {
      if (cc.isGrounded)
      {
         var h = Input.GetAxis("Horizontal");
         var v = Input.GetAxis("Vertical");

         dir = new Vector3(h, 0, v) * speed;

         if (dir != Vector3.zero)
         {
            transform.rotation = Quaternion.Euler(0, Mathf.Atan2(h, v) * Mathf.Rad2Deg, 0);
            anim.SetBool("IsMove", true);            
         }
         else
         {            
            // 타겟(몬스터)이 설정되어 있고 타겟이 살아있는 상태인 경우
            if (target != null && target.HP > 0)
            {
               // 타겟을 바라보는 코드
               transform.LookAt(target.transform.position);

               // 몬스터와의 거리 계산
               var d = Vector3.Distance(target.transform.position, transform.position);

               // 몬스터와의 거리가 2 이하고 공격 가능한 상태인 경우
               if (d <= 2 && remainAttackTime <= 0f)
               {  
                  // 공격 animation 플레이
                  anim.Play("Attack02", -1, 0);
                  // 다음 공격 대기시간 설정
                  remainAttackTime = attackDelay;                  
               }  
            }

            anim.SetBool("IsMove", false);
         }

         if (Input.GetKeyDown(KeyCode.Space))
            dir.y = 7.5f;
      }

      dir.y += Physics.gravity.y * Time.deltaTime;
      cc.Move(dir * Time.deltaTime);

      // 공격 대기 시간 감소
      remainAttackTime -= Time.deltaTime;
   }

   private void OnTriggerEnter(Collider other)
   {
      // 콜라이더에 충돌한 객체가 Monster 컴포넌트가 있으면
      // 타겟으로 설정
      target = other.GetComponent<Monster>();
      if (target != null)
         transform.LookAt(target.transform);
   }

   public void GetDamage(int dmg)
   {
      HP -= dmg;
      if (anim.GetCurrentAnimatorStateInfo(0).IsName("Attack02") == false)
         anim.Play("GetHit", -1, 0);
   }

   void FootStep()
   {
      AudioSource.PlayClipAtPoint(footstep, Camera.main.transform.position);
   }

   void AnimHit()
   {
      // 공격 모션 중 특정 설저에 따라 프레임에서 발생한 이벤트
      if (target == null || target.HP <= 0) return;
      target.GetDamage(1);
   }
}

Attack02 animation 설정에서 생성한 이벤트인 AnimHit 가 위의 코드 마지막에 구현되어 있습니다. 플레이어가 공격 모션을 취할 때 설정한 프레임에 도달하면 AnimHit() 가 호출되고 몬스터에게 target.GetDamage(1) 함수를 호출하여 실제 데미지를 입힙니다. 몬스터는 GetDamage() 함수에서 피격당했을 때의 처리를 하면 됩니다.

 

몬스터도 공격을 하기 때문에 위의 스크립트에도 GetDamage 함수가 있습니다. 플레이어도 몬스터로부터 피격을 당하면 HP 가 감소하고 피격 animation 을 재생합니다. 단, 몬스터와 다르게 플레이어의 공격 모션이 재생 중이라면 피격 애니메이션을 재생하지 않습니다.

 

Inspector 창에서 speed, HP, attackDelay 값을 설정해 줍니다.


 

몬스터 수정

몬스터는 이미 플레이어 감지 기능과 공격 모션이 구현되어 있기 때문에 피격과 전투대기 모션을 추가하고 그에 따른 코드 수정만 해주면 됩니다.

 

몬스터의 Animator 창을 다음과 같이 만들어 주세요.

방법은 플레이어와 동일하며 애니메이션은 [RPG Monster Duo PBR Polyart > Animations > Slime] 에 있습니다. 

 

Slime의 Animator 상태
Slime 의 Animator 상태

 

아래의 그림을 참고하여 플레이어에서 공격 모션에 이벤트를 설정한 것과 같이 Attack01 에도 이벤트를 만들어 주세요.

 

Slime 모션 이벤트
몬스터 공격 모션에 이벤트 추가

 

다음은 Monster 스크립트의 코드입니다.

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

public class Monster : MonoBehaviour
{
   Transform target;   
   NavMeshAgent nmAgent;
   Animator anim;

   [HideInInspector]
   public float HP = 0;
   public float lostDistance;

   enum State
   {
      IDLE,
      CHASE,
      ATTACK,
      KILLED
   }

   State state;
   bool cancelWait;

   // Start is called before the first frame update
   void Start()
   {
      anim = GetComponent<Animator>();
      nmAgent = GetComponent<NavMeshAgent>();

      HP = 5;
      state = State.IDLE;
      StartCoroutine(StateMachine());
   }

   IEnumerator StateMachine()
   {      
      while (state != State.KILLED)
      {
         yield return StartCoroutine(state.ToString());
      }

      yield return StartCoroutine(State.KILLED.ToString());
   }

   IEnumerator CancelableWait(float t)
   {
      cancelWait = false;
      while(t > 0 && cancelWait == false)
      {
         t -= Time.deltaTime;
         yield return null;
      }
   }

   IEnumerator IDLE()
   {
      // 현재 animator 상태정보 얻기
      var curAnimStateInfo = anim.GetCurrentAnimatorStateInfo(0);

      // 애니메이션 이름이 IdleNormal 이 아니면 Play
      if (curAnimStateInfo.IsName("IdleNormal") == false)
         anim.Play("IdleNormal", 0, 0);

      // 몬스터가 Idle 상태일 때 두리번 거리게 하는 코드
      // 50% 확률로 좌/우로 돌아 보기
      int dir = Random.Range(0f, 1f) > 0.5f ? 1 : -1;

      // 회전 속도 설정
      float lookSpeed = Random.Range(25f, 40f);

      // IdleNormal 재생 시간 동안 돌아보기
      for (float i=0; i< curAnimStateInfo.length; i+=Time.deltaTime)
      {
         if (state == State.KILLED) break;
         transform.localEulerAngles = new Vector3(0f, transform.localEulerAngles.y + (dir) * Time.deltaTime * lookSpeed, 0f);         
         yield return null;
      }
   }

   IEnumerator CHASE()
   {
      var curAnimStateInfo = anim.GetCurrentAnimatorStateInfo(0);

      if (curAnimStateInfo.IsName("WalkFWD") == false)
      {
         anim.Play("WalkFWD", -1, 0);
         // SetDestination 을 위해 한 frame을 넘기기위한 코드
         yield return null;
      }

      // 목표까지의 남은 거리가 멈추는 지점보다 작거나 같으면
      if (nmAgent.remainingDistance <= nmAgent.stoppingDistance)
      {
         // StateMachine 을 공격으로 변경
         ChangeState(State.ATTACK);
      }
      // 목표와의 거리가 멀어진 경우
      else if (nmAgent.remainingDistance > lostDistance)
      {
         target = null;         
         nmAgent.SetDestination(transform.position);
         yield return null;
         // StateMachine 을 대기로 변경
         ChangeState(State.IDLE);
      }
      else
      {
         // WalkFWD 애니메이션의 한 사이클 동안 대기
         //yield return new WaitForSeconds(0.5f);
         yield return StartCoroutine(CancelableWait(0.5f));
      }
   }

   IEnumerator ATTACK()
   {
      var curAnimStateInfo = anim.GetCurrentAnimatorStateInfo(0);

      // 공격 애니메이션은 공격 후 Idle Battle 로 이동하기 때문에 
      // 코드가 이 지점에 오면 무조건 Attack01 을 Play

      yield return new WaitUntil(() => anim.GetCurrentAnimatorStateInfo(0).IsName("GetHit") == false);

      anim.Play("Attack01", -1, 0);      

      // 거리가 멀어지면
      if (nmAgent.remainingDistance > nmAgent.stoppingDistance)
      {
         // StateMachine을 추적으로 변경
         ChangeState(State.CHASE);
      }
      else
         // 공격 animation 의 두 배만큼 대기
         // 이 대기 시간을 이용해 공격 간격을 조절할 수 있음.         
         yield return StartCoroutine(CancelableWait(curAnimStateInfo.length * 2f));
   }

   IEnumerator KILLED()
   {
      anim.Play("Die", -1, 0);
      yield return null;
   }

   void ChangeState(State newState)
   {
      state = newState;
   }

   private void OnTriggerEnter(Collider other)
   {
      if (state == State.KILLED) return;
      if (other.name != "Player") return;
      // Sphere Collider 가 Player 를 감지하면      
      target = other.transform;
      // NavMeshAgent의 목표를 Player 로 설정
      nmAgent.SetDestination(target.position);
      ChangeState(State.CHASE);
   }

   // Update is called once per frame
   void Update()
   {
      if (target == null) return;

      if (state == State.ATTACK)
         transform.LookAt(target);
      
      nmAgent.SetDestination(target.position);
   }

   public void GetDamage(int dmg)
   {
      HP -= dmg;

      if (HP <= 0)
      {
         target = null;
         cancelWait = true;
         ChangeState(State.KILLED);
      }
      else
         anim.Play("GetHit", -1, 0);
   }

   void AnimHit()
   {
      if (target == null) return;
      target.GetComponent<Player>().GetDamage(1);
   }
}

 

Monster 스크립트는 StateMachine 을 적용해 프로그래밍이 되어 있고 Player 스크립트는 그렇지 않기 때문에 동작에 필요한 코드가 Update 에 집중되어 있습니다. 지금과 같이 짧은 코드에서는 크게 상관없지만 동작이 복잡해지면 이러한 방식은 코드의 복잡도가 올라가 좋지 않습니다. 


반응형

실행

실행결과
실행 결과

 

실행 결과에서 보이듯이 액션 RPG 근접 전투 시스템의 기본 사항이 잘 구현되었습니다. 지금까지 구현된 사항은 가장 기본적인 전투 시스템이고 디테일한 부분들은 또 하나하나 다듬어 나아가야 합니다. 

 

이 글들을 통해 제가 알려드리고 했던 건 다음과 같습니다.

 

  • Character Controller
  • Animator
  • Animation Event
  • Navigation (NavMeshAgent)
  • State Machine

자시만의 게임을 만드실 때 위의 내용들이 도움이 되었으면 좋겠습니다.

 

다음은 unitypackage 입니다. 필요한 무료 에셋은 이전글을 참고하셔서 asset store 에서 다운로드 받아주세요~ (동일한 외부 링크가 여러 글에 걸쳐 작성되는 게 좋지 않은 영향을 끼칠 수 있다는 얘기를 들어서 그렇습니다. 죄송합니다.)

 

BattleSystem.unitypackage
0.03MB

 

반응형

댓글