Unity의 AI 지원으로 FPS를 만드는 방법

1인칭 슈팅 게임(FPS)은 플레이어가 1인칭 시점에서 제어되는 슈팅 게임의 하위 장르입니다.

Unity에서 FPS 게임을 만들려면 플레이어 컨트롤러, 아이템 배열(이 경우 무기) 및 적이 필요합니다.

1단계: 플레이어 컨트롤러 생성

여기서는 플레이어가 사용할 컨트롤러를 생성하겠습니다.

  • 새 게임 개체를 만들고(Game Object -> Create Blank) 이름을 지정합니다. "Player"
  • 새 캡슐(게임 개체 -> 3D 개체 -> 캡슐)을 만들고 "Player" 개체 내부로 옮깁니다.
  • Capsule에서 Capsule Collider 구성 요소를 제거하고 위치를 (0, 1, 0)으로 변경합니다.
  • Main Camera를 "Player" Object 내부로 이동하고 위치를 (0, 1.64, 0)으로 변경합니다.
  • 새 스크립트를 만들고 이름을 "SC_CharacterController"로 지정한 후 그 안에 아래 코드를 붙여넣습니다.

SC_CharacterController.cs

using UnityEngine;

[RequireComponent(typeof(CharacterController))]

public class SC_CharacterController : MonoBehaviour
{
    public float speed = 7.5f;
    public float jumpSpeed = 8.0f;
    public float gravity = 20.0f;
    public Camera playerCamera;
    public float lookSpeed = 2.0f;
    public float lookXLimit = 45.0f;

    CharacterController characterController;
    Vector3 moveDirection = Vector3.zero;
    Vector2 rotation = Vector2.zero;

    [HideInInspector]
    public bool canMove = true;

    void Start()
    {
        characterController = GetComponent<CharacterController>();
        rotation.y = transform.eulerAngles.y;
    }

    void Update()
    {
        if (characterController.isGrounded)
        {
            // We are grounded, so recalculate move direction based on axes
            Vector3 forward = transform.TransformDirection(Vector3.forward);
            Vector3 right = transform.TransformDirection(Vector3.right);
            float curSpeedX = canMove ? speed * Input.GetAxis("Vertical") : 0;
            float curSpeedY = canMove ? speed * Input.GetAxis("Horizontal") : 0;
            moveDirection = (forward * curSpeedX) + (right * curSpeedY);

            if (Input.GetButton("Jump") && canMove)
            {
                moveDirection.y = jumpSpeed;
            }
        }

        // Apply gravity. Gravity is multiplied by deltaTime twice (once here, and once below
        // when the moveDirection is multiplied by deltaTime). This is because gravity should be applied
        // as an acceleration (ms^-2)
        moveDirection.y -= gravity * Time.deltaTime;

        // Move the controller
        characterController.Move(moveDirection * Time.deltaTime);

        // Player and Camera rotation
        if (canMove)
        {
            rotation.y += Input.GetAxis("Mouse X") * lookSpeed;
            rotation.x += -Input.GetAxis("Mouse Y") * lookSpeed;
            rotation.x = Mathf.Clamp(rotation.x, -lookXLimit, lookXLimit);
            playerCamera.transform.localRotation = Quaternion.Euler(rotation.x, 0, 0);
            transform.eulerAngles = new Vector2(0, rotation.y);
        }
    }
}
  • SC_CharacterController 스크립트를 "Player" 개체에 연결합니다(중간 값을 (0, 1, 0)으로 변경하여 Character Controller라는 또 다른 구성 요소도 추가했음을 알 수 있습니다)
  • SC_CharacterController의 플레이어 카메라 변수에 기본 카메라를 할당합니다.

이제 플레이어 컨트롤러가 준비되었습니다.

2단계: 무기 시스템 생성

플레이어 무기 시스템은 무기 관리자, 무기 스크립트, 총알 스크립트의 3가지 구성 요소로 구성됩니다.

  • 새 스크립트를 만들고 이름을 "SC_WeaponManager"로 지정한 후 그 안에 아래 코드를 붙여넣습니다.

SC_WeaponManager.cs

using UnityEngine;

public class SC_WeaponManager : MonoBehaviour
{
    public Camera playerCamera;
    public SC_Weapon primaryWeapon;
    public SC_Weapon secondaryWeapon;

