Przeciwnicy i system walki w platformowej grze 2D w Unity [OZDGD #7]

Poprzednio udało się nam zrobić w pełni kontrolowaną i animowaną postać gracza, która może biegać, skakać, dashować, ślizgać się i skakać po ścianach, a nawet atakować, więc czas dać jej coś, co atakować rzeczywiście będzie mogła. Innymi słowy, w tym artykule opisze proces powstawania przeciwników i od razu was uspokoję – jeśli daliście sobie radę z graczem, to i z przeciwnikami pójdzie wam z płatka. 
Przeciwnicy i system walki w platformowej grze 2D w Unity [OZDGD #7]

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:

Niniejszy tekst jest kontynuacją serii, w ramach to której tworzę pełnoprawną, ale prostą platformową grę 2D:

Gra bez audio, to nie gra. Zabieramy się za udźwiękowienie

W tego typu platformowej grze będziemy mieli wiele różnych rodzajów przeciwników, więc możemy podejść do tematu na dwa sposoby. Albo opierając się o interfejs, albo o dziedziczenie i w tym akurat przypadku postawię na to drugie. Będzie to tak naprawdę jedyna nowość względem tego, co udało się nam już zrobić, bo przeciwnicy będą odzwierciedleniem postaci gracza tyle że bez tych wszystkich funkcji sterowania. 

Oznacza to, że będziemy musieli stworzyć ich prefaby oraz animacje, a finalnie sprawić, że jako gameobjecty będą wchodzić w interakcję z pozostałymi elementami gry, czyli środowiskiem oraz graczem. Dla przyśpieszenia projektu i ułatwienia pracy, porzucę w ich przypadku podział na logikę i dane, aby pokazać wam, że wcale nie trzeba tego robić, aby gra “działała”. Ba, mimo tego, że łamiemy tym sposobem zasady, to uprościmy całość, dzięki dziedziczeniu właśnie.

Zacznijmy więc od głównego elementu – szkieletu naszych przeciwników, czyli głównej klasy Enemy, po której będziemy dziedziczyć, tworząc patrolujących, atakujących i strzelających wrogów. W niej musimy zawrzeć przede wszystkim bazowe statystyki przeciwnika takie jak życie, obrażenia oraz ewentualnie siłę odrzucenia postaci w razie bezpośredniej interakcji naszej postaci z wrogiem. To będzie zresztą podstawą do atakowania głównego bohatera dla pierwszego przeciwnika – patrolującego muchomorka, którego stworzeniem w silniku zajmiemy się za moment. 

Pewne jest, że przeciwnik musi również doczekać się animatora, ale z racji dziedziczenia tej danej, zamiast wyciągać go do inspektora, lepiej zdecydować się na pobranie komponentu w metodzie Start, aby uniknąć niepotrzebnego przeklikiwania (to samo można zrobić z obiektem gracza, ale akurat w jego przypadku wolałem.

Jeśli z kolei idzie o interakcje z graczem, możemy zamknąć ją w czterech głównych metodach: 

  • zadawania obrażeń przy każdej kolizji z postacią gracza (interakcji ze sobą dwóch Colliderów), co wymaga przypięcia do prefabu bohatera tagu Player, a do przeciwnika Enemy i wykorzystania wbudowanej metody OnTriggerEnter2D
  • przyjmowania obrażeń od ataków gracza, co wymaga rozszerzenia klasy PlayerController o dotychczas pustą metodę Attack i wykorzystania metody TakeDamage w klasie Enemy
  • odpychania postaci gracza w chwili przyjmowania obrażeń związanych z interakcją colliderów (PushBack)
  • umierania, kiedy życie przeciwnika spadnie do 0 (lub poniżej) i odtwarzania animacji otrzymania obrażeń w chwili otrzymania nieśmiertelnego ciosu (Die)

Najbardziej wymagające jest zdecydowanie to ostatnie, bo obejmuje wykorzystanie zarówno korutyny (pozwalają ograniczyć wykonywanie metod zależnie od czasu), jak i wcześniej stworzonych animacji, ale to praktycznie dokładnie to samo, co robiliśmy przy postaci gracza… tyle że znacznie prostsze. Zanim jednak się tym zajmiemy, musimy opracować skrypt dla przeciwnika patrolującego, który to będzie dziedziczył po stworzonym właśnie skrypcie Enemy.

Tworzymy więc klasę PatrolEnemy, która to będzie dziedziczyła z abstrakcyjnej (niemożliwej do stworzenia) klasy Enemy, co oznacza, że zapożyczy z niej poszczególne metody oraz zmienne niebędące prywatnymi. Te oznaczone dopiskiem virtual można nadpisywać poprzez kluczowe słówko override, a jeśli mielibyśmy jakieś metody lub zmienne abstrakcyjne, ich indywidualna implementacja byłaby koniecznością. 

Jako że w klasie Enemy określiliśmy większość danych, to w PatrolEnemy musimy tylko rozszerzyć podstawę o cechy charakterystyczne dla tego przeciwnika, czyli szybkość poruszania się, długość oczekiwania w miejscu patrolowym i same punkty patrolowe, między którymi przeciwnik będzie się kolejno poruszał. 

Po stronie zmiennych prywatnych konieczne jest też wprowadzenie zmiennej typu float odpowiadającej za wskazanie upływu czasu, zmiennej bool wskazującej pozycję sprite’a postaci oraz indeksu dla tablicy, po której będziemy się poruszać, wybierając patrolowe miejsca zapisane w zmiennej patrolPointsPosition. Ich inicjalizację przeprowadzamy w metodzie Start, a resztę logiki można zamknąć w ledwie kilkudziesięciu linijkach. 

Po stronie silnika gry musimy z kolei stworzyć prefab przeciwnika patrolującego, który to będzie poruszał się między konkretnymi punktami. Musimy więc zadbać o animacje i odpowiednie komponenty, aby finalnie uzyskać coś w tym stylu:

Punkty patrolowe, to puste GO, które rozstawiamy po świecie tak, aby wskazać postaci Muchomorka miejsca, w które musi się udawać. Dzięki postawieniu na tablicę bez ograniczeń, możemy upchnąć w inspektorze tak naprawdę dowolną liczbę punktów, a postać i tak będzie do nich się kierować, nie zważając na otoczenie. Możemy wprawdzie postawić tylko na dwa punkty, ale lepiej rozstawić więcej losowych, żeby nadać przeciwnikowi więcej różnorodności, dbając jednocześnie o to, aby ruch Muchomorka miał sens i animacja sprawiała wrażenie, że postać rzeczywiście chodzi po obiektach. W praktyce bowiem nie nadaliśmy tej postaci fizyki, czyli jeśli usuniemy platformę, to będzie lewitować. Czy to błąd? Z jednej strony tak, ale z drugiej, mając taką podstawę, musimy jedynie ukrywać tę niedoskonałość przed graczem, aby zapewnić sobie możliwość zrobienia prostej postaci latającej, podmieniając jedynie grafikę i ustawiając inaczej punkty patrolowe. 

Tak oto w naszym projekcie dokonaliśmy pierwszego słodkiego oszustwa widocznego dla nas, ale całkowicie ukrytego przed graczem, które w procesie produkcyjnym może się nam zwyczajnie opłacać, bo oszczędza czas. Podobnie możemy postąpić z planowaną postacią, która będzie nie tylko patrolować, ale też w razie wykrycia gracza w zasięgu ataku, zada mu obrażenia swoją bronią. 

Jak możecie zobaczyć na powyższym zrzucie ekranu, prefab przeciwnika-goblina doczekał się u mnie nie tylko skryptu PatrolEnemy, ale też nowego skryptu AttackingObject, który został przemianowany z pierwotnie planowanego AttackingPatrolEnemy. 

Zrobiłem to głównie po to, aby pokazać wam możliwość stworzenia z patrolującej postaci “chodzącej pułapki”. Dzięki dodatkowemu skryptowi, w którym określamy liczbę obrażeń zadawanych przez taki obiekt, wskazujemy jego zasięg i częstotliwość występowania, możemy urozmaicić goblina o animację ataku… albo wykorzystać dokładnie tę samą formę skryptu do aktywowania pułapki zadającej obrażenia.

W tej implementacji to nasz goblin, pełniący tę samą funkcję, co muchomor, stał się “nosicielem” takiej “pułapki”, a animacja załatwia sprawę wizualizacji. Proste? Proste, a do tego szybkie i uniwersalne, bo taki oto AttackingObject może stanowić podstawę dla np. pułapki z kolcami.

Nadszedł więc czas na ostatniego przeciwnika – goblina bombardiera, który będzie nieustannie rzucał swoje bomby na wrogów. Ponownie dziedziczymy z klasy Enemy, rozszerzamy nową klasę (Shooting Enemy) o potrzebne zmienne i pozwalamy mu “tworzyć” GameObjecty w formie bomb, do których potrzebujemy prefaba z własnym stosownym skryptem Projectile, mogącym również pełnić pewną formę pułapki po stosownym rozwinięciu.

Logika jest bardzo prosta – statyczny przeciwnik rzuca w kółko swoje bomby, aktywując metodę FireProjectile w animacji, a te wybuchają przy kontakcie z postaciami oraz podłożem, ale oczywiście nic nie stoi na przeszkodzie, aby nadać mu więcej sensu i dorzucić logikę patrolowania lub wykrywania naszej postaci. 

Finalnie warto zadbać o to, aby nasza postać mogła umrzeć, co tym samym kończy zwieńczy nasze wysiłki w zakresie tworzenia wstępnej wersji gry. Oczywiście do jej pełni kształtu potrzebujecie jeszcze głównego menu, nagłośnienia, efektów nadających grze charakter, aktywującego się po śmierci panelu Game Over i warunków pomyślnego zakończenia danego poziomu, ale tym zajmiemy się już w najpewniej ostatnim artykule z tej części serii Od Zera do Game-Developera.