Unity3d FPS Tutorial, czyli tworzymy własną grę FPS od podstaw z wykorzystaniem silnika Unity3d.

Temat: Proste AI przeciwnika, sztuczna inteligencja

Spis treści

Jeżeli nie używałeś do tej pory Unity3d:

#0 – Podstawy Podstaw

FPS Tutorial:

#1 – Tworzenie nowego projektu i narzędzie terenu

#2 – Sterowanie postacią

#3 – Życie, pancerz i wytrzymałość postaci

#4 – Regeneracja życia i energii. Efekty trafienia

#5 – Kamera z bronią i strzelanie

#6 – Przeładowanie i amunicja

#7 – Zbieranie przedmiotów

#8 – Druga broń

#9 – Rzut granatem i seria z karabinu

#10 – Przybliżenie i dziury po kulach

– Proste AI przeciwnika

#12 – Animacja postaci przeciwnika, Animator

#13 – Menu główne gry. GUI

#14 – Ostatnie szlify i budujemy projekt

Teoria

Dzisiaj przyjrzymy się prostej sztucznej inteligencji. Nie będzie tutaj bardzo zaawansowanych wariantów i interaktywności. Przeciwnik będzie jedynie podążał za graczem, aby mu przywalić z bliska. Oraz będzie miał funkcję śmierci – cudowna funkcjonalność.

Co będzie nam potrzebne? Model jakiegoś przeciwnika, ja pobrałem sobie darmowy model zombie z AssetStora.

Dodatkowo przyda się efekt cząsteczkowy krwi, ale tutaj nie ma problemu, bo przerobimy sobie jeden z efektów ze Standardowych Assetów.

Ruchawy zombie

Pierwszy krok to zaimportowanie modelu do projektu. Nie będę się rozwodził jak to zrobić, w końcu to 11 odcinek! Tym bardziej, jeśli pobieracie go z AssetStore, bo tam wystarczy znaleźć model i kliknąć pobierz.

Teraz czas przygotować nasze zombie. Po pierwsze dodajemu mu tag “Enemy”. Powinniśmy dysponować tym tagiem, jeśli nie, można go łatwo dodać w menu: [Edit -> Project Settings -> Tags nad Layers]. Po drugie, dodajemy naszemu zombie Box Collider, który ustawiamy na trochę większy od modelu, oraz Sphere Collider, który ustawiamy w trybie IsTrigger. Dodatkowo okrągły Collider, powinien być znacznie większy od postaci. Będzie to nasz “zasięg wzroku i słuchu”. Czyli obszar, w którym zombie dostrzega gracza. Na koniec tworzymy sobie nowy skrypt (u mnie EnemyAI.cs) i dodajemy go do modelu. Całość powinna wyglądać mniej więcej tak:

Przykład dobrze przygotowanej postaci przeciwnika.
Przykład dobrze przygotowanej postaci przeciwnika.

Mogło cię przerazić, że skrypt na obrazku, ma już kilka publicznych zmiennych. Nie martw się, nie przegapiłeś połowy poradnika! Po prostu screeen jest z końcowego etapu, tej części.

Jeżeli jesteśmy już przy skrypcie. Czas dodać poruszanie się. Wchodzimy wiec do skryptu EnemyAI.cs. Standardowo, zaczynamy od kilku zmiennych pomocniczych:

public float walkSpeed = 5.0f;
public float attackDistance = 2.0f;

Jak łatwo się domyślić, pierwsza to szybkość poruszania się zombie, a druga to odległość z jakiej może zaatakować. Co ciekawe, większość funkcjonalności, nie pojawi się w funkcji Update, a w funkcji OnTriggerStay, czyli tej, która jest wykonywana do póki nasz Trigger wykrywa kolizję. Nasz proces poruszania będzie bardzo prosty. Zombie obraca się w stronę gracza, po czym za nim podąża. O ile skrypt podążania jest banalny, skrypt obrotu jest nieco bardziej skomplikowany. Pierwszy ruch jaki się nasuwa to funkcja LookAt. Spełni swoją funkcję, tyle że obrót będzie natychmiastowy, przez co nierealistyczny. Dlatego, my zrobimy to tak:

void OnTriggerStay(Collider other)
{
	if(other.tag.Equals("Player")) {
		Quaternion targetRotation = Quaternion.LookRotation(other.transform.position - transform.position);
		float oryginalX = transform.rotation.x;
		float oryginalZ = transform.rotation.z;

		Quaternion finalRotation = Quaternion.Slerp(transform.rotation, targetRotation, 5.0f * Time.deltaTime);
		finalRotation.x = oryginalX;
		finalRotation.z = oryginalZ;
		transform.rotation = finalRotation;
	}
}

