개발/개발일지

3D 게임 개발 공부 5 - 적 캐릭터 시야 구현, 상태 머신에 패트롤 상태 추가

메피카타츠 2023. 4. 8. 22:20

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

 

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

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

 

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