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

Dzisiejszy odcinek: Dziennik zadań

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

Dziennika zadań nie trzeba przedstawiać żadnemu fanowi gier RPG. Jest to miejsce, gdzie spisujemy wszystkie zadania gracza oraz ich postępy.

Zaproponowana przeze mnie metoda tworzenia zadań, niekoniecznie będzie najlepsza. Raczej nie sprawdzi się też przy zadaniach z masą rozgałęzień i z wieloma zakończeniami. Jednak daje dobry punkt zaczepiania, do tego, żeby taki system rozwinąć. Z racji, że kodu jest sporo, przejdziemy od razu do rzeczy.

Przygotowanie

Z racji, że elementów jest sporo, dodam screen i szybko omówię, co jest czym:

Przygotowana plansza testowa
Przygotowana plansza testowa

Oświetlenia i podłogi nie ma za bardzo co omawiać. FPSController, wzięty ze standardowych assetów Unity (Paczka Character). Podłoga i kolory wzięte ze standardowych assetów, paczka: Prototyping.

Obiekty: Quest_1_1, Quest_1_2, Quest_1_3, to te różowe kulki, symulujące zadanie w którym mamy dostarczyć kilka zebranych przedmiotów.

Enemy, to pomarańczowy walec. Key to mały zielony (seledynowy?) sześcian. Te dwa obiekty, służą do symulacji zadania, w którym ktoś każe nam zabić kogoś, a potem przynieść jeszcze jakiś przedmiot.

NPC_Quest_1 i NPC_Quest_2, symulują dających zadanie NPC. Jedynka to różowy, dwójka to żółty.

Każdy z obiektów ma Collider typu Trigger!

Przygotowujemy dziennik

Zanim przystąpimy do tworzenia faktycznego dziennika, stworzymy sobie klasę zadania. Tej klasy nie będziemy nigdzie przypisywać, ale to ona odpowiada za stan danego zadania i pozwala nim operować.

Klasa nazywa się: Quest, więc mamy ją w pliku Quest.cs

Zaczynamy od zmiennych:

public enum status : int { INACTIVE, ACTIVE, FAILD, SUCCED };

[Header("Quest Details")]
public int questId;
public string questTitle;
public string questDescription;
[Header("Quest Params")]
public status questStatus; 
public int questStage;
public int questFinalStage;
public int questCollectedItems;
[Header("Quest Prize")]
public int questXP;
public int questGold;
public GameObject[] questItems;

Pierwsza rzecz to enum statusu zadania. Pomaga nam w ogarnięciu statusu, żeby używać ACTIVE, FAILD etc. zamiast nic nie mówiących numerków.

Reszta to dane, które mogą się przydać: unikalne id zadania, jego tytuł i opis, status, obecny stan, finalny stan, liczba zebranych przedmiotów (wykorzystana przy zadaniach, ze zbieraniem przedmiotów) i nagrody – ja ich później nie wykorzystałem, ale jak najbardziej można to zrobić. Można też dodać inne elementy.

Teraz tworzymy konstruktor:

public Quest(int qId, string qTitle, string qDescription) {
	this.questId = qId;
	this.questTitle = qTitle;
	this.questDescription = qDescription;
	questStage = 0;
	questCollectedItems = 0;
	questStatus = status.INACTIVE;
}

Aby zadanie mogło istnieć musi mieć id, tytuł i opis.

Tutaj wypadałoby sprawdzić, czy zadanie o podanym ID już nie istnieje, ale nie chciałem dodatkowo komplikować kodu.

Teraz kilka prostych funkcji, które zamieszczę razem i razem omówię:

public void questActive() {
	this.questStatus = status.ACTIVE;
}

public void questFaild() {
	this.questStatus = status.FAILD;
}

public void questSucced() {
	this.questStatus = status.SUCCED;
}

public void itemCollected() {
	questCollectedItems++;
}

Nie ma tutaj cudów. Funkcję, które pozwalają szybko zmienić status zadania i dodać przedmiot do zebranych.

Na koniec najważniejsza chyba funkcja:

public void nextStage() {
	if (questStatus != status.FAILD && questStatus != status.SUCCED) {
		questStage++;

		if (questStage == 1) {
			questStatus = status.ACTIVE;
		}

		if (questStage == questFinalStage) {
			questStatus = status.SUCCED;
		}
	}
}

Przejście do kolejnego etapu. Etap można zmienic tylko, gdy zadanie jest nieaktywne, albo aktywne. Gdy gracz zakończył zadanie sukcesem, albo porażką, nie ma sensu zmieniać jego stanów.

Później zwiększamy numer etapu i sprawdzamy czy etap jest równy 1 (wtedy gracz musiał aktywować zadanie, więc ustawiamy taki stan), albo czy etap obecny to numer etapu końcowego. Jeśli tak, to ustalamy status zadania na zakończone sukcesem.

Jak widać, ta klasa przechowuje informację o konkretnym zdaniu i będziemy się do niej odwoływać. Czas na dziennik!

Klasę dziennika nazwałem: QuestDiary.cs

Najpierw zmienne i podstawowe funkcję:

private List<Quest> questList;
public bool displayDiary = false;

void Start () {
	loadQuests ();
}

void Update () {
	if (Input.GetKeyDown (KeyCode.J)) {
		displayDiary = !displayDiary;
	}
}

Pierwsza zmienna to lista zadań – oparta o klasę, którą wcześniej stworzyliśmy, więc jeśli Twoja klasa zadania nazywa się inaczej, musisz to teraz uwzględnić.  Aby móc korzystać z listy, należy do skryptu dopisać: using System.Collections.Generic; Druga zmienna to zwykła flaga mówiąca czy dziennik jest otwarty.

W funkcji Start wywołujemy sobie funkcję, ładującą zadania, a w funkcji Update, zmieniamy wartość flagi po kliknięciu klawisza J.

Przykładowa funkcja loadQuests może wyglądać tak:

private void loadQuests() {
	questList = new List<Quest> ();

	Quest q1 = new Quest (1, "Collect Balls", "Pink balls!");
	q1.questFinalStage = 2;
	q1.questXP = 100;
	q1.questGold = 200;
	questList.Add (q1);

	Quest q2 = new Quest (2, "Get Key!", "Talk to guy and collect key!");
	q2.questFinalStage = 3;
	q2.questXP = 500;
	q2.questGold = 300;
	questList.Add (q2);
}

Czemu przykładowa? Bo tutaj dla ułatwienia, ładujemy sobie dwa zadania na sztywno. W normalnych warunkach, lista zadań powinna znajdować się w pliku XML czy CSV, którą projektant zadań, mógłby sobie łatwo edytować. I w tym miejscu, powinniśmy taki plik załadować i przerobić na listę klas. Ja tego nie robiłem, aby po pierwsze nie rozbudowywać zbytnio całości, a po drugie zostawić rozwiązanie otwartym. Bo równie dobrze zadania można ładować z pliku tekstowego czy bazy danych. Samo ładowanie danych w ten czy inny sposób to temat bardzo otwarty i jeśli będzie taka potrzeba, opiszę jak to zrobić w oddzielnym poradniku, bo ta wiedza przyda się w większej liczbie miejsc niż dziennik zadań.

Przechodzimy do wyświetlania dziennika:

void OnGUI() {
	if (displayDiary) {
		int i = 0;

		foreach (Quest q in questList) {
			GUI.Label (new Rect (20, 20 + 50 * i, 300, 30), q.questTitle + " Status: " + q.questStatus + " Stage: " + q.questStage + "/" + q.questFinalStage);
			i++;
		}
	}
}

Budowa niezwykle prosta i oczywista. Wyświetlamy wszystkie zadania jeden po drugim, opisując je sobie. Prawdopodobnie nie wykorzystasz tego kodu w faktycznej grze. Całość jest brzydka i wyświetla dane, których gracz nie powinien widzieć. Jednak nam ten kod bardziej służy do debugowania i pokazania jak dane zadań wyświetlić. Tworzenie pięknego dziennika mija się z celem, bo dla każdej gry, będzie wyglądał inaczej. Mi chodziło o pokazanie funkcjonalności, a nie strony artystycznej. Jeśli będzie zainteresowanie, w kolejnym poradniku, mogę opisać jak zrobić dziennik bardziej funkcjonalnym, czyli pozwolić graczowi na wybieranie aktywnego zadania, wyświetlanie opisu zadania po kliknięciu w nie, etc.

Zostało nam dodanie kilku funkcji do operowania zadaniami:

public void questNextStage(int id) {
	foreach(Quest q in questList) {
		if (q.questId == id) {
			q.nextStage ();
			break;
		}
	}
}

Funkcja podobna do tej z klasy Quest. Posuwamy zadanie do przodu, o kolejną fazę. Tylko tutaj, konkretne zadanie o konkretnym ID. Teraz widać, czemu id musi być unikatowe i czemu jest ważne.

ID może się wydawać nieporęczne, ale w momencie kiedy masz zadania spisane w tabeli (plik CSV) to numeracja będzie naturalna i nie utrudni sprawy.

public void questFail(int id) {
	foreach (Quest q in questList) {
		if (q.questId == id) {
			q.questFaild ();
			break;
		}
	}
}

Kolejna funkcja określa zadanie jako porażkę. Nasza funkcja zmieniająca fazę zadania, potrafi zmienić status na aktywne i zakończone sukcesem, ale porażka może wyniknąć z tak wielu czynników, że sami będziemy ją wymuszać.

public int questItemsCollected(int id) {
	foreach (Quest q in questList) {
		if (q.questId == id) {
			return q.questCollectedItems;
		}
	}
	return 0;
}

public void itemCollect(int id) {
	foreach (Quest q in questList) {
		if (q.questId == id) {
			q.questCollectedItems++;
		}
	}
}

Te funkcję daję razem bo są ze sobą powiązane. Pierwsza zwraca liczbę zebranych przedmiotów w danym zdaniu, a druga dodaje zebrany przedmiot.

public bool ifQuestInactive(int id) {
	foreach (Quest q in questList) {
		if (q.questId == id) {
			if (q.questStatus == Quest.status.INACTIVE) {
				return true;
			}
			break;
		}
	}
	return false;
}

Ostatnia funkcja sprawdza czy zadanie jest nieaktywne. Przyda nam się to później.

Tyle, jeśli chodzi o główny dziennik. Skrypt QuestDiary powinien być przypisany do FPSControllera.

Obiekt zadania

Gracz ma swój dziennik, ale teraz fajnie jak by inne obiekty mogły wchodzić z nim w interakcję. Stworzymy coś, co nazwałem QuestObject.cs. Czyli skrypt, mający za zadanie operować danym zdaniem. Znów zaczniemy od zmiennych:

public enum StageType : int{ COLLECT, GIVE, RECEIVE, KILL };

public int questId;
public QuestDiary diary;
public PlayerStats stats;
public Inventory inventory;
public StageType[] stageType;
private int curStage = 0;
[Header("COLLECT Quest")]
public int objectsToCollect;
[Header("GIVE Quest")]
public GameObject itemToGive;
[Header("RECEIVE Quest")]
public GameObject itemToReceive;
[Header("KILL Quest")]
public GameObject[] objectsToKill;
[Header("Stage Prize")]
public int stageGold;
public int stageXP;
public GameObject[] stagePrize;

Znów mamy enum, który określa nam rodzaje zadań, jakie można obsłużyć. Zbieranie przedmiotów, danie przedmiotu graczowi, odebranie przedmiotu od gracza i zabicie kogoś.

Zmienne to ID zadania, do którego się odnosimy. Obiekt dziennika, obiekt statystyk i inwentarza gracza (to zaraz dodamy).

Następny jest typ etapu, będący tablicą. Czemu? W naszym przykładzie jeden NPC będzie chciał uśmiercić przeciwnika i zdobyć klucz – co daje dwa etapy. Nie moglibyśmy przypisać dwa razy tego samego skryptu, dlatego pomaga nam tu tablica.

Następnie mamy obiekty zależne od rodzaju questu: Dla zbierania, liczba przedmiotów, które trzeba zebrać, dla otrzymania i odebrania: przedmiot, którego oczekujemy. Dla zabicia, lista obiektów do zabicia. Na koniec nagrody za dany etap – jeśli chcemy za różne etapy, dawać różne nagrody, można tutaj również umieścić tablicę.

void Awake() {
	diary = GameObject.FindGameObjectWithTag ("Player").GetComponent<QuestDiary> ();
	stats = GameObject.FindGameObjectWithTag ("Player").GetComponent<PlayerStats> ();
	inventory = GameObject.FindGameObjectWithTag ("Player").GetComponent<Inventory> ();
}

Na samym początku szukamy sobie dziennika, statystyk gracza i inwentarza.

Warto tutaj zauważyć, że gracza szukamy po tagu. Więc, jeśli jeszcze Twój FPSController nie ma tagu “Player”, dodaj go teraz do swojej postaci.

private bool allEnemiesKilled() {
	foreach (GameObject go in objectsToKill) {
		if (go != null) {
			return false;
		}
	}
	return true;
}

Ta funkcja, sprawdza czy wszystkie obiekty, które miał zabić gracz, zostały zabite. Zakładam tutaj, że po zabiciu przeciwnika wywołujemy funkcję Destroy. Wtedy odwołanie do obiektu da null. Sprawdzamy wszystkie obiekty w tablicy i jeśli, któryś z obiektów nie jest nullem to znaczy, że żyje. Przy samych nullach wszystkie obiekty zostały uśmiercone.

Teraz najważniejsza funkcja:

public void continueQuest() {

	if (curStage < stageType.Length) {

		if (diary.ifQuestInactive (questId)) {
			diary.questNextStage (questId);
		}

		switch (stageType [curStage]) {
		case StageType.COLLECT: 
			if (diary.questItemsCollected (questId) >= objectsToCollect) {
				stats.gold += stageGold;
				stats.xp += stageXP;
				diary.questNextStage (questId);
				curStage++;
				continueQuest ();
			}
			break;

		case StageType.GIVE:
			inventory.addItem (itemToGive);
			stats.gold += stageGold;
			stats.xp += stageXP;
			diary.questNextStage (questId);
			curStage++;
			continueQuest ();
			break;

		case StageType.RECEIVE:
			if (inventory.playerHaveItem (itemToReceive)) {
				inventory.removeItem (itemToReceive);
				stats.gold += stageGold;
				stats.xp += stageXP;
				diary.questNextStage (questId);
				curStage++;
				continueQuest ();
			} 
			break;

		case StageType.KILL:
			if (allEnemiesKilled()) {
				Debug.Log (objectsToKill.Length);
				stats.gold += stageGold;
				stats.xp += stageXP;
				diary.questNextStage (questId);
				curStage++;
				continueQuest ();
			}
			break;
		}

	}
}

 

Zanim cokolwiek zrobimy, sprawdzamy czy obecna faza, jest mniejsza niż maksymalna – nie chcemy wyjść po za zakres tablicy. Chodzi tutaj o stany dla danego obiektu, czyli tą naszą tablicę, a nie stany całego zadania.

Innymi słowy, jeśli całe zadanie ma 10 etapów, ale jakiś NPC realizuje etapów 4, to będzie miał ten skrypt, stageType będzie miała 4 elementy. Nie chcemy przekroczyć indeksu tej tablicy.

Teraz sprawdzamy czy zadanie jest nieaktywne, jeśli tak, to przechodzimy do kolejnego etapu i kontynuujemy działanie funkcji przechodząc do Switcha.

Tutaj należy rzucić parę słów wyjaśnienia. Czasami zdarza się tak, że wykonamy jakieś zadanie, zanim ktoś je nam zleci. Np. zabijemy 5 groźnych wilków w drodze do miasta, gdzie ktoś nam zleca to zadanie. Bez tej linijki, gdy dojdziemy do kupca to po rozmowie z nim zadanie by się aktywowało, zamiast od razu zakończyć sukcesem.

Case’y w switchu są dość podobne, dlatego omówię jeden z nich, a reszta to analogia:

if (diary.questItemsCollected (questId) >= objectsToCollect) {
	stats.gold += stageGold;
	stats.xp += stageXP;
	diary.questNextStage (questId);
	curStage++;
	continueQuest ();
}

Najpierw jest warunek przejścia danego etapu. Jeśli chodzi o zbieranie, to tutaj musimy mieć tyle elementów ile wymaga zadanie. W przypadku odebrania przedmiotu, gracz musi mieć je w ekwipunku, w przypadku zabicia, lista musi być pusta.

To co robimy później to dodajemy graczowi nagrody, potem wywołujemy funkcję questNextStage w dzienniku. Teraz jeśli zadanie zakończyło się sukcesem to będzie to już zaznaczone. Zwiększamy też obecną fazę zadania dla tego obiektu i… rekurencyjnie wywołujemy jeszcze raz tą funkcję.

Po co? W przypadku gdy dla tego obiektu będziemy mieli x etapów, np. przyniesienie klucza i zabicie wroga i wykonalibyśmy te zadania na raz to bez rekurencji uznano by nam tylko jeden etap. Efekt w grze? Mamy 3 etapy u jednego NPC i wykonaliśmy wszystkie na raz. Musimy teraz z NPC rozmawiać 3 razy, żeby powiedzieć o realizacji całego zadania.

Skrypty testowe

Mamy już wszystko odnośnie dziennika. Czas na kilka skryptów, które pomogą nam w testowaniu dziennika.

PlayerStats – Przypisany do: FPSController

using UnityEngine;
using System.Collections;

public class PlayerStats : MonoBehaviour {

	public int xp;
	public int gold; 

}

Nie ma nic do wyjaśniania

Inventory – Przypisany do: FPSController

using UnityEngine;
using System.Collections;

public class Inventory : MonoBehaviour {

	public bool key;

	public bool playerHaveItem(GameObject item) {
		return key;
	}

	public void removeItem(GameObject item) {
		key = false;
	}

	public void addItem(GameObject item) {
		key = true;
	}
		
}

W tej wersji jako przedmiot mamy tylko klucz. Ale w normalnych warunkach funkcje powinny realizować swoje zadania i odpowiednio dodawać/usuwać przedmiot, oraz sprawdzać czy ten faktycznie jest w ekwipunku – co jest kluczowe dla zadań typu dostarczenie czy odebranie przedmiotu.

Enemy – Przypisany do: Enemy

using UnityEngine;
using System.Collections;

public class Enemy : MonoBehaviour {

	void OnTriggerEnter(Collider other) {
		Destroy (gameObject);
	}
}

Przeciwnik ginie, gdy się do niego zbliżmy.

Key – Przypisany do: Key

using UnityEngine;
using System.Collections;

public class Key : MonoBehaviour {

	void OnTriggerEnter(Collider other) {
		GameObject.FindGameObjectWithTag ("Player").GetComponent<Inventory> ().addItem (gameObject);
		Destroy (gameObject);
	}
}

Dodajemy klucz do ekwipunku.

TalkScript – Przypisany do: Obu NPC

using UnityEngine;
using System.Collections;

public class TalkScript : MonoBehaviour {

	bool collision = false;

	void OnTriggerEnter(Collider other) {
		collision = true;
	}

	void OnTriggerExit(Collider other) {
		collision = false;
	}

	void OnGUI() {
		if (collision) {
			GUI.Label (new Rect (Screen.width / 2, Screen.height / 2, 200, 40), "Press E to Talk");
		}
	}

	void Update() {
		if(Input.GetKeyDown(KeyCode.E) && collision) {
			gameObject.GetComponent<QuestObject> ().continueQuest ();
		}
	}
}

Ten skrypt, pozwala symulować rozmowę z NPC. Wtedy odwołujemy się do skryptu QuestObject i kontynuujemy zadanie.

QuestCollect – Przypisany do: Kulek, które ma zbierać gracz

using UnityEngine;
using System.Collections;

public class QuestCollect : MonoBehaviour {

	public int questId;
	public QuestDiary diary;

	void Awake() {
		diary = GameObject.FindGameObjectWithTag ("Player").GetComponent<QuestDiary> ();
	}

	void OnTriggerEnter(Collider collision) {
		diary.itemCollect (questId);
		Destroy (gameObject);
	}
}

Kiedy gracz wejdzie w Trigger, dopisujemy zadaniu obiekt.

W przypadku większej różnorodności, można w zadaniu opisać obiekt, jaki ma być zbierany i tutaj sprawdzać, czy obiekt pasuje do oczekiwanego przez zadanie.

Konfiguracja QuestObject

Skrypt QuestObject przypisujemy do obu NPC. Ich konfiguracja wygląda następująco:

Dla NPC od zbierania:

NPC od zadania ze zbieraniem
NPC od zadania ze zbieraniem

Dla NPC od dwuetapowego zadania:

NPC od dwuetapowego zadania
NPC od dwuetapowego zadania

Zmienne Diary, Stats i Inventory zostawiamy puste, bo skrypt sam je sobie znajdzie. Etapy ustawiamy tak jak powinny być realizowane i wypełniamy dane potrzebne do realizacji danych etapów. Na koniec nagrody.

Podsumowanie

Kod jest już rozwlekły, a nie realizuje wszystkiego. Co jest nie do końca OK?

  • Jeden NPC nie może odpowiadać za kilka różnych zdań.
  • Zadania są liniowe i nie mogą mieć rozgałęzień
  • Nie omówiliśmy wyglądu dziennika
  • Nie pobieramy danych z zewnętrznego źródła jak plik XML, CSV czy baza danych
  • Etapy u jednego NPC nie mogą się pokrywać – tzn. jeden NPC nie zleci 3 zadań zbierania, ale to da się rozwiązać, zmieniając pojedyncze zmienne na tablicę.
  • Każdy etap ma tą samą nagrodę – to się da zmienić, zmieniając zmienne nagród na tablicę

Kod nie jest perfekcyjny i sam nie do końca jestem z niego zadowolony, ale to było w sumie pierwsze podejście i pomysł na jaki wpadłem. Może za jakiś czas spróbuję stworzyć coś bardziej uniwersalnego.

Do prostej gry RPG, to powinno starczyć, albo dać wam przynajmniej inspirację, do stworzenia czegoś lepszego.