Як створити FPS із підтримкою AI в Unity

Шутер від першої особи (FPS) — це піджанр шутерів, де гравцем керують від першої особи.

Щоб створити гру FPS у Unity, нам знадобиться контролер гравця, набір предметів (у цьому випадку зброя) і вороги.

Крок 1: Створіть контролер програвача

Тут ми створимо контролер, який буде використовуватися нашим гравцем.

  • Створіть новий ігровий об’єкт (Game Object -> Create Empty) і назвіть його "Player"
  • Створіть нову капсулу (Ігровий об’єкт -> 3D-об’єкт -> Капсула) і перемістіть її всередину об’єкта "Player"
  • Видаліть компонент Capsule Collider з Capsule і змініть його положення на (0, 1, 0)
  • Перемістіть головну камеру всередину об’єкта "Player" і змініть її положення на (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))
  • Призначте головну камеру змінній Player Camera у SC_CharacterController

Тепер контролер Player готовий:

Крок 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"
  • Перемістіть WeaponManager всередину головної камери гравця та змініть його положення на (0, 0, 0)
  • Додайте сценарій SC_WeaponManager "WeaponManager"
  • Призначте головну камеру змінній Player Camera у SC_WeaponManager

Налаштування гвинтівки

  • Перетягніть модель зброї на сцену (або просто створіть куб і розтягніть його, якщо у вас ще немає моделі).
  • Змініть розмір моделі так, щоб її розмір був відносно капсули гравця

У моєму випадку я буду використовувати виготовлену на замовлення модель гвинтівки (BERGARA BA13):

BERGARA BA13

  • Створіть новий ігровий об’єкт і назвіть його "Rifle", а потім перемістіть у нього модель гвинтівки
  • Перемістіть об’єкт "Rifle" всередину об’єкта "WeaponManager" і розмістіть його перед камерою так:

Вирішіть проблему обрізки камери в Unity.

Щоб виправити відсікання об’єкта, просто змініть ближню площину відсікання камери на щось менше (у моєму випадку я встановив її на 0,15):

BERGARA BA13

Значно краще.

  • Приєднайте сценарій SC_Weapon до об’єкта «Гвинтівка» (Ви помітите, що він також додав компонент «Джерело аудіо», він потрібен для відтворення вогню та перезавантаження аудіо).

Як бачите, SC_Weapon має 4 змінні для призначення. Ви можете відразу призначити змінні Fire audio та Reload audio, якщо у вашому проекті є відповідні аудіокліпи.

Змінна Bullet Prefab буде пояснена пізніше в цьому посібнику.

Наразі ми просто призначимо змінну Fire point:

  • Створіть новий GameObject, перейменуйте його на "FirePoint" і перемістіть всередину Rifle Object. Розмістіть його прямо перед бочкою або трохи всередині, ось так:

  • Призначте FirePoint Transform змінній Fire point у SC_Weapon
  • Призначте гвинтівку до змінної Secondary Weapon у сценарії SC_WeaponManager

Налаштування пістолета-кулемета

  • Скопіюйте об’єкт «Гвинтівка» та перейменуйте його на Пістолет-кулемет
  • Замініть модель пістолета всередині на іншу модель (у моєму випадку я буду використовувати виготовлену на замовлення модель TAVOR X95)

TAVOR X95

  • Перемістіть трансформацію Fire Point, щоб вона відповідала новій моделі

Налаштування об’єкта Weapon Fire Point в Unity.

  • Призначте пістолет-кулемет до змінної основної зброї в сценарії SC_WeaponManager

Налаштування збірної конструкції Bullet

