Come realizzare un FPS con il supporto AI in Unity

Gli sparatutto in prima persona (FPS) sono un sottogenere dei giochi sparatutto in cui il giocatore è controllato da una prospettiva in prima persona.

Per creare un gioco FPS in Unity avremo bisogno di un controller giocatore, una serie di oggetti (armi in questo caso) e i nemici.

Passaggio 1: crea il controller del lettore

Qui creeremo un controller che verrà utilizzato dal nostro giocatore.

  • Crea un nuovo oggetto di gioco (Oggetto di gioco -> Crea vuoto) e assegnagli un nome "Player"
  • Crea una nuova capsula (oggetto di gioco -> oggetto 3D -> capsula) e spostala all'interno dell'oggetto "Player"
  • Rimuovi il componente Capsule Collider da Capsule e cambia la sua posizione in (0, 1, 0)
  • Sposta la videocamera principale all'interno dell'oggetto "Player" e cambia la sua posizione in (0, 1.64, 0)
  • Crea un nuovo script, chiamalo "SC_CharacterController" e incolla il codice sottostante al suo interno:

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);
        }
    }
}
  • Allega script SC_CharacterController a "Player" oggetto (noterai che ha anche aggiunto un altro componente chiamato Controller carattere, cambiando il suo valore centrale in (0, 1, 0))
  • Assegna la videocamera principale alla variabile Videocamera del giocatore in SC_CharacterController

Il controller del lettore è ora pronto:

Passaggio 2: creare il sistema d'arma

Il sistema di armi del giocatore sarà composto da 3 componenti: un gestore di armi, uno script di armi e uno script di proiettili.

  • Crea un nuovo script, chiamalo "SC_WeaponManager" e incolla il codice sottostante al suo interno:

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;
        }
    }
}
  • Crea un nuovo script, chiamalo "SC_Weapon" e incolla il codice sottostante al suo interno:

SC_Arma.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);
    }
}
  • Crea un nuovo script, chiamalo "SC_Bullet" e incolla il codice sottostante al suo interno:

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;
    }
}

Ora noterai che lo script SC_Bullet contiene alcuni errori. Questo perché abbiamo un'ultima cosa da fare, ovvero definire l'interfaccia IEntity.

Le interfacce in C# sono utili quando è necessario assicurarsi che lo script che le utilizza abbia determinati metodi implementati.

L'interfaccia IEntity avrà un metodo che è ApplyDamage, che in seguito verrà utilizzato per infliggere danni ai nemici e al nostro giocatore.

  • Crea un nuovo script, chiamalo "SC_InterfaceManager" e incolla il codice sottostante al suo interno:

SC_InterfaceManager.cs

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

Impostazione di un gestore di armi

Un gestore di armi è un oggetto che risiederà sotto l'oggetto della telecamera principale e conterrà tutte le armi.

  • Crea un nuovo GameObject e chiamalo "WeaponManager"
  • Sposta il WeaponManager all'interno della visuale principale del giocatore e cambia la sua posizione in (0, 0, 0)
  • Allega lo script SC_WeaponManager a "WeaponManager"
  • Assegna la videocamera principale alla variabile Videocamera del giocatore in SC_WeaponManager

Installazione di un fucile

  • Trascina e rilascia il tuo modello di pistola nella scena (o semplicemente crea un cubo e allungalo se non hai ancora un modello).
  • Ridimensiona il modello in modo che le sue dimensioni siano relative a una Player Capsule

Nel mio caso, userò un modello di fucile su misura (BERGARA BA13):

BERGARA BA13

  • Crea un nuovo GameObject e chiamalo "Rifle", quindi sposta il modello del fucile al suo interno
  • Sposta l'oggetto "Rifle" all'interno dell'oggetto "WeaponManager" e posizionalo davanti alla videocamera in questo modo:

Risolvi il problema di ritaglio della fotocamera in Unity.

Per correggere il ritaglio dell'oggetto, cambia semplicemente il piano di ritaglio vicino della fotocamera in qualcosa di più piccolo (nel mio caso l'ho impostato su 0.15):

BERGARA BA13

