개발/개발일지

3D RPG 개발 8 - 자동 전투 구현

메피카타츠 2023. 5. 25. 18:08

오늘은 3D RPG 개발하던 부분을 이어서 자동 전투를 구현해보았다.

 

이전 글 마지막 부분에 언급을 해놓긴 했는데, DirectX 책으로 공부를 하다가 핸들을 돌려서 다시 돌아왔다. 벡터까지는 유니티에서 자주 사용하는 데다가, 유니티 내에서 내가 사용한 함수들이 직접 어떻게 작동하는지 알아볼 수 있었고, Vector는 구조체라 가비지가 생성되지 않는 것까지도 알 수 있었다. 여기서 더 나아가서 가비지 컬렉터에 대해서 더 깊게 알 수 있게 되었다.

그런데, 행렬의 경우나 렌더링 파이프라인 같은 경우는 벡터와는 많이 달랐다. 먼저 내가 다뤄본 경험이 전무하기도 하고, 실제로 유니티 내에서는 이런 작업들을 알아서 처리해주기 때문에 어떻게 접근해야 할지도 상당히 어려웠다. 때문에 내용을 이해하는 것도 상당히 벅찼고, 활용에 대한 부분도 가닥을 잡기가 힘들었다. 이것을 왜 공부해야 되는지 모르는 상태였고, 목적성을 잃다 보니 공부가 진전이 되지도 않았다. 물론 알아두면 도움은 되겠지만, 지금 당장 의미를 모르는 상태에서 배우는 것이 머리 속에 자리잡긴 어려울 것 같으며, 당장은 다뤄보지 않은 기능이나 기술들이 많기 때문에 이런 부분을 경험해보는 것이 당장은 실무에서 더 도움이 될 것 같다고 생각했다. 아직까지 게임 개발에 있어서 깊은 부분을 경험해보지 못했는데, 경험이 쌓이고 연차가 쌓이면서 점차 이런 공부에 대한 필요성을 느끼게 되면 더 의미있고 깊이있는 공부가 가능하지 않을까 기대해본다.

 

 

아무튼, 자동 전투를 구현한 내용을 정리해보겠다. 먼저 자동 전투를 구현하기 위해 UI 작업을 하면서 가장 고민했던 부분이 자동 전투를 위해 적을 선택하는 구조를 어떻게 구현해야 할까? 였다. 필드에 따라 적들의 목록이 달라지기 때문에 적들의 데이터를 받아와서 목록을 수정하고 적들한테 번호는 어떻게 매겨야 하는지... 고민을 많이 했는데, 생각해보니 플레이어가 속해있는 필드에 따라서 다른 창을 띄워주면 될 것 같았다. 만약 필드 이동에 따라 씬이 변경된다면 더 간단한 작업일 것 같다. 때문에 구조 관련해서는 특별한 작업을 하지는 않았다. 새로운 필드나 몬스터가 추가될 때마다 미리 만들어 둔 UI를 띄우면 될 것으로 생각된다.

 

두 번째로 고민했던 부분은 선택한 적들에 대한 정보를 어떤 형태로 저장할 것인가? 였다. 처음에는 적의 번호를 int 형태로 List에 저장하도록 하였는데, 아무래도 List는 가비지가 생성되기도 하고, 적을 선택하고 끌 때 On/Off 되며 한 필드 내에서 적의 수가 20명이 넘지 않기 때문에 길이가 20인 bool 배열을 사용하기로 하였다. 또, PlayerCharacerController에서 작업을 다루기에는 양이 많은 것 같아 AutoBattleManager라는 스크립트를 따로 추가하여 관련 기능들을 처리하도록 하였다.

 

AutoBattleManager에서 작성한 코드는 다음과 같다.

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

namespace SingletonPattern
{
    public class AutoBattleManager : Singleton<AutoBattleManager>
    {
        public GameObject enemyArea;
        public GameObject enemyList;

        private bool[] _enemyAreaArray = new bool[20];

        private GameObject _nearestArea;
        private GameObject _nearestEnemy;

        private bool _isOnAutoBattle = false;
        private bool _isCompletedMove = false;
        private bool _isCompletedSetPath = false;

        public bool IsOnAutoBattle => _isOnAutoBattle;
        public bool IsCompleteMove { get { return _isCompletedMove; } set { _isCompletedMove = value; } }
        public bool IsCompletedSetPath { get { return _isCompletedSetPath; } set { _isCompletedSetPath = value; } }


        public void AddEnemyAreaArray(int enemyNum)
        {
            _enemyAreaArray[enemyNum] = !_enemyAreaArray[enemyNum];
            enemyList.transform.GetChild(enemyNum).GetChild(4).GetChild(0).gameObject.SetActive(!enemyList.transform.GetChild(enemyNum).GetChild(4).GetChild(0).gameObject.activeSelf);
        }

        public void StartAutoBattle()
        {
            for (int i = 0; i < _enemyAreaArray.Length; ++i)
            {
                if (_enemyAreaArray[i] == true) break;

                if (i + 1 == _enemyAreaArray.Length) return;
            }

            _isOnAutoBattle = true;
        }