    [HideInInspector]
    public SC_Weapon selectedWeapon;

    // Start is called before the first frame update
    void Start()
    {
        //At the start we enable the primary weapon and disable the secondary
        primaryWeapon.ActivateWeapon(true);
        secondaryWeapon.ActivateWeapon(false);
        selectedWeapon = primaryWeapon;
        primaryWeapon.manager = this;
        secondaryWeapon.manager = this;
    }

    // Update is called once per frame
    void Update()
    {
        //Select secondary weapon when pressing 1
        if (Input.GetKeyDown(KeyCode.Alpha1))
        {
            primaryWeapon.ActivateWeapon(false);
            secondaryWeapon.ActivateWeapon(true);
            selectedWeapon = secondaryWeapon;
        }

        //Select primary weapon when pressing 2
        if (Input.GetKeyDown(KeyCode.Alpha2))
        {
            primaryWeapon.ActivateWeapon(true);
            secondaryWeapon.ActivateWeapon(false);
            selectedWeapon = primaryWeapon;
        }
    }
}
  • 새 스크립트를 만들고 이름을 "SC_Weapon"로 지정한 후 그 안에 아래 코드를 붙여넣습니다.

SC_Weapon.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(AudioSource))]

public class SC_Weapon : MonoBehaviour
{
    public bool singleFire = false;
    public float fireRate = 0.1f;
    public GameObject bulletPrefab;
    public Transform firePoint;
    public int bulletsPerMagazine = 30;
    public float timeToReload = 1.5f;
    public float weaponDamage = 15; //How much damage should this weapon deal
    public AudioClip fireAudio;
    public AudioClip reloadAudio;

    [HideInInspector]
    public SC_WeaponManager manager;

    float nextFireTime = 0;
    bool canFire = true;
    int bulletsPerMagazineDefault = 0;
    AudioSource audioSource;

    // Start is called before the first frame update
    void Start()
    {
        bulletsPerMagazineDefault = bulletsPerMagazine;
        audioSource = GetComponent<AudioSource>();
        audioSource.playOnAwake = false;
        //Make sound 3D
        audioSource.spatialBlend = 1f;
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetMouseButtonDown(0) && singleFire)
        {
            Fire();
        }
        if (Input.GetMouseButton(0) && !singleFire)
        {
            Fire();
        }
        if (Input.GetKeyDown(KeyCode.R) && canFire)
        {
            StartCoroutine(Reload());
        }
    }

    void Fire()
    {
        if (canFire)
        {
            if (Time.time > nextFireTime)
            {
                nextFireTime = Time.time + fireRate;

                if (bulletsPerMagazine > 0)
                {
                    //Point fire point at the current center of Camera
                    Vector3 firePointPointerPosition = manager.playerCamera.transform.position + manager.playerCamera.transform.forward * 100;
                    RaycastHit hit;
                    if (Physics.Raycast(manager.playerCamera.transform.position, manager.playerCamera.transform.forward, out hit, 100))
                    {
                        firePointPointerPosition = hit.point;
                    }
                    firePoint.LookAt(firePointPointerPosition);
                    //Fire
                    GameObject bulletObject = Instantiate(bulletPrefab, firePoint.position, firePoint.rotation);
                    SC_Bullet bullet = bulletObject.GetComponent<SC_Bullet>();
                    //Set bullet damage according to weapon damage value
                    bullet.SetDamage(weaponDamage);

                    bulletsPerMagazine--;
                    audioSource.clip = fireAudio;
                    audioSource.Play();
                }
                else
                {
                    StartCoroutine(Reload());
                }
            }
        }
    }

    IEnumerator Reload()
    {
        canFire = false;

        audioSource.clip = reloadAudio;
        audioSource.Play();

        yield return new WaitForSeconds(timeToReload);

        bulletsPerMagazine = bulletsPerMagazineDefault;

        canFire = true;
    }

    //Called from SC_WeaponManager
    public void ActivateWeapon(bool activate)
    {
        StopAllCoroutines();
        canFire = true;
        gameObject.SetActive(activate);
    }
}
  • 새 스크립트를 만들고 이름을 "SC_Bullet"로 지정한 후 그 안에 아래 코드를 붙여넣습니다.

SC_Bullet.cs

using System.Collections;
using UnityEngine;

public class SC_Bullet : MonoBehaviour
{
    public float bulletSpeed = 345;
    public float hitForce = 50f;
    public float destroyAfter = 3.5f;

