오늘은 어제 계획했던 적의 AI 구현과 전투에 더해 적들이 소환되는 필드를 구현하였다.

 

먼저 오늘 개발한 것들에 대해 정리하기 전에 구현한 적의 행동 패턴에 대해서 설명하겠다.

적이 종류별로 소환되는 일정한 구역이 있고, 해당 범위 내에 적이 일정 수만큼 스폰된다. (기본값 5) 적은 지역 안에서 랜덤한 시간 주기로(10~30초) 조금씩 움직인다. 적은 플레이어의 공격을 받으면 전투 상태에 돌입하여 플레이어를 따라온다. 플레이어가 지역 밖으로 나가면 추격을 중지하고 원래 위치로 되돌아간다. 이미 전투 상태에 돌입한 적은 시간이 지나도 전투 상태가 유지된다.

 

 

적의 AI 구현에는 이전에 강의에서 배울 때 구현했던 EnemyController와 IdleState, MoveState, AttackState, DeadState, MoveToWaypointState 등을 사용했다. MoveState는 명칭이 명확하지 않은 것 같아 MoveToTarget으로 이름을 변경했다. 그리고 기존에 구현했던 기능은 주변에 적을 발견하면 쫓아오는 것이었기 때문에, 위에 서술한 내용대로 AI를 구성하기 위해서 내부 코드들을 조금씩 수정해주었다.

 

public Transform Target
    {
        get
        {
            if (target.GetComponent<PlayerCharacterController>().IsAlive && _enemyAreaController.IsPlayerInArea && isInBattle)
            {
                return target;
            }
            else return null;
        }
    }

우선 Target을 찾는 방법이 조금 달라졌다. 플레이어가 살아있어야함은 물론이고, 플레이어가 지역 내에 있어야한다. 또, 현재 전투 상태에 돌입한 상태여야 Player를 Target으로 반환하도록 하였다.

public override void Update(float deltaTime)
    {
        // if searched target
        // change to move state

        if (context.Target)
        {
            if (context.IsAvailableAttack)
            {
                // check attack cool time
                // and transition to attack state
                stateMachine.ChangeState<AttackState>();
            }
            else
            {
                stateMachine.ChangeState<MoveToTargetState>();
            }
        }
        else if (_isPatrol)
        {
            if (stateMachine.ElapsedTimeInState > _idleTime)
            {
                context.SetPatrolPosition();
            }
            if (Vector3.Distance(context.transform.position, context.patrolPosition) > _agent.stoppingDistance)
            {
                stateMachine.ChangeState<MoveToWaypointState>();
            }            
        }
    }

그리고 가장 크게 바뀐 것은 IdleState인 것 같다. 타겟을 찾았을 때의 동작은 동일하지만, 타겟을 찾지 못했을 경우, 패트롤을 하는지 확인하고, IdleState에서 일정 시간이 지나면 PatrolPosition을 설정해준다. 그리고 patrolPosition 위치에 있지 않을 경우, MoveToWaypointState로 전환하여 patrolPosition까지 이동한다.

 

몬스터가 움직이는 반경은 중심으로부터 15까지, 적을 감지하는 범위는 20까지로 설정하여 적의 감지범위 밖에서 적을 공격할 수 없도록 하였다.

 

public void TakeDamage(int damage, GameObject hitEffectPrefab, Transform attackFrom)
    {
        isInBattle = true;
        target = attackFrom.transform;

        if (!IsAlive)
        {
            return;
        }

        _currentHP -= damage;

        if (_healthBar)
        {
            _healthBar.Value = _currentHP;
        }

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

        if (IsAlive)
        {
            if (stateMachine.CurrentState is not AttackState)
            {
                animator.SetTrigger(_hitTriggerHash);
            }
        }
        else
        {
            if (_healthBar != null)
            {
                _healthBar.enabled = false;
            }

            stateMachine.ChangeState<DeadState>();
        }
    }

그리고 TakeDamage의 기능도 수정해주었다. attackFrom이라는 매개변수를 추가하여 어떤 상대로부터 공격받았는지를 저장하도록 하였다. 또, AttackState가 아닐 때만 피격 모션을 재생하도록 하였다. 이 부분을 없애면 연속으로 공격을 받을 때 공격을 하지 못하고 계속해서 피격당하는 굶지마의 거미 같은 몬스터를 구현할 수 있을 것 같다.

 

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

public class EnemyAreaController : MonoBehaviour
{
    #region Variables

    private Transform _player;
    public GameObject MonsterPrefab;

    public int numberOfEnemy = 5;

    public float checkPlayerRange = 20;
    public float enemyMoveRange = 15;

    #endregion Variables

    #region Properties

    public float DistanceToPlayer => Vector3.Distance(transform.position, _player.transform.position);

    public bool IsPlayerInArea
    {
        get
        {
            if (checkPlayerRange > DistanceToPlayer) return true;
            else return false;
        }
    }

    #endregion Properties

    #region Unity Methods

    void Start()
    {
        _player = PlayerCharacterController.Instance.transform;

        StartCoroutine(CheckEnemyCoroutine());
    }

#if UNITY_EDITOR
    private void OnDrawGizmos()
    {
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(transform.position, checkPlayerRange);
    }
#endif

    #endregion Unity Methods

    private IEnumerator CheckEnemyCoroutine()
    {
        for (int i = 0; i < numberOfEnemy; ++i)
        {
            SpawnEnemy();
        }

        while(true)
        {
            while (transform.childCount < numberOfEnemy)
            {
                yield return new WaitForSeconds(1f);
                SpawnEnemy();
            }
            yield return new WaitForSeconds(5f);
        }
    }

    private void SpawnEnemy()
    {
        float randomX = Random.Range(-10, 10f);
        float randomZ = Random.Range(-10, 10f);
        float randomRotation = Random.Range(0, 360);

        Vector3 spawnPosition = new Vector3(randomX, MonsterPrefab.transform.position.y, randomZ) + transform.position;
        Quaternion spawnRotation = Quaternion.Euler(0, randomRotation, 0);

        GameObject enemy = Instantiate(MonsterPrefab, spawnPosition, spawnRotation, transform);
        enemy.GetComponent<EnemyController>().patrolPosition = spawnPosition;
    }

    public bool CheckEnemyMoveRange(Vector3 position)
    {
        return Vector3.Distance(position, transform.position) < enemyMoveRange;
    }
}

적이 소환되는 구역은 이런식으로 구현하였다. 중심부를 기준으로 거리를 측정하여 원형 범위로 적이 소환되고 움직일 수 있도록 하였다. 위에 언급했듯, 적이 움직일 수 있는 범위보다 지역 내 플레이어 감지 범위를 넓게 하여 플레이어가 감지범위 밖에서 때릴 수 없도록 하였다.

또, 5초마다 범위 내의 몬스터 수를 체크하도록 하여 성능에 부담을 적게 주도록 하였다.

 

이외에도 여기저기서 수정한 부분들이 많긴 한데, 여기저기 흩어져있어서 정리하기가 어려운 것 같다.

 

오늘까지 구현된 부분은 다음과 같다.

 

뭔가 전반적인 전투가 갖춰지긴 했으나.. UI라든가 공격 효과, 효과음 등이 없어서 전투가 상당히 밋밋해보이는 것 같다. 공격 버튼 효과도 아직 구현하지 못했다. 내일 계획은 Inventory와 Item을 구현하는 것인데, 우선 이것들을 먼저 구현해야겠다. UI라든가 효과 등은 부가적인 부분이고, 핵심적인 기능들을 구현하는 것이 우선이라고 생각한다. 스크롤형 인벤토리를 구현하는 것이나, 장비의 소켓, 장비를 클릭하여 장착하거나 판매하는 것 등... 이전 구현 때와는 달라진 부분들이 상당히 많아서 구현이 조금 어려울 수도 있을 것 같다. 적을 처치하면 일정 확률로 캐릭터의 인벤토리에 아이템이 추가되는 기능도 추가해야할 것 같다. UI작업 등 해야할 것들이 상당히 많아서 하루보다 더 걸릴 수 있을 것 같다. 먼저 Inventory와 Item의 기본 구현을 마무리하고, UI라든지, 효과음, 공격 효과, 버튼 효과 등의 작업을 해야할 것 같다.

오늘은 어제 계획했던 대로 캐릭터의 공격과 사망 상태를 구현했다. 그리고 어제 발견했던 버그들도 수정했다.

 

먼저 카메라 줌 관련 수정사항부터 되짚어보려고 한다.

using UnityEngine;

public class TopDownCamera : MonoBehaviour
{
    public Transform target;

    public float height = 15f;
    public float distance = 15f;
    public float lookAtHeight = 0f;
    public float wheelValue = 1;

    private Vector3 _cameraPosition = new();
    private Vector3 _finalTargetPosition = new();

    private void LateUpdate()
    {
        // ToDo: wheelInput의 단계를 나누어 height, distance, angle 값을 정해놓는 것이 좋을 듯. 혹은 비율을 똑같이 맞추든가.
        float wheelInput = Input.GetAxis("Mouse ScrollWheel");

        if (wheelInput > 0)
        {
            wheelValue -= 0.025f;
        }
        else if (wheelInput < 0)
        {
            wheelValue += 0.025f;
        }
        if (wheelValue < 0) wheelValue = 0;
        else if (wheelValue > 1) wheelValue = 1;

        height = 2 + 13 * wheelValue;
        distance = 6 + 9 * wheelValue;
        lookAtHeight = 1 - wheelValue;

        _cameraPosition = target.position;
        _cameraPosition.y += height;
        _cameraPosition.z -= distance;
        transform.position = _cameraPosition;

        _finalTargetPosition = target.position + Vector3.up * lookAtHeight;
        transform.LookAt(_finalTargetPosition);
    }
}

