게임 개발 공부 기록

29일차 - AI Navigation

00lwt 2025. 3. 5. 20:48

▶▷ AI Navigation

인공지능이 게임이나 시뮬레이션 등 가상 환경에서 캐릭터나 NPC가 지형, 장애물, 목표 지점 등을 고려하여 지능적으로 적절한 경로를 생성하고 이동하는데 사용

 

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

2. Pathfinding

  • 캐릭터의 현재 위치에서 목표 지점까지 가장 적절한 경로를 찾는 알고리즘

 

3. Steering Behavior

  • 캐릭터나 NPC가 경로를 따라 이동할 때 자연스러운 동작을 구현하기 위해 사용
  • 동적으로 캐릭터의 이동 방향과 속력을 조정하여 부드럽고 현실적인 이동을 시뮬레이션함

 

4. Obstacle Avoidance

  • 캐릭터가 이동 중에 장애물과 충돌하지 않도록 하는 기술
  • 각종 센서나 알고리즘을 사용하여 장애물을 감지하고 피하는 동작 수행

 

5. Local Avoidance (근접 회피)

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

 

▶▷ 적용

적 오브젝트에 NavMeshAgent 컴포넌트 추가

 

적 스크립트 작성 (세부 내용은 주석으로 작성)

using System.Collections;
using UnityEngine;
using UnityEngine.AI;

// NPC의 AI 상태 정의: 대기, 배회, 공격
public enum AIState
{
    Idle,       // 대기 상태
    Wandering,  // 배회 상태
    Attacking   // 공격 상태
}

public class NPC : MonoBehaviour, IDamagable
{
    [Header("Stat")]
    public int health;                // 체력
    public float walkSpeed;           // 걷기 속도
    public float runSpeed;            // 달리기 속도
    public ItemData[] dropOnDeath;    // 사망 시 떨어지는 아이템

    [Header("AI")]
    private NavMeshAgent agent;       // 네비게이션 에이전트
    public float detectDistance;      // 플레이어 탐지 거리
    private AIState aiState;          // 현재 AI 상태

    [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 Animator animator;        // 애니메이터 컴포넌트
    private SkinnedMeshRenderer[] meshRenderers;  // 모델의 메쉬 렌더러 배열

    private void Awake()
    {
        // 컴포넌트 초기화
        agent = GetComponent<NavMeshAgent>();
        animator = GetComponent<Animator>();
        meshRenderers = GetComponentsInChildren<SkinnedMeshRenderer>();
    }

    void Start()
    {
        // 시작시 배회 상태로 전환
        SetState(AIState.Wandering);
    }

    void Update()
    {
        // 플레이어와의 거리 계산
        playerDistance = Vector3.Distance(transform.position, CharacterManager.Instance.Player.transform.position);

        // 이동 여부에 따른 애니메이터 파라미터 설정
        animator.SetBool("Moving", aiState != AIState.Idle);

        // 현재 상태에 따른 업데이트 처리
        switch (aiState)
        {
            case AIState.Idle:
            case AIState.Wandering:
                PassiveUpdate();  // 대기 및 배회 상태 처리
                break;
            case AIState.Attacking:
                AttackingUpdate();  // 공격 상태 처리
                break;
        }
    }

    // 상태 변경 메서드
    public void SetState(AIState state)
    {
        aiState = state;

        // 상태에 따라 속도와 이동 제어 설정
        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;
        }

        // 애니메이터 재생 속도를 이동 속도에 맞춰 조정
        animator.speed = agent.speed / walkSpeed;
    }

