Notice
Recent Posts
Recent Comments
Link
«   2025/08   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

메피카타츠의 블로그

3D 게임 개발 공부 6 - 전투 시스템 구현 본문

개발/개발일지

3D 게임 개발 공부 6 - 전투 시스템 구현

메피카타츠 2023. 4. 10. 22:31

어제와 오늘은 전투를 할 때 데미지를 주고 받는 기능이나, 근접 공격, 원거리 공격, UI 구성 등 전투 시스템을 구현했다.

강의가 기존에 구현했던 기능이 있는 프로젝트에서 코드를 새로 작성하고, 실제 기능은 기존 코드를 통해서 보여주기 때문에 스크립트들의 의존 관계나 작동 방식을 확실하게 파악하기 어려워 오늘 강의까지 총 8개의 강의를 전부 듣고 요약하기로 하였다. 추가로 작성하거나 수정한 스크립트가 약 20개에 달하기 때문에 상당히 복잡했다. 정리하는 데에도 아마 시간이 꽤나 걸릴 것 같다.

먼저 저번에 작성했던 부분까지의 그림을 살펴보겠다. MoveToWaypoint라는 새로운 State가 추가되었는데, 그림에는 그려져있지 않지만, FieldOfView라는 스크립트에서 시야 내에 있는 적을 발견하는 기능을 수행한다. EnemyController에서 FieldOfView 컴포넌트를 찾아서 저장하고, MoveState와 AttackState에서는 이 EnemyController에 있는 FieldOfView에서 찾은 target을 가져와 이동과 공격을 수행하도록 하는 보조적인 역할을 한다.

 

 

오늘 추가했던 기능들을 나열해보겠다.

1. 캐릭터가 데미지를 주거나 데미지를 받는 기능

2. 근접 공격

3. 원거리 공격(투사체를 적이 있는 위치까지 발사)

4. 원거리 공격(투사체가 적을 따라다님)

5. 체력바, 데미지 텍스트 등 UI 구성

 

이를 구현한 스크립트들의 구조를 먼저 정리해보겠다.

 

처음에는 캐릭터가 데미지를 주거나 받는 기능을 구현하기 위해서 IAttackable, IDamagable을 추가했다. 이 두 가지는 이름에서 짐작할 수 있다시피 interface로, 공격을 하여 데미지를 주거나 공격을 받아 데미지를 입는 기능이 포함되어있다. 따라서 공격할 수 있는 Controller는 IAttackable을, 공격받을 수 있는 Controller는 IDamagable을 상속받도록 하였다. 강의에서 구현한 CharacterController와 EnemyController는 둘 다 공격을 할 수 있고, 공격을 받을 수 있기 때문에 둘 다 상속받아 구현해주었다.

(CharacterController, EnemyController <----(상속받음)---- IAttackable, IDamagable)

 

그리고 기존의 AttackState와 현재 수행 중인 공격을 관리하기 위한 AttackStateController를 추가했다. 각각의 공격이 시작되고 끝날 때 함수를 실행시켜줄 AttackStateMachineBehaviour도 추가하였다.

 

이후에는 근접 공격과 원거리 공격을 구현했는데, 각각 AttackBehaviour_Melee, AttackBehaviour_Projectile 이라는 이름으로 구현했다. 이 두 개의 공격은 공격의 기본 틀을 구현한 AttackBehaviour라는 클래스를 상속받아 구현했다.

(AttackBehaviour_Melee, AttackBehaviour_Projectile <----(상속받음)---- AttackBehaviour)

 

그 다음으로는, 근접 공격을 할 때 전방의 Box 범위 내에 있는 적들을 가져오는 ManualCollision 라는 스크립트를 추가해주었다. 이를 AttackBehaviour_Melee에서 활용하여 근접 공격을 구현하였다.

 