원래는 angle이라는 변수를 통해서 0~45도 각도로 카메라의 각도를 변경했는데, 카메라가 캐릭터의 위치를 바라보도록 스크립트를 수정하였다. 이렇게 하니 가깝게 다가갈수록 각도가 크게 변하는 것을 자연스럽게 구현할 수 있었다. 줌이 최대로 당겨진 상태에서는 캐릭터의 발끝을 봐서 캐릭터가 중앙에 위치하도록 하였고, 캐릭터와 가까워질수록 캐릭터의 중심을 바라보며 캐릭터의 정면 모습이 담길 수 있도록 구현하였다.

 

 

두 번째는 반대로 방향을 전환하는 경우 애니메이션이 멈추는 이슈를 해결하였다.

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

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

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

 

 

방향을 반대로 전환하는 경우, input값이 +에서 -로 전환되는 사이에 0을 거쳐가기 때문에 멈춘 것으로 간주되어 Idle 애니메이션이 재생되는 문제가 있었다. 때문에 직전 프레임의 이동 값을 가져와 이동하고 있던 도중이라면 Move 애니메이션을 계속 재생하도록 하였다. 다만 이렇게 하면 멈추는 경우 1프레임 뒤에 Idle로 넘어가긴 하겠지만, 딱히 그 문제가 눈에 띄지는 않았다.

 

그리고 각종 애니메이션들의 연결을 자연스럽게 수정해주었다. 저 파란색 겹치는 구간을 조절하면 애니메이션을 조금 더 자연스럽게 연결할 수 있다.

 

그리고 플레이어의 공격을 구현했다. 공격을 할 떄 사용되는 AttackStateController나 AttackBehaviour는 강의에서 사용했던 구조를 거의 그대로 들고 와서 사용했다. 처음부터 구현해보는 것이 좋지 않을까? 라는 생각이 조금 들기는 했지만, 다른 사람이 작성한 코드를 가져와서 사용하는 일은 종종 있는 일이기 때문에 그러진 않았다. 다만 이럴 때마다 늘 코드를 내가 필요한대로 바꾸어 사용할 수 있을 정도의 이해는 하면서 사용하도록 한다.

 

다음으로 했던 기본 공격을 연속해서 사용하면 콤보로 3번 연속 공격이 나가도록 하는 작업은 굉장히 힘들었다. 하지만 어떻게든 해냈다. 여러 문제들을 해결한 과정을 정리해보겠다.

먼저 공격 버튼을 추가해서 버튼을 눌러 공격 기능을 수행하도록 하였다.

public void OnClickAttackButton(int buttonIndex)
    {
        if (!IsAlive) return;

        CurrentAttackBehaviour = null;

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

        Attack();
    }

공격 버튼을 누르면 PlayerCharacter의 OnClickAttackButton이 호출된다. 공격버튼 이외에 스킬 버튼도 추가할 것이기 때문에 buttonIndex를 매개변수로 가져오도록 하였다. 기본 공격 버튼의 buttonIndex는 0이다. 따라서 attackIndex가 0인 공격들을 찾게 된다. 해당 공격이 현재 재사용 대기 중이 아니라면, 우선 순위에 따라 수행할 공격을 가져온다.

같은 attackIndex가 있는 경우는 기본 공격을 수행하는 경우밖에는 없을 것으로 생각된다.

3가지 기본 공격은 첫 번째 공격이 가장 우선순위가 높도록, 2번째 공격이 2번째, 3번째 공격이 3번째 우선순위를 가지게 하여 1번째 기본공격 -> 2번째 기본공격 -> 3번째 기본공격 순으로 공격을 수행하게끔 설계하였다. 각각의 공격을 수행할 때마다 쿨타임이 적용되어 다음 공격으로 자연스럽게 넘어가도록 하였다. 또, 3번째 공격을 수행하거나 마지막 공격이 종료된 뒤 1초 이상이 지나면 전체 기본 공격의 쿨타임을 초기화하여 처음부터 공격을 수행하도록 하였다.

 

private void Attack()
    {
        if (CurrentAttackBehaviour == null) return;

        // 기본 공격 콤보 연결
        if (IsInNornalAttackState && CurrentAttackBehaviour.attackIndex == 0 && CurrentAttackBehaviour.attackAnimationIndex != 0 && !_animator.GetBool(_comboAttackTriggerHash))
        {
            _animator.SetTrigger(_comboAttackTriggerHash);
            StartCoroutine(CurrentAttackBehaviour.StartCooltime());

            // 3연속 기본공격을 성공하면 다시 처음부터 수행할 수 있도록 1, 2번째 기본 공격의 쿨타임을 초기화
            if (CurrentAttackBehaviour.attackAnimationIndex == 2)
            {
                ResetNormalAttack();
            }
            
            return;
        }

        if (!IsInAttackState && CurrentAttackBehaviour.IsAvailable)
        {
            _attackStateController.IsInAttackState = true;

            StartCoroutine(CurrentAttackBehaviour.StartCooltime());
            _animator.SetTrigger(_attackTriggerHash);
            _animator.SetInteger(_attackAnimationIndexHash, CurrentAttackBehaviour.attackAnimationIndex);

            ResetIdleTime();

            // 기본 공격을 일정 시간 이내에 연결하지 않으면 첫 번째 기본공격부터 다시 공격하도록 함
            if (CurrentAttackBehaviour.attackIndex == 0)
            {
                _normalAttackComboTime = 1;
                IsInNornalAttackState = true;

                // 3연속 기본공격을 성공하면 다시 처음부터 수행할 수 있도록 1, 2번째 기본 공격의 쿨타임을 초기화
                if (CurrentAttackBehaviour.attackAnimationIndex == 2)
                {
                    ResetNormalAttack();
                }
            }
        }
    }

OnClickAttackButton의 마지막에는 Attack()을 호출하여 공격을 수행하도록 하였다.

먼저 준비된 공격이 없다면 공격하지 않도록 하였다. 그리고 공격을 수행하는 부분을 앞에 두면 기본 공격 상태로 들어간 이후에 콤보 공격을 체크하기 때문에 기본적으로 2연속 공격을 수행하게 된다.

때문에 기본 공격 콤보를 연결하는 부분을 먼저 체크해주었다. 현재 기본 공격을 수행중이고, 수행할 공격이 기본 공격이며 첫 번째 기본 공격이 아니면서 현재 Trigger가 체크되어있지 않은 경우에 콤보 공격을 수행하도록 하였다.

조건이 상당히 복잡한데, 각 조건의 이유를 하나씩 살펴보겠다. 첫 번째 조건은 기본 공격이 수행중이어야 콤보 공격을 이을 수 있다. 두 번째 조건은 기본 공격 수행 중, 기본 공격에 대한 입력이 들어오면 콤보 공격을 수행하도록 한 것이다. 세 번째 조건은, 3번째 공격을 수행하는 도중에 기본 공격 버튼을 누르면 1번째 공격에 대한 연속 공격을 수행하는데, 3번째 공격이 끝난 이후로는 잠시 멈춰야하기 때문에 이것을 막아준 것이다. 그리고 이미 연속 공격이 입력된 경우에 중복 입력되는 경우가 없도록 트리거를 체크해주었다.

 

연속 기본 공격을 체크한 이후에, 현재 공격 상태이지 않고, 공격이 준비되었다면 해당 공격을 수행하도록 하였다.

원래는 IsInAttackState가 AttackState에 진입할 때 자동으로 true가 되는데, 해당 상태에 전이될 때 까지 약간의 텀이 있어서 그 사이에 버튼을 연속으로 입력하면 의도치 않은 상황이 발생하여 공격을 수행하면서 동시에 IsInAttackState를 설정해주도록 하였다.

 

public IEnumerator StartCooltime()
    {
        calcCoolTime = 0;
        while (calcCoolTime < coolTime)
        {
            yield return new WaitForSeconds(0.1f);
            calcCoolTime += 0.1f;
        }
    }

그리고 쿨타임 계산은 기존 강의에서는 Update를 사용했는데, 스킬마다 쿨타임 중이지도 않은데 Update가 계속 돌아가는 것은 성능상 좋지 않을 것 같아서 쿨타임 중에만 0.1초마다 호출되는 코루틴을 사용하도록 수정했다.

 

쿨타임을 시작한 이후에는 애니메이션을 재생하고, 대기 시간을 초기화하였다. 또, 기본 공격인 경우 연속 공격을 위한 타이머를 설정해주고 현재 기본 공격 상태임을 저장하도록 하였다. 3번째 기본 공격이면 1, 2번째 공격의 쿨타임을 초기화하여 다시 처음부터 기본 공격을 수행할 수 있도록 하였다.

 

기본 공격은, 공격 상태인 AttackState 내부에 추가로 NormalAttackState를 만들어 사용했다. 해당 State에서 빠져나갈 때 IsInNormalAttackState를 false로 바꾸어주기 위함이다. 기본적으로는 종료되면 Exit으로 나가고, Trigger가 켜져있으면 다음 공격 동작을 수행하도록 하였다.

추가로, 애니메이션에서 Transitions의 순서에 따라 우선순위가 바뀌는 것도 주의해야겠다고 생각했다.

 

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

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

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

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

        if (IsInAttackState) return;

        if (_moveDirection == Vector3.zero)
        {
            _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)
            {
                ResetNormalAttack();
            }
        }
    }

Update 함수도 수정하였다. 공격 중이더라도 이동 키를 입력 중인지 체크하도록 하였다. 만약 체크하지 않으면 공격이 끝난 이후 Idle 상태를 거쳐 이동하는 문제가 있어 애니메이션을 자연스럽게 연결해주기 위해서 이동키 입력을 체크해주도록 하였다. 그리고 연속 기본 공격 대기시간을 갱신해주어 1초가 넘어가면 다시 처음부터 기본공격을 수행하도록 해주었다.

 

 

