Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!

Dzisiejszy odcinek: Jak zrobić wzrok przeciwnika?

Uwaga! Jest to poradnik typu QuickTip. Zatem skupia się on na osiągnięciu założonego celu. Zatem zakładamy że użytkownik zna na tyle program Unity3d, aby samodzielnie wykonać najprostsze czynności, jak np. dodanie modelu kostki do sceny czy dodanie modelowi jakiegoś komponentu. Jeżeli brakuje Ci tej podstawowej wiedzy, zapraszam do tutoriala:
Unity Tutorial – Podstawy

Teoria

Sztuczna inteligencja w grach to bardzo szeroki temat. Aby symulować w jakiś sposób inteligencję przeciwnika, potrzebujemy zaprogramować mu pewne reakcje na różne zdarzenia. Zanim się za to zabierzemy, potrzebujemy żeby przeciwnik mógł takie zdarzenia zarejestrować. W przypadku obcych czy robotów, możemy symulować różne czujniki takie jak czujnik ruchu czy radar, które mogą działać bardzo umownie. Gdy chcemy oprogramować ludzi, musimy odwołać się do zmysłów. Tych mamy kilka, a najbardziej kluczowe w kontekście człowieka, będą wzrok, dotyk i słuch.

Słuch jest relatywnie prosty do ogarnięcia, bo słyszymy wszystko dookoła nas – czyli tutaj dobrze sprawdzi się sfera. Jednak w przypadku wzroku, nasz obszar widzenia przypomina stożek i zrobienie czegoś takiego jest znacznie trudniejsze. To o czym mówię, dobrze obrazują stożki wzorku przeciwników w grze Commandos:

Stożek wzroku w Commandos
Stożek wzroku w Commandos

Przygotowanie

W sumie potrzebujemy tutaj dwóch rzeczy, a mianowicie jakiegoś obiektu przeciwnika oraz obiektu gracza, który będziemy wykrywać. Graczowi ustawiamy tag Player.

Aspekty

Pierwsze co sobie tworzymy to aspekt. Kod jest prosty:

using UnityEngine;
using System.Collections;

public class Aspect : MonoBehaviour {

	public enum aspect {
		Player,
		Enemy
	}
	public aspect aspectName;
}

Nie ma tu prawie nic, tworzymy sobie enumerację i jedną publiczną zmienną, która w tym kontekście będzie polem typu select (lista rozwijana), pozwalająca wybrać jedną z opcji z naszej enumeracji. Po co to? Zmysł musi na coś reagować. Tzn. patrzysz na szklankę i wiesz, że jest to szklanka. Nasz przeciwnik też musi wiedzieć na co patrzy. Więc jego wzrok będzie sprawdzał aspekt obiektu na który patrzy i reagował na ten poszukiwany.

Kod przypisujemy do obiektu gracza i ustawiamy mu Aspect Name na Player.

Zmysły

Aby kod był jak najbardziej uniwersalny, wykorzystamy sobie dziedziczenie. Tym samym na początku tworzymy ogólną klasę dla zmysłów:

using UnityEngine;
using System.Collections;

public class Sense : MonoBehaviour {

	public Aspect.aspect aspectName = Aspect.aspect.Player;
	public float detectionRate = 1.0f;

	protected float elapsedTime = 0.0f;

	protected virtual void Initialize() { }
	protected virtual void UpdateSense() { }

	void Start () {
		elapsedTime = 0.0f;
		Initialize ();
	}

	void Update () {
		UpdateSense ();
	}
}

Co tutaj mamy za cuda? Najpierw mamy nasz Aspekt, który od razu ustawiamy na szukanie gracza. DetectionRate to coś w rodzaju opóźnionego zapłonu, to znaczy po osiągnięciu tego czasu zmysł zacznie działać. Daje nam to pewność, że wszystkie obiekty zdążą się załadować. Dodatkowo możemy to wykorzystać do czasowego wyłączenia zmysłu (np. granat oślepiający, albo granat EMP dla jakiegoś elektronicznego czujnika).