Dokumentacja: Quaternion.LookRotation ; Quaternion.Slerp

Klasycznie sprawdzamy, czy to co jest z nami w kolizji to gracz, jeśli tak, to dokonujemy obliczeń. Pierwsza linia, to funkcja LookRotation, która określa jaka musi być rotacja obiektu, tak aby wskazany wektor, był wektorem przodu (Vector3.forward). Dzięki odjęciu od pozycji gracza, pozycji zombie, mamy punkt pomiędzy nimi, dzięki czemu przód, dla zombie, będzie zwrócony w kierunku gracza.

Przez typ modelu jaki wybrałem, był mały problem, otóż jego centrum znajduje się przy ziemi, przez co zombie odchylał się, a przy poruszaniu zdarzało mu się wisieć w powietrzu. Dlatego zablokujemy jego rotację w osiach x i z, tak aby obracał się tylko lewo-prawo. Aby to zrobić, zapamiętujemy sobie pozostałe 2 osie.

Teraz obliczamy sobie końcową rotację. Służy do tego funkcja Slerp, która stopniowo zwiększa wartość rotacji, od startowej (pierwszy parametr), do końcowej (drugi parametr), o wartość podaną w trzecim. Pierwszy parametr, to obecna rotacja zombie, drugi parametr to rotacja, którą liczyliśmy wcześniej, a trzeci parametr to szybkość obrotu razy czas, tak aby obrót był maksymalnie płynny.

Następny krok, to do finalnej rotacji, przywrócić rotację w osiach x i z, sprzed obrotu. Po czym cały wektor podmieniamy. Obecnie zombie powinien być w stanie się obrócić.

Czas dołożyć ruch, który jak wspominałem jest bardzo prosty:

float distance = Vector3.Distance(transform.position, other.transform.position);
if(distance > attackDistance) {
	transform.Translate(Vector3.forward * walkSpeed * Time.deltaTime);
} else {

}

Dokumentacja: Vector3.Distance ; Transform.Translate

Pierwsza linia oblicza dystans pomiędzy dwoma punktami. U nas jest to pozycja zombie i gracza. Później w ifie, sprawdzamy czy zombie jest dostatecznie blisko, aby atakować. Jeżeli dystans jest większy od dystansu wymaganego do ataku, zombie podąża za graczem, co rozwiązuje funkcja Translate. Nie ma tu nic skomplikowanego. Zostawiamy sobie elese, ponieważ wykorzystamy je przy robieniu zadawania obrażeń.  Obecnie zombie powinien już podążać za graczem, będąc obróconym do niego przodem.

Zombie atakuje

Na początek kilka przydatnych zmiennych:

public float attackDemage = 10.0f;
public float attackDelay = 1.0f;

private float timer = 0;

Zadawane obrażenia, opóźnienie ataku (w końcu ręka zombie to nie karabin, żeby zadawać obrażenia bez przerwy), oraz timer.

Wracamy do porzuconego w poprzednim rozdziale else i wzbogacamy je o kod:

if(distance > attackDistance) {
	transform.Translate(Vector3.forward * walkSpeed * Time.deltaTime);
} else {
	if(timer <= 0) {
		other.SendMessage("takeHit", attackDemage);
		timer = attackDelay;
	}
}

Dokumentacja: GameObject.SendMessage

Jeżeli timer jest mniejszy lub równy zero, czyli opóźnienie minęło, to wykonujemy atak, który sprowadza się do wysłania do obiektu gracza wywołania funkcji “takeHit”, za pomocą funkcji SendMessage. Funkcję takeHit napisaliśmy bardzo dawno temu, więc powinna istnieć i działać poprawnie. Dodatkowo dodajemy timerowi opóźnienie. Jednak by wszystko działało, musimy ten timer zmniejszać. Dlatego dodajemy 3 linijki, kompletując funkcję OnTriggerStay:

void OnTriggerStay(Collider other)
{
	if(other.tag.Equals("Player")) {
		Quaternion targetRotation = Quaternion.LookRotation(other.transform.position - transform.position);
		float oryginalX = transform.rotation.x;
		float oryginalZ = transform.rotation.z;

		Quaternion finalRotation = Quaternion.Slerp(transform.rotation, targetRotation, 5.0f * Time.deltaTime);
		finalRotation.x = oryginalX;
		finalRotation.z = oryginalZ;
		transform.rotation = finalRotation;

		float distance = Vector3.Distance(transform.position, other.transform.position);
		if(distance > attackDistance) {
			transform.Translate(Vector3.forward * walkSpeed * Time.deltaTime);
		} else {
			if(timer <= 0) {
				other.SendMessage("takeHit", attackDemage);
				timer = attackDelay;
			}
		}

		if(timer > 0) {
			timer -= Time.deltaTime;
		}
	}
}