Збірна куля створюватиметься відповідно до скорострільності зброї та використовуватиме Raycast, щоб визначити, чи вона влучила у щось і завдала шкоди.

  • Створіть новий GameObject і назвіть його "Bullet"
  • Додайте до нього компонент Trail Renderer і змініть його змінну Time на 0,1.
  • Встановіть для кривої ширини нижче значення (наприклад, початок 0,1 і кінець 0), щоб додати слід, що має загострений вигляд
  • Створіть новий матеріал і назвіть його bullet_trail_material і змініть його шейдер на Particles/Additive
  • Призначте щойно створений матеріал Trail Renderer
  • Змініть колір Trail Renderer на інший (наприклад, початок: яскраво-помаранчевий, кінець: темніше оранжевий)

  • Збережіть об’єкт Bullet у Prefab і видаліть його зі сцени.
  • Призначте щойно створений Prefab (перетягніть із вікна Project) змінній Rifle and Machinegun Bullet Prefab

Пістолет-кулемет:

Гвинтівка:

Тепер зброя готова.

Крок 3: Створіть ШІ ворога

Вороги будуть простими кубиками, які слідуватимуть за гравцем і атакуватимуть, коли вони будуть досить близько. Вони будуть атакувати хвилями, з кожною хвилею буде більше ворогів, яких потрібно знищити.

Налаштування ШІ ворога

Нижче я створив 2 варіанти Куба (Лівий призначений для живого екземпляра, а Правий буде породжений, коли ворога буде вбито):

  • Додайте компонент Rigidbody до мертвих і живих примірників
  • Збережіть мертвий екземпляр у Prefab і видаліть його зі сцени.

Тепер живому екземпляру знадобиться ще пара компонентів, щоб мати можливість переміщатися на рівні гри та завдавати шкоди гравцеві.

  • Створіть новий сценарій і назвіть його "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 Agent, який необхідний для навігації NavMesh)
  • Призначте нещодавно створений префаб мертвого екземпляра змінній Npc Dead Prefab
  • Для точки вогню створіть новий GameObject, перемістіть його всередину екземпляра живого ворога та розташуйте трохи попереду екземпляра, а потім призначте його змінній Fire Point:

  • Нарешті, збережіть живий екземпляр у Prefab і видаліть його зі сцени.

Налаштування Enemy Spawner

Тепер перейдемо до SC_EnemySpawner. Цей сценарій породжуватиме ворогів хвилями, а також відображатиме на екрані певну інформацію інтерфейсу користувача, наприклад HP гравця, поточні боєприпаси, кількість ворогів, що залишилося в поточній хвилі тощо.

  • Створіть новий GameObject і назвіть його "_EnemySpawner"
  • Прикріпіть до нього сценарій SC_EnemySpawner
  • Призначте щойно створений штучний інтелект противника до змінної Enemy Prefab
  • Призначте наведену нижче текстуру змінній Texture Crosshair

  • Створіть пару нових GameObjects і розмістіть їх навколо сцени, а потім призначте їх до масиву Spawn Points

Ви помітите, що залишилася остання змінна, яку потрібно призначити, це змінна Player.

  • Приєднайте сценарій SC_DamageReceiver до екземпляра Player
  • Змініть тег екземпляра Player на "Player"
  • Призначте змінні Player Controller і Weapon Manager у SC_DamageReceiver

  • Призначте екземпляр Player змінній Player у SC_EnemySpawner

І, нарешті, ми маємо запекти NavMesh у нашій сцені, щоб ворожий ШІ міг орієнтуватися.

Також не забудьте позначити кожен статичний об’єкт у сцені як навігаційний статичний перед запіканням NavMesh:

  • Перейдіть до вікна NavMesh (Вікно -> ШІ -> Навігація), клацніть вкладку «Випікати», а потім натисніть кнопку «Випікати». Після запікання NavMesh він має виглядати приблизно так:

Тепер настав час натиснути Play і перевірити:

Sharp Coder Відеоплеєр

Все працює як очікувалося!

Джерело
📁SimpleFPS.unitypackage4.61 MB
Рекомендовані статті
Як створити AI оленя в Unity
Unity Додайте ворогів у 2D-платформер
Впровадження ШІ ворога в Unity
Робота з NavMeshAgent в Unity
Створіть NPC, який слідує за гравцем в Unity
Огляд пакета Unity Asset Store Package - Zombie AI System
Як створити гру на виживання в Unity