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

Dzisiejszy odcinek: Jak zrobić respawn 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

Czym jest respawn w grach, chyba nie muszę tłumaczyć. Po śmierci, nasz przeciwnik się odnawia i pojawia w jakimś miejscu. Rozróżniamy jednak 3 kluczowe rodzaje respawnu:

  • W tym samym miejscu – czyli zabijamy przeciwnika, po pewnym czasie gracz wraca i przeciwnik znów jest tam, gdzie był.
  • Na pewnym obszarze – najczęściej w grach multi. Np. w Counter-Strike’u mamy obszary, gdzie gracze zaczynają zabawę. Zawsze jest to ten sam obszar o pewnej powierzchni, ale miejsce na nim jest dość losowe.
  • Losowo – kompletnie losowe rozmieszczenie przeciwników. Często stosowane w H’n’S, gdzie w danym obszarze, ma być po prostu x takich i takich przeciwników.

Respawn: W tym samym miejscu

Tutaj sprawa jest bardzo prosta. Ustalamy miejsce startowe przeciwnika ręcznie, więc możemy je sobie zapisać do zmiennej i po pewnym czasie odtworzyć przeciwnika. Nie musimy w żaden sposób sprawdzać, czy np. pozycja pojawienia przeciwnika nie pokrywa się z czymś, bo zaplanowaliśmy te pozycję wcześniej.

Nie podam tutaj pełnego kodu przeciwnika, bo nie ma on znaczenia. Podaje tylko elementy istotne dla samego respawnu, tego typu:

using UnityEngine;
using System.Collections;

public class Respawn_1 : MonoBehaviour {

	private Vector3 startPosition;

	public float respawnTime = 3.0f;
	
	void Start() 
	{
		startPosition = gameObject.transform.position;
		StartCoroutine(enemyDie());
	}

	IEnumerator enemyDie()
	{
		// Te dwie linijki potrzebne tylko do pokazania efektu
		transform.Translate (new Vector3 (2, 0, 0));
		yield return new WaitForSeconds(2.0f);

		gameObject.GetComponent<MeshRenderer> ().enabled = false;
		yield return new WaitForSeconds(respawnTime);
		gameObject.transform.position = startPosition;
		gameObject.GetComponent<MeshRenderer> ().enabled = true;
	}
}

To co potrzebujemy to zmienna do przechowania początkowej lokacji i określenie czasu na respawn. W funkcji Start zapisujemy sobie aktualną pozycję naszego obiektu, żeby móc do niej wrócić. Uruchamiam też współprogram (normalnie robilibyśmy to w momencie śmierci przeciwnika lub przy zniszczeniu obiektu).

Pierwsze dwie linijki nie są konieczne do jego działania, ale pokazują, że kod działa. Pierwsza przesuwa obiekt, a druga każe zaczekać 2 sekundy – żeby było widać efekt.

Następnie chowamy naszego przeciwnika. Odwołujemy się do komponentu renderującego, u mnie MeshRenderer i wyłączam komponent: enabled = false. Odczekujemy czas respawnu, przestawiamy obiekt na pozycję startową i włączamy mu znów rednerer, dzięki czemu jest widoczny. W sumie tyle. Jeśli powyższy kod dodasz sobie do obiektu Cube i odpalisz grę, zobaczysz jak całość działa.

Metoda ta, wykorzystywana jest najczęściej w platformówkach. Ktoś uważny, może spostrzec: “Hej, a co jeśli gracz będzie na pozycji respawnu?”. Słuszna uwaga, ale da się ten problem prosto ominąć. Otóż, przeciwnika nie respawnujemy dokładnie tam gdzie ma być, ale nieco nad jego pozycją. Czyli gracz ma czas na usunięcie się, a gdy tego nie zrobi, zostanie to potraktowane jak zetknięcie się z przeciwnikiem. Jakaś animacja pojawiania się przeciwnika, da mu czas na reakcję.

Respawn: Na pewnym obszarze