이외에도 IDamageable이나 IAttackable과 같은 피격, 공격에 필요한 인터페이스를 추가하고 연결해주는 작업도 하였다. 근접 공격 범위를 설정하고 해당 범위 내의 적을 가져오는 부분도 가져왔다. 다만 아직까지 적이 구현되지 않았기 때문에 아마 이 부분은 내일 적을 구현한 이후에 추가로 작업을 해줄 것 같다. 애니메이션의 중간에 OnExecuteAttack을 수행하도록 이벤트를 넣어주는 것을 기억해야겠다. 또, 적 캐릭터를 추가하기도 하였고, 이전에 사용하던 AudioManager를 가져와서 배경음악도 넣어주었다. 배경음악은 I'm Home이라는 비상업용 무료 BGM을 사용하였다.

 

https://youtu.be/9K0SBnbkkCA

 

 

오늘까지 구현한 부분은 다음과 같다.

구현하면서 애니메이션이나 행동이 부자연스러운이 많이 있었는데, 대부분 수정하여 지금은 꽤나 자연스럽게 작동하는 것 같다. 애니메이션의 변화 등을 담기 위해서 전체 화면이 아닌 에디터 화면을 담았다. 현재는 체력이 깎이는 부분이 없어서 죽음 트리거를 직접 설정하고 해제하였기 떄문에 마지막에 부활하는 모습이 있긴 한데, 이 부분은 내일 구현을 마치면 문제없을 것 같다.

 

 

오늘도 꽤나 많은 것을 배우고 경험한 것 같다. 겪으면서 해결했던 문제들도 하나씩 적어놔서 배운 내용들에 대한 정리도 잘 할 수 있었던 것 같다. 오늘이야말로 쉽게 구현하지 않을까 싶었는데, 어제와 마찬가지로 쉽지 않았다. 아무래도 강의에서 다루었던 내용과 차이점들이 있다보니 그런 것 같다. 원래는 지점 클릭을 통해 이동했는데 키 입력을 받아서 이동하는 방식으로 바뀌었고, 적을 지정하면 자동으로 전투를 진행했으나 버튼을 클릭하여 적을 공격하는 식으로 바뀌었다. 이런 부분들을 새롭게 다뤄보면서 약간씩 어려움을 마주치는 것 같다. 아마 적 AI는 기존 구현과 비슷할테니 그렇게까지 어려운 부분은 없을 것 같은데, 실제로 해보면 또 어떻게 다를지 모르겠다. 아, 그리고 오늘 만든 부분 중에 버튼을 클릭하는 모션도 추가할 필요가 있어보인다. 내일도 부지런히 만들어서 하나씩 차곡차곡 기능들을 쌓아나가야겠다.

 

내일은 기존에 계획했던 대로 적의 AI를 구현할 생각이다. 플레이어와 적이 서로 공격을 주고 받는 기능까지도 구현하면 좋겠다.

오늘 구현한 내용은 목표로 했던 PlayerCharacter의 움직임과 카메라를 구현했다.

 

처음에는 너무 적나? 내일 할 것도 미리 해야지~ 하는 생각을 하고 있었는데, 예상치 못한 문제들을 만나서 생각보다 시간이 굉장히 오래 소요되었다.

 

 

첫 번째 문제는 애니메이션과 Character Controller와 관련된 문제였다.

 

춤을 추는 애니메이션은 Mixamo에 있는 애니메이션을 가져왔는데 애니메이션의 종료 시점에서 Idle 모션으로 돌아올 때, Rotation이 변경되지 않기 때문에 애니메이션 종류에 따라 다르지만 약간씩 캐릭터가 회전하는 이슈가 있었다.

이를 맞춰주기 위해 해당 애니메이션을 시작할 때 캐릭터의 Rotation을 저장하고, 끝나면 원래대로 되돌리는 작업을 추가로 해주었다.

이외에도 비슷한 문제로 position 또한 약간씩 움직이는 이슈가 있었다. 이도 마찬가지로 시작할 때 position을 저장하고 해당 position으로 맞춰주는 작업을 했는데, 캐릭터의 position이 전혀 움직이지 않았다. 여러 방면으로 시도를 했으나 문제는 Character Controller가 직접적인 position 변경을 막은 것이 문제였다. 

_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;

때문에 이런식으로 Character Controller의 enabled를 false로 바꿔줬다가 다시 true로 변경해주는 번거로운 작업이 필요했다. LateUpdate에서 실행을 하면 정상적으로 적용되긴 했지만, Update와 LateUpdate 두 번을 호출하면 너무 효율이 떨어질 것 같아서 이 방법을 사용했다.

 

 

두 번째 문제는 휠로 줌 인/줌 아웃을 할 때 카메라의 위치와 관련된 문제였다.

줌을 할 때, 캐릭터에게 가까워질수록 각도가 더 많이 변하는 방식으로 구현을 하고 싶었는데, 이 과정에서 문제가 조금 있었다. 카메라의 높이인 height, 카메라와 캐릭터와의 거리인 distance, 카메라의 각도인 angle 세 가지를 사용했는데, 세 가지 값의 가중치가 다르다보니 값이 크게 변하는 특정 시점에서 카메라 줌 인/줌 아웃을 반복하면 카메라 포지션이 이상해지는 문제가 있었다. 현재는 Linear한 움직임을 적용해놓았는데, 원하는대로 구현하기 위해서는 세 값의 비율을 완전히 동일하게 맞추든가, 구간 별로 정해진 값을 정해서 비율이 일그러지지 않도록 조정이 필요할 것 같다.

 

 

이외에도 크고 작은 문제들을 마주했었던 것 같은데, 가장 곤란했던 문제는 이 두 가지였던 것 같다.

 

이후에는 오늘 구현했던 기능들에 대해 간단히 설명해보겠다.

먼저 플레이어를 따라다니면서 캐릭터를 비추는 카메라를 구현했다. height, distance, angle 값을 가지며 이 값에 따라 카메라의 높이, 플레이어와의 거리, 비추는 각도가 변경된다. 모바일에서는 두 손을 모으거나 펼치는 식으로 줌 인/줌 아웃을 하지만 컴퓨터에서 해당 동작을 수행하기는 어렵기 때문에 휠을 사용하여 줌 인/줌 아웃을 구현하였다.

최대로 확대하면 캐릭터를 정면에서 가까운 모습으로 볼 수 있다.

 

 

그리고 캐릭터의 움직임을 구현했다. Character Controller를 활용해서 캐릭터의 이동을 구현했고, 이동할 때 캐릭터가 움직이는 애니메이션을 재생하도록 하였다. 캐릭터가 가만히 있으면 대기 모션을 취하는데, 10초간 가만히 있으면 특수한 대기 모션인 춤을 춘다. 춤은 총 2가지로 랜덤하게 재생된다.

캐릭터의 이동은 키보드 WASD키, 혹은 화면을 터치한 지점에 생성되는 조이스틱으로 움직일 수 있다.

WASD의 입력을 처리한 이후에 조이스틱 입력이 있을 경우 조이스틱 입력을 기존 값에 덧씌우기 때문에 WASD키보다 조이스틱을 통한 조작이 우선시된다.

 

using UnityEngine;
using UnityEngine.EventSystems;

public class JoystickController : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [SerializeField] private RectTransform _joystick;
    [SerializeField] private RectTransform _handle;
    [SerializeField] private PlayerCharacterController _characterController;

    private Vector2 _centerPosition = new();
    private Vector2 _normalizedPosition = new();

    public void OnBeginDrag(PointerEventData eventData)
    {
        _centerPosition = eventData.position;
        _joystick.position = _centerPosition;
        _handle.position = _centerPosition;
        _joystick.gameObject.SetActive(true);
    }

    public void OnDrag(PointerEventData eventData)
    {
        _normalizedPosition = (eventData.position - _centerPosition).normalized;
        _characterController.SetJoystickInput(_normalizedPosition.x, _normalizedPosition.y);

        if (Vector2.Distance(eventData.position, _centerPosition) < 75)
        {
            _handle.position = eventData.position;
        }
        else
        {
            _handle.position = _centerPosition + (_normalizedPosition * 75);
        }
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        _characterController.SetJoystickInput(0, 0);
        _joystick.gameObject.SetActive(false);
    }
}

조이스틱을 통한 조작은 다음과 같이 구현하였다. 드래그를 시작할 때 해당 지점에 조이스틱을 생성하고, 중심으로부터 일정 거리 이내에서 핸들이 움직일 수 있도록 구현했다. 범위를 벗어나더라도 조이스틱이 올바른 위치로 이동하도록 Vector의 normalized를 사용해주었다. 또한 이동할 때도 일정한 이동 속도로 움직이기 위해서 normalized를 사용해주었다.

 

using UnityEngine;

public class PlayerCharacterController : MonoBehaviour
{

    private Animator _animator;
    private CharacterController _controller;

    private readonly int _moveHash = Animator.StringToHash("Move");
    private readonly int _specialIdleHash = Animator.StringToHash("SpecialIdle");
    private readonly int _specialIdleNumberHash = Animator.StringToHash("SpecialIdleNumber");

    private Vector3 _moveDirection = new();
    private Vector3 _idleStartPosition = new();
    private Quaternion _lookRotation = new();
    private Quaternion _idleStartRotation = new();

    private float _defaultMoveSpeed = 7f;
    private float _idleElapsedTime = 0;
    private float _inputX = 0;
    private float _inputZ = 0;

    private float _joystickInputX = 0;
    private float _joystickInputY = 0;

    private bool _isPlayedSpecialIdle = false;

    private const float TwistDanceTime = 9.433f;
    private const float SwingDanceTime = 4.4f;

    // Start is called before the first frame update
    private void Start()
    {
        _controller = GetComponent<CharacterController>();
        _animator = GetComponent<Animator>();
    }