        public void StopAutoBattle()
        {
            _isOnAutoBattle = false;
            _isCompletedMove = false;
            _isCompletedSetPath = false;

            PlayerCharacterController.Instance.ResetAgentPath();
        }

        public Vector3 GetNearestAreaPosition()
        {
            _nearestArea = null;
            float minDistance = 0;

            for (int i = 0; i < _enemyAreaArray.Length; ++i)
            {
                if (_enemyAreaArray[i] == false) continue;

                float distance = Vector3.Distance(PlayerCharacterController.Instance.transform.position, enemyArea.transform.GetChild(i).position);
                if (distance < minDistance || _nearestArea == null)
                {
                    minDistance = distance;
                    _nearestArea = enemyArea.transform.GetChild(i).gameObject;
                }
            }

            return _nearestArea.transform.position;
        }

        public GameObject GetNearestEnemy()
        {
            _nearestEnemy = null;
            float minDistance = 0;

            for (int i = 0; i < _enemyAreaArray.Length; ++i)
            {
                if (_enemyAreaArray[i] == false) continue;

                for (int j = 0; j < enemyArea.transform.GetChild(i).childCount; ++j)
                {
                    if (enemyArea.transform.GetChild(i).GetChild(j).GetComponent<EnemyController_Melee>().IsAlive == false) continue;

                    float distance = Vector3.Distance(PlayerCharacterController.Instance.transform.position, enemyArea.transform.GetChild(i).GetChild(j).position);

                    if (distance < minDistance || _nearestEnemy == null)
                    {
                        minDistance = distance;
                        _nearestEnemy = enemyArea.transform.GetChild(i).GetChild(j).gameObject;
                    }
                }
            }

            return _nearestEnemy;
        }
    }
}

AddEnemyAreaArray()에서는 버튼을 누를 때마다 UIManager를 통해 호출되는 함수인데, 적 선택을 On/Off해주고 UI에서 체크 표시를 보여주고, 사라지게 하는 기능을 담당한다.

 

StartAutoBattle() 에서는 체크된 적이 하나라도 있다면 자동 전투를 시작하고, 그렇지 않으면 return하도록 하였다.

 

StopAutoBattle() 에서는 각종 변수들을 초기화해주었다.

 

GetNearestAreaPosition()는 가장 가까운 EnemyArea의 Position을 return 해주도록 하였다. 처음에는 GameObject를 반환하도록 생각했으나, Vector가 구조체이고 상당히 가볍다는 것을 알았기 때문에 Vector를 return 해주었다.

GetNearestEnemy()는 가장 가까운 적의 GameObject를 return 해준다. 적을 찾는 과정에서 처음에는 기본으로 0번에 있는 적을 넣고 순회하며 비교를 했는데, 0번에 있는 적이 가장 가깝고 죽었을 경우 죽은 적이 사라질 때까지 때리는 이슈가 있어서 기본을 null로 하고, null인 경우에도 적을 추가하도록 수정하여 문제를 해결했다.

 

위 두 개의 함수는 PlayerCharacterController의 Update에서 호출된다. 자동 전투에 NavMeshAgent를 활용하여 이동할 것이기 때문에 Update 문에서 자동 전투 관련 기능들 수행하도록 해주었다.

 