Tutaj przykładem zostaje u mnie Counter-Strike. Tzn. mamy jakiś obszar, gdzie ma się pojawić gracz. Znamy ramy tego obszaru, więc możemy sobie zagwarantować, że będzie to płaski kawałek terenu bez żadnych przeszkód (np. beczek). Jeśli przyjrzycie się lokacjom startowym w CSie, właśnie takie one są. Jedynym naszym problemem jest sytuacja, kiedy gracz A pojawiłby się zbyt blisko gracza B.

Miejsce Respawnu w Counter-Strike
Miejsce Respawnu w Counter-Strike

Powyżej, fotka potwierdzająca moją tezę. Więc bierzmy się do roboty. Nakreślmy sobie szybko sytuację (Podłoga pochodzi ze standardowej paczki Prototyping):

Obszar respawnu
Obszar respawnu

Będę chciał dokonać respawnu 3 cubów, na zaznaczonym obszarze, tak, aby nie pojawiły się na sobie.

Krokiem pierwszym, będzie sprawienie, żeby nasze postaci pojawiły się w ogóle we wskazanym obszarze. Musimy sobie na szybko przygotować 3 prefabrykaty. Ja utworzyłem 3 kule, każdej nadałem materiał mający inny kolor i przeciągając je z panelu Hierarchy do panelu Project, utworzyłem gotowe prefabrykaty. Moje kule mają kolory: czerwony, niebieski i żółty. Oczywiście obiekty wzorcowe usuwamy ze sceny po utworzeniu prefabrykatu (czyli usuwamy je z panelu hierarchy). Jednak zanim to zrobimy, mogą posłużyć do określenia naszego minimum i maksimum – czyli 2 punktów skrajnych, gdzie mogą pojawić się kule.

Najprościej jest to zrobić, przez ustawienie sobie przykładowego obiektu w odpowiednim miejscu i odczytanie jego położenia z panelu Inspector.

 

Odczytanie położenia
Odczytanie położenia

Analogicznie robimy z drugim narożnikiem. Dzięki temu, wiem, że minimalne położenie mojego obiektu to: v3(-3.5, 0.5, 3.5), a maksymalne: v3(-1.5, 0.5, 1.5). Połówki wynikają z tego, że pivoty obiektów umieszczone są w ich środku. Więc to dodatkowe 0.5 to połowa szerokości/głębokości samego obiektu. No to czas na skrypt:

using UnityEngine;
using System.Collections;

public class Respawn_2 : MonoBehaviour {

	public GameObject[] players;
	public Vector3 minLocation;
	public Vector3 maxLocation;

	
	void Start () {
		foreach(GameObject player in players) {
			Instantiate(player, 
			            new Vector3(Random.Range(minLocation.x, maxLocation.x),
			                                Random.Range(minLocation.y, maxLocation.y),
			                                Random.Range(minLocation.z, maxLocation.z)), 
			            new Quaternion(0, 0, 0, 0));
		}
	}
}

Mamy 3 zmienne, listę prefabrykatów obiektów, oraz dwa wektory określające minimalne i maksymalne położenie. W funkcji start, mamy tylko funkcję foreach, wykonaną dla każdego obiektu gracza, a później klasyczne Instantiate, które tworzy obiekt gracza. Parametry są proste: Najpierw prefab (wzięty z tablicy), potem położenie, które losujemy, ale w ramach wybranych przez nas granic. Obrót obiektu nie ma tutaj znaczenia, więc wstawiłem pusty.

Skrypt dodajemy do MainCamera i uzuepłeniamy:

Uzupełnienie skryptu Respawn
Uzupełnienie skryptu Respawn

Możemy sobie teraz całość testować, ale po którejś próbie, na pewno trafimy na taki obrazek:

Błąd w działaniu skryptu
Błąd w działaniu skryptu

Wypadałoby coś z tym zrobić.

using UnityEngine;
using System.Collections;

public class Respawn_2 : MonoBehaviour {

	public GameObject[] players;
	public Vector3 minLocation;
	public Vector3 maxLocation;

	private GameObject[] created; 
	private int createdObjects = 0;