    float currentTime = 0;
    Vector3 newPos;
    Vector3 oldPos;
    bool hasHit = false;

    float damagePoints;

    // Start is called before the first frame update
    IEnumerator Start()
    {
        newPos = transform.position;
        oldPos = newPos;

        while (currentTime < destroyAfter && !hasHit)
        {
            Vector3 velocity = transform.forward * bulletSpeed;
            newPos += velocity * Time.deltaTime;
            Vector3 direction = newPos - oldPos;
            float distance = direction.magnitude;
            RaycastHit hit;

            // Check if we hit anything on the way
            if (Physics.Raycast(oldPos, direction, out hit, distance))
            {
                if (hit.rigidbody != null)
                {
                    hit.rigidbody.AddForce(direction * hitForce);

                    IEntity npc = hit.transform.GetComponent<IEntity>();
                    if (npc != null)
                    {
                        //Apply damage to NPC
                        npc.ApplyDamage(damagePoints);
                    }
                }

                newPos = hit.point; //Adjust new position
                StartCoroutine(DestroyBullet());
            }

            currentTime += Time.deltaTime;
            yield return new WaitForFixedUpdate();

            transform.position = newPos;
            oldPos = newPos;
        }

        if (!hasHit)
        {
            StartCoroutine(DestroyBullet());
        }
    }

    IEnumerator DestroyBullet()
    {
        hasHit = true;
        yield return new WaitForSeconds(0.5f);
        Destroy(gameObject);
    }

    //Set how much damage this bullet will deal
    public void SetDamage(float points)
    {
        damagePoints = points;
    }
}

이제 SC_Bullet 스크립트에 몇 가지 오류가 있음을 알 수 있습니다. 그 이유는 마지막으로 해야 할 일이 하나 있기 때문입니다. 바로 IEntity 인터페이스를 정의하는 것입니다.

C#의 인터페이스는 이를 사용하는 스크립트에 특정 메서드가 구현되어 있는지 확인해야 할 때 유용합니다.

IEntity 인터페이스에는 ApplyDamage라는 하나의 메서드가 있으며 나중에 적과 플레이어에게 피해를 입히는 데 사용됩니다.

  • 새 스크립트를 만들고 이름을 "SC_InterfaceManager"로 지정한 후 그 안에 아래 코드를 붙여넣습니다.

SC_InterfaceManager.cs

//Entity interafce
interface IEntity
{ 
    void ApplyDamage(float points);
}

무기 관리자 설정

무기 관리자는 주 카메라 개체 아래에 위치하며 모든 무기를 포함하는 개체입니다.

  • 새로운 GameObject를 생성하고 이름을 지정하세요. "WeaponManager"
  • Player Main Camera 내부에서 WeaponManager를 이동하고 위치를 (0, 0, 0)으로 변경합니다.
  • SC_WeaponManager 스크립트를 다음에 연결하세요. "WeaponManager"
  • SC_WeaponManager의 플레이어 카메라 변수에 기본 카메라를 할당합니다.

소총 설정

  • 총 모델을 장면에 끌어다 놓습니다(또는 아직 모델이 없는 경우 큐브를 만들고 늘이기만 하면 됩니다).
  • 플레이어 캡슐을 기준으로 크기가 조정되도록 모델 크기 조정

제 경우에는 맞춤형 소총 모델(BERGARA BA13)을 사용하겠습니다.

베르가라 BA13

  • 새로운 GameObject를 생성하고 이름을 "Rifle"으로 지정한 다음 그 안에 소총 모델을 옮깁니다.
  • "Rifle" 개체를 "WeaponManager" 개체 내부로 이동하고 다음과 같이 카메라 앞에 배치합니다.

Unity에서 카메라 클리핑 문제를 해결합니다.

객체 클리핑을 수정하려면 카메라의 근거리 클리핑 평면을 더 작은 것으로 변경하면 됩니다(제 경우에는 0.15로 설정했습니다).

베르가라 BA13

훨씬 낫다.

  • 소총 개체에 SC_Weapon 스크립트를 연결합니다(오디오 소스 구성 요소도 추가된 것을 볼 수 있습니다. 이는 발사를 재생하고 오디오를 다시 로드하는 데 필요합니다).