    // Update is called once per frame
    private void Update()
    {
        _inputX = Input.GetAxis("Horizontal");
        _inputZ = Input.GetAxis("Vertical");

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

        if (_moveDirection == Vector3.zero)
        {
            _animator.SetBool(_moveHash, false);
            _idleElapsedTime += Time.deltaTime;
            CheckIdleElapsedTime();
        }
        else
        {
            MoveCharacter(_moveDirection);
            _isPlayedSpecialIdle = false;
            _animator.SetBool(_moveHash, true);
            _idleElapsedTime = 0;
        }

        // 특수 대기 모션의 종료 지점이 원점이 아니기 때문에 시작 지점의 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;
        }
    }

    private void MoveCharacter(Vector3 moveDirection)
    {
        _controller.Move(_defaultMoveSpeed * Time.deltaTime * moveDirection);
        _lookRotation = Quaternion.LookRotation(moveDirection);
        transform.rotation = Quaternion.Slerp(transform.rotation, _lookRotation, Time.deltaTime * 10);
    }

    public void SetJoystickInput(float joystickInputX, float joystickInputY)
    {
        _joystickInputX = joystickInputX;
        _joystickInputY = joystickInputY;
    }

    private void CheckIdleElapsedTime()
    {
        if(_idleElapsedTime > 10)
        {
            _isPlayedSpecialIdle = true;
            _idleStartRotation = transform.rotation;
            _idleStartPosition = transform.position;

            int randomNumber = Random.Range(0, 2);

            _animator.SetTrigger(_specialIdleHash);
            _animator.SetInteger(_specialIdleNumberHash, randomNumber);

            if (randomNumber == 0) _idleElapsedTime = -SwingDanceTime;
            else if (randomNumber == 1) _idleElapsedTime = -TwistDanceTime;
        }
    }
}

플레이어의 움직임은 위와 같이 구현하였다.

조이스틱 입력을 유지하고 있어도 계속 움직이고, WASD를 통한 움직임과 조이스틱을 통한 움직임을 동일하게 하기 위해 Update에서 _moveDirection이라는 하나의 변수를 사용하였다.

 

원래는 플레이어의 캐릭터에도 상태 머신을 적용할 생각이었는데, 찾아보니 상태 머신은 주로 몬스터나 아군 NPC 등 AI를 가진 객체에 사용을 하는 것 같았다. 때문에 PlayerCharacterController 안에서 각종 행동들을 따로 처리해주기로 하였다. 그리고 StateMachine과 State는 기본 틀을 이전에 배웠던 내용과 거의 비슷하게 사용할 것 같다. 다만 사용되지 않은 기능들은 빼고 구현할 것이다.

 

오늘 구현한 부분은 다음과 같다.

테스트를 하면서 버그를 발견했는데, 키보드로 입력을 할 때, W를 입력한 상태에서 S로 방향을 바꾸면 잠시동안 대기 모션이 재생되어 애니메이션이 부자연스럽게 끊기는 현상이 발견되었다. 이 부분도 내일 수정해야겠다.

 

오늘 구현에 대해서 상당히 쉽게 생각했었는데, 막상 직접 해보니 어려운 부분들이 꽤나 많았다. 아마 내일 이후로도 배웠던 내용을 적용하는 과정에서 많은 어려움이 있을 것 같다. 처음엔 '너무 쉽게 완성해버리면 어떡하지?' 하는 걱정이 있었는데, 기우였던 것 같다.

 

내일은 계획했던 대로 플레이어의 공격과 사망 상태를 구현할 것이다. 다만 원래는 StateMachine을 사용할 생각이었지만 사용하지 않게 되어서 구현에 시간이 조금 덜 걸릴 것 같으므로 카메라 줌 관련 기능을 개선하고, 적 캐릭터 구현도 어느정도 해 놓으면 좋을 것 같다. 조금 어렵긴했지만 재미있었고, 배운 것도 많이 있었던 것 같다. 앞으로도 부지런히 개발에 몰두해서 계획한 기능들을 순조롭게 구현할 수 있으면 좋겠다.

오늘부터는 여태까지 배운 내용을 토대로 실제 3D RPG를 개발해보려고 한다.

우선은 RPG에서 사용되는 기본적인 기능들을 직접 구현해보면서 배운 내용들을 복습하며 3D 개발을 몸에 익히는 것이 목표이다. 루나 모바일이라는 게임의 기능들을 분석해서 일부 기능들을 구현해보기로 했다.

 

아래는 구현할 기능들을 정리한 내용이다.

 

==========================================

 

[필요한 에셋]
공격모션 3+ 이상, 대기 모션 2개 정도는 있는 플레이어 캐릭터 에셋.
플레이어 캐릭터, 방어구는 바꿀 수 있으면 좋고, 무기나 방패 정도는 바뀌었으면 좋겠지만 불가능해도 괜찮을 듯.
아이템, 포션 등 각종 아이콘은 이미지만 있으면 되니 따로 구해오면 될듯

대기 모션 + 이동 + 공격 모션 + 사망 모션이 있는 적 캐릭터.

=====구현할 기능===== (★ : 필수적으로 구현할 기능)
[캐릭터]
★대기 모션 1종+(가능하면 2종+)
★이동 모션 1종
★공격 모션 3종+
★사망 모션 1종
피격 모션 1종

 

10초마다 기본 대기모션 <-> 특수 대기모션 반복
★가상 조이스틱(화면에 터치한 지점에 생성)/WASD를 이용한 캐릭터 이동
★1초 이내 입력 시 최대 3회 연속 공격)
무기/방어구 변경할 때 캐릭터 모델에 적용(아이템 이미지와 이미지에 맞는 모델을 구해야 할 듯)
구현하려는 게임에서는 캐릭터의 피격모션이 없는 것 같음.

[몬스터]
★대기 모션 1종
★이동 모션 1종
★공격 모션 1종
★사망 모션 1종
피격 모션 1종

20~30초마다 근처 위치로 이동
★선제 공격을 받으면 플레이어 공격
몬스터는 두 종류 이상 있으면 좋을 듯

[인벤토리]
★플레이어 인벤토리
★플레이어 장비창
★적 처치 시 자동으로 아이템, 금화, 경험치 획득

★장비는 총 9종(무기, 방패, 장갑, 갑옷, 부츠, 반지, 목걸이, 귀걸이, 벨트)
무기, 방패 변경할 때 플레이어 모델에 적용

★장비를 클릭하면 능력치가 나오고 잠금/강화/옵션/판매/장착 가능.
잠금하면 강화/판매 불가능, 옵션을 누르면 강화석(보석) 장착 가능.

장비창이 꽉찼을 때 아이템을 획득하면 최대 6시간까지 보관되는 임시 아이템 탭 추가(최대 50칸)

아이템 강화석 슬롯, 장착 기능(최소 1슬롯 ~ 최대 4슬롯)
인벤토리 스크롤?

[플레이어 스탯]
★물리 공격력, 마법 공격력 (고정 수치)
★물리 방어력, 마법 방어력 (방어구 => 고정 수치, 악세서리 => % 수치)
★물리 속성 저항(% 수치)
★원소 속성 저항(% 수치, 얼음, 불, 독, 빛)
★최대 체력, 최대 마나 %

장비 변경 시 스탯 변경사항 표시

 

[기타]
적 몬스터를 골라서 자동 전투를 눌러 자동 전투 구현(자동 이동?)
일정 시간 이상 입력이 없으면 절전 모드로 전환
물약 / 자동 물약 기능 구현

피격 애니메이션은 살아있을 때 && 공격중이지 않을 때 적용

[사용할 에셋]

플레이어 캐릭터 에셋: Casual RPG Character - 04 Avelyn

https://www.assetsready.com/product-page/casual-rpg-character-04-avelyn

 

Casual RPG Character - 04 Avelyn | Assets ready main

Update Ver. 1.1Animations : Characters are humanoid mecanim ready. (See Mixamo motion test) Polys: 2948Verts: 2549Texture: 1024x1024-1, 256x256-1Character Icon: 256x320-1Animations(x8) :(Animation Type_Generic)Sword_F_BattleIdle_01Sword_F_Run_01Sword_F_Att

www.assetsready.com

기본적으로 필요한 모션들이 있고, Mixamo 애니메이션도 추가로 활용 가능, 아이템 변경은 불가능하지만 무기랑 방패는 손에 위치 잘 시키면 가능할 수도 있음.
대기 모션, 사망 모션 Mixamo에서 추가 필요, 일부 애니메이션을 적용해보니 정상적으로 작동했음.

 

몬스터1 에셋: Mini Legion Lich PBR HP Polyart

https://assetstore.unity.com/packages/3d/characters/humanoids/fantasy/mini-legion-lich-pbr-hp-polyart-91497

 

Mini Legion Lich PBR HP Polyart | 캐릭터 | Unity Asset Store

Get the Mini Legion Lich PBR HP Polyart package from Dungeon Mason and speed up your game development process. Find this & other 캐릭터 options on the Unity Asset Store.

assetstore.unity.com

대기, 이동, 공격, 사망 모션이 전부 존재하고, 한 번 다뤄봤기 때문에 구현하기 좋을 것 같다.

 

몬스터2 에셋: Fantasy Monster - Skeleton

https://assetstore.unity.com/packages/3d/characters/humanoids/fantasy-monster-skeleton-35635

 

Fantasy Monster - Skeleton | 3D 휴머노이드 | Unity Asset Store

Elevate your workflow with the Fantasy Monster - Skeleton asset from Teamjoker. Find this & other 휴머노이드 options on the Unity Asset Store.

assetstore.unity.com

Humanoid 적용시 애니메이션이 약간 꼬이긴 하지만 기본적으로 필요한 애니메이션들을 전부 가지고 있기 때문에 큰 문제는 없을 것 같다.

 

환경 에셋: Casual Tiny Environment - Jungle Set

Casual Tiny Environment - Jungle Set | Assets ready main

 

Casual Tiny Environment - Jungle Set | Assets ready main