이후에는 원거리 공격의 투사체를 구현하기 위한 Projectile과 FollowProjectile을 구현하였다. Projectile은 투사체의 Prefab에 등록되며, 원거리 공격을 수행할 때 인스턴스화된다. Projectile_Follow는 Projectile을 상속받아 적을 따라가는 기능을 추가한 것이다.

 

이후에는 적의 체력바와 체력바를 관리해 줄 NPCBattleUI 라는 이름의 스크립트를 추가했다. 이것들은 데미지를 입을 때 값이 수정되고, 체력바를 갱신해주는 역할을 한다. 또, 적이 입은 데미지를 표시해 줄 Text를 추가하고, 마찬가지로 이를 관리해 줄 DamageText 라는 스크립트를 추가했다. 또, 카메라의 각도가 바뀌더라도 체력바 등의 UI가 카메라를 향하도록 CameraFacing이라는 스크립트도 추가해주었다.

 

마지막으로, 이동할 위치를 지정하거나 적을 지정할 때 커서를 표시하기 위해 PlaceTargerWithMouse라는 스크립트도 추가해주었다.

 

 

이 또한 그림으로 정리하면 굉장히 깔끔할 것 같은데, 굉장히 구성이 복잡해질 것 같아 하지 않기로 했다. 전체적인 틀은 위 그림과 동일하고, 각각의 그림에 몇 가지씩 기능을 추가한 것이기 때문에 머리속으로 이해하는 것도 크게 어렵지는 않기도 하다.

 

그러나 문제가 하나 있었는데, 동작 구조를 완전히 파악하지 못한 채로 스크립트들을 연결하는 것이 어려워 기존에 프로젝트에 구현되어 있는 스크립트들을 살펴보면서 정리해보기로 했다.

 

먼저 적 캐릭터를 살펴보기로 했다.

적 캐릭터의 컴포넌트에 있는 스크립트는 EnemyController_Range, AttackStateController, ProjectileAttackBehaviour, MeleeAttackBehaviour, FieldOfView 총 5개이다.

 

using FastCampus.Core;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace FastCampus.AI
{
    public class FieldOfView : MonoBehaviour
    {
        #region Variables

        [Header("Sight Settings")]
        public float viewRadius = 5f;
        [Range(0, 360)]
        public float viewAngle = 90f;

        [Header("Find Settings")]
        public float delay = 0.2f;

        public LayerMask targetMask;
        public LayerMask obstacleMask;

        private List<Transform> visibleTargets = new List<Transform>();
        private Transform nearestTarget;

        private float distanceToTarget = 0.0f;
        #endregion Variables

        #region Properties

        public List<Transform> VisibleTargets => visibleTargets;
        public Transform NearestTarget => nearestTarget;
        public float DistanceToTarget => distanceToTarget;

        #endregion Properties

        #region Unity Methods
        // Start is called before the first frame update
        void Start()
        {
            StartCoroutine("FindTargetsWithDelay", delay);
        }

        #endregion Unity Methods

        #region Logic Methods
        IEnumerator FindTargetsWithDelay(float delay)
        {
            while (true)
            {
                yield return new WaitForSeconds(delay);
                FindVisibleTargets();
            }
        }

        void FindVisibleTargets()
        {
            distanceToTarget = 0.0f;
            nearestTarget = null;
            visibleTargets.Clear();

            Collider[] targetsInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);

            for (int i = 0; i < targetsInViewRadius.Length; i++)
            {
                Transform target = targetsInViewRadius[i].transform;

                Vector3 dirToTarget = (target.position - transform.position).normalized;
                if (Vector3.Angle(transform.forward, dirToTarget) < viewAngle / 2)
                {
                    float dstToTarget = Vector3.Distance(transform.position, target.position);

                    if (!Physics.Raycast(transform.position, dirToTarget, dstToTarget, obstacleMask))
                    {
                        if (target.GetComponent<IDamagable>()?.IsAlive ?? false)
                        {
                            visibleTargets.Add(target);

                            if (nearestTarget == null || (distanceToTarget > dstToTarget))
                            {
                                nearestTarget = target;
                            }

                            distanceToTarget = dstToTarget;
                        }
                    }
                }
            }
        }

        public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal)
        {
            if (!angleIsGlobal)
            {
                angleInDegrees += transform.eulerAngles.y;
            }
            return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0, Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
        }
        #endregion Logic Methods
    }
}

