Unity 숙련주차 - Survival 게임 적 생성과 로직

Amberjack·2024년 2월 5일
0

Unity

목록 보기
32/44
post-custom-banner

🤖 AI 네비게이션(AI Navigation)

AI 네비게이션(AI Navigation)은 인공지능이 게임이나 시뮬레이션 등 가상 환경에서 이동하는 방법을 결정하는 기술이다. 주로 3D 게임에서 캐릭터나 NPC가 지능적으로 이동하도록 만들어진다. 이를 위해 AI 네비게이션 시스템은 지형, 장애물, 목표 지점 등을 고려하여 적절한 경로를 생성하고 이동하는데 사용된다.

AI 네비게이션을 구현하는 주요 기술과 개념!

    • 3D 공간을 그리드로 나누어 이동 가능한 지역과 장애물이 있는 지역을 구분하는 매쉬이다.
    • 캐릭터가 이동할 수 있는 영역과 이동할 수 없는 영역을 정의하고, 이를 기반으로 경로를 계산한다.
  1. Pathfinding (경로 탐색)

    • 캐릭터의 현재 위치에서 목표 지점까지 가장 적절한 경로를 찾는 알고리즘.
    • 주로 A* 알고리즘 등이 사용되며, 지정된 목표 위치까지 최단 경로를 탐색한다.
  2. Steering Behavior (스티어링 동작)

    • 캐릭터나 NPC가 경로를 따라 이동할 때, 보다 자연스러운 동작을 구현하는데 사용된다.
    • 동적으로 캐릭터의 이동 방향과 속력을 조정하여 부드럽고 현실적인 이동을 시뮬레이션한다.
  3. Obstacle Avoidance (장애물 피하기)

    • 캐릭터가 이동 중에 장애물과 충돌하지 않도록 하는 기술.
    • 각종 센서나 알고리즘을 사용하여 장애물을 감지하고 피하는 동작을 수행한다.
  4. Local Avoidance (근접 회피)

    • 여러 캐릭터나 NPC가 서로 충돌하지 않도록 하는 기술.
    • 캐릭터들 사이의 거리를 유지하거나 회피 동작을 수행하여 서로 부딪히지 않도록 한다.

🤖 AI 네비게이션 설정하기

Window → Package Manager에서 AI Navigation을 찾아 설치한다.

걸을 수 있는 땅과 없는 땅 선택하기

적이 생성되었을 때 걸을 수 있는 땅과 없는 땅을 선택해주자.

Window → AI → Navigation (Obsolete) 을 선택한다.

선택하면 Inspector 창에 Navigation (Obsolete) 창이 뜨게 된다.

Navigation을 설정해주기 위해서는 Navigation Static을 설정해주어야 한다.

걸을 수 있도록 설정할 지역은 Navigation Area를 Walkable로 변경해주면 된다.

반대로 못 걷게 설정할 경우 Not Walkable로 변경해주면 된다.

Bake 하기

이동 경로, 경사를 설정할 수 있다.

Bake를 하게 되면 맵의 색상이 바뀌고 Node들이 생기는 데, 해당 지역에서 연결된 Node들은 AI가 움직일 수 있는 최적의 경로를 의미한다. 또한 Not Walkable한 지역들은 변함이 없는 것을 확인할 수 있다. 이는 AI가 움직일 수 없는 곳이기 때문이다.

때문에 위의 경우, 플레이어는 Rigidbody를 통해 움직이기 때문에 두 섬 사이를 왔다갔다 할 수 있지만, AI는 Navigation을 쓰기 때문에 한 섬에서만 돌아다니게 된다.

자원 나무들에게 Nav Mesh Obstacle 컴포넌트를 추가하자. 이 컴포넌트를 통해 AI가 맵에서 움직일 때, 해당 컴포넌트를 지닌 객체를 장애물로 인식하고 회피를 할 수 있게 된다. 또한 이 컴포넌트를 통해 만약 객체가 사라졌을 경우에 대한 AI의 경로 처리에도 도움을 준다.

Nav Mesh Obstacle을 추가하고 Carve를 켜게 되면 AI Navigation의 Node에 영향을 미치는 것을 확인할 수 있다.

Nav Mesh Obstacle 컴포넌트를 가진 객체는 움직일 때마다 AI Navigation 경로 계산에 영향을 주므로, 동적으로 자주 움직이는 객체에게는 사용하지 않는 것이 좋다.

🐻 NPC 만들기

빈 오브젝트를 생성해서 _NPC를 만들고, 그 아래에 다시 빈 오브젝트를 생성해서 NPC로 만들어 준다.

이후 NPC가 실제로 맵을 돌아다닐 수 있도록 Navi Mesh Agent 컴포넌트를 추가해준다.

또한 충돌 처리를 위한 Box Collider를 추가해준다.

에셋 이용하기

적 NPC로 에셋을 사용하여 적용해보자.

