Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!
Dzisiejszy odcinek: Jak stworzyć kompletny system strzelania? Od wystrzału, przez przeładowanie, do dziury po kuli.
#303030;">Teoria
W tym przypadku, nie ma się za bardzo rozpisywać. Każdy chyba grał kiedykolwiek w jakąś strzelankę. Meritum gry, jest – jak nazwa wskazuje – strzelanie. Dlatego, dziś zajmiemy się kompletnym systemem strzelania.
Jakiś czas temu tworzyłem już poradnik tworzenia strzelania. Użyliśmy tam funkcji instantinate. Jednak ta metoda ma swoje wady. Funkcja instantinate jest bardzo zasobożerna. Do tego, na nowo utworzony obiekt, działa fizyka – dodatkowe obliczenia. Dodatkowo, zanim utworzony pocisk pokona wyznaczoną trasę, cel może się przemieścić, co sprawi, że gra będzie niegrywalna, bo może nie dać się trafić przeciwników. Więc jeśli tworzymy strzelanie z pistoletu, gdzie kula jest i tak niewidoczna dla oka i przemieszcza się z ogromną prędkością, nie potrzebujemy tworzyć jej instancji. Lepszym rozwiązaniem będzie funkcja RayCast, która sprawdza kolizje w linii prostej i to jej dziś użyjemy.
Do zrobienia całości, będziecie potrzebować cztery rzeczy, niedostępne w standardowych assetach Unity: Model pistoletu, teksturę dziury po kuli, teksturę celownika. Gdzie jako teksturę, rozumiem po prostu obrazek, najlepiej w formacie .png. Do tego dźwięk wystrzału. Tutaj polecam serwis z darmowymi samplami.
Jeżeli chcesz użyć tych samych modeli, sampli i tekstur co ja, możesz je pobrać na samym dole tego postu. W zamian za wyręczenie Cię w poszukiwaniach, biorę tylko jednego lajka. ;)
Bonus: Skąd wziąć model pistoletu?
Bardzo dobrym źródłem darmowych modeli, które możesz wykorzystać do robienia prototypów gier jest Google 3D Warhouse, gdzie użytkownicy Google SketchUp, wrzucają swoje dzieła.
Jednak, modele, można pobrać w dwóch formatach: .skp oraz .kmz. Obu Unity nie obsługuje. Zatem, jak wgrać model do Unity? W 4 prostych krokach:
- Pobierz dowolny model w formacie .kmz
- Po pobraniu pliku, zmień jego rozszerzenie z .kmz na .zip
- Wejdź do archiwum i wypakuj z niego plik o rozszerzeniu .dae (najczęściej znajduje się w folderze: models)
- Zaimportuj plik .dae do Unity.
Przygotowanie sceny
Zaczynamy oczywiście od utworzenia nowego projektu. Potrzebujemy dwóch standardowych assetów: Character Controller oraz Particles.
Co będziemy potrzebowali na scenie? Jakiegoś podłoża. Dwóch dodatkowych cubów. Ja obu nadałem materiały o różnych kolorach. Jeden jest u mnie czerwony i ma tag Enemy, zaś drugi jest niebieski.
Aby dodać tag, wchodzimy w menu: [Edit -> Project Settings -> Tags and Layers] wtedy w menu inspector pojawią się nam nowe opcje. Rozwijamy zakładkę Tags i w wolnym polu wprowadzamy nazwę nowego tagu. Później zaznaczamy obiekt przeciwnika i u samej góry wybieramy nowo utworzony tag.
Aby nadać kolor obiektom, w menu project klikamy prawym przyciskiem myszy, wybieramy: Create -> Material. Klikamy na nowo utworzony materiał i wybieramy w panelu inspector kolor. Następnie przeciągamy utworzony materiał, na wybrany obiekt.
Ze standardowych assetów, wstawiamy First Person Controller. Można teraz usunąć obiekt Main Camera.
Ostatnim krokiem, jest stworzenie oświetlenia: [GameObject -> Create Other -> Directional Light]
Polecam teraz zaimportować, wszystkie cztery wspomniane elementy do projektu, co ułatwi późniejsze korzystanie z nich. Aby to zrobić, wystarczy, że przeciągniesz pliki z okna windowsa (lub innego systemu operacyjnego ;)) do panelu Project. Teraz możemy zacząć zabawę!
Wyświetlanie pistoletu
Wyświetlenie obiektu pistoletu składa się z dwóch etapów. Po pierwsze wstaw obiekt pistoletu do sceny i ustaw go względem kamery tak, by wyglądał naturalnie. (Nie podam dokładnych parametrów, bo nie wiem jaki model broni wybrałeś, a dla każdego modelu parametry będą inne. Najlepsza tutaj będzie metoda prób i błędów).
Sam pistolet w menu Hierarchy ustaw jako dziecko (child) obiektu Main Camera, znajdującego się wewnątrz First Person Controllera. Dzięki temu, pistolet będzie podążał za kamerą. Aby to zrobić, wystarczy że przeciągniesz obiekt pistoletu na obiekt Main Camera w oknie Hierarchy.
Teoretycznie będzie to działało. Ale… Jeżeli pochylisz się np. w stronę podłogi, zobaczysz, że pistolet zniknie w obiekcie. Aby tego uniknąć, trzeba zastosować małą sztuczkę.
Najpierw, musimy utworzyć nową warstwę (Layer). Wracamy do menu: [Edit -> Project Settings -> Tags and Layers]. Tym razem rozwijamy panel Layers. Na pierwszej wolnej warstwie, wprowadzamy nazwę naszej nowej warstwy. Swoją nazwałem Guns.
Teraz tworzymy nowy obiekt kamery. [GameObject -> Create Other -> Camera]. Nowo utworzona kamera, powinna podobnie jak model broni, zostać dzieckiem obiektu Main Camera. Powinno to wyglądać mniej więcej tak:
Teraz musimy nieco zmienić ustawienia naszej nowo utworzonej kamery. Po pierwsze, dla pewności resetujemy jej położenie. Dla jej komponentu transform klikamy na ikonkę koła zębatego i wybieramy Reset. Dzięki temu nowa kamera, będzie spoglądać w tą samą stronę co kamera główna. Teraz musimy zmienić parę opcji komponentu Camera. Po pierwsze zmieniamy opcję Clear Flags na Depth Only (tylko głębia), a następnie opcję Culling Mask na nazwę naszej nowo utworzonej warstwy (u mnie Guns). Dzięki temu, nowa kamera wyświetlać będzie tylko obiekty znajdujące się na tej warstwie. Ostatnia opcja, którą musimy zmienić to opcja Depth, którą ustawiamy na jakieś 80 – im więcej, tym obiekt jest jak by bardziej na wierzchu.
Teraz możemy przejść do obiektu broni i u góry wybrać warstwę dla niej. Pewnie się domyślasz, że będzie to nasza nowo utworzona warstwa (Guns). Został tylko jeden problem. Obecnie obie kamery wyświetlają pistolet. Dlatego teraz wybieramy kamerę Main Camera. Tutaj zmieniamy tylko opcję Culling Mask, a dokładniej odznaczamy naszą nową warstwę (Guns).
Po tych zabiegach, nowa kamera zawsze i niezależnie będzie wyświetlać broń, a główna kamera zajmie się wyświetlaniem całej reszty. Dzięki dobremu ustawieniu dziedziczenia, broń będzie zawsze podążać za obrotem postaci.
Strzelanie za pomocą RayCast
Zaczynamy oczywiście od utworzenia nowego skryptu (U mnie: Shooting.cs) i przypisujemy go do obiektu Main Camera.
Zaczynamy od deklaracji zmiennych pomocniczych:
public AudioClip pistolShot; float range = 10.0f; GameObject pistolSparks; Vector3 fwd; RaycastHit hit;
Wracamy na chwilę do Unity. Jak widać, pierwsza zmienna jest publiczna, więc z poziomu Unity uzupełniamy ją. AudioClip czyli dźwięk strzału. Wstawiamy tutaj pobrany wcześniej klip dźwiękowy (Wystarczy go przeciągnąć).
Pozostałe zmienne opowiadają kolejno za: range – zasięg strzału, pistolSparks – to efekt wystrzału, omówimy to dokładniej później, fwd to wektor oznaczający gdzie obecnie znajduje się przód, hit to obiekt typu RaycastHit, czyli klasy, która wykryje nam kolizje – też wyjaśni się to później.
Zanim przejdziemy do dalszego kodowania, rozwiążemy sprawę pistolSparks. W zaimportowanych standardowych assetach, znajdują się efekty cząsteczkowe. Wykorzystamy jeden z obiektów, by wystrzał wyglądał fajniej. Wrzuć do sceny obiekt znajdujący się w: Standard Assets/Particles/Misc/Sparks. Teraz efekt przypisz jako child pistoletu i ustaw go tak, by pojawił się przy lufie i był skierowany w stronę wystrzału. Dokonałem też kilku zmian emitera, by wystrzał wyglądał lepiej. Mianowicie dla komponentu Ellipsoid Particle Emitter, zmieniłem opcję Max Energy na 0.1. Dzięki temu wystrzał jest krótszy i bardziej realistyczny.
Wracamy do kodu.
void Start() { Screen.showCursor = false; pistolSparks = GameObject.Find("Sparks"); pistolSparks.particleEmitter.emit = false; audio.clip = pistolShot; }
Pierwsza linia kodu usuwa nam myszkę. W edytorze nie zauważymy tego, ale w produkcie końcowym, będzie to istotne.
Kolejne dwie linie kolejno odnajdują obiekt efektów cząsteczkowych i zatrzymują jego emisję. (Nie chcemy by pistolet miał rozbłyski wystrzału cały czas). Ostatnia linia ustawia naszemu emiterowi dźwięków (Komponent Audio Source), odtwarzany klip, na nasz dźwięk wystrzału.
Jeżeli przy Audio Source jesteśmy. Możemy go dodać ręcznie, albo zastosować prosty trick. Wystarczy przed deklaracją klasy dodać kod:
[RequireComponent(typeof(AudioSource))] public class Shooting : MonoBehaviour {
Oznacza to, że nasz skrypt wymaga od obiektu w którym się znajduje komponentu Audio Source. Jeśli takowego nie ma, dodaje go automatycznie.
Czas na sam efekt strzelania:
void Update () { fwd = transform.TransformDirection(Vector3.forward); if(Input.GetButtonDown("Fire1")) { pistolSparks.particleEmitter.Emit(); audio.Play(); if (Physics.Raycast(transform.position, fwd, out hit)) { if(hit.transform.tag == "Enemy" && hit.distance < range) { Debug.Log ("Trafiony przeciwnik"); } else if(hit.distance < range) { Debug.Log ("Trafiona Sciana"); } } } }
Najpierw aktualizujemy nasz wektor określenia gdzie jest przód. Funkcja TransformDirection, skaluje współrzędne z lokalnych na globalne. Gdybyśmy tego nie zrobili, przód byłby zawsze w tym samym miejscu, niezależnie od obrotu postaci.
Następnie sprawdzamy czy przycisk strzału został naciśnięty. Jeśli tak, to uruchamiamy błysk wystrzału (Funkcja Emit() wykonuje efekt cząsteczkowy tylko raz.), oraz odtwarzamy dźwięk wystrzału.
Następnie dzieje się magia. “Wystrzeliwujemy” promień sprawdzający kolizję. Pierwszy parametr to pozycja z której strzelamy. U nas jest to pozycja naszego obiektu, czyli kamery głównej (Liczony od środka obiektu, dzięki czemu, nasz cel, będzie zawsze na środku ekranu). Drugi parametr określa gdzie strzelamy. Dla nas jest to nasz wektor przodu. Ostatnia zmienna wskazuje, gdzie zapisać parametry wykrytej kolizji (w C# potrzebne jest słówko out, by kod mógł zadziałać).
Wewnątrz ifa, sprawdzamy sobie dwa warunki. W pierwszym, sprawdzamy czy element w który trafiliśmy ma tag Enemy oraz czy dystans nas od niego dzielący jest mniejszy od zasięgu broni, który zdefiniowaliśmy na początku. W drugim wariancie, sprawdzamy tylko, czy dystans jest mniejszy od zasięgu broni. Pierwszy if ma oczywiste zastosowanie. W przypadku drugiego, założymy że trafiliśmy w ścianę i zrobimy dziurę po kuli. Jeżeli żaden z warunków nie został spełniony, nie trafiliśmy w nic.
Póki co w kodzie mamy logi, do sprawdzenia w konsoli czy wszystko działa. Dodatkową przydatną sztuczką może być:
Debug.DrawRay(transform.position, fwd, Color.green);
Ten kod narysuje nam w panelu Scene linie, która pokazuje jak pada promień rzucany przez nasz RayCast. Dzięki czemu wiemy, czy celowanie zachowuje się tak, jak tego chcemy. Na tą chwilę powinniśmy mieć głośny, błyskający się pistolet i logi jeśli w coś trafiliśmy. Jednak fajnie byłoby wiedzieć, w co celujemy.
Celownik
Sprawa celownika jest bardzo prosta. Dodajemy sobie dwie zmienne:
public Texture2D crosshairTexture; // Ta została dodana public AudioClip pistolShot; float range = 10.0f; Rect position; // Ta została dodana GameObject pistolSparks; Vector3 fwd; RaycastHit hit;
Pierwsza to tekstura celownika. Wykonać trzeba tutaj tylko dwa ruchy. Po pierwsze przypisać nasz pobrany obrazek celownika do tej zmiennej z poziomu Unity. Po drugie dostosować jego rozmiar. Jeżeli po uruchomieniu gry, rozmiar tekstury celownika jest za duży, można to łatwo zmienić. Wybieramy obiekt tekstury z panelu Project. W panelu Inspector pojawią się dodatkowe opcje. Na samym dole będzie widać opcję “Max Size”, zmieniając ją, możemy dostosować rozmiar celownika. Dla mnie maksymalny rozmiar na poziomie 32, był idealny.
Druga zmienna, będzie odpowiadała za wyśrodkowanie celownika. W funkcji Start, ustawiamy jej wartość:
void Start() { Screen.showCursor = false; position = new Rect((Screen.width - crosshairTexture.width) / 2, (Screen.height - crosshairTexture.height) /2, crosshairTexture.width, crosshairTexture.height); pistolSparks = GameObject.Find("Sparks"); pistolSparks.particleEmitter.emit = false; audio.clip = pistolShot; }
Oczywiście pojawia się tu stara sztuczka, gdzie od wysokości i szerokości okna gry, odejmujemy rozmiar obrazka i dzielimy całość przez dwa, by otrzymać zawsze środek ekranu, niezależny od rozdzielczości. Zaś rozmiar obrazka, to pobrane z tekstury wymiary.
Zostało już tylko wyświetlić naszą teksturę:
void OnGUI() { GUI.DrawTexture(position, crosshairTexture); }
Przeładowanie i magazynek
Oczywiście, każda szanująca się gra, nie traktuje tematu jak filmy akcji – tzn.: amunicja się kończy i czasami trzeba przeładować. Znów dodamy sobie parę zmiennych:
public Texture2D crosshairTexture; public AudioClip pistolShot; float range = 10.0f; Rect position; GameObject pistolSparks; Vector3 fwd; RaycastHit hit; // Te poniżej, zostały dodane: float maxAmmo = 10.0f; float curAmmo = 10.0f; bool isReloading = false; float timer = 0.0f;
Tutaj mamy akurat dość oczywiste zmienne. Maksymalna ilość amunicji, obecna ilość amunicji, zmienna bool, odpowiadająca za informację czy akurat trwa przeładowanie, oraz timer.
Dodajmy teraz małą zmianę w systemie strzelania.
if(Input.GetButtonDown("Fire1") && curAmmo > 0 && !isReloading) { curAmmo--;
Teraz, oprócz sprawdzenia czy został naciśnięty spust, sprawdzamy też, czy mamy czym strzelać, oraz czy nie próbujemy strzelać w czasie przeładowania. Dodatkowo obok dźwięku i blasku wystrzału, dodajemy prosty kod, który z każdym strzałem, zmniejsza liczbę posiadanych kul.
Mamy już kule, tracimy je przy strzale, czas na przeładowanie:
void Update () { timer += Time.deltaTime; (...) if(Input.GetButtonDown("Reload") || Input.GetButtonDown("Fire1") && curAmmo == 0) { isReloading = true; timer = 0; } if(isReloading && timer > 1) { curAmmo = maxAmmo; isReloading = false; } }
Wewnątrz funkcji update, dostawiamy dwa ify. Pierwszy z nich sprawdza czy nacisnęliśmy przycisk przeładowania, lub przycisk strzału po osiągnięciu zera kul. Standardowy system z wielu gier. Jeżeli tak się stanie, zmienna isReloading zmienia się na true, blokując opcję strzelania. Do tego zerujemy timer.
W drugim ifie sprawdzamy czy właśnie przeładowujemy i czy timer jest większy od 1 sekundy. Jeżeli tak, to obecny stan amunicji ustawiamy na maksymalny dostępny i zmieniamy zmienną bool na false, by znów móc strzelać.
Timer niestety musi znajdować się wewnątrz funkcji update i wykonywać się cały czas. Jeśli dalibyśmy go do wewnątrz ifa, nie wykonywałby się stale, przez co byłby przekłamania w czasie obliczeń. Bardziej optymalne byłoby użycie podprogramu, jednak starałem się by kod został możliwie prosty.
Na koniec zostaje wyświetlić licznik pocisków:
void OnGUI() { GUI.DrawTexture(position, crosshairTexture); GUI.Label(new Rect(10,10,50,50), "Ammo: " + curAmmo + " / " + maxAmmo); }
Odpowiada za to oczywiście dodana do funkcji OnGUI linijka, gdzie w lewym górnym rogu, wyświetlamy napis ze zmiennymi. Nie wymaga to chyba dodatkowego tłumaczenia.
Jak mawiał Klocuch: Kule po dziurach
Ostatnia rzecz, jaką się zajmiemy to dziury po kulach. Dodajemy ostatnią zmienną pomocniczą:
public Texture2D crosshairTexture; public AudioClip pistolShot; public GameObject bulletHole; // Właśnie ta! float range = 10.0f; Rect position; GameObject pistolSparks; Vector3 fwd; RaycastHit hit; float maxAmmo = 10.0f; float curAmmo = 10.0f; bool isReloading = false; float timer = 0.0f;
I na chwilę musimy wrócić do Unity, aby dobrze przygotować obiekt.
Po pierwsze tworzymy nowego cuba i nakładamy na niego teksturę dziury (wystarczy przeciągnąć). Samą kostkę spłaszczamy (ustawiamy zero dla parametru Scale w osi y). Zmieniamy jeszcze dla grafiki ustawienia Shadera. Wybieramy opcję: Transparent -> Bumped Diffuse. Dzięki temu, uzyskamy przeźroczystość (Oczywiście sama tekstura, musi być w formacie .png i mieć przeźroczyste tło, żeby to zadziałało).
Teraz tworzymy nowy pusty gameObject [GameObject -> Create Empty]. Poprzednio przygotowaną kostkę, wstawiamy jako child pustego obiektu. Zmieniamy tylko ustawienia pozycji dla childa. Powinny one wyglądać następująco: X: 0, Y: 0.1, Z: 0. Dzięki temu małemu przesunięciu, dziura zawsze będzie na wierzchu obiektu, na którym ją “pojawimy”.
Kolejnym krokiem jest stworzenie nowego prefabu. Klikamy prawym klawiszem w okienku Project, po czym wybieramy Create -> Prefab. Na w ten sposób utworzony prefab, przeciągamy z panelu Hierarchy nasz obiekt dziury. Teraz możemy dziurę usunąć ze sceny, a prefab przeciągamy do nowo utworzonej zmiennej w skrypcie.
Zostało nam tylko dodać kod:
} else if(hit.distance < range) { GameObject go; go = Instantiate(bulletHole, hit.point, Quaternion.FromToRotation(Vector3.up, hit.normal)) as GameObject; Destroy(go, 5); Debug.Log ("Trafiona Sciana"); }
Całość znajduje się w ifie przygotowanym na wypadek trafienia w ścianę.
Tworzymy sobie pomocniczą zmienną go, do której przypisujemy nowo utworzoną instancję obiektu dziury, wykreowaną za pomocą funkcji instantinate. Pierwsza zmienna to nasz obiekt dziury, druga określa gdzie ma się pojawić nasz obiekt, dla nas jest to miejsce gdzie trafiliśmy z pistoletu. Ostatni parametr, pokazuje rotację obiektu. Wykonaliśmy aż tak zawiłą operację, by obiekt dziury, był zawsze górą w stosunku do obiektu na którym się pojawia. Dzięki temu dziura nie pojawi się np. bokiem. Warto też odnotować tutaj, konieczność rzutowania “as GameObject”. Bez tego, posypią nam się errory.
Dokładniej działa to tak:
Funkcja Quaterion.FromToRotation, przekształca rotację obiektu, tak by oś podana w pierwszym parametrze, była skierowana w stronę obiektu w drugim parametrze. Jako pierwszy parametr mamy Vector3.up, czyli wektor Y. A ustawiamy się w kierunku hit.normal, czyli w kierunku normala (w Grafice 3D jest to np. ściana cuba), my prościej możemy powiedzieć, że obraca się w stronę powierzchni w którą uderzył. Innymi słowy, oś Y naszego obiektu dziury, ma być zawsze skierowana w stronę powierzchni w którą uderzył nasz RayCast.
Kolejna linia, to tylko usunięcie dziury po czasie 5 sekund. Jeśli zostawimy wszystkie tego typu obiekty, to po kilku godzinach gry i intensywnego strzelania, przepełnilibyśmy pamięć, dlatego warto pamiętać o usunięciu.
Co jeszcze można zrobić?
Projekt nie jest w 100% kompletny. Można zrobić jeszcze kilka rzeczy takich jak: Podział amunicji na magazynki, zmiana broni, ogień ciągły, animacja odrzutu broni.