Comment créer un FPS avec le support de l'IA dans Unity
Le jeu de tir à la première personne (FPS) est un sous-genre de jeux de tir où le joueur est contrôlé à la première personne.
Pour créer un jeu FPS dans Unity, nous aurons besoin d'un contrôleur de joueur, d'un ensemble d'objets (des armes dans ce cas) et des ennemis.
Étape 1: créer le contrôleur de lecteur
Ici, nous allons créer un contrôleur qui sera utilisé par notre joueur.
- Créez un nouvel objet de jeu (Game Object -> Create Empty) et nommez-le "Player"
- Créez une nouvelle capsule (Objet de jeu -> Objet 3D -> Capsule) et déplacez-la à l'intérieur de l'objet "Player"
- Retirez le composant Capsule Collider de Capsule et changez sa position en (0, 1, 0)
- Déplacez la caméra principale à l'intérieur de l'objet "Player" et changez sa position en (0, 1.64, 0)
- Créez un nouveau script, nommez-le "SC_CharacterController" et collez-y le code ci-dessous:
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);
}
}
}
- Attachez le script SC_CharacterController à l'objet "Player" (vous remarquerez qu'il a également ajouté un autre composant appelé Character Controller, en changeant sa valeur centrale en (0, 1, 0))
- Attribuez la caméra principale à la variable Player Camera dans SC_CharacterController
Le contrôleur du lecteur est maintenant prêt:
Étape 2: Créer le système d'arme
Le système d'arme du joueur comprendra 3 composants: un gestionnaire d'armes, un script d'arme et un script de balle.
- Créez un nouveau script, nommez-le "SC_WeaponManager" et collez-y le code ci-dessous:
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;
}
}
}
- Créez un nouveau script, nommez-le "SC_Weapon" et collez-y le code ci-dessous:
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);
}
}
- Créez un nouveau script, nommez-le "SC_Bullet" et collez-y le code ci-dessous:
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;
}
}
Maintenant, vous remarquerez que le script SC_Bullet contient des erreurs. C'est parce que nous avons une dernière chose à faire, qui est de définir l'interface IEntity.
Les interfaces en C# sont utiles lorsque vous devez vous assurer que le script qui l'utilise a implémenté certaines méthodes.
L'interface IEntity aura une méthode qui est ApplyDamage, qui sera ensuite utilisée pour infliger des dégâts aux ennemis et à notre joueur.
- Créez un nouveau script, nommez-le "SC_InterfaceManager" et collez-y le code ci-dessous:
SC_InterfaceManager.cs
//Entity interafce
interface IEntity
{
void ApplyDamage(float points);
}
Configurer un gestionnaire d'armes
Un gestionnaire d'armes est un objet qui résidera sous l'objet caméra principal et contiendra toutes les armes.
- Créez un nouveau GameObject et nommez-le "WeaponManager"
- Déplacez le WeaponManager à l'intérieur de la caméra principale du joueur et changez sa position en (0, 0, 0)
- Attachez le script SC_WeaponManager à "WeaponManager"
- Attribuez la caméra principale à la variable Player Camera dans SC_WeaponManager
Configuration d'un fusil
- Faites glisser et déposez votre modèle de pistolet dans la scène (ou créez simplement un cube et étirez-le si vous n'avez pas encore de modèle).
- Mettez le modèle à l'échelle afin que sa taille soit relative à une capsule de joueur
Dans mon cas, j'utiliserai un modèle de fusil sur mesure (BERGARA BA13):
- Créez un nouveau GameObject et nommez-le "Rifle" puis déplacez le modèle de fusil à l'intérieur
- Déplacez l'Objet "Rifle" à l'intérieur de l'Objet "WeaponManager" et placez-le devant la Caméra comme ceci:
Pour corriger l'écrêtage de l'objet, changez simplement le plan d'écrêtage proche de la caméra en quelque chose de plus petit (dans mon cas, je l'ai réglé sur 0,15):
Beaucoup mieux.
- Attachez le script SC_Weapon à un objet fusil (vous remarquerez qu'il a également ajouté un composant source audio, cela est nécessaire pour jouer le feu et recharger les audios).
Comme vous pouvez le voir, SC_Weapon a 4 variables à assigner. Vous pouvez assigner immédiatement des variables audio Fire et Reload audio si vous avez des clips audio appropriés dans votre projet.
La variable Bullet Prefab sera expliquée plus loin dans ce tutoriel.
Pour l'instant, nous allons juste assigner la variable Fire point:
- Créez un nouveau GameObject, renommez-le "FirePoint" et déplacez-le dans Rifle Object. Placez-le juste devant le canon ou légèrement à l'intérieur, comme ceci:
- Affecter la transformation FirePoint à une variable de point d'incendie à SC_Weapon
- Attribuer un fusil à une variable d'arme secondaire dans le script SC_WeaponManager
Configuration d'une mitraillette
- Dupliquez l'objet fusil et renommez-le en mitraillette
- Remplacez le modèle de pistolet à l'intérieur par un modèle différent (dans mon cas, j'utiliserai le modèle sur mesure de TAVOR X95)
- Déplacez la transformation Fire Point jusqu'à ce qu'elle corresponde au nouveau modèle
- Attribuer Submachinegun à une variable d'arme principale dans le script SC_WeaponManager
Configuration d'un préfabriqué Bullet
Le préfabriqué Bullet sera généré en fonction de la cadence de tir d'une arme et utilisera Raycast pour détecter s'il a touché quelque chose et infligé des dégâts.
- Créez un nouveau GameObject et nommez-le "Bullet"
- Ajoutez-y le composant Trail Renderer et changez sa variable Time en 0.1.
- Réglez la courbe de largeur sur une valeur inférieure (par exemple, début 0,1 et fin 0), pour ajouter une traînée au look pointu
- Créez un nouveau matériau et nommez-le bullet_trail_material et changez son shader en particules/additif
- Attribuer un matériau nouvellement créé à un Trail Renderer
- Changez la couleur de Trail Renderer en quelque chose de différent (ex. Start: Bright Orange End: Darker Orange)
- Enregistrez l'objet Bullet dans Prefab et supprimez-le de la scène.
- Attribuez un préfabriqué nouvellement créé (glisser-déposer depuis la vue du projet) à la variable de préfabriqué de fusil et de mitraillette
Mitraillette:
Fusil:
Les armes sont maintenant prêtes.
Étape 3: créer l'IA ennemie
Les ennemis seront de simples cubes qui suivront le joueur et attaqueront une fois qu'ils seront suffisamment proches. Ils attaqueront par vagues, chaque vague ayant plus d'ennemis à éliminer.
Configuration de l'IA ennemie
Ci-dessous, j'ai créé 2 variantes du cube (celle de gauche est pour l'instance vivante et celle de droite apparaîtra une fois l'ennemi tué):
- Ajouter un composant Rigidbody aux instances mortes et vivantes
- Enregistrez l'instance morte dans Prefab et supprimez-la de Scene.
Maintenant, l'instance vivante aura besoin de quelques composants supplémentaires pour pouvoir naviguer dans le niveau du jeu et infliger des dégâts au joueur.
- Créez un nouveau script et nommez-le "SC_NPCEnemy" puis collez-y le code ci-dessous:
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);
}
}
}
- Créez un nouveau script, nommez-le "SC_EnemySpawner" puis collez-y le code ci-dessous:
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++;
}
}
}
- Créez un nouveau script, nommez-le "SC_DamageReceiver" puis collez-y le code ci-dessous:
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;
}
}
}
- Attachez le script SC_NPCEnemy à l'instance ennemie vivante (vous remarquerez qu'il a ajouté un autre composant appelé NavMesh Agent, qui est nécessaire pour naviguer dans le NavMesh)
- Attribuez le préfabriqué d'instance morte récemment créé à la variable Npc Dead Prefab
- Pour le Fire Point, créez un nouveau GameObject, déplacez-le à l'intérieur de l'instance ennemie vivante et placez-le légèrement devant l'instance, puis affectez-le à la variable Fire Point:
- Enfin, enregistrez l'instance vivante dans Prefab et supprimez-la de Scene.
Configuration du générateur d'ennemis
Passons maintenant à SC_EnemySpawner. Ce script fera apparaître des ennemis par vagues et affichera également des informations sur l'interface utilisateur à l'écran, telles que les PV du joueur, les munitions actuelles, le nombre d'ennemis restants dans une vague actuelle, etc.
- Créez un nouveau GameObject et nommez-le "_EnemySpawner"
- Attachez-lui le script SC_EnemySpawner
- Attribuez l'IA ennemie nouvellement créée à la variable Enemy Prefab
- Attribuez la texture ci-dessous à la variable Crosshair Texture
- Créez quelques nouveaux GameObjects et placez-les autour de la scène, puis affectez-les au tableau Spawn Points
Vous remarquerez qu'il reste une dernière variable à affecter qui est la variable Player.
- Attacher le script SC_DamageReceiver à une instance Player
- Remplacez la balise d'instance de joueur par "Player"
- Affectez les variables Player Controller et Weapon Manager dans SC_DamageReceiver
- Attribuez une instance Player à une variable Player dans SC_EnemySpawner
Et enfin, nous devons intégrer le NavMesh dans notre scène afin que l'IA ennemie puisse naviguer.
N'oubliez pas non plus de marquer chaque objet statique de la scène comme navigation statique avant de cuire NavMesh:
- Allez dans la fenêtre NavMesh (Window -> AI -> Navigation), cliquez sur l'onglet Bake puis cliquez sur le bouton Bake. Une fois le NavMesh cuit, il devrait ressembler à ceci:
Il est maintenant temps d'appuyer sur Play et de le tester:
Tout fonctionne comme prévu !