■ Jungle 1Verts: 19971Tris: 15510Textures :Diffuse map_ 1024x1024-2, 512x512-1Light map_ 1024x1024-1■ Jungle 2Verts: 24777Tris: 13232Textures :Diffuse map_ 1024x1024-2Light map_ 1024x1024-1■ Jungle 3Verts: 21875Tris: 11403Textures :Diffuse map_ 1024x

www.assetsready.com

실제 RPG의 느낌을 주기 위한 배경 에셋도 사용하기로 했다. 크기가 좀 작지만 임의로 넓혀서 사용해도 괜찮을 것 같다.

 

==================================

 

다음으로는 코드의 구조를 계획한 내용이다.

 

==================================

 

카메라 관리

TopDownCamera

 

플레이어 캐릭터 행동 관리
PlayerCharacterController
PlayerStateMachine
State
IdleState_Player, AttackState_Player, MoveToTargetState_Player, DeadState_Player

 

적 캐릭터 행동 관리
EnemyController
EnemyController_Melee
EnemyStateMachine (적의 State를 관리)
State
IdleState_Enemy, AttackState_Enemy, MoveToTargetState_Enemy, MoveToWaypointState_Enemy, DeadState_Enemy


IDamagable, IAttackable(피격, 공격 가능) (플레이어 캐릭터, 적 캐릭터 모두 적용)


인벤토리 관리
PlayerEquipment: 장착한 장비 정보 저장
StaticInventory: 장비창과 같은 고정된 인벤토리 창
DynamicInventory: 아이템이 저장되는 인벤토리 창

InventoryObject: 장비창, 소지품창 같은 인벤토리의 정보를 저장하는 Scriptable 객체
Inventory: 인벤토리의 Slot들을 저장하고 있는 클래스
InventorySlot: 인벤토리 각각의 칸의 슬롯을 관리
InventoryUI: UI를 표시해주며, 이것을 상속받아 StaticInventory, DynamicInventory 구현

ItemObjectDatabase: ItemObject들에 id를 매겨주고 정보를 저장, 관리해주는 Database (ScriptableObject)
ItemObject: 각각의 아이템에 대한 정보 전체를 담고 있는 ScriptableObject
Item: ItemObject의 이름, 능력치, id와 같은 기본적인 정보들을 보관
ItemStat: Item의 능력치의 최소/최댓값을 정하고, 이 값을 결정해주고, 능력치와 값에 대한 정보를 저장

 

=================================================

 

구현할 핵심적인 내용들을 정리해보니 대부분 강의에서 배우고 다루었던 내용들이었다. 그래서 구조도 강의에서 사용했던 구조와 거의 비슷하게 계획하게 된 것 같다. 이렇게 돌아보니 굉장히 알차고 도움이 되는 내용들인 것 같다는 점이 새삼 느껴졌다.

 

오늘 계획은 먼저 기본 프로젝트 세팅과 에셋들을 추가하여 Github에 업로드를 하는 것이다. 이후에는 PlayerCharacter의 움직임을 구현하고, TopDownCamera를 구현할 것이다.

 

내일인 화요일에는 PlayerStateMachine, State, IdleState_Player,  AttackState_Player, MoveToTargetState_Player, DeadState_Player 등 플레이어의 상태를 관리하는 기능을 구현할 것이다.

 

수요일에는 EnemyController, EnemyController_Melee, EnemyStateMachine, State, IdleState_Enemy, AttackState_Enemy, MoveToTargetState_Enemy, MoveToWaypointState_Enemy, DeadState_Enemy 등 적 캐릭터를 구현할 것이다.

 

목요일에는 Item과 Inventory 기능을 구현할 것이다.

 

금요일에는 개발한 내용들을 다듬고, 우선 순위에서 밀린 기능들을 추가로 구현해볼 생각이다.

 

대략적인 일정을 잡았는데, 조금씩 일찍 하거나 늦게 하는 등 약간의 차이는 있을 것 같다. 구현하다보면 생각지 못한 문제를 만나서 시간이 오래 걸릴 수도 있기 마련이기 때문이다. 그래도 한 번 구현해보았던 내용들이기 때문에 이번 주 안에는 마무리를 지을 수 있으면 좋을 것 같다.

오늘은 인게임 UI 설정과 다이얼로그, 퀘스트 등의 대화 시스템, 문 함정 등의 레벨 디자인에 대해 배웠다.

그리 새롭거나 어려운 내용은 아니었다. 다이얼로그는 직접 만들어보기도 했었고, UI에서 작동하는 것이다보니 구현이 비슷비슷했다. 다만, 다이얼로그를 저장하고 불러오는 방식에 약간 차이가 있었다. 퀘스트는 아이템과 비슷하게 구현이 되었는데, 이전에 구현했던 기능을 활용해서 구현했기 때문에 그렇게 어렵지는 않은 내용이었다.

문과 함정도 상대적으로 간단하게 구현이 가능했고, 전부 이전에 다뤘던 내용들을 활용한 것이라 오늘 내용들은 전체적으로 어렵지 않은 내용이었다.

 

때문에 오늘은 작성한 스크립트들을 간단하게 정리하는 시간이 될 것 같다.

 

AttributeType: 능력치들의 타입을 저장한 enum이다. 힘, 지능 등 6개의 능력치를 가지고 있다.
ModifiableInt: 능력치들의 각각의 값을 저장하는 클래스이다.
Attribute: AttributeType과 ModifiableInt를 세트로 가져 능력치의 종류와 값을 가지고 있는 클래스이다.
StatsObject: Attribute 배열을 가지고 있어 능력치들에 대한 정보를 저장하게 되는 ScriptableObject이다. 강의에서는 플레이어의 능력치를 저장하는 데에 사용한다.
PlayerStatsUI: 플레이어의 스탯을 스테이터스창에 표시해주며, 아이템을 장착하거나 해제할 때 능력치를 갱신한다.
PlayerInGameUI: 플레이어의 레벨, 체력, 마나 등의 UI를 갱신해준다.

Dialogue: 이야기를 하는 NPC의 이름과 대화 내용 배열을 저장하는 클래스이다.
DialogueManager: 대화 시스템을 관리해주며, 대화의 시작이나 한글자씩 표시해주거나 다음 문장을 나타내주는 등의 기능을 한다.
DialogueNPC: NPC의 컴포넌트에 추가되어 상호작용을 관리해준다.

QuestType: 퀘스트의 타입을 저장한 enum이다. 몬스터를 처치하거나, 아이템을 수집하는 2종류의 타입을 가지고 있다.

QuestStatus: 퀘스트의 현재 상태를 저장한 enum이다. 아무 상태도 아닌지, 수락했는지, 완료했는데, 보상을 받았는지의 상태가 있다.
Quest: 퀘스트의 id, QuestType, 보상 정보 등의 정보를 가지고 있는 클래스이다.
QuestObject: Quest와 QuestType을 한 쌍으로 가지는 ScriptableObject이다. 실제 퀘스트는 이 오브젝트를 통해 만든다.
QuestObjectDatabase: QuestObject가 등록되어 관리할 수 있는 데이터베이스이다.
QuestManager: 아이템을 획득하거나 적을 처치했을 때 QuestManager의 함수를 참조하여 퀘스트의 진행상태를 갱신하고, 완료했으면 완료 상태로 변경해주는 역할을 한다.
QuestNPC: 퀘스트를 주는 NPC에 등록되는 스크립트로, 퀘스트의 정보와 대화 정보를 가지고 있다. DialogueManager를 활용하여 퀘스트 진행 상태에 따라 대화창을 띄워준다.

 

DoorEventObejct: 문이 열리고 닫힐 때 이벤트를 저장하고 처리하는 ScriptableObject이다.

DoorController: 문이 열리고 닫히는 기능이 구현되어있는 스크립트로, DoorEventObject를 통해 이벤트를 처리한다.

DoorTriggerArea: Collider를 활용하여 캐릭터가 Collider에 들어오거나 나갈 때 문을 열고 닫는 기능을 호출한다.

 

TrapController: 함정에 컴포넌트로 추가되는 스크립트로, 데미지를 주는 간격이나 데미지 등의 정보를 가지고 있다. Collider 내에 다른 오브젝트가 들어오면 적에게 피해를 주고, 밖으로 나가면 더이상 피해를 주지 않는다.

 

 

오늘 구현했던 내용은 여기까지다. 구현한 내용 자체는 어렵진 않았지만 이번에도 구조가 약간 복잡한 느낌이 있었다. 이제 내일부터 기획을 세우고 이런 구조를 짜서 기능을 구현할 생각에 약간 부담이 되기는 한다. 여태까지 배운 내용들을 종합해서 필요한 기능들을 나열하고, 이에 알맞은 구조를 짜야겠다.

 

약 9일 간 패스트캠퍼스의 강의를 들으며 3D 게임 개발에 대해서 공부했다. 아주 많이 도움이 되었던 것 같다. 이전에 구현했던 게임의 구조도 개선할 부분이 많다고 느꼈다.

그리고 RegidBody, CharacterController, Nav Mesh Agent, Animation StateMachine, Camera, Lighting, Terrain, Navigation, Finite State Machine, 적 캐릭터의 시야 구현, 전투 시스템 구현, 인벤토리, 아이템 구현, 인게임 UI, 다이얼로그, 퀘스트, 문, 함정 등의 기능들을 구현해보았다. 이전에 다뤄본 기능도 있었지만 대개 처음 다뤄보는 생소한 것이었기 때문에 어려움이 있었다. 이런 어려움을 극복하면서 한층 성장한 것 같다. 그렇지만 아직은 보고 따라하면서 기본적인 방법에 대해서만 배운 것이기 때문에 앞으로 직접 실제 게임을 구현해나가며 나의 능력을 갈고 닦아야겠다.

 