FieldOfView는 이전에 구현한 것과 동일하게 시야 내에 공격할 수 있는 적을 찾지만, 적이 공격받을 수 있는 경우에만 공격하도록 IDamagable이 있는 경우에만 타겟으로 등록하도록 수정된 것을 확인했다. ?. 연산자 외에 ?? 에 대해서도 알았는데, ?.는 null이 아니면 그 뒤를 수행하는 것이고, ??는 왼쪽이 null이면 오른쪽을 반환한다.

 

using FastCampus.Characters;
using FastCampus.Core;
using System.Collections;
using UnityEngine;

public class AttackStateController : MonoBehaviour
{
    public delegate void OnEnterAttackState();
    public OnEnterAttackState enterAttackHandler;

    public delegate void OnExitAttackState();
    public OnExitAttackState exitAttackHandler;

    public bool IsInAttackState
    {
        get;
        private set;
    }

    private void Start()
    {
        enterAttackHandler = new OnEnterAttackState(EnterAttackState);
        exitAttackHandler = new OnExitAttackState(ExitAttackState);
    }

    public void OnStartOfAttackState()
    {
        IsInAttackState = true;
        enterAttackHandler();
    }

    public void OnEndOfAttackState()
    {
        IsInAttackState = false;
        exitAttackHandler();
    }

    private void EnterAttackState()
    {
    }

    private void ExitAttackState()
    {
    }

    public void OnCheckAttackCollider(int attackIndex)
    {
        GetComponent<IAttackable>()?.OnExecuteAttack(attackIndex);
    }
}

다음은 AttackStateController에 대해서 살펴보았다. 여기서는 delegate라는 것을 사용하는데, 함수의 포인터와 같은 개념이라고 한다. 다만 실질적으로 작동하는 기능을 구현하지는 않은 것 같다. 애니메이션을 수행할 때, AttackStateMachineBehaviour에서 이 스크립트를 호출하여 IsInAttackState에 현재 공격이 수행 중인지, 아닌지 정보를 저장하는 역할을 한다.

 

MeleeAttackBehaviour와 ProjectileAttackBehaviour는 AttackBehaviour를 상속받았기 떄문에 AttackBehaviour를 먼저 살펴보겠다.

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

namespace FastCampus.Characters
{
    //[Serializable]
    //public enum AttackType : int
    //{
    //    Melee,
    //    Range,
    //}

    public abstract class AttackBehaviour : MonoBehaviour
    {
#if UNITY_EDITOR
        [Multiline]
        public string developmentDescription = "";
#endif  // UNITY_EDITOR
        public int animationIndex;

        //public AttackType type;
        public int priority;

        public int damage;
        public float range = 3f;

        [SerializeField]
        private float coolTime;

        public GameObject effectPrefab;

        protected float calcCoolTime = 0.0f;

        // [HideInInspector]
        public LayerMask targetMask;

        [SerializeField]
        public bool IsAvailable => calcCoolTime >= coolTime;

        protected virtual void Start()
        {
            calcCoolTime = coolTime;
        }

        // Update is called once per frame
        protected void Update()
        {
            if (calcCoolTime < coolTime)
            {
                calcCoolTime += Time.deltaTime;
            }
        }

        public abstract void ExecuteAttack(GameObject target = null, Transform startPoint = null);
    }
}

어떤 애니메이션을 재생할 것인지 animationIndex, 공격의 우선순위를 정할 priority가 있고, damage, range, coolTime 등의 기본적인 정보를 저장한다. 이 정보들은 인스펙터에서 수정할 수 있다. calcCoolTime이 coolTime보다 크다면 스킬을 사용할 준비가 된 것이다. 어떤 방식으로 공격할 것인지 ExecuteAttack를 구현해주어야 한다.

 