보시다시피 SC_Weapon에는 할당할 변수가 4개 있습니다. 프로젝트에 적합한 오디오 클립이 있으면 Fire audio 및 Reload audio 변수를 즉시 할당할 수 있습니다.

Bullet Prefab 변수는 이 튜토리얼의 뒷부분에서 설명됩니다.

지금은 Fire point 변수를 할당하겠습니다.

  • 새로운 GameObject를 생성하고 이름을 "FirePoint"로 바꾸고 Rifle Object 내부로 옮깁니다. 다음과 같이 배럴 바로 앞이나 약간 안쪽에 배치합니다.

  • SC_Weapon의 Fire Point 변수에 FirePoint Transform 할당
  • SC_WeaponManager 스크립트의 보조 무기 변수에 소총 할당

기관단총 설정

  • Rifle Object를 복제하고 이름을 Submachinegun으로 바꿉니다.
  • 내부에 있는 총 모델을 다른 모델로 교체합니다. (저의 경우 TAVOR X95의 맞춤 모델을 사용하겠습니다.)

테이버 X95

  • 새 모델에 맞을 때까지 Fire Point 변환을 이동합니다.

Unity의 Weapon Fire Point 객체 설정.

  • SC_WeaponManager 스크립트의 기본 무기 변수에 Submachinegun 할당

총알 구조물 설정

총알 프리팹은 무기의 발사 속도에 따라 생성되며 Raycast를 사용하여 그것이 무엇인가에 부딪혔는지 감지하고 피해를 입힙니다.

  • 새로운 GameObject를 생성하고 이름을 지정하세요. "Bullet"
  • Trail Renderer 구성 요소를 추가하고 Time 변수를 0.1로 변경합니다.
  • 폭 곡선을 더 낮은 값(예: 시작 0.1 끝 0)으로 설정하여 뾰족한 흔적을 추가합니다.
  • 새 머티리얼을 생성하고 이름을 bullet_trail_material로 지정하고 셰이더를 Particles/Additive로 변경합니다.
  • 새로 생성된 재료를 트레일 렌더러에 할당
  • 트레일 렌더러의 색상을 다른 것으로 변경합니다(예: 시작: 밝은 주황색 끝: 어두운 주황색).

  • Bullet 객체를 Prefab에 저장하고 장면에서 삭제합니다.
  • 새로 생성된 프리팹(프로젝트 보기에서 드래그 앤 드롭)을 소총 및 기관단총 총알 프리팹 변수에 할당합니다.

기관단총:

소총:

이제 무기가 준비되었습니다.

3단계: 적 AI 생성

적들은 플레이어를 따라가며 충분히 가까워지면 공격하는 단순한 큐브가 됩니다. 그들은 파도로 공격할 것이며, 각 파도에는 제거해야 할 적들이 더 많습니다.

적 AI 설정

아래에서는 큐브의 2가지 변형을 만들었습니다(왼쪽은 살아있는 인스턴스용이고 오른쪽은 적이 죽으면 생성됩니다).

  • 죽은 인스턴스와 살아있는 인스턴스 모두에 Rigidbody 구성 요소 추가
  • Dead Instance를 Prefab에 저장하고 Scene에서 삭제합니다.

이제 살아있는 인스턴스는 게임 레벨을 탐색하고 플레이어에게 피해를 입힐 수 있도록 몇 가지 추가 구성 요소가 필요합니다.

  • 새 스크립트를 만들고 이름을 "SC_NPCEnemy"으로 지정한 다음 그 안에 아래 코드를 붙여넣습니다.

SC_NPCEnemy.cs

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]

public class SC_NPCEnemy : MonoBehaviour, IEntity
{
    public float attackDistance = 3f;
    public float movementSpeed = 4f;
    public float npcHP = 100;
    //How much damage will npc deal to the player
    public float npcDamage = 5;
    public float attackRate = 0.5f;
    public Transform firePoint;
    public GameObject npcDeadPrefab;

    [HideInInspector]
    public Transform playerTransform;
    [HideInInspector]
    public SC_EnemySpawner es;
    NavMeshAgent agent;
    float nextAttackTime = 0;

    // Start is called before the first frame update
    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        agent.stoppingDistance = attackDistance;
        agent.speed = movementSpeed;