    // 대기 및 배회 상태 업데이트
    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);
        }
    }

    // 새로운 배회 위치로 이동하는 메서드
    void WanderToNewLocation()
    {
        // 현재 상태가 Idle이 아니면 실행하지 않음
        if (aiState != AIState.Idle) return;

        SetState(AIState.Wandering);
        agent.SetDestination(GetWanderLocation());
    }

    // 배회할 새로운 위치를 반환
    Vector3 GetWanderLocation()
    {
        NavMeshHit hit;
        // 랜덤한 방향과 거리를 이용하여 위치 샘플링
        NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)), out hit, maxWanderDistance, NavMesh.AllAreas);

        int i = 0;
        // 플레이어와 너무 가까운 위치는 피함
        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;
    }

    // 공격 상태 업데이트
    void AttackingUpdate()
    {
        // 플레이어가 공격 범위 내에 있고 시야에 들어오면 공격 실행
        if (playerDistance < attackDistance && IsPlayerInFieldOfView())
        {
            agent.isStopped = true;  // 공격 시 이동 정지
            if (Time.time - lastAttackTime > attackRate)
            {
                lastAttackTime = Time.time;
                // 플레이어에게 피해 주기
                CharacterManager.Instance.Player.controller.GetComponent<IDamagable>().TakePhysicalDamage(damage);
                animator.speed = 1;
                animator.SetTrigger("Attack");
            }
        }
        else
        {
            // 플레이어가 여전히 감지 범위 내에 있을 때 플레이어 추격
            if (playerDistance < detectDistance)
            {
                agent.isStopped = false;
                NavMeshPath path = new NavMeshPath();
                if(agent.CalculatePath(CharacterManager.Instance.Player.transform.position, path))
                {
                    agent.SetDestination(CharacterManager.Instance.Player.transform.position);
                }
                else
                {
                    // 경로 계산 실패 시 배회 상태로 전환
                    agent.SetDestination(transform.position);
                    agent.isStopped = true;
                    SetState(AIState.Wandering);
                }
            }
            else
            {
                // 플레이어가 감지 범위를 벗어나면 배회 상태로 전환
                agent.SetDestination(transform.position);
                agent.isStopped = true;
                SetState(AIState.Wandering);
            }
        }
    }

    // 플레이어가 시야에 들어왔는지 확인
    bool IsPlayerInFieldOfView()
    {
        Vector3 directionToPlayer = CharacterManager.Instance.Player.transform.position - transform.position;
        float angle = Vector3.Angle(transform.forward, directionToPlayer);
        return angle < fieldOfView * 0.5f;
    }

    // 물리 피해를 입는 메서드
    public void TakePhysicalDamage(int damage)
    {
        health -= damage;
        if(health <= 0)
        {
            Die();  // 체력이 0 이하이면 사망 처리
        }

        StartCoroutine(DamageFlash());  // 피격 시 깜빡임 효과
    }

    // 사망 처리: 아이템 드랍 후 오브젝트 파괴
    void Die()
    {
        for (int i = 0; i < dropOnDeath.Length; i++)
        {
            Instantiate(dropOnDeath[i].dropPrefab, transform.position + Vector3.up * 2, Quaternion.identity);
        }

        Destroy(gameObject);
    }

    // 피해 입을 때 깜빡임 효과를 주는 코루틴
    IEnumerator DamageFlash()
    {
        // 색상 변경 (피해 효과)
        for(int i = 0; i < meshRenderers.Length; i++)
        {
            meshRenderers[i].material.color = new Color(1.0f, 0.6f, 0.6f);
        }

        yield return new WaitForSeconds(0.1f);

        // 원래 색상으로 복구
        for(int i = 0; i < meshRenderers.Length; i++)
        {
            meshRenderers[i].material.color = Color.white;
        }
    }
}

 

적 스크립트 컴포넌트에 수치 작성

 

결과

 

▶▷ 오늘의 회고

내비게이션 기능은 예전에 독학으로 한번 사용해보려고 도전했다가 실패했었던 기능이었는데 그때부터 남아있던 아쉬움이 이번 강의를 통해 사라지게 되었고 앞으로 3D 프로젝트를 할 때 정말 많이 사용할 기능일 것 같아 확실히 이해하는 시간을 가져야 할 것 같다.