Jak widać, zwykłe zmniejszanie timera o czas, jeśli jest on większy od zera, czyli jest co zmniejszać.

Zombie ginie

Nasze zombie już podąża za graczem i może mu zrobić krzywdę. Ale co to za zabawa, gdy tylko jedna strona cierpi? Czas oddać zombiakowi. Zaczniemy od ostatniej pomocniczej zmiennej:

public float hp = 20.0f;

Już bardziej oczywistej linijki, chyba nie dało się napisać. Aby wszystko śmigało, robimy sobie bliźniaczą funkcję, do funkcji takeHit stworzonej dla gracza:

void takeHit(float demage) 
{
	hp -= demage;
	if(hp <= 0) {
		Destroy(gameObject);
	}
}

Odejmujemy od życia obrażania. Jeśli hp spada poniżej zera, usuwam obiekt zombie. Później można tutaj dodać krzyki agonii przeciwnika i animację śmierci. Nam na tą chwilę tyle wystarcza.

Ale to nie koniec, bo trzeba poprawić nieco skrypt Shooting.cs. Co jest nieco śmieszne, w skrypcie dotyczącym strzelania, nie ma nic o obrażeniach, dlatego na samym początku dodajemy sobie dwie zmienne:

public GameObject bloodParticles;
public float demage = 5.0f;

Pierwsza da efekt tryskającej z zombie krwi, a druga zapewni informację ile życia odebrać. Przenosimy się w dół, do kodu, gdzie tworzyliśmy promień strzału i atakowaliśmy przeciwnika. Powinna być tam linijka:

Debug.Log ("Trafiony przeciwnik");

Zmieniamy ją na:

hit.transform.gameObject.SendMessage("takeHit", demage);	
GameObject go;
go = Instantiate(bloodParticles, hit.point, Quaternion.FromToRotation(Vector3.up, hit.normal)) as GameObject; 
Destroy(go, 0.3f);

Jest to kod dość podobny do kodu z części 10 tutoriala, gdzie robiliśmy dziurę po kuli, z dodatkowym SendMessage, aby zadać obrażenia.

Została jedna rzecz do poprawy. Otóż domyślnie Raycast zatrzymuje się na każdym colliderze, czyli nawet tym w trybie IsTrigger. Przez co gdy strzelamy do zombie z daleka, trafiamy w collider, odpowiedzialny za wykrywanie gracza. Co jest bez sensu. Profesjonalnym rozwiązaniem, byłoby stworzenie warstwy, na której będzie ten collider, oraz dodanie do raycasta maski, która pominie tę warstwę. Jednak to dużo zachodu, a nasza gra jest prosta, więc zrobimy to w trochę łatwiejszy sposób. Wejdziemy w menu: [Edit -> Project Settings -> Physics]. W panelu Inspector odznaczymy opcję “Raycast Hit Triggers”.

Sprawiamy, że rzucanie promienia pomija Collidery w trybie IsTrigger.
Sprawiamy, że rzucanie promienia pomija Collidery w trybie IsTrigger.

Gotowe! Dla pewności, wrzucam oba modyfikowane dziś skrypty w całości:

Shooting.cs

using UnityEngine;
using System.Collections;

[RequireComponent(typeof(AudioSource))]
public class Shooting : MonoBehaviour 
{
	public Texture2D crosshairTexture;
	public AudioClip pistolShot;
	public AudioClip reloadSound;
	public int maxAmmo = 200;
	public int clipSize = 10;
	public GUIText ammoText;
	public GUIText reloadText;
	public float reloadTime = 3.0f;
	public bool automatic = false;
	public float shotDelay = 0.5f;
	public GameObject bulletHole;
	public GameObject bloodParticles;
	public float demage = 5.0f;


	private int currentAmmo = 30;
	private int currentClip;
	private Rect position;   
	private float range = 200.0f;
	private GameObject pistolSparks; 
	private Vector3 fwd;
	private RaycastHit hit;
	private bool isReloading = false;
	private float shotDelayCounter = 0.0f;
	private float zoomFieldOfView = 40.0f;
	private float defaultFieldOfView = 60.0f;


	private float timer = 0.0f;

	void Start()
	{
		Screen.showCursor = false;
		position = new Rect((Screen.width - crosshairTexture.width) / 2,
		                    (Screen.height - crosshairTexture.height) /2,
		                    crosshairTexture.width,
		                    crosshairTexture.height);
		
		pistolSparks = GameObject.Find("Sparks");
		pistolSparks.particleEmitter.emit = false;
		audio.clip = pistolShot;
		currentClip = clipSize;
	}