private void Update()
        {
            if (!IsAlive) return;

            _inputX = Input.GetAxis("Horizontal");
            _inputZ = Input.GetAxis("Vertical");

            _previousMoveDirection = _moveDirection;
            _moveDirection = Vector3.Normalize(new Vector3(_inputX, 0, _inputZ));
            if (_joystickInputX != 0 || _joystickInputY != 0)
            {
                _moveDirection = new Vector3(_joystickInputX, 0, _joystickInputY);
            }

            // 자연스러운 애니메이션 연결을 위해 공격 중에도 이동 애니메이션 체크
            if (_moveDirection == Vector3.zero)
            {
                // 반대로 방향을 전환할 때 Idle 모션으로 전환되지 않도록 직전 프레임 상태 확인
                if (_previousMoveDirection == Vector3.zero)
                {
                    _animator.SetBool(_isMovingHash, false);
                }
            }
            else
            {
                _animator.SetBool(_isMovingHash, true);

                if (_stepSoundCooltime <= 0)
                {
                    _stepSoundCooltime = 0.3f;
                    AudioManager.Instance.PlaySFX("Step");
                }
            }

            if (IsInAttackState)
            {
                if (target == null)
                {
                    return;
                }

                Vector3 direction = Vector3.Normalize(target.transform.position - transform.position);
                Quaternion lookRotation = Quaternion.LookRotation(new Vector3(direction.x, 0, direction.z));
                transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, Time.deltaTime * 5f);

                if (_autoBattleManager.IsOnAutoBattle && _moveDirection == Vector3.zero && target.GetComponent<EnemyController_Melee>().IsAlive)
                {
                    ExecuteAttackButton(0);
                }

                return;
            }

            if (_moveDirection == Vector3.zero)
            {
                // 자동 전투 기능 수행
                if (_autoBattleManager.IsOnAutoBattle)
                {
                    if (_autoBattleManager.IsCompleteMove)
                    {
                        if (target != null)
                        {
                            _agent.stoppingDistance = 2f;
                            _agent.SetDestination(target.transform.position);

                            if (_agent.remainingDistance < _agent.stoppingDistance)
                            {
                                _animator.SetBool(_isMovingHash, false);
                                _autoBattleManager.IsCompleteMove = true;
                                ExecuteAttackButton(0);
                            }
                            else
                            {
                                MoveCharacter(_agent.velocity.normalized);
                                _animator.SetBool(_isMovingHash, true);
                            }
                        }
                        else
                        {
                            target = _autoBattleManager.GetNearestEnemy();
                            target.GetComponent<EnemyController>().OnDrawEnemyOutline();
                            _agent.SetDestination(target.transform.position);
                        }
                    }
                    else
                    {
                        if (!_autoBattleManager.IsCompletedSetPath)
                        {
                            _agent.SetDestination(_autoBattleManager.GetNearestAreaPosition());
                            _autoBattleManager.IsCompletedSetPath = true;
                        }
                        else
                        {
                            _agent.stoppingDistance = 1f;
                            MoveCharacter(_agent.velocity.normalized);
                            _animator.SetBool(_isMovingHash, true);

                            if (_agent.remainingDistance < _agent.stoppingDistance)
                            {
                                _animator.SetBool(_isMovingHash, false);
                                _autoBattleManager.IsCompleteMove = true;

                                target = null;
                                UIManager.Instance.ResetEnemyUI();
                            }
                        }                        
                    }
                }
                else
                {
                    _idleElapsedTime += Time.deltaTime;
                    CheckIdleElapsedTime();
                }
            }
            else
            {
                MoveCharacter(_moveDirection);
                ResetIdleTime();
            }

            // 특수 대기 모션의 종료 지점이 원점이 아니기 때문에 시작 지점의 rotation과 position을 맞춰줌
            if (_isPlayedSpecialIdle && _idleElapsedTime > 0)
            {
                _controller.enabled = false;
                transform.rotation = Quaternion.Slerp(transform.rotation, _idleStartRotation, Time.deltaTime * 5);
                transform.position = Vector3.Lerp(transform.position, _idleStartPosition, 0.1f);
                _controller.enabled = true;
            }

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

                if (_normalAttackComboTime <= 0)
                {
                    ResetNormalAttackCooltime();
                }
            }

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

유저가 이동을 입력하거나, 공격을 하고 있을 때는 자동 전투 기능이 실행되지 않도록 해당 기능들에 대한 처리를 마친 후에 자동 전투 관련 기능을 수행하도록 추가해주었다.

 

자동 전투 기능을 켜면, 가장 가까운 EnemyArea까지 이동을 하고, 이후에 가장 가까운 적을 찾아서 가까이 다가가고, 공격을 수행한다. 현재는 적당한 거리를 넣어놨는데, 스킬 등을 추가한다면 다음에 수행할 공격 혹은 스킬의 사거리만큼 이동하도록 수정할 필요가 있겠다. 개인적으로는 가장 가까운 적을 바로 탐색하는 편이 더 명료할 것 같은데, 현재 개발중인 게임의 기반인 루나 모바일의 기능이 이렇게 구현되어 있어서 고증을 살리는 방향을 선택했다.

 

이 부분을 다루면서 조금 어려웠던 것이, SetDestination을 설정한 이후에 바로 remainingDistance가 업데이트되지 않는다는 점이었다. SetDestination을 호출한 프레임에서는 remainingDistance가 0으로 인식되기 때문에, 다음 프레임에서 처리하도록 작업을 할 필요가 있었다. 그리고, 공격 도중에는 자동 전투 관련 기능이 작동하지 않도록 했기 때문에 연속 공격 처리를 위해 공격 상태를 처리할 때 연속 공격을 이어가도록 하였다. 이 결과 적이 죽은 이후에도 공격을 1회 더 한다는 문제가 있기는 하지만, 오히려 적이 죽자마자 다른 적을 찾아서 공격하는 편이 더 어색할 것으로 보여서 냅두었다. 만약 이 부분을 수정한다면 적이 죽을 때 수행하려고 했던 공격을 취소하는 작업을 추가해줘야 할 것 같다.

 

 

오늘 완성된 부분은 다음과 같다.

전체적으로 자동 전투 관련 기능은 잘 작동하는 것 같은데, 스켈레톤이 춤추는 문제가 아주 두드러지게 보인다. 원인이 뭘까? 리치의 경우는 괜찮은 걸로 봐서는 모델 자체의 문제인가 싶기도 하고... 조금 미뤘는데 원인을 알아봐야겠다. 또, 적들과 충돌이 있어 통과하지 못하는 부분도 개선이 필요할 것 같다.