플레이어와 분리해두기 위해 다른 섬에 옮겨 놓고 구현을 해보자.

NPC 애니메이션 이용하기

에셋에 함께 있는 애니메이션 중 필요한 것들만 사용하자.

  • 공격 애니메이션 만들기

    Parameter로 Attack을 Trigger로, Moving을 bool로 설정해준다.

    Has Exit Time을 해체하고 Conditions에 Attack을 설정해준다.


이후 공격에서 idle, Walk로 다시 돌아올 때, 각각 Moving이 false일 때, True일 때로 나누어 Transition을 만들어 준다.

공격 → idle

공격 → Walk

  • 무빙 애니메이션 만들기

    idle에서 Walk으로 갈 때 Moving을 true로, walk에서 idle로 돌아올 때 Moving을 false로 변경해주면 된다.

👨‍💻 NPC 코드 작성

Scripts 폴더 밑에 NPC 폴더를 생성, NPC.cs를 생성하여 NPC 오브젝트에게 붙여준다.

using UnityEngine;
using UnityEngine.AI;

public enum AIState
{
    Idle,
    Wandering,
    Attacking,
    Fleeing
}

public class NPC : MonoBehaviour, IDamagable
{
    [Header("Stats")]
    public int health;
    public float walkSpeed;
    public float runSpeed;
    public itemData[] dropOnDeath;

    [Header("AI")]
    private AIState aiState;
    public float detectDistance;
    public float safeDistance;

    [Header("Wandering")]
    public float minWanderDistance;
    public float maxWanderDistance;
    public float minWanderWaitTime;
    public float maxWanderWaitTime;

    [Header("Combat")]
    public int damage;
    public float attackRate;
    private float lastAttackTime;
    public float attackDistance;

    private float playerDistance;

    public float fieldOfView = 120f;    // 시야각. 플레이어가 시야각 안에 들어와야 추격을 시작하도록

    private NavMeshAgent agent;
    private Animator animator;
    private SkinnedMeshRenderer[] meshRenderers;    // 피격을 처리하기 위함.

