Unity3d FPS Tutorial, czyli tworzymy własną grę FPS od podstaw z wykorzystaniem silnika Unity3d.
Temat: Proste Animator, animacja postaci
Spis treści
Jeżeli nie używałeś do tej pory Unity3d:
FPS Tutorial:
#1 – Tworzenie nowego projektu i narzędzie terenu
#3 – Życie, pancerz i wytrzymałość postaci
#4 – Regeneracja życia i energii. Efekty trafienia
#5 – Kamera z bronią i strzelanie
#9 – Rzut granatem i seria z karabinu
#10 – Przybliżenie i dziury po kulach
#12 – Animacja postaci przeciwnika, Animator
#14 – Ostatnie szlify i budujemy projekt
Teoria
Temat na który pewnie wiele osób czekało. Dzisiaj przyjrzymy się działaniu animatora i wprawimy w ruch nasze zombie z poprzedniego odcinka. Samo tworzenie animacji, jest procesem bardziej złożonym i pojedynczy wpis to zdecydowanie za mało, żeby ten proces omówić. Ponadto same animację oparte na szkieletach, najczęściej robione są w programie do grafiki 3D (np. Blender), a w Unity jedynie taką animacją sterujemy.
Oczywiście Unity, dysponuje opcją animowania różnych elementów. Przykładowo w części czwartej, robiliśmy prosty, zanikający efekt bycia trafionym, animując kanał alfa (przeźroczystość) tekstury. Jednak animowanie postaci jest nieco bardziej skomplikowane.
Ja grafikiem nie jestem, dlatego (przynajmniej na razie) nie pokażę wam procesu animowania od samego początku (czyli od wytworzenia modelu w Blenderze). Skupimy się na animatorze. Dlatego, ważne było pobranie dobrego modelu z AssetStore, który posiada własne animację. Jeżeli odcinek poprzedni, zrobiłeś dużo wcześniej, masz pewnie starszy model przeciwnika, który wstawiłem tam wcześniej – on nie dysponuje animacjami. Dzisiaj (13-02-2015) dokonałem aktualizacji i jest tam dostępny model, który w zestawie ma kilka podstawowych animacji. Dlatego przejdź na chwilę do odcinka 11 i podmień sobie model na aktualny. Stworzony skrypt będzie pasował w pełni, pamiętaj, żeby dodać dwa Collidery, odpowiednio je ustawić i aby przeciwnik posiadał tag “Enemy”.
Jeżeli masz odpowiedni model. Masz wszystko co nam się dziś przyda. Do pracy!
Przypisanie animacji – animator
Zaczynamy zabawę od dodania do animatora odpowiedniego kontrolera. Animator powinien być przypisany do postaci od razu. Jeżeli skorzystałeś z zasugerowanego przeze mnie modelu, poprawny model, który należy dodać do sceny znajduje się w folderze “Animations”. Poprawnie dodany model, jako komponent będzie posiadał Animator, z podanym Avatarem. Jedyne co musimy zrobić to uzupełnić pole Controller, przygotowanym kontrolerem, z folderu: “AnimatorController”. Powinno to wyglądać tak:
Teraz czas otworzyć kontroler. Wystarczy na niego kliknąć dwukrotnie, co powinno spowodować otwarcie takiego okna:
Na początku wygląda to dość strasznie. Jednak śpieszę z wyjaśnieniami. Każdy z prostokątów, symbolizuje konkretną animację. Atak, śmierć, otrzymanie obrażeń, bieg etc. Klikając na nie, możemy sprawdzić kilka parametrów. Nazwę animacji, tag, prędkość odtwarzania (1 to domyślny czas), konkretną animację jaka się uruchomi. Są to podstawowe, najważniejsze i wystarczające parametry. Możemy też obejrzeć listę przejść (Transitions). Czyli na jakie stany możemy przejść, z danego.
Możemy również wybrać samo przejście, czyli strzałkę wychodzącą z danego stanu do innego.
Ma
Mamy tutaj również opis przejścia, mamy wykres pokazujący jak animacje mają przechodzić jedna w drugą. Oraz warunki. Warunki mogą się opierać na parametrach, które były widoczne w lewym dolnym rogu na screenie animatora. Parametry są zwykłymi zmiennymi, typu int, bool, float lub Trigger. Możemy się do nich bezpośrednio odwołać w skrypcie i w ten sposób sterować animacją. Ostatnie okienko to podgląd animacji.
W samym Animatorze możemy jeszcze sterować warstwami, jednak dziś się tą kwestią nie będziemy zajmować.
Względem domyślnych ustawień, jedyne co ja zrobiłem, to usunąłem przejście z animacji śmierci (Death) do animacji nicnierobienia (idle0). Zapobiega to pewnemu problemowi, który sobie omówimy później. Przejście usuwamy wybierając je i naciskając delete.
Oczywiście, możemy sobie sami tworzyć nowe stany czy sekwencje. Aby dodać stan, wystarczy kliknąć prawym przyciskiem w wolnej przestrzeni i wybrać opcję Create State. Mamy tam trzy wybory. Pusta (Empty), z wybranego clipu (From selected Clip) oraz z drzewa mieszania (Blend Tree). Pierwsze dwa są dość oczywiste. Ostatni punkt, to sprytne narzędzie, które potrafi wymieszać ze sobą kilka animacji. W teorii powinno pozwolić animować otrzymanie obrażenia, bez przerywania animacji biegu. Jednak nie testowałem tego narzędzia dość dokładnie, więc na razie je pomijam.
Samą transakcję również możemy tworzyć. W tym celu klikamy prawym klawiszem myszy na danym stanie i wybieramy opcję Make Transition.
Ostatnią kwestią jest pomarańczowy prostokąt. Jest to domyślna animacja, czyli ta, która zostanie uruchomiona na początku. Można zmienić domyślny stan, klikając prawym klawiszem myszki na innym stanie i wybierając opcję “Set As Default”.
Zastanawiający może być jeszcze zielony (seledynowy?) prostokąt z hasłem “Any State”. Jak pewnie się domyślasz, aby móc przejść z jednego stanu, do innego, musi istnieć pomiędzy nimi ścieżka przejść. Jednak gdy mamy np. stan, do którego da się wejść bezpośrednio z każdego innego stanu, możemy go podpiąć do tego prostokąta. Należy też pamiętać, że każde przejście jest jednokierunkowe! Co oznacza, że jeśli chcesz stworzyć przejście w obie strony, trzeba przygotować dwa przejścia.
Gmerajmy w kodzie
Teorii tyle. Dość sporo, a to i tak minimalne podstawy, do tego twórca modelu odwalił za nas kawał roboty. Nam zostało rozpracowanie sterowania. W tym celu przechodzimy do naszego skryptu EnemyAI.cs
Do naszych zmiennych pomocniczych dodajemy kilka opcji:
public float walkSpeed = 5.0f; public float attackDistance = 3.0f; public float attackDemage = 10.0f; public float attackDelay = 1.0f; public float hp = 20.0f; public Transform[] transforms; private float timer = 0; private string currentState; private Animator animator; private AnimatorStateInfo stateInfo;
Pierwszy to obiekt transform, z którego pobierzemy animator. String i AnimatorStateInfo posłużą do sterowania animacjami – żeby wiedzieć co włączyć musimy wiedzieć co obecnie trwa. Animator to komponent Animatora.
Należy oczywiście uzupełnić zmienną publiczną. Na chwilę wracamy do Unity. Zaznaczamy sobie obiekt ze skryptem. Przy zmiennej transforms, zmieniamy parametr size z 0 na 1 – chcemy tablicę z jednym elementem. Na nowo utworzoną pozycję dodajemy naszego zombie – czyli wystarczy go przeciągnąć z panelu Hierarchy lub Scene na wolne miejsce w skrypcie.
Po powstaniu obiektu, ustawiamy sobie wymagane zmienne.
void Start () { animator = transforms[0].GetComponent<Animator>(); currentState = ""; }
Czyli pod obiekt animatora, wstawiamy już konkretny animator. A obecny stan obiektu ustawiamy na pusty.
Nasz kod, odpowiedzialny za “inteligencję” przeciwnika zmieniamy dosłownie w trzech miejscach:
void OnTriggerStay(Collider other) { if (other.tag.Equals ("Player") && hp > 0) { Quaternion targetRotation = Quaternion.LookRotation (other.transform.position - transform.position); float oryginalX = transform.rotation.x; float oryginalZ = transform.rotation.z; Quaternion finalRotation = Quaternion.Slerp (transform.rotation, targetRotation, 5.0f * Time.deltaTime); finalRotation.x = oryginalX; finalRotation.z = oryginalZ; transform.rotation = finalRotation; float distance = Vector3.Distance (transform.position, other.transform.position); if (distance > attackDistance && !stateInfo.IsName ("Base Layer.wound")) { animationSet ("run"); transform.Translate (Vector3.forward * walkSpeed * Time.deltaTime); } else if(distance <= attackDistance) { if (timer <= 0) { animationSet ("attack0"); other.SendMessage ("takeHit", attackDemage); timer = attackDelay; } } if (timer > 0) { timer -= Time.deltaTime; } } }
Dodajemy w ifie warunek, że inteligencja działa tylko gdy przeciwnik ma więcej niż zero punktów życia. Gdy tego nie ma, dochodziło do sytuacji, gdzie zwłoki podążały za graczem i dalej go atakowały. AnimationSet to nasza mała funkcja (którą za chwilę napiszemy), odpowiedzialna za uruchomienie odpowiedniej animacji.
Jednak przed tym, dokładamy jeszcze jedną funkcję, a właściwie zdarzenie:
void OnTriggerExit(Collider other) { if (other.tag.Equals ("Player")) { animationSet ("idle0"); } }
Funkcja OnTriggerExit, wykonuje się gdy jakiś obiekt opuści strefę wykrywania. Sprawdzamy czy to gracz, jeśli tak, ustawiamy animację na Idle. Przez to, gdy gracz ucieknie od zombie wystarczająco daleko, nie będzie on biegł w miejscu, a ponownie stanie.
Czas na funkcję animationSet:
private void animationSet(string animationToPlay) { stateInfo = animator.GetCurrentAnimatorStateInfo(0); animationReset(); if (currentState == "") { currentState = animationToPlay; if (stateInfo.IsName ("Base Layer.run") && currentState != "run") { animator.SetBool ("runToIdle0", true); } string state = "idle0To" + currentState.Substring(0, 1).ToUpper() + currentState.Substring(1); animator.SetBool(state, true); currentState = ""; } }
Dokumentacja: Animator.GetCurrentAnimatorStateInfo ; AnimatorStateInfo.IsName ; Animator.SetBool
Wygląda bardzo groźnie, ale wiele tam nie ma. Najpierw pobieramy informacje na temat obecnego stanu animatora. Czyli dowiadujemy się, który stan obecnie jest animowany. Podajemy parametr 0, ponieważ funkcja jako parametr przyjmuje index warstwy. My mamy tylko jedną warstwę, dlatego 0.
animationReset, to również nasza funkcja, którą za chwilę podam i omówię. Ogólnie zeruje ona stany animatora, czyli tak jak by zatrzymuje wszystko.
Teraz mamy ifa, który sprawdza czy jakaś animacja obecnie jest wybrana, jeśli nie, wykonujemy kod. Jako currentState, zapisujemy podaną jako parametr animację (Np. Run), która ma być uruchomiona. Teraz, gdy kolejny kod będzie chciał wykonać animację, zobaczy że currentState jest różne od niczego i nie wejdzie nam w środek. Na samym końcu zerujemy tą zmienną, by pozwolić na dostęp kolejnej animacji.
Kolejny if sprawdza, czy obecnie trwająca animacja to bieg, oraz czy chcemy uruchomić animację inną niż bieg. Jeżeli tak, na true ustawia zmienną runToIdle0, czyli jeden z parametrów, które omawialiśmy wcześniej. W tym modelu, to te zmienne sterują tym, które przejście ma się wykonać.
Poruszać się możemy tylko zgodnie z przejściami. W takim układzie stanów i przejść jakie mamy w naszym modelu, Idle0 staje się przedpokojem, przez który musimy przejść zawsze gdy chcemy zmienić stan. Dlatego, gdy chcemy uruchomić atak, nie wystarczy ustawić zmiennej idle0ToAttack0 na true. Musimy najpierw opuścić pokój w którym byliśmy wcześniej, czyli np. stan biegu. Stąd ta funkcja.
Kolejny kod ustawia wejście już w odpowiedni stan. Przez to, że zmienne mają stałą budowę (stan1ToStan2), możemy sobie łatwo wygenerować string. Tym bardziej, że przez poprzedni kod, wiemy, że jesteśmy w stanie idle0. Funkcja Substring wycina odpowiedni fragment stringa. W pierwszym wypadku jest to od 0 znaku 1 znak, w drugim przypadku od znaku 1 do końca. Funkcja ToUpper zmienia literę na dużą. Parametry podajemy małą literą, a schemat parametrów wymaga dużej, stąd taka wariacja.
Funkcja animationReset:
private void animationReset() { if (!stateInfo.IsName("Base Layer.idle0")) { animator.SetBool("idle0ToIdle1", false); animator.SetBool("idle0ToWalk", false); animator.SetBool("idle0ToRun", false); animator.SetBool("idle0ToWound", false); animator.SetBool("idle0ToSkill0", false); animator.SetBool("idle0ToAttack1", false); animator.SetBool("idle0ToAttack0", false); animator.SetBool("idle0ToDeath", false); } else { animator.SetBool("runToIdle0", false); } }
Cudów tutaj nie ma. Ustawiamy praktycznie wszystkie parametry na false, jeśli obecnym stanem jest idle0, czyli gdy mamy nic nie robić. Jeżeli jesteśmy w dowolnym innym stanie, zerujemy tylko zmienną odpowiedzialną za powrót z biegu do idle0 – tym stanem może być bieg i nie chcemy od razu wracać, a biec dłuższy czas.
Została jeszcze modyfikacja funkcji otrzymania obrażeń:
void takeHit(float demage) { hp -= demage; if (hp <= 0) { animationSet ("death"); } else { animator.CrossFade ("wound", 0.5f); } }
Dokumentacja: Animator.CrossFade
Podmieniliśmy funkcję Destroy na animację śmierci. Dzięki temu, że usunęliśmy przejście powrotne, nie musimy się martwić, że postać mimo śmierci wykona inne animacje. Dobrym pomysłem, może być dodanie tutaj funkcji Destroy z opóźnieniem. Przy jednym przeciwniku nie ma to znaczenia. Jednak gdy przeciwników będzie spawnować w nieskończoność, a nie będziemy ich usuwać z gry po śmierci, w końcu zapchamy całkowicie pamięć komputera, a gra się zawiesi. Opóźnienie powinno być na tyle duże, żeby animacja się dokonała w pełni, a gracz miał czas np. odejść z obszaru śmieci przeciwnika – głupio wygląda jak wróg od tak znika na jego oczach bez powodu.
Druga funkcja jest ciekawsza. Używamy tam funkcji CrossFade. Ma ona dwa parametry. Pierwszy to stan animacji jaki chcemy uruchomić, drugi parametr to czas przejścia między animacjami. Funkcja CrossFade jest o tyle wygodna, że znajduje sobie ona sama drogę ze stanu w jakim się znajduje do wymuszanej funkcji. Przez to perfekcyjnie nadaje się jako funkcja do wymuszenia animacji trafienia przeciwnika.
Podsumowanie
Temat animowania jest bardzo złożony i trudny. Starałem się wyjaśnić wszystko w możliwe prosty sposób, ale wiem, że mimo to, wiele rzeczy może być niejasnych. Jeśli macie jakieś pytania, pytajcie w komentarzu, postaram się na nie odpowiedzieć, a do samej Animacji podejść w jakimś oddzielnym, temacie nastawionym tylko na animację.
Cały skrypt EnemyAI.cs
using UnityEngine; using System.Collections; public class EnemyAI : MonoBehaviour { public float walkSpeed = 5.0f; public float attackDistance = 3.0f; public float attackDemage = 10.0f; public float attackDelay = 1.0f; public float hp = 20.0f; public Transform[] transforms; private float timer = 0; private string currentState; private Animator animator; private AnimatorStateInfo stateInfo; void Start () { animator = transforms[0].GetComponent<Animator>(); currentState = ""; } void takeHit(float demage) { hp -= demage; if (hp <= 0) { animationSet ("death"); } else { animator.CrossFade ("wound", 0.5f); } } void OnTriggerStay(Collider other) { if (other.tag.Equals ("Player") && hp > 0) { Quaternion targetRotation = Quaternion.LookRotation (other.transform.position - transform.position); float oryginalX = transform.rotation.x; float oryginalZ = transform.rotation.z; Quaternion finalRotation = Quaternion.Slerp (transform.rotation, targetRotation, 5.0f * Time.deltaTime); finalRotation.x = oryginalX; finalRotation.z = oryginalZ; transform.rotation = finalRotation; float distance = Vector3.Distance (transform.position, other.transform.position); if (distance > attackDistance && !stateInfo.IsName ("Base Layer.wound")) { animationSet ("run"); transform.Translate (Vector3.forward * walkSpeed * Time.deltaTime); } else if(distance <= attackDistance) { if (timer <= 0) { animationSet ("attack0"); other.SendMessage ("takeHit", attackDemage); timer = attackDelay; } } if (timer > 0) { timer -= Time.deltaTime; } } } void OnTriggerExit(Collider other) { if (other.tag.Equals ("Player")) { animationSet ("idle0"); } } private void animationReset() { if (!stateInfo.IsName("Base Layer.idle0")) { animator.SetBool("idle0ToIdle1", false); animator.SetBool("idle0ToWalk", false); animator.SetBool("idle0ToRun", false); animator.SetBool("idle0ToWound", false); animator.SetBool("idle0ToSkill0", false); animator.SetBool("idle0ToAttack1", false); animator.SetBool("idle0ToAttack0", false); animator.SetBool("idle0ToDeath", false); } else { animator.SetBool("walkToIdle0", false); animator.SetBool("runToIdle0", false); animator.SetBool("deathToIdle0", false); } } private void animationSet(string animationToPlay) { stateInfo = animator.GetCurrentAnimatorStateInfo(0); animationReset(); if (animationToPlay != "run") { Debug.Log (stateInfo.IsName ("Base Layer.wound")); } if (currentState == "") { currentState = animationToPlay; if(currentState != "run") { Debug.Log (currentState); } if (stateInfo.IsName ("Base Layer.walk") && currentState != "walk") { animator.SetBool ("walkToIdle0", true); } if (stateInfo.IsName ("Base Layer.run") && currentState != "run") { animator.SetBool ("runToIdle0", true); } if (stateInfo.IsName ("Base Layer.death") && currentState != "death") { animator.SetBool ("deathToIdle0", true); } string state = "idle0To" + currentState.Substring(0, 1).ToUpper() + currentState.Substring(1); animator.SetBool(state, true); currentState = ""; } } }