	void Start () {
		created = new GameObject[players.Length];

		for(int i = 0 ; i < players.Length ; i++) {

			Vector3 objectPos = new Vector3(0f, 0f, 0f);
			Bounds bounds;

			for(int j = 0 ; j < 10 ; j++) {
				objectPos = new Vector3(Random.Range(minLocation.x, maxLocation.x),
				                        Random.Range(minLocation.y, maxLocation.y),
				                        Random.Range(minLocation.z, maxLocation.z));
				
				bounds = new Bounds(objectPos, new Vector3(0.5f, 0.5f, 0.5f));

				if(BoundsCheck(bounds)) {
					break;
				}
			}

			GameObject playerPref = Instantiate(players[i], objectPos, new Quaternion(0, 0, 0, 0)) as GameObject;
			created[i] = playerPref;
			createdObjects++;
		}
	}

	bool BoundsCheck(Bounds bounds) {
		for (int i = 0; i < createdObjects; i++){    
			if (bounds.Intersects(created[i].GetComponent<MeshRenderer>().bounds)) {
				return false;
			}
		}
		return true;
	}
}

Jest sporo zmian, więc omówię je fragmentami:

Po pierwsze mamy dwie nowe zmienne. Listę utworzonych już obiektów, oraz ich liczbę. Na początku funkcji Start ustawiamy rozmiar tablicy obiektów utworzonych, na równą rozmiarowi tablicy z listą obiektów.  Teraz for:

for(int i = 0 ; i < players.Length ; i++) {

	Vector3 objectPos = new Vector3(0f, 0f, 0f);
	Bounds bounds;

	for(int j = 0 ; j < 10 ; j++) {
		objectPos = new Vector3(Random.Range(minLocation.x, maxLocation.x),
				        Random.Range(minLocation.y, maxLocation.y),
				        Random.Range(minLocation.z, maxLocation.z));
				
		bounds = new Bounds(objectPos, new Vector3(0.5f, 0.5f, 0.5f));

		if(BoundsCheck(bounds)) {
			break;
		}
	}

	GameObject playerPref = Instantiate(players[i], objectPos, new Quaternion(0, 0, 0, 0)) as GameObject;
	created[i] = playerPref;
	createdObjects++;
}

Po pierwsze, zmieniamy foreach na for, potrzebujemy teraz operować na indeksach tabel, więc tak jest nam wygodniej to zrobić, niż wewnątrz foreach deklarować dodatkową zmienną.

Na początku deklarujemy sobie dwie zmienne. Zmienną pozycji nowo tworzonego obiektu i zmienną bounds typu Bounds. Jest to specjalny typ zmiennej, równoległy do każdej osi, będący pudełkiem. Służy do sprawdzania czy coś znajduje się w jego obszarze, czy coś go przecina etc.

Kolejnych ruch to pętla mająca 10 obrotów. Może być ich więcej lub mniej. Reprezentują one 10 prób znalezienia danemu obiektowi miejsca do wyświetlenia. Nie możemy takiego miejsca szukać w nieskończoność, bo może go nie być. Pierwsza linia w wewnętrznym forze to znów wylosowanie pozycji. Później tworzymy obiekt bounds. Jego budowa jest prosta: Podajemy środek, a potem jak bardzo ściany w danej osi mają być oddalone od środka.

Na koniec sprawdzamy napisaną przez nas funkcją BoudsCheck czy tak utworzony boks, spełnia warunki – czyli nie styka się z utworzonymi wcześniej obiektami. Jeśli nie styka się, funkcją break przerywamy pętlę, bo mamy dobrą pozycję dla obiektu gracza.

Ostatecznie wrzucamy obiekt gracza do gry (jak wcześniej), ale dodatkowo dodajemy go do listy utworzonych i zwiększamy licznik utworzonych obiektów.

No i zostaje funkcja BoudsCheck:

bool BoundsCheck(Bounds bounds) {
	for (int i = 0; i < createdObjects; i++){    
		if (bounds.Intersects(created[i].GetComponent<MeshRenderer>().bounds)) {
			return false;
		}
	}
	return true;
}