내일부터는 하나의 게임을 분석해서 몇 가지 기능을 추려 구현할 기획을 세워야겠다. 이후에는 그에 맞는 에셋들을 찾아야겠다. 무료 에셋이 있으면 좋겠지만 여의치 않으면 유료 에셋을 구매할 필요도 있을 것 같다. 구현할 기능들의 기획이 완료되면 각 기능들에 대한 구조를 짜야겠다. 구조를 짠 이후에는 배웠던 내용들을 토대로 실제 구현에 들어가겠다. 많은 내용을 구현하지는 않을 것이기 때문에 아마 내일 하루면 구현할 기능의 기획을 세우고, 에셋들을 찾는 단계에 진입할 것 같다. 에셋을 찾고 나서 구조를 짜는 데에는 시간이 조금 걸릴 수도 있을 것 같다. 그래도 하루 정도면 충분할 것 같다. 빠르면 내일, 늦어도 모레에는 개발에 착수하고, 계획을 세워 차근차근 기능을 구현해야겠다. 아마 1주일 이상 걸리지는 않을 것 같기 떄문에 다음 주 중으로 마무리할 수 있을 것 같다. 이를 토대로 포트폴리오를 만들고, 자기소개서 등을 작성해서 지원하면 아마 4월 24일 경이면 지원할 수 있을 것 같다. 빠르면 4월 안으로 결과가 나올 것 같다. 결과가 어떻게 될지는 모르겠지만, 이전에 지원할 때와 비교하면 많이 성장한 것 같다. 그 점만으로도 만족한다. 아마 이번에는 취업에 성공할 수 있을 것 같다. 만약 안 된다면, 이전에 만들던 TSCB를 끝까지 완성할 수도 있을 것 같고, 새로운 게임을 하나 기획해서 출시하는 것을 목표로 할 계획이다.

어제와 오늘은 인벤토리와 아이템과 관련된 기능을 구현했다.

이전에 간단하게 RPG를 만들어 본 경험이 있어서 어렵지 않게 구현할 수 있을 것이라고 생각했는데, 인벤토리와 아이템도 제대로 된 구조를 짜기 위해서는 꽤나 많은 노력이 필요했다. 거기다 3D게임이다보니, 모든 게임이 그런 것은 아니지만 아이템을 장착할 때마다 캐릭터의 모델링을 바꿔주는 등의 작업이 필요했기 때문에 예상했던 것보다 굉장히 어려웠다. 모델링 관련 지식이 부족하기 때문인지 저번에 구현했던 전투 시스템을 구현하는 것보다 어려웠던 것 같다.

 

오늘은 어쩌다보니 시간도 꽤나 늦어져서... 이번에는 그림 없이 글로만 정리해야할 것 같다. 먼저 작성한 스크립트 17개에 대한 정리부터 시작하겠다.

 

ItemObjectDatabase: ItemObject들을 보관하는 데이터베이스이며 각각의 아이템에 id를 매겨줌(기본적으로 1개만 존재 가능하나 부위별로 나누어 id가 겹치지 않도록 해주면 여러 개도 가능)
ItemObject: 아이템을 찍어낼 수 있는 기본 틀
Item: ItemObject의 정보를 가지고 있는, 실질적으로 유저에게 드롭되고 보여지는 아이템이 가지게 되는 스크립트 (id, 이름, 효과와 같은 게임 내 처리에서 필요한 정보를 담고 있음(각각의 생성된 아이템을 처리할 때는 이 Item을 사용))
=> 여기서 id는 ItemObject, 즉 틀에 대한 id를 가지고 있는 것임.
ItemBuff: Item 내에 있는 효과에 대한 정보를 생성하고 저장(힘, 스태미너 등의 스탯)

 

 

InventoryObject: 가방, 장비창, 퀵슬롯, 상자 등 아이템이 들어가는 InventorySlot을 여러 개 가질 수 있는, 흔히 생각하는 인벤토리의 정보를 가지고 있는 ScriptableObject이다.
Inventory: InventorySlot들에 대한 정보를 가지고 있다.
InventorySlot: InventoryObject 내에서 아이템을 놓을 수 있는 각각의 한 칸에 대한 정보를 가지고 다.
InventoryUI: 시작할 때 각각의 InventorySlot을 초기화해줌(parent, 슬롯 내 아이템의 이미지, 개수 등 표시). 또한, 아이템을 드래그하는 이벤트 등도 구현되어있으며, 이 InventoryUI를 상속받아 구현하도록 하는 추상 클래스이다.

 

 

DynamicInventoryUI_New: InventoryUI를 상속받아 구현하며, 플레이어의 인벤토리나 혹은 상자와 같이 칸의 수를 변경하여 적용할 수 있는 인벤토리를 구현하는 데 사용한다.

StaticInventoryUI_New: InventoryUI를 상속받아 구현하며, 플레이어의 장비창처럼 정해진 개수의 정해진 위치에 인벤토리가 있는 경우에 사용하는 인벤토리를 구현하는 데 사용한다.

 

 

TestItem: ItemObjectDatabase에 있는 아이템을 랜덤으로 생성하고 전부 없애주는 등 기본적인 기능을 테스트하기 위한 스크립트이다.

 

 

EquipmentCombiner: 장비의 부위마다 bone들이 있고, 장비들마다 bone이 겹치는 부위가 있는데, 따로 장비를 추가하게 되면 중복되는 bone들이 많아지게 되기 때문에 효율적으로 bone을 추가하기 위해 사용되는 스크립트다. 이름을 HashCode로 변환하여 Dictionary에 저장하기 때문에 중복되는 bone이 있어도 하나만 저장된다. bone들의 최상위 부모인 rootGameObject가 있고, 본들의 이름을 Hash로 변환하여 int형 값을 통해 해당 bone의 transform 정보를 저장하는 Dictionary 데이터를 저장하고 있다. 이외에 처리에 필요한 각종 함수들이 구현되어 있다.

PlayerEquipment: 시작할 때, 혹은 아이템을 장착하거나 해제할 때 EquipmentCombiner의 기능을 사용하여 캐릭터의 모델링 관련 처리를 해주는 스크립트다. 캐릭터의 장비 등은 연결된 bone에 따라 움직이는 애니메이션이 다르기 때문에 SkinnedMeshRenderer를 사용하지만, 무기 등은 통째로 움직이기 때문에 MeshRenderer를 사용한다. 이런 것들을 분리하여 처리해주기도 한다. SkinnedMeshRenderer는 EquipmentCombiner를 통해 하나의 Transform에 bone들을 추가하여 해당 transform을 ItemInstances에 추가하여 저장한다.

ItemInstances: bone들로 구성된 SkinnedMeshRenderer 혹은 MeshRenderer를 컴포넌트로 가지고 있는 transform의 정보를 부위별로 저장한다.

 

 

IInteractable: 상호작용할 수 있도록 구현할 때 상속받아 구현하도록 사용하는 인터페이스이다.

PickupItem: 우클릭으로 상호작용하여 주울 수 있는 아이템에 등록하는 스크립트로, 어떤 아이템으로 설정할 것인지, 주울 수 있는 거리 내에 있는지 등을 판별하는 기능을 제공한다.
GroundItem: 근처에 가면 주울 수 있는 아이템에 등록하는 스크립트로, 어떤 아이템을 등록할 것인지의 정보가 등록되어 있다.

 

 

오늘은 대부분의 기능을 직접 씬 내에서 등록해보고 사용해봤기 때문에 새로 알게 된 내용, 어려웠던 내용에 대해서만 정리해보겠다.

 

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

[CreateAssetMenu(fileName = "New Item Database_New", menuName = "Inventory System/Items/DataBase_New")]
public class ItemObjectDatabase_New : ScriptableObject
{
    public ItemObject_New[] _itemObjects;

    public void OnValidate()
    {
        for (int i = 0; i < _itemObjects.Length; ++i)
        {
            _itemObjects[i]._data._id = i;
        }
    }
}

먼저 ScriptableObject와 CreateAssetMenu에 대해서 알게 되었다. ScriptableObject는 적은 양의 데이터를 유니티에서 효율적으로 처리할 수 있도록 해주는 것 같다. CreateAssetMenu를 통해 우클릭으로 해당 오브젝트를 생성할 수도 있다. 여기서는 아이템이라든지, 아이템을 관리해주는 데이터베이스 등을 프로젝트 내에 오브젝트로 생성해서 편리하게 등록하고 관리할 수 있었다.

 

public InventorySlot_New GetEmptySlot()
    {
        return Slots.FirstOrDefault(i => i._item._id < 0);
    }

FirstOrDefault라는 것에 대해서도 배웠다. for문을 활용하지 않고 한 줄로 간단하게 for문을 사용하는 것과 같은 효과를 내는 것 같다. 위의 코드는 Slots[i]._item._id를 순회하면서 0보다 작은 것이 있는지를 찾는 느낌이다. 한 눈에 보기에 명료한 코드는 아니라고 느껴지지만 간단하고 직관적으로 이해할 수 있는 코드인 것 같다.

이것과 비슷하게 SingleOrDefault도 있는데, 이 안에 해당하는 값이 딱 1개만 있는지를 찾아오는 함수라고 한다. 중복되는 값이 없어야 하는 경우에 사용하면 좋을 것 같은데, 하나만 있는지 확인하기 위해서는 전체를 순회해야 할테니까 굉장히 비효율적일 것이라는 생각이 들었다. 다만 그만큼 안전성을 확보할 수 있는 것 같다.

 

public Action<InventorySlot_New> _OnPostUpdate;

Action에 대해서도 알게 되었는데, delegate와 비슷하지만 약간 다른 부분이 있는 것 같다. 반환값이 없는 메소드를 추가할 수 있다고 하는데... 등록해놓고 외부에서 해당 Action에 함수들을 추가하여 여러 기능을 수행할 수 있도록 해주는 것 같다.

 