Molto meglio.

  • Collega lo script SC_Weapon a un oggetto Rifle (noterai che ha anche aggiunto un componente Sorgente audio, necessario per riprodurre il fuoco e ricaricare l'audio).

Come puoi vedere, SC_Weapon ha 4 variabili da assegnare. Puoi assegnare immediatamente le variabili audio Fire e Reload audio se disponi di clip audio adatte nel tuo progetto.

La variabile Bullet Prefab verrà spiegata più avanti in questo tutorial.

Per ora, assegneremo solo la variabile Fire point:

  • Crea un nuovo GameObject, rinominalo in "FirePoint" e spostalo all'interno di Rifle Object. Posizionalo proprio davanti alla canna o leggermente all'interno, in questo modo:

  • Assegna FirePoint Transform a una variabile Firepoint in SC_Weapon
  • Assegna il fucile a una variabile Arma secondaria nello script SC_WeaponManager

Installazione di un fucile mitragliatore

  • Duplica l'oggetto fucile e rinominalo in fucile mitragliatore
  • Sostituisci il modello della pistola al suo interno con un modello diverso (Nel mio caso userò il modello su misura di TAVOR X95)

TAVORE X95

  • Sposta la trasformazione Fire Point finché non si adatta al nuovo modello

Configurazione dell'oggetto Weapon Fire Point in Unity.

  • Assegna il fucile mitragliatore a una variabile arma primaria nello script SC_WeaponManager

Installazione di un prefabbricato proiettile

Il proiettile prefabbricato verrà generato in base alla frequenza di fuoco di un'arma e utilizzerà Raycast per rilevare se ha colpito qualcosa e infliggere danni.

  • Crea un nuovo GameObject e chiamalo "Bullet"
  • Aggiungi il componente Trail Renderer e cambia la sua variabile Time in 0.1.
  • Imposta la curva Larghezza su un valore inferiore (es. Inizio 0.1 fine 0), per aggiungere una scia dall'aspetto appuntito
  • Crea un nuovo materiale e chiamalo bullet_trail_material e cambia il suo Shader in Particles/Additive
  • Assegna un materiale appena creato a un Trail Renderer
  • Cambia il colore di Trail Renderer in qualcosa di diverso (es. Inizio: arancione brillante Fine: arancione più scuro)

  • Salva l'oggetto proiettile in Prefabbricato ed eliminalo dalla scena.
  • Assegna un prefabbricato appena creato (trascina e rilascia dalla vista Progetto) alla variabile Prefabbricato per fucile e mitragliatrice

Fucile mitragliatore:

Fucile:

Le armi sono ora pronte.

Passaggio 3: crea l'IA nemica

I nemici saranno semplici Cubi che seguono il Giocatore e attaccano una volta che sono abbastanza vicini. Attaccheranno a ondate, con ogni ondata che avrà più nemici da eliminare.

Configurazione dell'IA nemica

Di seguito ho creato 2 varianti del Cubo (quello sinistro è per l'istanza viva e quello destro verrà generato una volta ucciso il nemico):

  • Aggiungi un componente Rigidbody sia alle istanze morte che a quelle vive
  • Salva l'istanza morta in prefabbricato ed eliminala da Scene.

Ora, l'istanza viva avrà bisogno di un paio di componenti in più per poter navigare nel livello di gioco e infliggere danni al giocatore.

  • Crea un nuovo script e chiamalo "SC_NPCEnemy", quindi incolla il codice sottostante al suo interno:

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);
        }
    }
}
  • Crea un nuovo script, chiamalo "SC_EnemySpawner" quindi incolla il codice sottostante al suo interno:

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++;
        }
    }
}
  • Crea un nuovo script, chiamalo "SC_DamageReceiver" quindi incolla il codice sottostante al suo interno:

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;
        }
    }
}
  • Collega lo script SC_NPCEnemy a un'istanza nemica viva (noterai che ha aggiunto un altro componente chiamato NavMesh Agent, necessario per navigare nel NavMesh)
  • Assegna il prefabbricato dell'istanza morta creato di recente alla variabile Npc Dead Prefab
  • Per il Fire Point, crea un nuovo GameObject, spostalo all'interno dell'istanza del nemico vivo e posizionalo leggermente davanti all'istanza, quindi assegnalo alla variabile Fire Point:

  • Infine, salva l'istanza live su Prefab ed eliminala da Scene.

Impostazione di Enemy Spawner

Passiamo ora a SC_EnemySpawner. Questo script genererà i nemici in ondate e mostrerà anche alcune informazioni dell'interfaccia utente sullo schermo, come Player HP, munizioni attuali, quanti nemici sono rimasti in un'ondata attuale, ecc.

  • Crea un nuovo GameObject e chiamalo "_EnemySpawner"
  • Allega lo script SC_EnemySpawner ad esso
  • Assegna l'IA nemica appena creata alla variabile Enemy Prefab
  • Assegna la texture sottostante alla variabile Crosshair Texture

  • Crea un paio di nuovi GameObjects e posizionali attorno alla scena, quindi assegnali all'array Spawn Points

Noterai che è rimasta un'ultima variabile da assegnare che è la variabile Player.

  • Collega lo script SC_DamageReceiver a un'istanza del lettore
  • Cambia il tag dell'istanza del lettore in "Player"
  • Assegna le variabili Player Controller e Weapon Manager in SC_DamageReceiver

  • Assegna l'istanza Player a una variabile Player in SC_EnemySpawner

Infine, dobbiamo inserire la NavMesh nella nostra scena in modo che l'IA nemica possa navigare.

Inoltre, non dimenticare di contrassegnare ogni oggetto statico nella scena come statico di navigazione prima di cuocere NavMesh:

  • Vai alla finestra NavMesh (Finestra -> AI -> Navigazione), fai clic sulla scheda Bake, quindi fai clic sul pulsante Bake. Dopo che il NavMesh è cotto, dovrebbe assomigliare a questo:

Ora è il momento di premere Play e provarlo:

Sharp Coder Lettore video

Tutto funziona come previsto!