        //Set Rigidbody to Kinematic to prevent hit register bug
        if (GetComponent<Rigidbody>())
        {
            GetComponent<Rigidbody>().isKinematic = true;
        }
    }

    // Update is called once per frame
    void Update()
    {
        if (agent.remainingDistance - attackDistance < 0.01f)
        {
            if(Time.time > nextAttackTime)
            {
                nextAttackTime = Time.time + attackRate;

                //Attack
                RaycastHit hit;
                if(Physics.Raycast(firePoint.position, firePoint.forward, out hit, attackDistance))
                {
                    if (hit.transform.CompareTag("Player"))
                    {
                        Debug.DrawLine(firePoint.position, firePoint.position + firePoint.forward * attackDistance, Color.cyan);

                        IEntity player = hit.transform.GetComponent<IEntity>();
                        player.ApplyDamage(npcDamage);
                    }
                }
            }
        }
        //Move towardst he player
        agent.destination = playerTransform.position;
        //Always look at player
        transform.LookAt(new Vector3(playerTransform.transform.position.x, transform.position.y, playerTransform.position.z));
    }

    public void ApplyDamage(float points)
    {
        npcHP -= points;
        if(npcHP <= 0)
        {
            //Destroy the NPC
            GameObject npcDead = Instantiate(npcDeadPrefab, transform.position, transform.rotation);
            //Slightly bounce the npc dead prefab up
            npcDead.GetComponent<Rigidbody>().velocity = (-(playerTransform.position - transform.position).normalized * 8) + new Vector3(0, 5, 0);
            Destroy(npcDead, 10);
            es.EnemyEliminated(this);
            Destroy(gameObject);
        }
    }
}
  • 새 스크립트를 만들고 이름을 "SC_EnemySpawner"로 지정한 다음 그 안에 아래 코드를 붙여넣습니다.

SC_EnemySpawner.cs

using UnityEngine;
using UnityEngine.SceneManagement;

public class SC_EnemySpawner : MonoBehaviour
{
    public GameObject enemyPrefab;
    public SC_DamageReceiver player;
    public Texture crosshairTexture;
    public float spawnInterval = 2; //Spawn new enemy each n seconds
    public int enemiesPerWave = 5; //How many enemies per wave
    public Transform[] spawnPoints;

    float nextSpawnTime = 0;
    int waveNumber = 1;
    bool waitingForWave = true;
    float newWaveTimer = 0;
    int enemiesToEliminate;
    //How many enemies we already eliminated in the current wave
    int enemiesEliminated = 0;
    int totalEnemiesSpawned = 0;

    // Start is called before the first frame update
    void Start()
    {
        //Lock cursor
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;

        //Wait 10 seconds for new wave to start
        newWaveTimer = 10;
        waitingForWave = true;
    }

    // Update is called once per frame
    void Update()
    {
        if (waitingForWave)
        {
            if(newWaveTimer >= 0)
            {
                newWaveTimer -= Time.deltaTime;
            }
            else
            {
                //Initialize new wave
                enemiesToEliminate = waveNumber * enemiesPerWave;
                enemiesEliminated = 0;
                totalEnemiesSpawned = 0;
                waitingForWave = false;
            }
        }
        else
        {
            if(Time.time > nextSpawnTime)
            {
                nextSpawnTime = Time.time + spawnInterval;

                //Spawn enemy 
                if(totalEnemiesSpawned < enemiesToEliminate)
                {
                    Transform randomPoint = spawnPoints[Random.Range(0, spawnPoints.Length - 1)];

                    GameObject enemy = Instantiate(enemyPrefab, randomPoint.position, Quaternion.identity);
                    SC_NPCEnemy npc = enemy.GetComponent<SC_NPCEnemy>();
                    npc.playerTransform = player.transform;
                    npc.es = this;
                    totalEnemiesSpawned++;
                }
            }
        }

        if (player.playerHP <= 0)
        {
            if (Input.GetKeyDown(KeyCode.Space))
            {
                Scene scene = SceneManager.GetActiveScene();
                SceneManager.LoadScene(scene.name);
            }
        }
    }