using FastCampus.Core;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace FastCampus.Characters
{
    public class MeleeAttackBehaviour : AttackBehaviour
    {
        public ManualCollision attackCollision;

        public override void ExecuteAttack(GameObject target = null, Transform startPoint = null)
        {
            Collider[] colliders = attackCollision?.CheckOverlapBox(targetMask);

            foreach (Collider col in colliders)
            {
                col.gameObject.GetComponent<IDamagable>()?.TakeDamage(damage, effectPrefab);
            }
            
            calcCoolTime = 0.0f;
        }
    }
}

MeleeAttackBehaviour를 살펴보면, 근접 공격 범위를 체크할 ManualCollision과 ExecuteAttack만 구현되어있다. ManualCollision은 박스 범위 내에 있는, LayerMask에 해당하는 적들의 정보를 가져온다. 이렇게 가져온 적들의 IDamagable을 활용하여 적에게 데미지를 입힌다. 마지막으로 calcCoolTime을 0으로 초기화해주어 쿨타임을 계산한다.

 

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

namespace FastCampus.Characters
{
    public class ProjectileAttackBehaviour : AttackBehaviour
    {
        public override void ExecuteAttack(GameObject target = null, Transform startPoint = null)
        {
            if (target == null)
            {
                return;
            }

            Vector3 projectilePosition = startPoint?.position ?? transform.position;

            if (effectPrefab != null)
            {
                GameObject projectileGO = GameObject.Instantiate<GameObject>(effectPrefab, projectilePosition, Quaternion.identity);
                Projectile projectile = projectileGO.GetComponent<Projectile>();
                if (projectile != null)
                {
                    projectile.owner = this.gameObject;
                    projectile.target = target;
                    projectile.attackBehaviour = this;
                }
            }
            calcCoolTime = 0.0f;
        }
    }
}

다음은 ProjectileAttackBehaviour를 살펴보겠다. ExecuteAttack만 구현되어있는데, 적이 있다면 발사체를 나타낼 부분에서 발사체 Prefab을 생성하여 적의 방향을 향하게 한다. 그리고 이 Projectile에 적의 GameOjbect와 이 공격을 발사한 Gameobject의 정보를 전달한다. Projectile은 정면으로 곧장 나아가거나 적을 추격하며, 적에게 닿으면 효과를 나타내며 사라진다. 수정할 부분은 발사체가 충돌하지 않으면 계속 나아가기 때문에 소멸 시간을 설정해주고, 오브젝트 풀링 기법을 적용하면 상황에 따라 메모리를 효율적으로 사용할 수 있을 것 같다.

 

using FastCampus.Characters;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;

namespace FastCampus.Characters
{
    public interface IAttackable
    {
        AttackBehaviour CurrentAttackBehaviour
        {
            get;
        }

        void OnExecuteAttack(int attackIndex);
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;

namespace FastCampus.Core
{
    public interface IDamagable
    {
        bool IsAlive
        {
            get;
        }

        void TakeDamage(int damage, GameObject hitEffect);
    }
}

EnemyController_Range에 대해 살펴보기 전에 이 스크립트가 상속받는 IAttackable과 IDamagable을 살펴보겠다. 둘 다 간단한 정보를 담고 있다. 현재의 공격 상태와 간단한 공격 함수, 현재 살아있는지의 여부와 데미지를 받는 기능을 구현해야한다.

 

using FastCampus.AI;
using FastCampus.Core;
using FastCampus.UIs;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.UIElements;

namespace FastCampus.Characters
{
    public class EnemyController_Range : EnemyController, IAttackable, IDamagable
    {
        #region Variables

        [SerializeField]
        public Transform hitPoint;
        public Transform[] waypoints;

        public override float AttackRange => CurrentAttackBehaviour?.range ?? 6.0f;

        [SerializeField]
        private NPCBattleUI battleUI;

        public float maxHealth => 100f;
        private float health;