[Min(1), SerializeField] protected int _numberOfColumn = 4;

이런 식으로 Min(1)과 같이 최소값을 지정해줄 수 있는 것도 새로 알았다.

 

protected void AddEvent(GameObject go, EventTriggerType type, UnityAction<BaseEventData> action)
    {
        EventTrigger trigger = go.GetComponent<EventTrigger>();
        if (!trigger)
        {
            Debug.LogWarning("No EventTrigget component found!");
            return;
        }

        EventTrigger.Entry eventTrigger = new EventTrigger.Entry { eventID = type };
        eventTrigger.callback.AddListener(action);
        trigger.triggers.Add(eventTrigger);
    }

그리고 이벤트를 추가하는 방법에 대해서도 알게 되었는데, C#에서는 이런식으로 등록하는 방식이 조금 복잡했다. 커서가 들어오거나, 나가거나, 드래그를 하거나 등의 이벤트를 등록할 때 필요한 기능이다.

 

bool isOnUI = EventSystem.current.IsPointerOverGameObject();

그리고 현재 마우스 포인터가 UI 위에 있는지를 확인해주는 함수에 대해서도 알게 되었다.

 

 

이외에도 static을 사용해왔는지 정확하게 무엇이라고 설명하기 어려워서 찾아보았다. static은 하나만 가질 수 있는 것이다. 예를 들어 static을 사용해서 변수를 생성하면, 해당 변수를 가지고 있는 클래스가 여러 개 있더라도 그 변수는 공용으로 사용된다는 것이다. 아! 그리고 이전에 배웠던 것이 생각났는데, 클래스를 생성하지 않아도 static 변수는 사용이 가능하다.

 

그리고 const와 readonly의 차이점도 알아보았다. const는 컴파일 타임 때 값이 고정되는 것이고 readonly는 런타임 때 메모리에 등록되는 시점에 값이 고정되는 것이라는 차이가 있다고 한다. 때문에 성능 상의 이점을 보기 위해서는 const를 사용하는 것이 좋다고 한다. 다만 readonly는 생성자에서 초기화를 하여 값을 정할 수 있기 때문에 훨씬 유동적으로 프로그래밍이 가능하다는 것 같다.

 

이외에는 변수 명명에 대해서도 고민을 했다. 여태까지 전역 변수 앞에는 무조건 _를 붙였는데, 프로퍼티에는 붙이면 안되기도 하고, 헷갈리는 경우가 종종 있기도 해서, 규칙을 어떻게 정할지가 상당히 고민스럽다. 보통은 어떻게 하는지가 상당히 궁금하다.

 

 

이렇게 정리를 해보았는데, 새로 알게된 기능보다, 구조에 대해서 새로 알게 된 것이 굉장히 많은 것 같다. 이전에 아이템 관련 기능을 구현했을 때는, 벌써 4년이나 된 일인 것 같지만, 이런 구조를 신경쓰지 않고 주먹구구식으로 구현했는데, 이런 식으로 구조를 확실하게 정해놓고 프로그래밍을 하는 것이 훨씬 좋은 것 같다. 상당히 어렵고 복잡하고 난해했지만 실무에서 구현하는 방법에 대해서 자세히 배우게 된 것 같다. 다만 내가 생각한 게임에서는 사용되지 않는 기능들이 제법 있어서 필요 이상으로 배운 부분도 있는 것 같은데, 그래도 한 번이라도 구현해 본 경험이 있으면 좋을 것 같다고 생각한다.

 

 

 

어제 오늘 강의를 들으며 굉장히 힘들었는데, 그래도 어떻게든 듣고 구현을 하고 정리까지 완료했다. 이제 강의가 5개 남았기 때문에 내일이면 RPG 구현에 대한 기초적인 공부를 마칠 수 있을 것 같다. UI와 다이얼로그, 퀘스트, 레벨 디자인 등인데... 오늘만큼 구조가 복잡하지는 않았으면 하는 바람이다. 아무튼, 내일까지 강의를 다 듣고, 모레부터 3D개발에 착수할 생각이다. 물론 게임의 시작부터 끝까지는 아니고, 특정 게임의 특정 기능을 구현할 생각이다. 캐릭터의 움직임과, 적 캐릭터와 전투, 스킬, 아이템 획득, 장착, 판매 등 여태까지 배운 내용들을 활용하여 구현해볼 생각이다. 그리고 이것을 포트폴리오로 다시 취업에 도전할 생각이다. 1주일 전만 해도 앞으로 얼마나 성장해있을지 기대된다고 글을 썼었는데, 1주일만에 상당히 많은 것을 배운 것 같다. 지금이라면 리소스와 시간만 있으면 내가 플레이했던 게임들 대부분의 기능을 구현할 수 있을 것 같다. 물론 여전히 어려움은 있겠지만, 예전같았으면 어떻게 저런 걸 구현했지? 라는 생각을 했던 기능들을 지금은 아 이런이런 기능을 사용하면 구현할 수 있겠다 라는 지식정도는 생긴 것 같다. 최근에 즐길만한 게임이 없어서 며칠 전 디아블로3를 구매했는데, 상당히 재미있었다. 디아블로2와도 상당히 비슷한 느낌인데, 확실히 디아블로2에 비하면 최근 게임이라는 느낌이 강하게 들었다. 그리고 이 디아블로3를 플레이하면서 배운 내용들이 계속 생각났다. 이런 부분은 이렇게 구현했겠구나, 이런 부분은 이렇게 구현할 수 있겠구나... 하는 것들 말이다. 덕분에 게임에 조금은 집중할 수 없게 되었지만, 이런 생각들을 하면서도 꽤나 재미있었다. 내일 강의를 마치고 빨리 내가 배운 것들을 활용해서 포트폴리오를 만들고, 내가 이런 것들을 할 수 있다는 것을 어필하고 싶은 마음이 넘친다. 그리고 실무를 하면서 게임 개발에 기여하고, 그 과정에서 많은 난관들을 만나 극복하면서 성장하고 싶다. 빠르면 다음 주 안에도 개발을 완료해서 지원이 가능할 것 같다. 힘내야겠다!

어제와 오늘은 전투를 할 때 데미지를 주고 받는 기능이나, 근접 공격, 원거리 공격, 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 게임들을 만들어보고 싶어서 살짝 근질거린다. 기획에 상당히 큰 노력이 들어간다는 걸 안 지금은 이미 존재하는 게임을 한 번 따라 만들어보고 싶다. 어떤 게임을 구현해보면 좋을지 찬찬히 고민해봐야겠다.

오늘은 적 캐릭터의 시야와 패트롤 관련 구현을 했다.

 

어제까지 구현한 부분은 방향에 상관없이 감지 거리 내에 플레이어가 접근하면 적이 발견하고 다가와서 공격을 했는데, 적의 정면에 플레이어가 있는 경우에만 플레이어를 감지하는 기능을 구현했다. 이 뿐만 아니라, 플레이어가 적의 정면에 있어도 벽 등의 장애물로 인해 가려지면 플레이어를 인식하지 못하도록 하는 기능 또한 구현했다.

이후에는 적이 일정한 위치를 반복해서 움직이는 패트롤 기능도 구현했다. 약간 잡임형 게임에 적합한 시스템인 것 같다.

 

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 directionToTarget = (target.position - transform.position).normalized;
            if (Vector3.Angle(transform.forward, directionToTarget) < _viewAngle / 2)
            {
                float distanceToTarget = Vector3.Distance(transform.position, target.position);
                if (!Physics.Raycast(transform.position, directionToTarget, distanceToTarget, _obstacleMask))
                {
                    _visibleTargets.Add(target);
                    if (_nearestTarget == null || (_distanceToTarget < distanceToTarget))
                    {
                        _nearestTarget = target;
                        _distanceToTarget = distanceToTarget;
                    }
                }
            }
        }
    }

적을 찾는 기능은 다음과 같은데, 찾은 타겟이나 저장한 거리를 초기화해주고, 이전에 사용했던 OverlapSphere를 사용해서 근처에 있는 타겟을 저장한다. 이후 transform을 기준으로 target이 서있는 위치를 정규화해준다. 이후 이 값을 Angle 함수를 통해 target이 정면으로부터 각도로 몇 도 이내에 있는지 계산한다. 이를 _viewAngle을 2로 나눈 값과 비교하여 이보다 작다면 시야 내에 있는 것으로 간주한다. 정면을 기준으로 왼쪽과 오른쪽이 있기 때문에 _viewAngle을 2로 나누어 사용한다. 이후 타겟까지의 거리를 저장하고, 해당 거리만큼 해당 방향으로 Raycast를 쏴서 장애물이 있으면 보이지 않는 것으로 간주하고, 장애물이 없으면 보이는 것으로 간주한다. 만약 장애물이 없다면, 현재 인식가능한 타겟 리스트에 추가해주고, 가장 가까운 거리에 있는 타겟과 거리를 저장한다.

 

public Transform _Target => _fov?._NearestTarget;

이전에 작성했던 EnemyController에서 단순히 거리 내에 있는 target을 찾았는데, 이제는 시야 범위 내에 있는 타겟을 대상으로 할 것이기 때문에 fieldOfView에서 찾은 NearestTarget을 사용하게 되었다. 위의 식은 람다식으로 프로퍼티를 나타낸 것인데, get만 가능한 것 같다. getter만 필요할 때 유용하게 사용할 수 있을 것 같다. 이전에 작성했던 코드 중 일부도 이 방법으로 수정하였다.

 