    void OnGUI()
    {
        GUI.Box(new Rect(10, Screen.height - 35, 100, 25), ((int)player.playerHP).ToString() + " HP");
        GUI.Box(new Rect(Screen.width / 2 - 35, Screen.height - 35, 70, 25), player.weaponManager.selectedWeapon.bulletsPerMagazine.ToString());

        if(player.playerHP <= 0)
        {
            GUI.Box(new Rect(Screen.width / 2 - 85, Screen.height / 2 - 20, 170, 40), "Game Over\n(Press 'Space' to Restart)");
        }
        else
        {
            GUI.DrawTexture(new Rect(Screen.width / 2 - 3, Screen.height / 2 - 3, 6, 6), crosshairTexture);
        }

        GUI.Box(new Rect(Screen.width / 2 - 50, 10, 100, 25), (enemiesToEliminate - enemiesEliminated).ToString());

        if (waitingForWave)
        {
            GUI.Box(new Rect(Screen.width / 2 - 125, Screen.height / 4 - 12, 250, 25), "Waiting for Wave " + waveNumber.ToString() + " (" + ((int)newWaveTimer).ToString() + " seconds left...)");
        }
    }

    public void EnemyEliminated(SC_NPCEnemy enemy)
    {
        enemiesEliminated++;

        if(enemiesToEliminate - enemiesEliminated <= 0)
        {
            //Start next wave
            newWaveTimer = 10;
            waitingForWave = true;
            waveNumber++;
        }
    }
}
  • 새 스크립트를 만들고 이름을 "SC_DamageReceiver"로 지정한 다음 그 안에 아래 코드를 붙여넣습니다.

SC_DamageReceiver.cs

using UnityEngine;

public class SC_DamageReceiver : MonoBehaviour, IEntity
{
    //This script will keep track of player HP
    public float playerHP = 100;
    public SC_CharacterController playerController;
    public SC_WeaponManager weaponManager;

    public void ApplyDamage(float points)
    {
        playerHP -= points;

        if(playerHP <= 0)
        {
            //Player is dead
            playerController.canMove = false;
            playerHP = 0;
        }
    }
}
  • SC_NPCEnemy 스크립트를 살아있는 적 인스턴스에 연결합니다. (NavMesh를 탐색하는 데 필요한 NavMesh Agent라는 또 다른 구성 요소가 추가된 것을 볼 수 있습니다.)
  • 최근 생성된 데드 인스턴스 프리팹을 Npc Dead Prefab 변수에 할당합니다.
  • Fire Point의 경우 새 GameObject를 생성하고 이를 살아있는 적 인스턴스 내부로 이동하여 인스턴스 약간 앞에 배치한 다음 Fire Point 변수에 할당합니다.

  • 마지막으로 살아있는 인스턴스를 Prefab에 저장하고 Scene에서 삭제합니다.

적 생성 장치 설정

이제 SC_EnemySpawner로 이동해 보겠습니다. 이 스크립트는 적을 웨이브로 생성하고 플레이어 HP, 현재 탄약, 현재 웨이브에 남은 적 수 등과 같은 일부 UI 정보를 화면에 표시합니다.

  • 새로운 GameObject를 생성하고 이름을 지정하세요. "_EnemySpawner"
  • SC_EnemySpawner 스크립트를 여기에 연결하세요.
  • 새로 생성된 적 AI를 Enemy Prefab 변수에 할당합니다.
  • 아래 텍스처를 Crosshair Texture 변수에 할당합니다.

  • 두 개의 새로운 GameObject를 생성하고 장면 주위에 배치한 다음 생성 지점 배열에 할당합니다.

할당할 마지막 변수가 Player 변수라는 것을 알 수 있습니다.

  • 플레이어 인스턴스에 SC_DamageReceiver 스크립트 연결
  • 플레이어 인스턴스 태그를 다음으로 변경합니다. "Player"
  • SC_DamageReceiver에 플레이어 컨트롤러 및 무기 관리자 변수 할당

  • SC_EnemySpawner의 Player 변수에 Player 인스턴스 할당

마지막으로 적 AI가 탐색할 수 있도록 장면에서 NavMesh를 구워야 합니다.

또한 NavMesh를 굽기 전에 장면의 모든 정적 객체를 Navigation Static으로 표시하는 것을 잊지 마세요.

  • NavMesh 창(Window -> AI -> Navigation)으로 이동하여 Bake 탭을 클릭한 다음 Bake 버튼을 클릭합니다. NavMesh가 구워진 후에는 다음과 같이 보일 것입니다.

이제 Play를 누르고 테스트할 차례입니다.

Sharp Coder 비디오 플레이어

모든 것이 예상대로 작동합니다!