Lecimy sobie pętlą po wszystkich utworzonych obiektach (potrzebujemy tutaj licznika, bo created.Length pokazywałby zawsze maksymalny rozmiar, gdzie na początku były by puste elementy.

Wewnątrz porównujemy po prostu zadane ramy obiektu (my jako parametr podajemy ramy oparte na miejscu gdzie chcemy utworzyć obiekt, a porównujemy je z ramami MeshRenderera utworzonych już obiektów. Zwracamy false albo true zależnie czy boundsy się przecięły.

Tyle. Obiekty mogą się stykać, ale nie będą nigdy się przecinać. Jeśli chcemy zachować większe odstępy między tworzonymi obiektami wystarczy zmienić drugi parametr w tej linijce:

bounds = new Bounds(objectPos, new Vector3(0.5f, 0.5f, 0.5f));

Respawn: Losowo

W tym wypadku mamy dwa problemy. Po pierwsze, nasz przeciwnik musi pojawić się na ziemi. Nie chcemy go na jakimś elemencie otoczenia, typu beczka czy drzewo. Po drugie nie chcemy, żeby losowo generowani przeciwnicy, pojawiali się na sobie.

 

Zastosowane poprzednio rozwiązanie ma pewne wady. Gdybyśmy chcieli porównywać pozycję, którą wybraliśmy do pozycji każdego elementu środowiska, zabilibyśmy procesor na wejściu. Dlatego, trzeba zachować się nieco inaczej.

Dla urozmaicenia sceny, dodajemy kilka “obiektów otoczenia”. U mnie standardowe Cube:

Urozmaicona scena
Urozmaicona scena

Czas na skrypt:

using UnityEngine;
using System.Collections;

public class Respawn_3 : MonoBehaviour 
{	
	public GameObject respawnObject;
	public Vector3 minLocation;
	public Vector3 maxLocation;

	void Start () 
	{
		for (int i = 0; i < 10; i++) {
			Vector3 objectPos = new Vector3(0, 0, 0);

			for(int j = 0 ; j < 10 ; j++) {
				objectPos = new Vector3 (Random.Range (minLocation.x, maxLocation.x),
			                             Random.Range (minLocation.y, maxLocation.y),
			                             Random.Range (minLocation.z, maxLocation.z));

				Collider[] hitColliders = Physics.OverlapSphere (objectPos, 0.5f);

				if(hitColliders.Length == 0) {
					break;
				}
			} 

			Instantiate (respawnObject, objectPos, new Quaternion (0, 0, 0, 0));
		}
	}
}

Przypomina nieco ten z poprzedniego rozdziału. Deklaracja zmiennych jest identyczna (tylko zamiast tablicy obiektów, mamy jeden, który się powtarza. Pierwszy for, definiuję ile obiektów chcemy utworzyć. Drugi to próby znalezienie miejsca. Jednak cała zmiana to w sumie te 5 zaznaczonych linijek.

Czym jest Pgysics.OverlapSphere? Tworzy nam niewidzialną sferę w podanym miejscu (objectPos) i o podanym promieniu. Ta sfera działa jak Collider i zwraca nam wszystkie obiekty (tablicę tych obiektów), z którymi nasza kolizyjna kula… ma kolizję.

Ten if, jest przerwaniem warunku, oznacza, że nie mamy kolizji z żadnym obiektem, bo tablica jest pusta. Tutaj warto pamiętać, że przy podaniu pozycji w osi OY dla obiektów, warto podać nieco więcej niż 0,5. Bez tego nasza sfera kolizyjna, zawsze będzie kolidować z podłożem. Ew. można zmienić warunek na równe 1.

Należy tutaj zwrócić uwagę na fakt, żeby nie dać za dużego promienia sfery, albo za małej liczby prób. W moim przykładzie, gdy obiekt w 10 próbach nie znajdzie dogodnej pozycji, pojawi się tam, gdzie próbował się pojawić w ostatniej próbie, a to nie zawsze będzie dobre miejsce.

To tyle.