Później mamy zmienną tymczasową do liczenia czasu oraz dwie funkcje wirtualne, które są odpowiednio funkcjami Start i Update, ale ich kod znajdzie się już wewnątrz konkretnego zmysłu – dziedziczenie. Na koniec faktyczna funkcja Start i Update, które tylko wykonują kod funkcji wirtualnych.

Zmysł wzroku

Jego kod jest nieco dłuższy, więc będę omawiał go fragmentami.

using UnityEngine;
using System.Collections;

public class Perspective : Sense {

	public int fieldOfView = 45;
	public int ViewDistance = 100;

	private Transform playerTrans;
	private Vector3 rayDirection;

(...)

}

Pierwsze o czym należy pamiętać, to fakt, że dziedziczymy po klasie Sense, a nie MonoBehaviour!

Potem deklarujemy zmienne. W przypadku zmiennych publicznych, mamy kąt i odległość widzenia przeciwnika. Zmienne prywatne to pozycja gracza oraz kierunek rzucania promienia (o tym zaraz).

Przechodzimy do funkcji i zaczniemy sobie od wypełnienia funkcji, które musimy nadpisać z racji dziedziczenia:

protected override void Initialize() {
	playerTrans = GameObject.FindGameObjectWithTag ("Player").transform;
}

protected override void UpdateSense() {
	elapsedTime += Time.deltaTime;

	if (elapsedTime >= detectionRate) {
		DetectAspect ();
	}
}

Należy pamiętać o słówku override, bo nadpisujemy funkcję wirtualne! Funkcja inicjująca, znajduje obiekt gracza po tagu. Robimy to z prostego powodu. Jego pozycja będzie nam potrzebna do obliczeń cały czas. Każdorazowe szukanie gracza, to spore obciążenie procesora, dlatego robimy to raz na początku.

W funkcji Update, odliczamy sobie nasz czas i jeśli go przekroczymy to wykonujemy funkcję DetectAspect, a ta wygląda tak:

void DetectAspect() {
	RaycastHit hit;
	rayDirection = playerTrans.position - transform.position;

	if ((Vector3.Angle (rayDirection, transform.forward)) < fieldOfView) {
		if (Physics.Raycast (transform.position, rayDirection, out hit, ViewDistance)) {
			Aspect aspect = hit.collider.GetComponent<Aspect> ();

			if (aspect != null) {
				if (aspect.aspectName == aspectName) {
					print ("Enemy Detected"); 
				}
			}
		}
	}
}

Najpierw deklarujemy sobie zmienną pomocniczą typu RaycastHit, która przyda nam się za chwilę, do tego obliczamy kierunek w którym mamy rzucać promień. Tutaj wchodzi prosta matematyka, jeśli mamy dwa punkty (tutaj pozycja gracza i przeciwnika) i odejmiemy od siebie ich współrzędne, to dostaniemy wektor z punktu A do punktu B – czyli tutaj od gracza do naszego AI.

Pierwszy if. Korzystamy z funkcji Vector3.Angle, która zwraca kąt pomiędzy dwoma wektorami. Pierwszy to nasz wektor znajdujący się pomiędzy graczem, a AI, zaś drugi to kierunek w którym patrzy nasze AI. Jeśli ten wektor jest mniejszy od naszej zmiennej fieldOfView, to znaczy że gracz jest w polu widzenia. Poniżej profesjonalna wizualizacja:

Pole widzenia
Pole widzenia

F (niebieskie) to wektor kierunku patrzenia AI, a V (fioletowe) wektor pomiędzy AI (czerwone) i graczem (zielone). Jeśli ten kąt wynosi mniej niż 45 to przeciwnik nas widzi, jeśli więcej niż 45 to nie. Proste.