    private void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        animator = GetComponent<Animator>();
        meshRenderers = GetComponentsInChildren<SkinnedMeshRenderer>();
    }

    private void Start()
    {
        // 처음 시작 시 NPC의 상태 설정. 탐색 상태로 설정해준다.
        SetState(AIState.Wandering);
    }

    private void Update()
    {
        // 플레이어와의 거리 가져오기
        playerDistance = Vector3.Distance(transform.position, PlayerController.instance.transform.position);

        animator.SetBool("Moving", aiState != AIState.Idle);

        switch (aiState)
        {
            case AIState.Idle:
                PassiveUpdate();
                break;
            case AIState.Wandering:
                PassiveUpdate();
                break;
            case AIState.Attacking:
                AttackingUpdate();
                break;
            case AIState.Fleeing:
                FleeingUpdate();
                break;
        }
    }

    private void FleeingUpdate()
    {
        if(agent.remainingDistance < 0.1f)
        {
            agent.SetDestination(GetFleeLocation());
        }
        else
        {
            SetState(AIState.Wandering);
        }
    }

    private void AttackingUpdate()
    {
        // 공격 중일 때 플레이어가 공격 범위보다 멀어지거나 시야에서 벗어나면
        if(playerDistance > attackDistance || !IsPlayerInFieldOfView())
        {
            agent.isStopped = false;
            // 경로 새로 검색. 
            NavMeshPath path = new NavMeshPath();
            // 플레이어와의 경로가 접근 가능한 경우
            if (agent.CalculatePath(PlayerController.instance.transform.position, path))
            {
                // 목적지 위치 정보 전달.
                agent.SetDestination(PlayerController.instance.transform.position);
            }
            else
            {
                // 도망
                SetState(AIState.Fleeing);
            }
        }
        else
        {
            // 플레이어 공격하기
            agent.isStopped = true;
            if(Time.time - lastAttackTime > attackRate)
            {
                lastAttackTime = Time.time;
                PlayerController.instance.GetComponent<IDamagable>().TakePhysicalDamage(damage);
                animator.speed = 1;
                animator.SetTrigger("Attack");
            }
        }
    }

    private void PassiveUpdate()
    {
        // 목적지까지 배회하다가 목적지에 도착하게 되면
        if(aiState == AIState.Wandering && agent.remainingDistance < 0.1f)
        {
            SetState(AIState.Idle);
            // 새로운 목적지 가져오기
            Invoke("WanderToNewLocation", Random.Range(minWanderWaitTime, maxWanderWaitTime));
        }

        // 플레이어가 감지 범위 안에 들어오면 공격하기
        if(playerDistance < detectDistance)
        {
            SetState(AIState.Attacking);
        }
    }

    // NPC의 상태 적용시키기
    private void SetState(AIState newState)
    {
        aiState = newState;

        switch (aiState)
        {
            case AIState.Idle:
                agent.speed = walkSpeed;
                agent.isStopped = true;     // 움직이지 않도록 설정
                break;
            case AIState.Wandering:
                agent.speed = walkSpeed;
                agent.isStopped = false;     // 움직일 수 있도록 설정
                break;
            case AIState.Attacking:
                agent.speed = runSpeed;
                agent.isStopped = false;     // 움직일 수 있도록 설정
                break;
            case AIState.Fleeing:
                agent.speed = runSpeed;
                agent.isStopped = false;     // 움직일 수 있도록 설정
                break;
        }
        
        // 애니메이션의 속도가 느리게 보이는 것을 수정. 
        animator.speed = agent.speed / walkSpeed;
    }

    private bool IsPlayerInFieldOfView()
    {
        // 거리 구하기 : 바라보고 싶은 객체 - 현 객체
        Vector3 directionToPlayer = PlayerController.instance.transform.position - transform.position;
        // 내 forward와 directionToPlayer의 각도가 몇 도인지 체크
        float angle = Vector3.Angle(transform.forward, directionToPlayer);

        // 플레이어가 시야에 들어와 있는지 체크
        // fieldOfView * 0.5f : 현재 내 시야각. 0.5f를 곱하는 이유는 전체 시야 360도에서 앞 쪽 180도만 체크하도록 설정하기 위함.
        // angle < fieldOfView * 0.5f : 플레이어와 나의 위치가 시야각보다 작을 경우 true, 시야각을 벗어나면 false
        return angle < fieldOfView * 0.5f;
    }

    private Vector3 GetFleeLocation()
    {
        NavMeshHit hit;
        // 경로 상의 가장 가까운 곳을 가져옴
        NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * safeDistance), out hit, maxWanderDistance, NavMesh.AllAreas);

        int i = 0;
        // 30번 while문을 시도해서 최적의 경로 가져오기
        while (GetDestinationAngle(hit.position) > 90 || playerDistance < safeDistance)
        {
            NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * safeDistance), out hit, maxWanderDistance, NavMesh.AllAreas);
            i++;
            if (i == 30) break;
        }

        return hit.position;
    }

    // 목적지 위치 가져오기
    private float GetDestinationAngle(Vector3 targetPos)
    {
        return Vector3.Angle(transform.position - PlayerController.instance.transform.position, transform.position + targetPos);
    }

    // 배회하다 배회 목적지에 도착하면 새로운 배회 목적지 할당하기
    void WanderToNewLocation()
    {
        if (aiState != AIState.Idle)
        {
            return;
        }
        SetState(AIState.Wandering);
        agent.SetDestination(GetWanderLocation());
    }

    private Vector3 GetWanderLocation()
    {
        NavMeshHit hit;
        // 경로 상의 가장 가까운 곳을 가져옴
        NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)), out hit, maxWanderDistance, NavMesh.AllAreas);

        int i = 0;
        // 30번 while문을 시도해서 최적의 경로 가져오기
        while (Vector3.Distance(transform.position, hit.position) < detectDistance)
        {
            NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)), out hit, maxWanderDistance, NavMesh.AllAreas);
            i++;
            if (i == 30) break;
        }

        return hit.position;
    }

    public void TakePhysicalDamage(int damageAmount)
    {
        health -= damageAmount;
        if (health <= 0) Die();

        StartCoroutine(DamageFlash());
    }

    void Die()
    {
        // 죽었을 때 아이템 드랍하기
        for(int x = 0; x < dropOnDeath.Length; x++)
        {
            Instantiate(dropOnDeath[x].dropPerfab, transform.position + Vector3.up * 2, Quaternion.identity);
        }

        Destroy(gameObject);
    }

    // 맞았을 때 색을 빨갛게 만들기
    IEnumerator DamageFlash()
    {
        for (int x = 0; x < meshRenderers.Length; x++)
        {
            meshRenderers[x].material.color = new Color(1.0f, 0.6f, 0.6f);
        }

        yield return new WaitForSeconds(0.1f);
            
        for(int x = 0; x < meshRenderers.Length; x++)
            meshRenderers[x].material.color = Color.white;
    }
}

NPC 스크립트에 데이터 입력하기

Unity로 돌아와서 NPC 스크립트에 필요한 데이터들을 입력하자.

다음으로 Nav Mesh Agent의 값도 수정해준다.

마지막으로 플레이어의 공격이 통하도록 Box Collider를 NPC의 크기에 맞춰 수정해주면 된다.

확인해보기

플레이어가 공격 시 NPC의 몸 색이 빨갛게 변경되고 죽을 때 아이템을 드랍하는 것을 확인할 수 있다.

플레이어가 인식 범위에 들어오면 공격을 하며, 설정되어 있는 Navigation Area를 벗어나자 쫓아오지 않는 것을 확인할 수 있다.

post-custom-banner

0개의 댓글