        private int hitTriggerHash = Animator.StringToHash("HitTrigger");

        [SerializeField]
        private Transform projectilePoint;

        #endregion Variables

        #region Proeprties
        public override bool IsAvailableAttack
        {
            get
            {
                if (!Target)
                {
                    return false;
                }

                float distance = Vector3.Distance(transform.position, Target.position);
                return (distance <= AttackRange);
            }
        }

        #endregion Properties

        #region Unity Methods

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

            stateMachine.AddState(new MoveState());
            stateMachine.AddState(new AttackState());
            stateMachine.AddState(new DeadState());

            health = maxHealth;

            if (battleUI)
            {
                battleUI.MinimumValue = 0.0f;
                battleUI.MaximumValue = maxHealth;
                battleUI.Value = health;
            }

            InitAttackBehaviour();
        }

        protected override void Update()
        {
            CheckAttackBehaviour();

            base.Update();
        }



        private void OnAnimatorMove()
        {
            // Follow NavMeshAgent
            //Vector3 position = agent.nextPosition;
            //animator.rootPosition = agent.nextPosition;
            //transform.position = position;

            // Follow CharacterController
            Vector3 position = transform.position;
            position.y = agent.nextPosition.y;

            animator.rootPosition = position;
            agent.nextPosition = position;

            // Follow RootAnimation
            //Vector3 position = animator.rootPosition;
            //position.y = agent.nextPosition.y;

            //agent.nextPosition = position;
            //transform.position = position;
        }

        #endregion Unity Methods

        #region Helper Methods
        private void InitAttackBehaviour()
        {
            foreach (AttackBehaviour behaviour in attackBehaviours)
            {
                if (CurrentAttackBehaviour == null)
                {
                    CurrentAttackBehaviour = behaviour;
                }

                behaviour.targetMask = TargetMask;
            }
        }

        private void CheckAttackBehaviour()
        {
            if (CurrentAttackBehaviour == null || !CurrentAttackBehaviour.IsAvailable)
            {
                CurrentAttackBehaviour = null;

                foreach (AttackBehaviour behaviour in attackBehaviours)
                {
                    if (behaviour.IsAvailable)
                    {
                        if ((CurrentAttackBehaviour == null) || (CurrentAttackBehaviour.priority < behaviour.priority))
                        {
                            CurrentAttackBehaviour = behaviour;
                        }
                    }
                }
            }
        }

        #endregion Helper Methods

        #region IDamagable interfaces

        public bool IsAlive => (health > 0);

        public void TakeDamage(int damage, GameObject hitEffectPrefab)
        {
            if (!IsAlive)
            {
                return;
            }

            health -= damage;

            if (battleUI)
            {
                battleUI.Value = health;
                battleUI.TakeDamage(damage);
            }

            if (hitEffectPrefab)
            {
                Instantiate(hitEffectPrefab, hitPoint);
            }

            if (IsAlive)
            {
                animator?.SetTrigger(hitTriggerHash);
            }
            else
            {
                if (battleUI != null)
                {
                    battleUI.enabled = false;
                }

                stateMachine.ChangeState<DeadState>();
            }
        }

        #endregion IDamagable interfaces

        #region IAttackable Interfaces


        [SerializeField]
        private List<AttackBehaviour> attackBehaviours = new List<AttackBehaviour>();

        public AttackBehaviour CurrentAttackBehaviour
        {
            get;
            private set;
        }

        public void OnExecuteAttack(int attackIndex)
        {
            if (CurrentAttackBehaviour != null && Target != null)
            {
                CurrentAttackBehaviour.ExecuteAttack(Target.gameObject, projectilePoint);
            }
        }