Ale to nie wszystko! Bo przecież przeciwnik widzi tylko na x metrów, a nie na nieskończoną odległość. Do tego nie powinien widzieć przez ściany.

Tutaj pojawia się drugi if. Rzucamy sobie promień z pozycji AI, w kierunku gracza, na określony wcześniej dystans. Jeśli gracz będzie po za zasięgiem, promień do niego nie doleci, więc tutaj załatwiamy zasięg widzenia. Zostaje sprawdzić czy po drodze nie ma innych obiektów.

Raycast zatrzymuje się na pierwszym obiekcie jaki trafi. Więc pobieramy sobie jego komponent Aspekt. Jeżeli takiego nie ma, to sprawę ignorujemy. Jeśli ma, to nazwa tego aspektu, musi się pokrywać z poszukiwanym przez nasz zmysł. Domyślnie dla zmysłów ustawiliśmy szukanie aspektu Player, a nasz gracz ma właśnie taki aspekt ustawiony. Czyli, szukamy własnie jego.

Na koniec tej ścieżki wypisujemy sobie log, ale można tam zrobić cokolwiek. Zaatakować gracza, schować się, uciekać etc.

Testowanie

Aby ułatwić sobie testowanie tego wszystkiego, dopiszemy sobie bardzo prostą funkcję:

void OnDrawGizmos() {
	if (playerTrans == null) {
		return;
	}

	Debug.DrawLine (transform.position, playerTrans.position, Color.red);

	Vector3 frontRayPoint = transform.position + (transform.forward * ViewDistance);

	Vector3 leftRayPoint = frontRayPoint;
	leftRayPoint.x += fieldOfView * 0.5f;

	Vector3 rightRayPoint = frontRayPoint;
	rightRayPoint.x -= fieldOfView * 0.5f;

	Debug.DrawLine (transform.position, frontRayPoint, Color.green);
	Debug.DrawLine (transform.position, leftRayPoint, Color.green);
	Debug.DrawLine (transform.position, rightRayPoint, Color.green);
}

OnDrawGizmos to funkcja Unity, która wykonuje się tylko w edytorze i służy do debugowania. Nie ma tutaj cudów. Rysujemy dokładnie 4 linie. Pierwsza, czerwona, to linia między graczem i AI. Pozostałe 3, to linie które ładnie nam nakreślą zasięg widzenia AI. Powinno nam to dać, mniej więcej coś takiego:

Wizualizacja wzroku
Wizualizacja wzroku

Oczywiście widać to tylko w okienku Scene!

Podsumowanie

Symulacja zmysłów to bardzo ważne zagadnienie, bo jak przeciwnik ma reagować na coś, jeśli nie potrafi tego wykryć? Mimo że wygląda to fajnie, to zmysł wzroku to jedynie wierzchołek góry lodowej. Zmysłów jest wiele, a mamy też bardzo nietypowe zmysły jak sensory ruchu, radary, a wymyśleni przez nas obcy mogą mieć kompletnie odjechane zmysły. Od nas zależy jak to ogarniemy.

Ważne jest, żeby pamiętać, że czasami dane, które mogłyby się wydawać oszustwem, bardzo pomagają za symulować faktyczne sytuację. W naszym przykładzie podajemy przeciwnikowi pozycję gracza na tacy, ale to dzięki tej wiedzy, zmysł wzroku może być tak realny i przy okazji nie obciąża procesora. Bez tej wiedzy, musielibyśmy wystrzelić promienie w całym zakresie widzenia i sprawdzać, czy któryś nie trafił na gracza.

Wykrywanie przeciwnika ląduje w kategorii sztucznej inteligencji w grach. Tyle, że to nie jest wszystko. Jedynie wykryliśmy gracza, teraz trzeba na to zareagować. Aby to zrobić najczęściej wykorzystuje się maszyny stanów, albo drzewa decyzyjne, ale to już temat na inny wpis.