Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!
Dzisiejszy odcinek: Ulepszamy ekwipunek (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
Tydzień temu zaczęliśmy tworzyć inwentarz. Dzisiaj będziemy go ulepszać. Więc aby skorzystać z tego poradnika, należy najpierw wykonać poprzedni. Link podrzucam oczywiście.
Przygotowanie
W sumie potrzebna będzie tylko jakaś broń (w zestawie poleconym w poprzednim odcinku, jest kij), oraz ikona do niego (znów polecam iconfindera).
Grupowanie przedmiotów
Dziś zaczniemy nieco od tyłu, bo najpierw poprawimy sobie skrypt przedmiotu, a dokładniej nasze kluczowe itemAbstract:
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; } public string getType() { return type; } public string getDescription() { return description; } public float getWeight() { return weight; } public float getCost() { return cost; } public bool getDisposable() { return disposable; } void OnTriggerEnter(Collider other) { if (other.tag.Equals ("Player")) { //if(other.GetComponent<Inventory>().canCarryNextItem(this)) { other.GetComponent<Inventory>().SendMessage("addItem", this); Destroy(gameObject); //} } } public abstract bool execute(PlayerStats ps); }
Co się zmieniło? Dodaliśmy sobie getery dla każdej zmiennej. Bardzo się to przyda w późniejszych edycjach kodu – możemy teraz pobierać więcej wartości, które nam będą potrzebne. Dodatkowo, mamy ifa, który sprawdza, czy możemy dodać przedmiot. Jednak póki co zakomentowałem ten fragment, aby nie rzucał błędu, że funkcja nie istnieje. Zajmiemy się tym później.
Aby móc grupować przedmioty (czyli, żeby zamiast wyświetlać 3 butelki wody w oddzielnych polach, wyświetlić jedną ikonę z cyferką 3), musimy dokonać zmian w sposobie zapisu i używania przedmiotów. Zacznijmy od zapisu. Dodajemy parę zmiennych:
private List<ItemAbstract> itemsList = new List<ItemAbstract> (); private List<Rect> buttonsList = new List<Rect> (); private int[] itemsCounter = new int[0]; private int itemsInInventory = 0; private bool openInventory; private PlayerStats ps;
W pierwszej trzymamy sobie informację o przyciskach (ikonach w menu), w kolejnej mamy liczbę przedmiotów na danej pozycji, a ostatnia zmienna to informacja o liczbie posiadanych przedmiotów.
Teraz czas zmienić dodawanie przedmiotów, wcześniej wystarczyło dopisanie do listy. Teraz mamy coś takiego:
public void addItem(ItemAbstract item) { Predicate<ItemAbstract> itemFind = (ItemAbstract i) => { return i.itemName == item.getName(); }; int index = itemsList.FindIndex(itemFind); if (index > -1) { itemsCounter[index] += 1; } else { itemsList.Add (item); itemsInInventory++; int[] tmp = new int[itemsInInventory]; for(int i = 0 ; i < itemsCounter.Length ; i++) { tmp[i] = itemsCounter[i]; } tmp[itemsInInventory - 1] = 1; itemsCounter = new int[itemsInInventory]; itemsCounter = tmp; } //currentWeight += item.getWeight(); }
Wygląda potwornie. Zacznijmy od tego, że aby wykorzystać Predicate musimy znów dodać kolejny namespace:
using UnityEngine; using System; using System.Collections; using System.Collections.Generic;
Teraz chwila na teorię. Czym jest Predicate? Jest to klasa, upraszczająca wyszukiwanie obiektu o zadanym kryterium w liście/tablicy obiektów. W naszym przypadku z listy przedmiotów, chcemy znaleźć przedmiot o konkretnej nazwie – żeby sprawdzić, czy to co dodajemy, jest już w ekwipunku. Normalnie trzeba by było zrobić pętle, która leci po obiektach i porównuje wartości pola itemName. Predicate pozwala określić, jaki jest ten “if”, czyli co trzeba porównywać i jak. Funkcja FindIndex, jest tą naszą pętlą lecącą po tablicy. Przyjmuje jako parametr Predicate i dzięki temu wie, jak ma szukać.
Jeżeli dostaniemy jakiś index, wtedy dowiadujemy się, że dysponujemy obiektem – czyli gracz zebrał już np. butelkę wody. I tutaj wchodzi pierwszy if. Jeśli mamy już dany przedmiot, tylko zwiększamy liczbę posiadanych sztuk. W przeciwnym wypadku jest zabawa.
Dodajemy przedmiot do listy, zwiększamy liczbę posiadanych (różnych) przedmiotów. A następnie… Dodajemy przedmiot do tablicy liczby posiadanych elementów. Tylko dlaczego wygląda to tak strasznie?
Tablice są niestety dość niewdzięczne. O ile zwiększenie liczby elementów i rozmiaru listy jest banalnie proste o tyle, tablica ma raz deklarowany rozmiar. Czyli jak zadeklarujemy 10 elementów, tyle będzie miała i koniec. Więc, jeśli chcemy mieć dynamiczny rozmiar tablicy, trzeba ją redeklarować. Co to oznacza w praktyce? Tworzymy tablicę tymczasową (zmienna tmp) o rozmiarze większym o 1 od obecnie posiadanej tablicy (czyli równą liczbie unikatowych przedmiotów – już zwiększonej o ten, który właśnie dodajemy). Pakujemy tam wszystkie elementy ze starej tablicy i na koniec dodajemy nowy element. Teraz redeklarujemy rozmiar naszej oryginalnej tablicy i wstawiamy to co stworzyliśmy wcześniej.
Spytacie więc… czemu nie użyłem listy? Dlatego, że lista mogłaby źle traktować nieunikatowe elementy. Np. Gdy miałbym 2 butelki wody i 2 kawałki szynki, musiałbym dwa razy wstawić inta o wartości 2, co lista mogłaby źle interpretować (jako jeden obiekt).
Ostatnia linia, to dodanie wagi przedmiotu do obecnej wagi posiadanych przedmiotów. Znów zakomentowana, bo to zrobimy później.
Jeżeli liczyliście, że to koniec wielkich zmian, to muszę was zmartwić… Jeżeli chodzi o zmianę wykorzystania przedmiotu, to zmienia się tylko to, co w podwójnej pętli (na razie), więc wrzucam tylko to:
Rect btnPosition = new Rect((buttonMargin + buttonMargin * j + buttonWidth * j), (buttonMargin + buttonMargin * i + buttonHeight * i), buttonWidth, buttonHeight); if(!buttonsList.Contains(btnPosition)) { buttonsList.Add(btnPosition); } int index = (i * itemsInRow) + j; if(index < numerOfItems) { if (GUI.Button(btnPosition, itemsList[(i * itemsInRow) + j].getItemIcon())) { if(itemsList[index].getDisposable()) { itemsCounter[index] = itemsCounter[index] - 1; //currentWeight -= itemsList[index].getWeight(); itemsList[index].execute(ps); if(itemsCounter[index] == 0) { itemsList.RemoveAt(index); for(int d = index ; d < itemsCounter.Length - 1 ; d++) { itemsCounter[d] = itemsCounter[d + 1]; } itemsInInventory--; } } } } else { if (GUI.Button(btnPosition, "")) { Debug.Log("Clicked the button!"); } }
Cóż to za zmiany? Najpierw drobna kosmetyczna, mianowicie pozycja przycisku jest obliczana na początku i trzymana jako pomocnicza zmienna. Dodatkowo, jeśli danego przycisku (ikony w menu) nie mamy w liście, to sobie do listy go dodajemy – przyda się, przy wyświetlaniu okienka opisu przedmiotu.
Kolejna zmiana, to fakt, że nie wykonujemy od razu kodu klikniętego przedmiotu. Najpierw sprawdzamy, czy ma on zniknąć, czy jest stały. Jeśli jest przedmiotem użytkowym (np. odnawia manę, dodaje życia etc.) to zmniejszamy liczbę o 1 i wykonujemy jego zadanie. Jeśli liczba wystąpień przedmiotu jest równa 0, to znaczy, że użyliśmy wszystkie egzemplarze przedmiotu i trzeba go “zniknąć” z ekwipunku. Aby to zrobić, kolejno usuwamy go z listy przedmiotów i… przesuwamy wszystkie przedmioty w tablicy liczącej ich liczbę. Zmniejszamy liczbę posiadanych unikalnych przedmiotów.
Po tym wszystkim widać, że łatwiej byłoby zastosować tablicę asocjacyjną. Jednak są z nią pewne problemy. Np. dodanie 2 butelek, kończy się uznaniem ich zawsze za dwa różne obiekty i uniemożliwia to grupowanie przedmiotów. Stąd tak rozwlekłe rozwiązanie.
Zostało jeszcze wyświetlić liczbę posiadanych przedmiotów danego rodzaju.
Wewnątrz ifa, mówiącego że inwentarz jest otwarty, ale za serią tworzącą ikonki wstawiamy nasz kod:
for(int i = 0 ; i < buttonsList.Count ; i++) { if(itemsInInventory > i) { GUI.Label(new Rect(buttonsList[i].x + 5, buttonsList[i].y + buttonsList[i].height - 20, buttonWidth - 10, 18), itemsCounter[i].ToString()); } }
Dla porządku. Kod wstawiamy w zaznaczonym miejscu:
void OnGUI() { if (openInventory) { int numerOfItems = itemsList.Count; int rows = Mathf.CeilToInt(numerOfItems / itemsInRow) + 1; GUILayout.BeginArea (inventoryPosition); // Tu były ikonki ekwipunku GUILayout.EndArea (); // Tutaj wstawiamy kod! } }
Co robi kod? Lecimy po wszystkich przyciskach, i gdzieś w ich dole wstawiamy sobie wartość z tablicy liczby przedmiotów. Tyle.
Ograniczenie wagowe/ilościowe
Teraz, żeby nieco ochłonąć coś znacznie łatwiejszego. Jak nadać ograniczenia wagowe i/lub ilościowe? Możesz odkomentować wszystkie linijki, które komentowaliśmy we wcześniejszym fragmencie. Jak mówiłem, pierwsza przed dodaniem przedmiotu do inwentarza sprawdza czy możemy – tylko trzeba dopisać funkcję, a druga dodaje wagę, a trzeci odejmuje wagę po użyciu przedmiotu. Jednak, żeby waga działała, do zmiennych w Inventory.cs trzeba dodać trzy zmienne: (OK, jedna sprawia, że ograniczenie ilościowe działa)
public GUISkin popUpSkin; 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); public int maxItems = 10; public int maxWeight = 30; private List<ItemAbstract> itemsList = new List<ItemAbstract> (); private List<Rect> buttonsList = new List<Rect> (); private int[] itemsCounter = new int[0]; private int itemsInInventory = 0; private bool openInventory; private PlayerStats ps; private float currentWeight = 0;
Oznaczenie jest chyba proste. Dwie pierwsze to maksymalna liczba i waga przedmiotów, a trzecie to aktualna waga niesionych przedmiotów. Czas dopisać funkcję sprawdzającą czy możemy podnieść dodatkowy przedmiot. Całość ląduje w klasie Inventory.cs:
public bool canCarryNextItem(ItemAbstract item) { if (maxItems == itemsInInventory) { return false; } if (currentWeight + item.getWeight () >= maxWeight) { return false; } return true; }
Chyba nie trzeba tutaj niczego tłumaczyć.
Okienko z opisem przedmiotu
Tutaj sprawa jest na szczęście dość prosta, dzięki temu co już napisaliśmy. Wewnątrz pętli, którą napisaliśmy do wyświetlania liczby przedmiotów dodajemy takie coś:
if(buttonsList[i].Contains(new Vector2(Input.mousePosition.x, Screen.height - Input.mousePosition.y))) { if(itemsList.Count > i) { GUILayout.BeginArea(new Rect(buttonsList[i].x + buttonsList[i].width, buttonsList[i].y + buttonsList[i].height, 200, 200)); GUI.Box (new Rect(0, 0, 200, 200), ""); GUI.Label(new Rect(10, 10, 180, 20), itemsList[i].getName()); GUI.Label(new Rect(10, 40, 180, 20), itemsList[i].getType()); GUI.Label(new Rect(10, 70, 180, 80), itemsList[i].getDescription()); GUI.Label(new Rect(10, 160, 80, 20), itemsList[i].getWeight().ToString()); GUI.Label(new Rect(100, 160, 80, 20), itemsList[i].getCost().ToString()); GUILayout.EndArea (); } }
Magii tutaj nie ma, z wyjątkiem ifa. Funkcja Contains sprawdza czy w danym obiekcie typu Rect zawiera się jakaś współrzędna. Nasza współrzędna to pozycja myszki (jeśli chodzi o wysokość musimy odjąć pozycję myszki od wysokości ekranu, bo nie wiedzieć czemu pozycja Y myszki jest liczona odwrotnie niż wszystkiego innego).
Reszta to klasyczne wyświetlenie Labeli i innych fragmentów GUI, aby wypisać co ciekawsze informację.
Wykorzystanie broni
Czas na wykorzystanie innego typu przedmiotu. Zaczniemy od tego co trzeba dodać do… ciągle Inventory.cs. Najpierw kilka bonusowych zmiennych:
public GUISkin popUpSkin; 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); public int maxItems = 10; public int maxWeight = 30; public Texture2D handTexture; private List<ItemAbstract> itemsList = new List<ItemAbstract> (); private List<Rect> buttonsList = new List<Rect> (); private int[] itemsCounter = new int[0]; private int itemsInInventory = 0; private bool openInventory; private PlayerStats ps; private float currentWeight = 0; private Texture2D currentWeapon; private int currentWeaponId = -1; private Rect handPosition;
handTexture, to tekstura gołej ręki – gdy gracz nie posiada broni. currentWeapon to tekstura dla obecnie posiadanej broni. currentWeaponId służy do określenia, jaka broń w ekwipunku, obecnie jest wyposażona. Ostatnia zmienna określa, gdzie ma się wyświetlać ikona broni.
Teraz ustawiamy wartości kilku ze zmiennych:
void Start() { currentWeapon = handTexture; handPosition = new Rect(200 + itemsInRow * (buttonWidth + buttonMargin), 20, 100, 200); openInventory = false; ps = gameObject.GetComponent<PlayerStats> (); inventoryPosition = new Rect (inventoryPosition.x, inventoryPosition.y, ((itemsInRow * buttonWidth) + ((itemsInRow + 1) * buttonMargin)), ((itemsInRow * buttonHeight) + ((itemsInRow + 1) * buttonMargin))); }
Robimy to w funkcji start, zamiast przy deklaracji zmiennej, bo do określenia np. położenia, gdzie wyświetlić miejsce na broń wykorzystujemy np. buttonWidth, które określa się z poziomu Unity, więc skrypt dopiero w funkcji Start wie, jaką on ma wartość.
Teraz szukamy tego ifa:
if(itemsList[index].getDisposable()) {
Bo dopiszemy mu else:
} else { switch(itemsList[index].type) { case "weapon": if(currentWeaponId != index){ if(currentWeaponId > -1) { itemsList[currentWeaponId].takeOff(ps); } currentWeapon = itemsList[index].getItemIcon(); currentWeaponId = index; itemsList[index].execute(ps); } else { currentWeapon = handTexture; itemsList[currentWeaponId].takeOff(ps); currentWeaponId = -1; } break; } }
If załatwia przedmioty użytkowe. Co z przedmiotami stałymi, jak broń czy pancerz? Zajmiemy się nimi tutaj. Robimy sobie switcha, czyli takiego ifa, który dostaje zmienną i leci po kolejnym przypadku (case) sprawdzając, czy wartość zmiennej podanej w switch jest równa temu co pomiędzy słówkiem case i znakiem dwukropka. Jeżeli znajdzie odpowiedni przypadek, wykonujemy to, co pomiędzy dwukrokiem, a słówkiem break.
Co robimy w przypadku broni? Jeżeli obecnie wybrana broń jest inna od tej, którą wybraliśmy klikając w menu, to podmieniamy teksturę obecnie wybranej broni, zapamiętujemy index obecnie wybranej broni i wykonujemy zadanie broni, uprzednio wykonując funkcję takeOff, ale tylko gdy jakaś inna broń była założona – o tym później.
Gdy wybraliśmy broń, która aktualnie jest wybrana to znaczy, że chcemy ją zdjąć. W tym wypadku jako obecnie wyświetlana broń zostanie przypisana powrotnie dłoń, a indeks wybranej broni ustawiamy na -1. Wykonujemy też funkcję takeOff.
Ostatnie co zostało to wyświetlić ikonę używanej właśnie broni:
void OnGUI() { if (openInventory) { // Tu wszystkie inne cuda, które tu były GUI.DrawTexture(handPosition, currentWeapon); } }
Serio, tylko tyle.
Wszystko bangla, ale żeby całość przetestować, trzeba dodać jeszcze broń. Obiekt tworzymy tak samo jak butelkę wody w poprzednim odcinku. Zmieni się za to skrypt. Tworzymy nowy o nazwie Weapon.cs, który wygląda tak:
using UnityEngine; using System.Collections; public class Weapon : ItemAbstract { public int demage = 4; public override bool execute(PlayerStats ps) { ps.demage += demage; return disposable; } public void takeOff(PlayerStats ps) { ps.demage -= demage; } }
W sumie nic strasznego, a całość wygląda znajomo – bo to kopia kodu z eatItem.cs Zmieniamy tylko HP na obrażenia. Niestety dodanie funkcji takeOff, wiąże się ze zmianami w itemAbstrack i etaItem, wyglądają one teraz tak:
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; } public string getType() { return type; } public string getDescription() { return description; } public float getWeight() { return weight; } public float getCost() { return cost; } public bool getDisposable() { return disposable; } void OnTriggerEnter(Collider other) { if (other.tag.Equals ("Player")) { if(other.GetComponent<Inventory>().canCarryNextItem(this)) { other.GetComponent<Inventory>().SendMessage("addItem", this); Destroy(gameObject); } } } public abstract bool execute(PlayerStats ps); public abstract void takeOff(PlayerStats ps); }
Na końcu dopisaliśmy deklarację takeOff – bez tego, skrypt burzyłby się wszędzie, że obiekt typu itemAbstract takiej funkcji nie ma. Niestety wymusza to na nas, dopisanie tej funkcji do eatItem, który jej nie wykorzystuje. Dlatego dopisujemy ją pustą:
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; } public override void takeOff(PlayerStats ps) { } }
I to by było na tyle! Pamiętaj, żeby uzupełnić wszystkie zmienne w Unity!