Gra platformowa 2D w Unity Od Zera do Game-Developera [OZDGD]
W ramach serii Od Zera do Game-Developera, w której to chcę zachęcić innych do zainteresowania się nie tylko grami, ale też sferą ich produkcji, powstały już następujące teksty:
- Wszyscy mówili: nie idź do gamedevu. Nie posłuchałem i dziś mogę się pochwalić pierwszą grą
- Programujesz w Unity? Ten kreacyjny wzorzec projektowy musisz znać
- Jaki komputer do Unity? Zobacz porównanie trzech zupełnie odmiennych sprzętów
Niniejszy tekst jest kontynuacją serii, w ramach to której tworzę pełnoprawną, ale prostą platformową grę 2D:
- Jak stworzyć grę w Unity? Stawiam repozytorium, Gita i projekt w Unity
- Poradnik Unity. Co musicie wiedzieć o tym silniku, żeby stworzyć własną grę?
- Platformowa gra 2D w Unity. Tworzę GDD i rozpisuję mechaniki
- Architektura kodu w grze platformowej. Opisuję najważniejsze podstawy [OZDGD #4]
- Platformówka 2D w Unity. Tworzę środowisko oraz postać gracza [OZDGD #5]
- Platformówka 2D w Unity. Tworzę główną postać i ją programuję [OZDGD #6]
- Przeciwnicy i system walki w platformowej grze 2D w Unity [OZDGD #7]
Jak zaprogramować postać gracza w Unity?
Spoglądając w naszą wcześniejszą rozpiskę, możemy od razu stwierdzić, że mamy aktualnie sporo do zrobienia. Zacznijmy więc od najprostszych etapów i małymi kroczkami wchodźmy coraz wyżej, a tak się składa, że nie ma nic prostszego, od ustalenia danych, które nasza postać będzie potrzebować, aby móc poruszać się po planszy. Musimy więc stworzyć skrypt dla ustawień, które sprowadzimy do formy tak zwanego ScriptableObjectu, aby zachować większą elastyczność i poznać więcej funkcji Unity. W tym celu otwieramy nasz folder Player/Scripts/Data i tam tworzymy nowy skrypt o nazwie PlayerStatsConfig.
Kiedy już go uruchomicie w swoim środowisku (osobiście używam JetBrains Rider, ale Visual Studio nie jest jakkolwiek gorszy i pozwala w tym zakresie na to samo) i zobaczycie pierwszą klasę naszego projektu oraz dwie funkcje (Start i Update), musicie usunąć obie z nich, dziedziczenie po MonoBehaviour zmienić na ScriptableObject (SO), a tuż nad klasą dodać atrybut, pozwalający nam tworzyć instancje tego configu bezpośrednio w plikach i w specjalnym formacie. Podczas gdy fileName określa nazwę pliku, który będzie tworzony, menuName to już miejsce w menu kontekstowym, w którym znajdziecie możliwość stworzenia pliku.
Uzyskacie tym samym skrypt ze wskazaniem wykorzystywanego aktualnie NameSpace, czyli czegoś w rodzaju “zbioru instrukcji z góry” oraz klasy o publicznym dostępie, która dziedziczy po klasie ScriptableObject. To właśnie w niej musicie określić potrzebne zmienne, aby wskazywać m.in. prędkość poruszania się czy siłę skoku. Mowa dokładnie o takim zestawie danych, które będą potrzebne do wcześniej planowanych funkcji, a więc:
Tak rozpisane dane gwarantują nam pewność, że mimo bycia prywatnymi, będą dostępne w inspektorze (dzięki atrybutowi SerializeField), a jednocześnie będą dostępne tylko do odczytu, dzięki uzależnieniu ich od specyficznego rodzaju metod, bo właściwości (Property) w trybie get. Można wprawdzie ustawić te pierwsze zmienne po prostu na public, ale nie jest to dobrą praktyką i pokazuje, że nie orientujemy się za bardzo w kwestii hermetyczności danych.
Następnie musimy stworzyć wspomniany plik SO bezpośrednio w plikach, czyli w Player/Objects. Na ten moment lepiej nie brać się za jego wypełnianie, bo to kwestia balansu, który zajdzie dopiero w przyszłości.
Niestety do tego typu zestawu danych nie jesteśmy w stanie wprowadzić niczego ze sceny w Unity i dlatego musimy stworzyć drugi skrypt o nazwie np. PlayerMonoScript, którego przeniesiemy bezpośrednio na postać gracza w formie prefabu.
Kiedy już to zrobicie, musicie określić bezpośrednio zmienne pod nagłówkiem Checkers, co oznacza zabawę w przeciąganie obiektów na scenie do skryptu podpiętego pod gracza właśnie i uprzednie dodanie nowej warstwy “Player” (pamiętajcie zmienić warstwę prefabu gracza) oraz “Enemy”, czyli przeciwników, których będziemy wkrótce potrzebować.
Po zadbaniu o dane, które posłużą nam do określenia postaci gracza (i które będziemy zapewne rozszerzać w toku produkcji), nadszedł czas na stworzenie dedykowanego mu “podstawowego” skryptu, w którym znajdzie się logiczna część. Mowa więc o tym, co nada naszej postaci m.in. możliwość poruszania się. W tym celu tworzymy nowy skrypt o nazwie PlayerController w Player/Scripts/Logic i bierzemy się za programowanie logiki.
Jak sterować postacią w Unity?
Aby dokładnie odpowiedzieć na to pytanie, musielibyśmy przeczytać wiele stron w dokumentacji, do której zgłębiania zawsze zachęcam, ale jako że tutaj nie ma na to miejsca, musicie wiedzieć, że w tej grze platformowej 2D do wykrywania i manipulowania tym, jak oddziałują na siebie poszczególne elementy, wykorzystamy zarówno warstwy, jak i komponenty typu Collider. To zresztą wymusza na nas powrót do skryptu PlayerMonoConfig i rozszerzenia go zarówno o komponent Rigidbody 2D oraz Collider 2D, które to przeciągniemy bezpośrednio z prefaba Player.
Jak z kolei poinformować klasę PlayerController, że to w PlayerMonoConfig i PlayerConfig znajdzie najważniejsze dane? Możemy zrobić to w najprostszy sposób, czyli tworząc zmienną MonoBehaviour w tym pierwszym, dodając klasę PlayerController jako kolejny komponent prefabu Player i przeciągając do niego podpięty wcześniej PlayerMonoConfig, uzyskując tym samym dostęp do obu zestawów danych.
Spytacie pewnie “dlaczego nie zrobiliśmy tak z podstawowymi danymi” i będzie to bardzo dobre pytanie. Odpowiedź na to jest jednak prosta – musimy zawsze dbać o to, aby dana klasa spełniała możliwie najbardziej zasady SOLID, wśród których znajduje się zasada, że klasa musi odpowiadać tylko za jedno, czyli np. przechowywać dane. Poza tym, taka praktyka sprawia, że zachowujemy znacznie okazalszą czystość kodu.
Czas więc na upragnione poruszenie naszej postaci. W celu osiągnięcia tego, musimy odpowiedzieć sobie na kilka podstawowych pytań, na które od razu podam Wam prostą odpowiedź, aby skrócić proces powstawania tak prostej mechaniki sterowania. Zaznaczę tylko, że wykorzystujemy do tego zarówno metodę FixedUpdate, jak i Update nie bez powodu. Podczas gdy pierwsza radzi sobie najlepiej z fizyką w grze, druga działa pozornie tak samo, ale zachodzi co klatkę. Innymi słowy, rozdzielając to na te dwie metody, które oferuje nam Unity poprzez klasę MonoBehaviour, optymalizujemy już na tym etapie grę, zachowując podręcznikowe wręcz podejście do wykorzystania obu metod.
- jak odczytamy sygnał z klawiatury?
- jak sprawdzimy, czy postać dotyka platformy lub ściany?
- jak poruszymy postać w pozycji horyzontalnej?
- jak uchronić postać od przewracania się?
- jak obracać postać zależnie od pozycji?
- jak umożliwić postaci skakać?
- jak umożliwić postaci zeskakiwanie z platform?
- jak uniemożliwić graczowi zeskoczenie z podstawowej platformy “poza mapę”?
- Nasz pierwszy bug! Można wręcz otwierać szampana. Aby go rozwiązać, musimy poprawić swoją wpadkę z kafelkami, które rozstawialiśmy w poprzednim kroku. Musimy rozdzielić platformy od ziemi, z której nie możemy zeskoczyć, co wymaga zarówno zmian w warstwach, jak i configach, ale tak naprawdę musicie tylko prześledzić wcześniejsze kroki (stawianie tiles, ustawianie warstw, rozwinięcie configu PlayerMonoConfig o dodatkową pozycję), aby go rozwiązać. Nie zapomnijcie też rozszerzyć metodę weryfikującą pozycję o tak:
- jak umożliwić graczowi ślizganie się po ścianie?
- jak zapewnić graczowi możliwość skakania po ścianie?
- jak umożliwić graczowi wykonywanie dashów?
- jak zablokować sterowanie podczas dashowania?
- jak pozwolić graczowi atakować? Jako że przeciwnikami zajmiemy się dopiero na następnym etapie, forma ataku na ten moment przyjmie postać zwyczajnego loga w konsoli
Tak oto zapewniliśmy naszej postaci możliwość poruszania się po mapie i symulowania atakowania. Jeśli nie rozumiecie jakiegokolwiek elementu kodu, który znajduje się poniżej, najlepiej będziej jeśli doczytacie dokumentację, a nastepnie poeksperymentujecie z tymi metodami. Na to niestety nie ma miejsca w tego typu poradnikach (przynajmniej nie w formie wideo), jako że zakrawa to o naukę programowania od podstaw, więc dlatego pozwoliłem sobie pominąć wszelkie opisy poszczególnych metod. Zamiast tego wolę już skupić się na tym, aby nasza postać nabrała nieco więcej życia, a osiągnąć to możemy w bardzo prosty sposób – tworząc animację dla każdego ruchu.
Animacje dla bohatera
Brzmi to wręcz przytłaczająco, ale jeśli wyposażyliście się w odpowiednią paczkę grafik z szeregiem sprite’ów postaci w różnych akcjach, będzie to tak naprawdę przyjemna zabawa na kilka godzin. Rozpoczniecie ją od zaprojektowania podstawowych trzech animacji, a więc animacji bezczynności (Iddle), ataku oraz biegu, do czego będziecie potrzebować specjalnego kontrolera animacji oraz samych animacji, które powinniście (dla zachowania odpowiedniej hierarchii) umieścić w folderze Player/Objects/Animations.
Kiedy już stworzycie te cztery nowe “pliki” (kliknijcie na prefab Player na scenie i Create w okienku Animation, a następnie Create New Clip) musicie tylko kliknąć dwukrotnie na kontroler o unikalnej ikonce, aby przenieść się do okienka animatora (musicie go otworzyć z menu Window > Animation > Animator), w którym znajdują się już trzy główne węzły czynności, bo węzeł początku, dowolnego stanu i wyjścia.
Ich nazwa nie jest przypadkowa i dlatego właśnie ze stanu Entry musicie wyprowadzić (PPM > Make Transition) połączenie do sekwencji Iddle, z Any State do Attack (bo gracz będzie mógł atakować praktycznie w każdym stanie animacji) i z Attack do Exit (animacja musi się zakończyć), a z Iddle dwustronne połączenie do Run, aby animacja mogła się przełączać między tymi stanami.
Jak tu jednak powiedzieć silnikowi gry, kiedy ma włączać konkretne animacje? Odpowiedź na to pytanie przynoszą warunki (Conditions), a więc coś, czego spełnienie, pozwala nam zmienić lub wywołać animację. Ustawiamy je w okienku Parameters podstawowego Animatora i dodajemy poprzez ikonkę plusa. Aktualnie potrzebujemy zarówno zwyczajnej zmiennej bool, aby określać, czy postać się porusza oraz triggera Attacking, aby wiedzieć, kiedy gracz wyprowadzi atak.
Mając dostęp do tych dwóch zmiennych, możemy wybrać jedno z połączeń i wskazać interesującą nas zmienną, jako warunek zmienienia animacji (np. z Iddle na Running, kiedy IsRunning jest równe true i odwrotnie) lub jej aktywowania (Player_Attack z wykorzystaniem triggera Attacking). Znajdującymi się wyżej wykresami oraz opcjami nie musicie się na tę chwilę przejmować, bo służą one do wygładzania poszczególnych przejść między animacjami, czego ewentualna potrzeba wychodzi dopiero w testach.
A jeśli już o animacjach mowa, to mamy już wszystkie interesujące nas teraz warunki, a więc nadszedł czas na ustawianie samych animacji. Na całe szczęście, dzięki dostępie do grafik postaci w różnych pozach, proces ten jest prozaiczny. Musimy włączyć okienko Animation, wybrać interesującą nas animację i następnie po prostu przeciągnąć zestaw Sprite, czyli w tym przypadku zestaw odpowiedzialny za wizualizację ataku, bezczynności oraz biegu.
Poszczególne sprite trzeba odpowiednio ułożyć, aby zmieniały się z interesującą nas szybkością (zweryfikujecie to w testach), a w przypadku animacji ataku powinniśmy też zadbać o specjalne połączenie jej z pustą aktualnie metodą typu void ataku w PlayerController. To sprawi, że będziemy wyprowadzać ataki w specyficznym momencie trwania animacji.
Nadszedł więc czas powrócić do skryptu PlayerController, aby to z jego właśnie poziomu określać, że chcemy wywoływać w danych momentach konkretne animacje. Aby to zrobić, musimy dodać do naszego Prefaba gracza stworzony wcześniej kontroler, a w skrypcie PlayerMonoConfig dodać kolejne dwie linijki, odnoszące się do animatora właśnie. Po przeładowaniu Unity, należy przeciągnąć animatora w GO Player do configu i tak oto dostaniemy do niego dostęp z poziomu kodu. W nim możemy wpływać bezpośrednio na poszczególne zmienne, podając ich nazwę w cudzysłowie.
Rozbudowując animatora i wprowadzając kolejne animacje, możecie stworzyć naprawdę kompleksowe i dokładne przejścia z jednego stanu do drugiego. Przykładowo w swoim projekcie rozwinąłem to do stopnia, którego możecie obejrzeć poniżej, dbając odpowiednio o jakość pixel-artu poprzez zmienienie ustawienia każdego zestawu sprite’ów z Filter Mode: Bilinear na Point (no filter).
Punkty życia gracza
Jak możecie zauważyć w animatorze, na tym etapie rozbudowałem również skrypt o zależności związane z otrzymywaniem obrażeń, aktywowaniem GodMode w celu uniknięcia masowego zadawania obrażeń naszemu bohaterowi przez przeciwników oraz samej śmierci. Wprawdzie przeciwników dodamy do gry dopiero na następnym etapie, ale już teraz postarajmy się o zapewnienie naszej postaci punktów życia. W tym celu musimy stworzyć nowy GO, odpowiadający za UI, czyli interfejs użytkownika (User Interface).
Jako że chcę uczynić punkty życia nie nudną cyferką, a graficzną ich reprezentacją, to w GO Canvas tworzę zarówno pusty GO o nazwie Hearts, a w nim element UI typu Image, którego nazwę zmieniam na Heart i tworzę z niego prefab, lądujący w folderze (UI/Health/Objects)
Następne kroki obejmują:
- wypozycjonowanie zbiorku serc do prawego górnego rogu poprzez manipulowanie komponentem Rect Transform
- dodanie komponentu Horizontal Layout Group do GO Hearts, aby utrzymać serca w tych samych odstępach
- stworzenie skryptu PlayerHealth w odpowiednim folderze (UI/Health/Scripts/Logic) i dodanie go do GO Hearts
Najtrudniejsze na tym etapie są oba skrypty, które to muszą przechowywać (odpowiednio) dane co do grafik, które chcemy podmieniać oraz logikę ich podmieniania po otrzymaniu obrażenia. Żeby to zrobić, musimy jakoś powiązać skrypt PlayerController z PlayerHealth, a dokonamy tego wzorcem kreacyjnym typu Singleton, o którym pisałem już wcześniej, dzięki któremu możemy pobrać aktualną wartość życia gracza i na tej podstawie np. stworzyć adekwatną liczbę serc w UI.
Poniżej możecie obejrzeć kod z małej symulacji zadawania obrażeń graczowi poprzez tak zwany event, czyli wydarzenie, które po zaistnieniu (Invoke) wysyła sygnał z opcjonalną wartością do podłączonych funkcji, aby je aktywować, co w ogólnym rozrachunku pozwala nam zachować czystość i hermetyczność klas. Po prawej stronie znajduje się z kolei logika dla wizualizacji serc gracza, która w metodzie Start manipuluje prefabem Hearts, tworząc kolejne serca (Heart) z podpiętego w inspektorze prefabu, dzięki czemu dostosowuje swój rozmiar zależnie od liczby punktów życia ustawionych w configu gracza. Metoda LoseHeart odpowiada z kolei za odejmowanie kolejnych serc poprzez zmienianie ich wizualizacji z pełnych na puste.
Po zakończeniu tej pracy, powinniśmy stworzyć prefab z całego UI, bo będzie czymś, co wykorzystamy w każdej scenie. Z kolei następnym krokiem w tworzeniu tej gry będzie dodanie do niej przeciwników, aby gracz mógł coś zabijać i sam mógł zostać zabity. Jesteśmy więc tak naprawdę na półmetku stworzenia podstawowej wersji gry, bo poza przeciwnikami, planuję dodać do niej również obiekty, które będzie można zebrać, aby ukończyć poziom. Dlatego też w tym momencie nie zajmuję się szlifowaniem projektu do perfekcji (m.in. animacji czy skryptów), bo to zwykle coś, co jest przeprowadzane już na końcu rozwoju projektu, kiedy łata się bugi i poprawia ogólną czytelność kodu, która ulega ciągłej zmianie.
Finalny efekt? Oto on: