Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!
Dzisiejszy odcinek: Prosty inwentarz
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
Nie ma jej za wiele. W każdym RPG mamy do czynienia z inwentarzem w tej czy innej postaci. Dzisiaj spróbujemy sobie ogarnąć go w bardzo prostej formie. Będziemy zbierać rzeczy, te będą się pojawiać w otwieranym inwentarzu. Do tego przedmiotów będzie dało się użyć. Sam w sobie temat jest dość rozległy, bo inwentarze występują w masie form. Mamy takie z ograniczonym miejscem, z ograniczoną wagą, z przedmiotami zajmującymi jedno pole, zajmującymi kilka pól. Do tego wszystkiego dojdziemy. Jednak od czegoś trzeba zacząć.
Takie proste coś stworzymy dziś. Spokojnie, całość będzie bardziej na wypasie, ale nie od razu Kraków zbudowano.
Przygotowanie
Do zrobienia tego tutorialu, potrzeba tylko kilka rzeczy. Postaci gracza (standardowa paczka Characters), jakiejś podłogi (standardowa paczka Prototyping), modelu 3D jakiegoś obiektu (u mnie padło na survivalPack z Asset Store – jest za free i posiada różne przedmioty, więc na jego podstawie ogarnie się potem dużo innych rzeczy), ikony dla obiektów (z racji, że w pierwszym etapie użyłem tylko butelki wody, wziąłem sobie ikonkę butelki z iconfindera).
Iconfindera polecam do szukania ikon, ale pamiętajcie, żeby sprawdzić licencję!
OK. Rozstawiamy to sobie jakkolwiek i bierzemy się za kodzenie.
Przygotowanie gracza
Do gracza przypiszemy sobie dwa skrypty.
/* * MWin GameDev - https://mwin.pl * * Plik stanowi część kursu Unity3d * * Unity3d QuickTip #28 - Prosty inwentarz * Autor: Marek Winiarski (https://mwin.pl) * */ using UnityEngine; public class PlayerStats : MonoBehaviour { public int playerHP { get; set; } public int demage { get; set; } public int defense { get; set; } public int playerGold { get; set; } void Start() { playerHP = 100; demage = 0; defense = 0; playerGold = 1000; } void OnGUI() { GUI.Label (new Rect (800, 0, 100, 20), "Max HP: " + playerHP); GUI.Label (new Rect (800, 30, 100, 20), "Attack: " + demage); GUI.Label (new Rect (800, 60, 100, 20), "Defense: " + defense); GUI.Label (new Rect (800, 90, 100, 20), "Gold: " + playerGold); } }
Pierwszy skrypt daje w całości (pamiętaj, żeby plik nazwać PlayerStats.cs), bo możecie go kojarzyć. Jest to skrypt wypisujący statystyki, z tutoriala o drzewku umiejętności. Zmieniłem tylko rodzaj i nazwy wypisanych statystyk. Wszystkie zmienne mają mutuatory (getery i setery), żeby łatwo z nich korzystać.
Drugi skrypt, będzie tym o który sobie stworzymy. Zaczynamy od deklaracji zmiennych:
public int itemsInRow = 3; public int buttonWidth = 50; public int buttonHeight = 50; public int buttonMargin = 10; public Rect inventoryPosition = new Rect(20, 20, 0, 0); private List<ItemAbstract> itemsList = new List<ItemAbstract> (); private bool openInventory; private PlayerStats ps;
Co tutaj mamy? Pierwsze zmienne deklarują wygląd inwentarza. Kolejno, określamy ile przedmiotów ma być w wierszu, jaka ma być szerokość i wysokość okienka przedmiotu, jaki margines między okienkami, oraz jaka jest pozycja całego inwentarza. Zmienne publiczne, żeby było łatwo to zmieniać.
Kolejna sekcja zmiennych to po pierwsze lista posiadanych przedmiotów (klasę ItemAbstract dopiszemy sobie później, więc póki co wszędzie będzie ją oznaczało na czerwono). Druga zmienna informuje czy gracz otworzył inwentarz (czy mamy go wyświetlić), a ostatnia to odwołanie do skryptu ze statystykami.
O ile ItemAbstract będzie wyświetlać się na czerwono i nie zrobimy z tym nic do póki nie stworzymy tej klasy, o tyle List pewnie też jest oznaczone jako błąd. Temu jednak możemy szybko zaradzić. Wystarczy doimportować bibliotekę:
using UnityEngine; using System.Collections; using System.Collections.Generic;
Czas na określenie zmiennych:
void Start() { openInventory = false; ps = gameObject.GetComponent<PlayerStats> (); inventoryPosition = new Rect (inventoryPosition.x, inventoryPosition.y, ((itemsInRow * buttonWidth) + ((itemsInRow + 1) * buttonMargin)), ((itemsInRow * buttonHeight) + ((itemsInRow + 1) * buttonMargin))); }
Chyba nie ma tutaj nic do tłumaczenia. Jedynie okiem można rzucić na pozycję inwentarza. Te wyglądające na skomplikowane obliczenia, to zwykłe określanie szerokości i wysokości inwentarza. Jeżeli wiemy ile przycisków ma się mieścić w linii, jakie są ich wymiary i w jakiej odległości mają być od siebie, wyliczenie wymiarów jest proste. A lepiej zrobić to tak, niż zostawić obliczenia człowiekowi, który może się pomylić i zepsuć całość.
void Update() { if (Input.GetKeyDown (KeyCode.I)) { openInventory = !openInventory; } }
Kolejny fragment, który mógłbym zostawić bez komentarza. Jeśli gracz naciśnie I to zmieniamy wartość naszej zmiennej bool na przeciwny. Czyli? Jak mamy otwarty inwentarz to zamykamy, jak mamy zamknięty to otwieramy.
public void addItem(ItemAbstract item) { itemsList.Add (item); }
Ta funkcja już obraża swoją prostotą. Potrzebujemy jej, aby pozwolić danemu obiektowi dodać się do listy posiadanych obiektów, gdy go zbierzemy. Czas na największy kawałek kodu. Całość wygląda tak:
void OnGUI() { if (openInventory) { int numerOfItems = itemsList.Count; int rows = Mathf.CeilToInt(numerOfItems / itemsInRow) + 1; GUILayout.BeginArea (inventoryPosition); for(int i = 0 ; i < rows ; i++) { for(int j = 0 ; j < itemsInRow ; j++) { if((i * itemsInRow) + j < numerOfItems) { if (GUI.Button(new Rect((buttonMargin + buttonMargin * j + buttonWidth * j), (buttonMargin + buttonMargin * i + buttonHeight * i), buttonWidth, buttonHeight), itemsList[(i * itemsInRow) + j].getItemIcon())) { bool disposable = itemsList[(i * itemsInRow) + j].execute(ps); if(disposable) { itemsList.RemoveAt((i * itemsInRow) + j); } } } else { if (GUI.Button(new Rect((buttonMargin + buttonMargin * j + buttonWidth * j), (buttonMargin + buttonMargin * i + buttonHeight * i), buttonWidth, buttonHeight), "")) { Debug.Log("Clicked the button!"); } } } } GUILayout.EndArea (); } }
Pierwszy krok to sprawdzenie czy mamy wyświetlić inwentarz. W środku ifa, sprawdzamy sobie ile mamy w sumie przedmiotów (funkcja Count na obiekcie List zwraca liczbę elementów w liście). Obliczamy ile wierszy otrzymamy przy takiej liczbie obiektów, dzieląc ich liczbę przez ilość miejsc w wierszu. Dodajemy do całości jeden, żeby zawsze był co najmniej jeden wiersz.
Następnie mamy dwie pętle. Odpowiadają za wyświetlenie wszystkich okienek inwentarza. Jeden to wiersze, drugi to kolumny. Wewnątrz trafiamy na kolejnego ifa. On sprawdza, czy ma wyświetlić okienko puste czy z obiektem (chcemy wypełnić wszystkie wiersze i kolumny, aby nasz inwentarz był równy. Więc gdy braknie przedmiotów, żeby wypełnić wiersz, wyświetlimy kilka pustych okienek).
Czas na sam kod przycisku. Pozwolę go sobie skopiować, żeby było lepiej widać.
if (GUI.Button(new Rect((buttonMargin + buttonMargin * j + buttonWidth * j), (buttonMargin + buttonMargin * i + buttonHeight * i), buttonWidth, buttonHeight), itemsList[(i * itemsInRow) + j].getItemIcon())) { bool disposable = itemsList[(i * itemsInRow) + j].execute(ps); if(disposable) { itemsList.RemoveAt((i * itemsInRow) + j); } }
Położenie okienka, jest wyliczane na podstawie rozmiaru okienek i tego ile okienek już wyświetliliśmy. Wszystko obudowane dodatkowymi marginesami. Ostatnia zmienna, to to funkcja obiektu ItemAbstract, która zwraca obiekt typu Texture2D, czyli ikonkę zebranego obiektu.
Funkcja execute, wykonuje jakieś zadanie danego obiektu. Piszę tak ogólnie dlatego, że cały system zaprojektowany jest jak najbardziej uniwersalnie i funkcja execute raz będzie dodawała życia, a raz zmieniała pancerz. Efekt funkcji przypisujemy do zmiennej bool. Dlaczego? Bo mój projekt zakłada, że jeśli funkcja zwróci true, to przedmiot był jednorazowy i trzeba go z ekwipunku usunąć (np. butelka wody, jabłko). Tym zajmuje się funkcja RemoveAt. Nie mylić z funkcją Remove! Funkcja Remove, usuwa konkretny obiekt. RemoveAt, usuwa obiekt o podanym indeksie.
Tyle! Inwentarz gotowy. Przypisujemy go do gracza, a graczowi ustawiamy tag Player. Przyda się później.
Tworzymy abstrakcję!
Teraz czas na klasę abstrakcyjną. Czym jest klasa abstrakcyjna? To klasa, która ma jakąś swoją budowę, ale nie może posiadać instancji. Jak to rozróżnić? Jeżeli napiszemy public class MyClass, to stworzyliśmy klasę. Aby stworzyć jej instancję, musimy napisać: MyClass obj = new MyClass(); W przypadku klasy abstrakcyjnej, nie możemy tak zrobić.
Więc po co nam klasy abstrakcyjne? Jeśli chcemy na pewnej grupie klas wymusić pewną budowę, ale nie definiować wszystkiego.
using UnityEngine; using System.Collections; public abstract class ItemAbstract : MonoBehaviour { public Texture2D itemIcon; public string itemName; public string description; public string type; public float weight; public float cost; public bool disposable; public Texture2D getItemIcon() { return itemIcon; } public string getName() { return itemName; } void OnTriggerEnter(Collider other) { if (other.tag.Equals ("Player")) { other.GetComponent<Inventory>().SendMessage("addItem", this); Destroy(gameObject); } } public abstract bool execute(PlayerStats ps); }
Tak wygląda nasza klasa ItemAbstract. Czym różni się od typowej klasy? Pomiędzy public i class wstawiliśmy słowo kluczowe abstract. Co mamy w samej klasie? Deklarację kilku zmiennych. Przy czym, są to zmienne wspólne dla WSZYSTKICH obiektów. Czy opisywać będziemy broń, pancerz czy przedmiot do wykorzystania (klucz, jabłko, potiona), te zmienne pojawią się wszędzie! Czyli: Ikona obiektu (w inwentarzu), nazwa obiektu, opis obiektu, typ obiektu (to będzie definiowało czy mamy do czynienia z bronią, pancerzem czy czymś innym), waga, cena oraz czy obiekt jest jednorazowego użytku.
Dwie pierwsze funkcję są proste. Zwracamy ikonę – tej funkcji użyliśmy w inwentarzu i nazwę obiektu. Kolejna funkcja to klasyczne OnTriggerEnter. Sprawdzamy czy wszedł w nas gracz i jeśli tak, to znajdujemy skrypt Inventory i korzystamy z przygotowanej wcześniej funkcji addItem by dodać nasz obiekt. Sam obiekt usuwamy ze sceny gry.
Ostatnia funkcja to funkcja abstrakcyjna. Na czym to polega? Na tym, że definiujemy strukturę takiej funkcji (jej nazwę, typ zwracany, dostępność oraz przyjmowane parametry), ale nie mówimy jej jak ma być wykorzystana. Cały myk polega na tym, że obiekt, który dziedziczy po naszej klasie abstrakcyjnej, musi posiadać wszystkie metody abstrakcyjne. Właśnie dziedzicząca klasa – już konkretna klasa, której będziemy używać – definiuje, co ta funkcja właściwie zrobi.
Brzmi może zawile, ale gdy pokaże ostatni skrypt, wszystko się wyjaśni.
Konkretyzujemy obiekt
Dorabiamy ostatni skrypt. Będzie to EatItem.cs:
using UnityEngine; using System.Collections; public class EatItem : ItemAbstract { public int regenerateHP = 20; public override bool execute(PlayerStats ps) { ps.playerHP = (ps.playerHP + regenerateHP); return disposable; } }
Co pierwsze rzuca się w oczy? Klasa zamiast po MonoBehaviour, dziedziczy po naszej abstrakcyjnej klasie ItemAbstract. Co nam to daje? Klasa posiada wszystkie wspomniane wcześniej funkcję i pola. Naszym zadaniem jest jedynie rozszerzenie i skonkretyzowanie całości.
Klasa EatItem ma reprezentować obiekty, które… da się zjeść. W moim zamyśle, takie obiekty regenerują życie. Dlatego dodałem zmienną publiczną regenerateHP, która określa, ile życia ma się odnowić.
Druga rzecz, to skonkretyzowanie funkcji execute. Słówko abstract, zostało zastąpione tutaj przez override, które oznacza, że nadpisujemy tamtą klasę abstrakcyjną. Jak widać, budowa jest dokładnie taka sama. Dowolne jest to, co robimy w środku. Ja skorzystałem z geterów i seterów klasy PlayerStats, aby pobrać ilość życia gracza i je zwiększyć o podany parametr. Potem zwracamy zmienną disposable, żeby określić czy obiekt ma być usunięty z inwentarza po usunięciu.
Zmiennej nie muszę definiować, bo zrobiłem to w klasie nadrzędnej i już dysponuje tym polem. Dlaczego nigdzie nie ustalam jego wartości? Bo to zmienna publiczna i designer ustawi ją i tak wedle upodobania.
Tworzymy obiekt do inwentarza
Na starcie zaczniemy od czegoś prostego. Przygotujemy sobie ikonę. Wybieramy w panelu Project naszą ikonę i ustawiamy Texture Type na Editor GUI and Legacy GUI. Drugi krok, to wrzucenie do sceny jakiegoś obiektu 3D, ja wrzuciłem butelkę.
Dodajemy do butelki SphereCollider i ustawiamy mu odpowiednio większy promień (radius) oraz włączamy opcję isTrigger.
Teraz przeciągamy na butelkę skrypt EatItem. Zauważysz, że w inspektorze pojawią się opcję ze skryptu EatItem oraz ItemAbstract. To dzięki dziedziczeniu! Zostaje tylko wypełnić wszystkie pola.
Jedno z pytań jakie może się tutaj nasuwać, to: “Czemu lista obiektów w skrypcie Inventory jest typu ItemAbstract, a my dodaliśmy EatItem?” Odpowiedź jest prosta. Jeśli klasa dziedziczy po jakiejś klasie, jest również reprezentantem tej klasy. Prosty przykład. Kwadrat jest figurą. Jeśli go rozciągniemy stanie się prostokątem, ale nie przestaje być figurą. Więc jeśli żądamy podania kwadratu, przyjmiemy tylko kwadrat. Jeśli żądamy figury, to skrypt przyjmie i kwadrat i prostokąt. Dzięki temu, nasz skrypt będzie do inwentarza przyjmował jedzenie, broń, pancerze itd. My będziemy jedynie dopisywać sobie klasy, które będą reprezentować dany typ ekwipunku.
Co zostało do zrobienia? Zmienić butelkę w prefabrykant, dodać kilka prefabrykantów na scenę i przetestować całość. Dla ścisłości: Butelka posiada skrypt EatItem, a obiekt gracza Inventory i PlayerStats. Skrypt ItemAbstract, nie powinien być dodany nigdzie!
Co dalej?
Przyznaje, ten inwentarz jest nieco bidny. W momencie kiedy piszę to zdanie, na liczniku mam już 1913 słów. Było tu sporo teorii dotyczącej polimorfizmu (dziedziczenia) i pojęcia abstrakcji. Nie ma się co martwić. Za tydzień rozbudujemy całość. Co planuję?
- Grupowanie przedmiotów (gdy mamy np. 3 butelki wody, żeby ikonka była jedna)
- Opcję wyrzucania przedmiotów
- Ograniczenie wagowe/ilościowe
- Krótki opis (po najechaniu myszką na obiekt w inwentarzu, pojawi się okienko z opisem)
- Ekwipunek (po prawej wyświetlimy postać gracza i pozwolimy go wyposażyć w broń czy pancerz)
Nie wiem czy ogarnę całość za jednym zamachem, ale zrealizuje na pewno wszystkie punkty. Jeśli macie jakieś sugestię, co fajnego można zrobić z inwentarzem – komentarze są wasze.