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):
- 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:
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):
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)
- Sposta la trasformazione Fire Point finché non si adatta al nuovo modello
- 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:
Tutto funziona come previsto!