private void OnSceneGUI()
    {
        FieldOfView fov = (FieldOfView)target;

        Handles.color = Color.white;
        Handles.DrawWireArc(fov.transform.position, Vector3.up, Vector3.forward, 360, fov._viewRadius);

        Vector3 viewAngleLeft = fov.DirectionFromAngle(-fov._viewAngle / 2, false);
        Vector3 viewAngleRight = fov.DirectionFromAngle(fov._viewAngle / 2, false);

        //float x = Mathf.Sin(fov._viewAngle / 2 * Mathf.Deg2Rad) * fov._viewRadius;
        //float z = Mathf.Cos(fov._viewAngle / 2 * Mathf.Deg2Rad) * fov._viewRadius;

        Handles.DrawLine(fov.transform.position, fov.transform.position + viewAngleLeft * fov._viewRadius);
        Handles.DrawLine(fov.transform.position, fov.transform.position + viewAngleRight * fov._viewRadius);

        Handles.color = Color.red;
        foreach (Transform visibleTarget in fov._VisibleTargets)
        {
            Handles.DrawLine(fov.transform.position, visibleTarget.position);
        }
    }
public Vector3 DirectionFromAngle(float angleInDegrees, bool isAngleGlobal)
    {
        if (!isAngleGlobal)
        {
            angleInDegrees += transform.eulerAngles.y;
        }

        return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0, Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
    }

그리고, 에디터로 이 시야각을 볼 수 있게 해주었다. 먼저 인식 범위만큼 하얀색 원을 그리고, 인식 범위를 표시해주었다.

인식 범위를 표시할 때는 삼각함수를 사용하였는데, 반지름과 각도를 알고 있기 때문에 Sin, Cos 함수를 사용하여 각도에 대한 값을 Vector3값으로 저장하고, 여기에 반지름을 곱해서 구한 위치까지 선을 긋도록 했다. 또, 발견한 적들에게 빨간색으로 선을 연결해주기도 하였다.

이를 통해서 이런식으로 하얀색으로 인식 범위와 시야각을 표시해주었다. 적을 인식하는 방식만 변경해주었기 때문에 다른 부분은 변경되지 않았다.

 

 

이후에는 캐릭터가 일정 지점을 반복하여 순찰하는 패트롤 기능을 추가해주었다.

public Transform FindNextWaypoint()
        {
            _targetWaypoint = null;
            if (_waypoints.Length > 0)
            {
                _targetWaypoint = _waypoints[_waypointIndex];
            }

            _waypointIndex = (_waypointIndex + 1) % _waypoints.Length;

            return _targetWaypoint;
        }

먼저 다음 waypoint(이동할 지점?)를 찾도록 해주었다. _waypoints는 transform의 배열로, 인스펙터에서 이동할 지점을 등록할 수 있도록 해놓았다. 나머지 연산을 통해 처음부터 끝까지 차례대로 순찰하도록 해주었다.

 

그리고 MoveToWaypoint라는 State를 추가해주었다.

public override void OnEnter()
        {
            if (_context._targetWaypoint == null)
            {
                _context.FindNextWaypoint();
            }

            if (_context._targetWaypoint)
            {
                _agent.SetDestination(_context._targetWaypoint.position);
                _animator?.SetBool(_hashMove, true);
            }
        }

이 State가 시작할 때, 이동할 지점이 없다면 지점을 찾고, 혹은 있다면 NavMeshAgent의 목적지를 설정해주고, 움직이는 애니메이션을 재생한다.

public override void Update(float deltaTime)
        {
            Transform enemy = _context.SearchEnemy();
            if (enemy)
            {
                if (_context.IsAvailableAttack)
                {
                    _stateMachine.ChangeState<AttackState>();
                }
                else
                {
                    _stateMachine.ChangeState<MoveState>();
                }
            }
            else
            {
                if (!_agent.pathPending && (_agent.remainingDistance <= _agent.stoppingDistance))
                {
                    Transform nextDestination = _context.FindNextWaypoint();
                    if (nextDestination)
                    {
                        _agent.SetDestination(nextDestination.position);
                    }

                    _stateMachine.ChangeState<IdleState>();
                }
                else
                {
                    _controller.Move(_agent.velocity * Time.deltaTime);
                    _animator.SetFloat(_hashMoveSpeed, _agent.velocity.magnitude / _agent.speed, .1f, deltaTime);
                }
            }
        }

Update 함수 내에서는, 적을 발견했을 때 적을 공격할 수 있으면 AttackState로 전환하고, 적을 공격할 수 없으면 적의 위치까지 이동하도록 MoveState로 전환하게 해주었다.

그리고 적이 없을 때는, NavMeshAgent의 연산이 종료되었고, 목적지까지 이동을 완료했으면 다음 위치를 저장한 후에 IdleState로 전환한다. 혹은 아직 등록된 waypoint까지 이동을 끝내지 못했다면 이동을 한다.

 

public override void OnEnter()
        {
            _animator?.SetBool(_hashMove, false);
            _animator?.SetFloat(_hashMoveSpeed, 0);
            _controller?.Move(Vector3.zero);

            if (_isPatrol)
            {
                _idleTime = Random.Range(_minIdleTime, _maxIdleTime);
            }
        }

        public override void Update(float deltaTime)
        {
            Transform enemy = _context.SearchEnemy();
            if (enemy)
            {
                if (_context.IsAvailableAttack)
                {
                    _stateMachine.ChangeState<AttackState>();
                }
                else
                {
                    _stateMachine.ChangeState<MoveState>();
                }
            }
            else if (_isPatrol && _stateMachine._ElapsedTimeInState > _idleTime)
            {
                _stateMachine.ChangeState<MoveToWaypoint>();
            }
        }

IdleState도 약간 변경을 해주었는데, 패트롤 기능이 켜져있으면 랜덤한 시간을 저장하여 waypoint까지 이동이 끝났을 때 랜덤한 시간동안 Idle상태로 있다가, Idle상태에서 해당 시간이 경과하면 다시 다음 waypoint로 이동해주도록 바뀌었다.

 

 

이렇게 상태 머신에 새로운 상태를 추가하는 방법에 대해서도 배웠다. 엄청 어렵지는 않았으나 구조에 대한 이해가 필요할 것 같았고, 상태가 많아질수록 구조가 복잡해질 것 같다는 생각이 들었다.

이전에 그려놨던 그림에 MoveToWaypoint를 추가했다. 이제보니 이름 뒤에 State가 붙어야할 것 같다. 또, IdleState에서 왔다갔다 하는 것은 좋지만, MoveState와 AttackState로 바로 전환되는 것이 약간 아쉬웠다. 어차피 IdleState에서 AttackState로, MoveState로 전환하는 것과 동일하게 구현되어 있어서, 바로 상태를 전환하여 구조가 복잡해진 것 같아 아쉬웠다. MoveToWaypoint에서 IdleState로 돌아가고, 이후 다시 MoveState나 AttackState로 전환하는 것이 구조상으로는 더 간결할 것 같은 느낌이다. 다만, MoveToWaypoint를 종료하고 IdleState로 넘어가서 다시 MoveState나 AttackState로 넘어가야하기 때문에 아주 약간의 반응속도의 차이는 있을 것 같다. 구조가 간결한 것이 좋을지, 약간이라도 반응속도를 향상시키는 것이 좋을지는 아직 잘 모르겠다. 다만, 반응속도에 큰 차이가 없다면 구조가 간결한 편이 낫지 않을까라고 생각했다. 실제로 MoveState나 AttackState로 바로 전환하지 않고 IdleState로 돌아가게 했음에도 정상적으로 작동하는 것을 확인할 수 있었다.

 

 

오늘은 이렇게 적의 시야각도 구현해보고, 상태 머신에 상태를 추가해서 패트롤하는 기능도 추가해보았다. 시야각은 게임에서 종종 볼 수 있는 기능이라 꽤나 신기하게 다가왔다. 그 중에 떠오른 게임이 세키로인데, 적의 뒤로 몰래 접근해서 인살하는 부분도 생각나고, 수풀에 숨어서 이동할 때도 있는데 세키로에서도 마찬가지로 이런 RayCast를 사용해서 풀을 장애물로 인식하여 적이 캐릭터를 인식하지 못하게 했을까? 싶은 생각이 들었다. 내일은 전투 시스템 구현에 대해 배울 게획이다. 어떤 방식으로 구현할지 궁금하다.

 

추가로 요즘 드는 생각이, 조금 내가 안일한가? 싶은 생각도 든다. 하루 4개 정도의 강의, 시간으로 따지면 1~2시간 내외되는 시간인데, 다시 듣거나 하는 부분이 많아서 2배 정도의 시간이 걸리고, 정리하는 데에도 강의 시간 정도는 걸리는 것 같다. 내용에 따라 다르지만 하루 3~6시간 정도 공부를 하는 것 같은데, 너무 적은가? 하는 생각이 이따금씩 든다. 특히나 잘 모르는 내용을 찾아보거나, 다른 뛰어난 개발자들을 볼 때마다도 종종 드는 생각이다. 약간 걱정이 되기도 하지만, 꾸준함을 좇고자 한다. 이전에도 이따금씩 개발 관련 공부를 하거나, 개발을 하기도 했지만 일정 기간 하루 종일 몰입하며 불태우면서 번아웃이 오고, 그 반동으로 쉬게 되고... 그런 경우가 많았던 것 같다. 작년도 사실 쉬면서 개발 공부를 거의 안 했던 것 같다. 지금처럼 꾸준히 했더라면 아마 지금과는 많이 다른 모습이었을 것 같다. 약간은 후회도 되긴 하지만, 지금부터라도 꾸준히 매일 조금씩이라도 배워간다면 머지 않아 괜찮은 개발자가 될 수 있으리라 생각한다. 그리고 개발을 재개하고 개발 공부를 시작한 지 딱 한 달이 되었는데, 요 한 달간은 주말이라고 쉰다거나 한 적은 거의 없는 것 같다. 개발을 배우는 것이 재밌기도 하고, 무리하지 않고 공부와 생활을 밸런스있게 가져간 덕분인 것 같기도 하다. 시간에 쫓기기보다는 조금 느리더라도 꾸준히 계속 배우겠다는 마인드로 임해야겠다.

+ Recent posts