	void Update () 
	{

		if(shotDelayCounter > 0) {
			shotDelayCounter -= Time.deltaTime;
		}
		Transform tf = transform.parent.GetComponent<Transform>();
		fwd = tf.TransformDirection(Vector3.forward);

		if(((Input.GetButtonDown("Fire1") && currentClip == 0) || Input.GetButtonDown("Reload")) && currentClip < clipSize) {
			if(currentAmmo > 0) {
				audio.clip = reloadSound;
				audio.Play();
				isReloading = true;
			}
		}

		if(isReloading) {
			timer += Time.deltaTime;
			if(timer >= reloadTime) {
				int needAmmo = clipSize - currentClip;
				
				if(currentAmmo >= needAmmo) {
					currentClip = clipSize;
					currentAmmo -= needAmmo;
				} else {
					currentClip += currentAmmo;
					currentAmmo = 0;
				}
				
				audio.clip = pistolShot;
				isReloading = false;
				timer = 0.0f;
			}
		}

		if(currentClip > 0 && !isReloading) {
			if((Input.GetButtonDown("Fire1") || (Input.GetButton("Fire1") && automatic)) && shotDelayCounter <= 0) {
				shotDelayCounter = shotDelay;
				currentClip--;
				pistolSparks.particleEmitter.Emit();
				audio.Play();
				if (Physics.Raycast(tf.position, fwd, out hit)) {
					if(hit.transform.tag.Equals("Enemy") && hit.distance < range) {
						hit.transform.gameObject.SendMessage("takeHit", demage);	
						GameObject go;
						go = Instantiate(bloodParticles, hit.point, Quaternion.FromToRotation(Vector3.up, hit.normal)) as GameObject; 
						Destroy(go, 0.3f);
					} else if(hit.distance < range) {
						GameObject go;
						go = Instantiate(bulletHole, hit.point, Quaternion.FromToRotation(Vector3.up, hit.normal)) as GameObject; 
						Destroy(go, 5);
						Debug.Log ("Trafiona Sciana");
					}
				}
			}
		}

		if(gameObject.GetComponentInParent<Camera>() is Camera) {
			Camera cam = gameObject.GetComponentInParent<Camera>();
			if(Input.GetButton("Fire2")) {
				if(cam.fieldOfView > zoomFieldOfView) {
					cam.fieldOfView--;
				}
			} else {
				if(cam.fieldOfView < defaultFieldOfView) {
					cam.fieldOfView++;
				}
			}
		}

	}

	void OnGUI()
	{
		GUI.DrawTexture(position, crosshairTexture);
		ammoText.pixelOffset = new Vector2(-Screen.width / 2 + 100, -Screen.height / 2 + 30);
		ammoText.text = currentClip + " / " + currentAmmo;

		if(currentClip == 0) {
			reloadText.enabled = true;
		} else {
			reloadText.enabled = false;
		}
	}	

	void addAmmo(Vector2 data) 
	{
		int ammoToAdd = (int)data.x;

		if(maxAmmo - currentAmmo >= ammoToAdd) {
			currentAmmo += ammoToAdd;
		} else {
			currentAmmo = maxAmmo;
		}
	}

	public bool canGetAmmo()
	{
		if(currentAmmo == maxAmmo) {
			return false;
		}
		return true;
	}

}

EnemyAI.cs

using UnityEngine;
using System.Collections;

public class EnemyAI : MonoBehaviour {

	public float walkSpeed = 5.0f;
	public float attackDistance = 3.0f;
	public float attackDemage = 10.0f;
	public float attackDelay = 1.0f;
	public float hp = 20.0f;

	private float timer = 0;

	void takeHit(float demage) 
	{
		hp -= demage;
		if(hp <= 0) {
			Destroy(gameObject);
		}
	}

	void OnTriggerStay(Collider other)
	{
		if(other.tag.Equals("Player")) {
			Quaternion targetRotation = Quaternion.LookRotation(other.transform.position - transform.position);
			float oryginalX = transform.rotation.x;
			float oryginalZ = transform.rotation.z;

			Quaternion finalRotation = Quaternion.Slerp(transform.rotation, targetRotation, 5.0f * Time.deltaTime);
			finalRotation.x = oryginalX;
			finalRotation.z = oryginalZ;
			transform.rotation = finalRotation;

			float distance = Vector3.Distance(transform.position, other.transform.position);
			if(distance > attackDistance) {
				transform.Translate(Vector3.forward * walkSpeed * Time.deltaTime);
			} else {
				if(timer <= 0) {
					other.SendMessage("takeHit", attackDemage);
					timer = attackDelay;
				}
			}

			if(timer > 0) {
				timer -= Time.deltaTime;
			}
		}
	}
}

 

Poprzednia część <- #10 – Przybliżenie i dziury po kulach

Następna część -> #12 – Animacja postaci przeciwnika, Animator