Devlog lamera 3: idź się bujać, strzel se
Witam witam, w kolejnym devlogu, to jest serii poświęconej demitologizacji gamedevu na praktycznych przykładach, tj. na gierce, którą właśnie tłukę w Godocie. Zrobienie własnej gierki nigdy nie było łatwiejsze, nawet przez ludzi którzy w życiu nie programowali! Wystarczy godzina dziennie pracy nad projektem i można całkiem szybko do czegoś dojść. W poprzednim odcinku ukazane było, jak utworzyć prostą relację: wroga rakieta uderza w statek, statek strzela pociskami, które, jeśli trafią, zadają obrażenia. Pora na nieco bardziej złożony problem.
problem
Założenia są następujące: mam kilka klatek statku obracającego się na jedno bądź drugie skrzydło. Chcę, aby taka animacja była powiązana z ruchem statku w górę bądź dół, ale! Kluczowe jest, aby animacja była płynna i nieprzerwana - jeśli szybko zmienię kierunek ruchu z dołu do góry, nie może nastąpić żaden sztuczny przeskok, tylko płynne odwrócenie bujania się od dokładnie tej klatki, w której byłem. To od razu dyskwalifikuje wykorzystanie AnimatedSprite, tj. prostej nody animacyjnej, którą wykorzystałem do animowania odrzutu silnika. Zrobienie takiego systemu w AS wymagało by tuzinów osobno ustawionych animacji, potężnego bloku kodowego śledzącego ile sekund/klatek już się animowało i galimatiasa switchów/matchów biorących wszystko pod uwagę i ustalających kolejną animację. A komplikowanie sobie kodu, tak samo jak komplikowanie życia, nigdy nie jest pożądane. Googlując i czytając docsy i googlując i dalej czytając docsy doszedłem do tego, że nieco bardziej zaawansowany obiekt AnimationPlayer być może byłby mi pomocny, gdyż posiada możliwość odtwarzania ustawionej animacji wstecz oraz kolejkowania. Dzięki temu posiadałbym tylko trzy osobne animacje, "bujaj w lewo" "bujaj w prawo" i "leć równo" i jedynie bym kazał odwarzać naprzód czy wstecz zależnie od potrzeb. Jednak po paru próbach szybko okazało się, że nie da rady, gdyż o ile AP może odtwarzać coś wstecz, ogólnie rzecz biorąc, do odtwarzanie czegoś wstecz od konkretnego momentu to już znacznie wyższa szkoła jazdy i nie wykombinowałem, jak by do tego efektu dojść. Są metody, ale nie działają jak konkretnie potrzebuję. Szukamy dalej. Jeszcze wyższy poziom abstrakcji: AnimationTree. AT przyjmuje formę całego diabelskiego grafu dyktującego które stany animacji kiedy mają być odtwarzane, w którą stronę, dlaczego itp. itd.
eee
Niestety! O ile AnimationTree pozwala wizualować sobie co kiedy i jak się animuje, nadal opiera się w zalążku na AnimationPlayer, który po prostu nie posiada dostatecznie dużo sygnałów i danych, żeby sprawnie zrobić to, o co mi chodzi. Na ogarnianiu zasad działania AT spędziłem coś tydzień i skończyło się głównie na przekonaniu, że to coś, od czego będę trzymał się z bardzo daleka tak długo, jak to tylko możliwe. Wracam do punktu wyjścia. W takiej sytuacji może bym się poddał i stworzył jakiegoś potworka w AnimatedSprite, ale nagle wpadła mi w oko informacja, że skromna podstawowa noda Sprite2D, w której już miałem postawiony statek, może przyjmować spritesheet, czyli obrazek zawierający wiele sprajtów rozłożonych w równych odstępach. Co więcej, jeśli dostanie spritesheet, oraz informację ile rzędów oraz kolumn obrazków jest w nim zamieszczone, kwestia wyboru wyświetlanej klatki to trywialne "frame = [numer]" w kodzie. W tym momencie już tylko chwila główkowania i plan się wykrystalizował.
0-1-2-3-4
Zatem tak: mamy spritesheet zawierający pięć klatek przedstawiających statek bujający się z jednego skrzydła na drugie. Klatka numer 0 to statek maksymalnie przechylony w lewo, "od" ekranu, nr 2 to statek w poziomie, numer 4 - maksymalnie przechylony w prawo. Podaję ten arkusz do Sprite2D reprezentującego obraz statku i dookreślam, że jest tam 5 kolumn i 1 rząd. Otwieram skrypt zawiadujący statkiem, podpięty pod statek. Tworzę dwie zmienne, nazwijmy je "kierunek_bujania" i "obecna_klatka". Jak pamiętamy (a może i nie), w każdej klatce gra sprawdza czy coś z przycisków kierunkowych jest naciśnięte i na podstawie tej informacji generuje wektor 2-wymiarowy który wykorzystuję do zmiany pozycji statku. Więc teraz, zaraz po uzyskaniu tego wektora, sprawdzam jaką wartość ma parametr y tego wektora, reprezentujący jego pionową oś. Dodatnia - kierunek w dół, to znaczy, że bujamy się w prawo. "kierunek_bujania" = 4 (czwarta klatka Sprite2D). Ujemna - kierunek w górę. "kierunek_bujania" = 0. Zero - "kierunek_bujania" = 2. To jedna rzecz. Teraz kawałek niżej, nadal w funkcji _physics_process sprawdzam czy zmienna kierunek_bujania różni się od zmiennej obecna_klatka. Jeśli tak, co ułamek sekundy (ponownie: nie chcemy tego co każdą jedną klatkę) obecna_klatka jest zmieniana o 1 w stronę kierunek_bujania, po czym Sprite2D.frame zostaje ustawione na nową wartość obecna_klatka.
if direction.y > 0:
kierunek_bujania = 4
elif direction.y < 0:
kierunek_bujania = 0
else:
kierunek_bujania = 2
(...)
if opóźnienie_animacji > 0:
opóźnienie_animacji -= delta # "-=" to skrótowiec, zapis oznacza "o_a = o_a - delta"
else
if obecna_klatka != kierunek_bujania: # != oznacza "nie jest równe"
if obecna_klatka < kierunek_bujania:
obecna_klatka += 1
$Sprite2D.frame = obecna_klatka
opóźnienie_animacji = 0.1
else:
obecna_klatka -= 1
$Sprite2D.frame = obecna_klatka
opóźnienie_animacji = 0.1
Mały test - i działa! Bujanie następuje prawidłowo, jest płynne, nie ma żadnych przeskoków, tylko gładki ciąg.
Następny, już nieco poważniejszy krok. Statek gracza nie będzie pluł wyłącznie jedną małą kulką. W planach jest pięć broni o trzech poziomach mocy. Niebieski szybkostrzelny "blaster", czerwony spread (prawie S z contry), zielony pocisk automatycznie celowany, laser, oraz fioletowy pocisk wystrzeliwany jednocześnie do tyłu i przodu. Poza laserem i zielonym bronie te są bardzo proste do implementacji: ot instancjowanie nowych pocisków, nadanie im pozycji oraz kąta strzału i add_child, gotowe. Jedyne urozmaicenie takie, że niektóre bronie strzelają w cyklu np. 1 pocisk prosto - 2 nieco odchylone w górę/dół - 1 pocisk prosto itd., ale to trywialna kwestia +1 zmiennej i dodatkowego match (aka select aka switch w innych językach) na ostatnim poziomie kodu. Sporo stukania, ale mało myślenia.
sporo, sporo stukania
Z pociskiem celowanym jest nieco trudniej. Gdy nadchodzi pora wystrzelić pocisk, trzeba skądś wynaleźć kierunek do istniejącego wroga. Jak? Okazało się to być bardzo proste. Na potrzeby wykrywania trafienia i wywołania odpowiedniej metody w trafionej rzeczy wszystkim wrogom już nadaję tag/grupę "enemies". Godot zaś ma funkcję zbierającą wszystkie obiekty posiadające wskazany tag. Zatem proszę go: "var lista_wrogów = get_tree().get_nodes_in_group("enemies")
". Żeby wiedzieć w którego spośród wszystkich obecnych wrogów wystrzelić, muszę jako programista podjąć decyzję co do kryteriów celowania. Czym się kierować? Najsensowniejsze w takiej grze jest ofc celowanie we wroga najbliższego. To łatwo znaleźć, za pomocą metody, w którą wyposażony jest każdy wektor, w tym pozycja danej nody - distance_to(). Więc idę po lista_wrogów, za kazdym razem sprawdzając odległość od gracza do danego wroga i porównując ją z wzorcem (który zaczyna od śmiesznie dużej wartości odległości, np. 20 000, żeby zawsze został wymieniony na coś). zostaje następnym wzorcem. Po przejściu przez całą listę wzorzec jest właśnie tym, czego szukałem, wrogiem obecnie najbliższym graczowi.
var wzorzec_odległość = 20000
var cel
for pomiar in lista_wrogów: #iteracja po tabeli
var odległość = self.position.distance_to(pomiar.position)
if odległość < wzorzec_odległość:
wzorzec_odległość = odległość
cel = pomiar.position
Następnie mała konwersja dwóch wektorów na kierunek w radianach (mamy na to metodę) i mamy kierunek strzału dla pocisku. Odpalenie jednak gry i krótkie testy szybko jednak ukazą kluczową słabość: jeśli nie ma żadnych wrogów na polu gry, program się zawiesi, ponieważ będzie próbował iterować (for pomiar
) po pustej wartości (lista_wrogów
). Wykonywanie kodu trzeba zatem dodatkowo uzasadnić warunkiem, że lista wrogów nie jest pusta (NULL). Jeśli jest, pocisk będzie celowany arbitralnie, no powiedzmy w tył statku, żeby gdzieś był.
Z laserem było nieco trudniej i w celu zrobienia całkiem ładnego musiałem przepisać gotowe rozwiązanie z jakiegoś tutoriala. Przyznam, że nadal niezupełnie rozumiem logikę na której postawiono tę implementację, ale hej! Działa to dziala. Rdzeniem takiego lasera jest funkcja RayCast2D, która bierze punkt początkowy, kierunek, i zwraca, czy w danym kierunku, z danego punktu, znajduje się cokolwiek (może zatrzymać się na pierwszym trafieniu, może lecieć aż przeleci cały dystans i zwrócić całą grupę wyników). To jeszcze jest zrozumiałe, tylko że potem w jakiejś magii kodowej oteksturowana linia zostaje rozciągnięta na przelecianą odległość i opcjonalnie prosty particle generator spawnuje świetliki na całej długości wiązki. W każdym bądź razie mam tę scenę, która każdej klatki sprawdza czy w coś uderza i jeśli tak, zadaje obrażenia. To powoduje dwa problemy. Po pierwsze, inne pociski zadają proste wartości obrażeń typu 1, 2; uderzają i znikają w tej samej klatce. Laser znowu zadaje dmg każdej jednej klatki, więc musi mieć wartość obrażeń znacznie mniejszą, by nie być rzędy wielkości mocniejszym od reszty broni. Załatwione. Po drugie, wszystkie pozostałe bronie działają na zasadzie "póki klawisz jest wciśnięty twórz nowy pocisk"; z laserem nie mogę tego zrobić, bo obiekt ten nie znika po trafieniu i szybko miałbym 1000 laserów walących naraz, bez przerwy. Dlatego jeśli aktualną bronią jest laser, gra czyta klawisz strzału nieco inaczej. Normalnie pytam o jego stan za pomocą metody "is_action_pressed()
", która zwraca "tak" każdej klatki tak długo jak klawisz jest wciśnięty. W przypadku lasera korzystam z innej metody: "is_action_just_pressed()
" która zwraca "tak" tylko w pierwszej klatce w której klawisz został wciśnięty. Wtedy do statku dodaję przygotowany obiekt lasera. Następnie zaraz sprawdzam "is_action_just_released()
", która jest bliźniaczą metodą do poprzedniej i zwraca "tak" tylko w pierwszej klatce po zwolnieniu klawisza. Wtedy za pomocą queue_free kasuję nodę lasera. Presto, gotowe.
bzzzzzzzzzz
Zanim zabiorę się za implementację właściwych wrogów wprowadzam jeszcze parę kosmetycznych rzeczy. Po pierwsze, odgłos strzału i odgłos trafienia. Kwestia prosta, dodać nodę AudioStreamPlayer2D do tego, co ma wydawać odgłos, wrzucenia pożądanego pliku dźwiękowego i wrzucenia w odpowiednim miejscu w kodzie komendy .play()
. W ograniczonych ramach tej gierki nawet nie muszę się bawić specjalnie w jakieś żonglowanie plikami, ustawianie streamów, po prostu na każdy efekt daję osobny ASP2D i już. Jedyne zmartwienie mam takie, że przy okazji zadeklarowałem dwie szyny dźwiękowe, "muzyka" i "efekty", na poczet tego, że w przyszlości może ustawię suwak audio w opcjach, w związku z czym muszę pamiętać żeby każdy nowy ASP przydzielać do jednej z szyn, żeby nie siedziała na domyślnym "Master". I druga, nieco powiązana rzecz - hit feedback. Pod tym terminem kryje się szeroko rozumiane dawanie graczowi znać, że nastąpiło trafienie, od trzęsienia ekranem po napis HIT; w strzelankach, szczególnie w miarę szybkich/gęstych, jest to może ważniejsze niż w innych gatunkach. Jeden aspekt już mam - wzięty z freesound i nieco jeszcze delikatnie przerobiony w Audacity odgłos synthowy. Sam dźwięk jednak nie wystarcza, gdyż wrogów może być wielu na ekranie, w związku z tym wymyśliłem, że trafiony wróg ma na ułamek sekundy błysnąć. W internetachTM znalazłem informację, że mogę to robić za pomocą komendy modulate(10, 10, 10, 1)
wykonanej na sprajcie który ma błysnąć, po czym modulate(1, 1, 1, 1)
kiedy ma zgasnąć. Co dokładnie robi modulate, nie mam pojęcia szczerze mówiąc, poza oczywistym że cyferki to RGBA czyli kolory i przezroczystość, ale ta wiedza jest mi - póki co - kompletnie zbędna.
Zatem co, mamy statek gracza (animowany), mamy bronie w grze, zaczątki szlifu wizualnego i dźwiękowego i ogarnięte podstawowe funkcje, jakie powinien wróg spełniać. Można spokojnie zacząć implementować wrogów właściwych... Ale to już w następnym odcinku.
przygoda za każdym zakrętem czyha