        #endregion IAttackable Interfaces
    }
}

마지막으로 EnemyController_Range를 살펴보겠다. 피격 지점과 기본 공격 사거리, UI 정보, 최대 체력, 현재 체력 등의 정보를 담고 있다. fieldOfView에서 target을 찾았고, 사거리 내에 있다면 공격이 가능하다고 판단한다. 이 ISAvailableAttack은 Idle에서 체크하여 AttackState로 넘어간다. 시작할 때는 각종 초기 세팅을 해주고, Update 함수에서 현재 공격 상태를 계속 체크해준다. 공격 상태가 비어있거나, 공격 상태가 현재 쿨타임일 때, 공격 상태를 비워주고, 현재 공격 가능한 공격 상태를 가져온다. 이 중에서도 가장 우선순위가 높은 공격 상태를 가져와 현재 공격 상태를 결정한다.

이 공격 상태는 AttackState에서 체크하며, 공격 상태가 null이 아니라면 공격 애니메이션을 실행하도록 한다.

 

OnExecuteAttack은 AttackStateController의 OnCheckAttackCollider에서 호출하는데, 이 함수는 애니메이션을 수행하는 도중, 공격에 맞는 순간에 호출된다. 이때 현재 공격 상태가 있고, 공격할 적이 있다면 해당 적에 대해서 공격 상태별로 구현되어 있는 ExecuteAttack을 수행하는 식으로 작동한다. 그리고 이 ExecuteAttack에서는 적이 피해를 입는 IDamagable의 TakeDamage가 있어 이런 방식으로 내부에서 전투가 수행되는 것을 확인할 수 있었다.

 

 

다음은 PlayerCharacter에 구성되어있는 컴포넌트들을 살펴보았다. 먼저 EnemyController에 해당하는 PlayerCharacter 스크립트를 살펴보았다. 좌클릭을 해서 이동, 우클릭을 하여 적을 지정하고 공격하는 기능 외에는 전반적으로 EnemyController와 비슷한 기능이 구현되어있었다. 나머지 스크립트는 동일한 AttackStateController와 MeleeAttackBehaviour가 3개 포함되어있었다. 해당 캐릭터에는 공격 모션이 3가지 있어서, 3가지 스크립트를 등록하여 각기 다른 우선순위로 두고, 애니메이션을 다르게 재생하며, 데미지, 쿨타임 등에 차이를 두는 식으로 구현되어 있었다. 여기서는 자동으로 전투를 하는 것을 구현했는데, 버튼을 눌러 작동한다면 더 간단하게 구현이 가능할 것으로 보였다.

 

 

 

어제, 오늘 이틀에 걸쳐서 이렇게 전투 시스템을 구현해보았다. 아마 내가 구상하고 처음부터 구현한다면 시간이 더 오래 걸릴 것 같다. 하지만 전투 시스템을 어떤 식으로 구현하는지 갈피를 잡았기 때문에 혼자 힘으로도 충분히 구현할 수 있을 것 같다. 또, 이를 응용해서 아군에게 힐을 준다든지, 버프를 준다든지, 범위 공격을 한다든지 등 다양한 스킬을 구현할 수 있을 것 같다. 머리 속에서 여러 가지 게임들이 떠오르며 아, 그 게임의 그런 기능 or 스킬은 이런 식으로 구현할 수 있겠구나, 하는 생각이 든다. 꽤나 신나는 기분이다. 이전에는 막막한 안개로 가려져있던 3D 게임이 비로소 내 머리 속에 자리를 잡은 것 같다. 물론 구현을 해보면서 많이 배우고 익혀가야겠지만, 지금이라면 충분히 그 과정을 헤쳐나갈 준비가 된 것 같다. 앞으로 남은 강의는 총 13개로 약 3일 분량인데, 인벤토리/상점/아이템/장비/퀘스트/대화 등의 기능이기 때문에 어렵지 않게 구현할 수 있을 것 같다. 벌써부터 3D 게임들을 만들어보고 싶어서 살짝 근질거린다. 기획에 상당히 큰 노력이 들어간다는 걸 안 지금은 이미 존재하는 게임을 한 번 따라 만들어보고 싶다. 어떤 게임을 구현해보면 좋을지 찬찬히 고민해봐야겠다.