Devlog lamera 6: szycie i zszywanie
Witam witam. Wakacyjny okres za nami, czas na dłubanie wrócił, postępy zaczęły znowu ee
...postępować... uzbierało się zatem dość materiału na kolejny odcinek devlogowy. W poprzednim odcinku opisywałem sklejanie UI, oraz ogólne prace szlifowo-backendowe. Prace te nie były zakończone w momencie pisania, ale zostało ich już wtedy niewiele; ot głównie abstrakcje i upewnianie się że nic nie będzie zakładało na ślepo że rzecz X jest zawsze w miejscu Y (rzecz X powie trackerowi gdzie jest przy ładowaniu, a wszystko co chce dostępu będzie pytało trackera). Bodaj najciekawszą (względnie...) innowacją jest dorzucenie miniaturowych animacji rozpadania się pocisku gracza po trafieniu i wymagane do tego precyzyjne ustalanie w którym miejscu pocisk trafił.
it is a mystery
Generalnie chodzi o to, że, jak opisywałem w odcinku czwartym, pociski gracza każdej aktualizacji stanu gry sprawdzają jaką odległość polecą (zależnie od czasu klatki tj. delta) i patrzą wbudowaną metodą shapecast2d, czy gdzieś po drodze nie ma obiektu z ktorym mają kolidować. Na samo wykrywanie trafienia to rozwiązanie sprawdza się znakomicie, nie ma sytuacji że pocisk przeleci niczym duch przez wroga czy inną dekorację (rzecz którą taki Nightdive np. nadal nie zawsze ogrania). Problem jest taki, że metoda shapecast nie daje informacji zwrotnej w którym punkcie rzuconego promienia nastąpiło trafienie. Ktoś tylko czytający godot wiki np. mógłby zaprotestować, że ale przecież stoi jak wół w dokumentacji, jest metoda get_collision_point()
, która zwraca punkt trafie- nie, nie zwraca. metoda g_c_p zwraca koordynaty trafionego obiektu, a nie koordynaty punktu trafienia. Nie wiem, czemu została wybrana tak myląca nazwa - muszę sobie z tym poradzić i już. Bez kombinowania efekt byłby paskudny - albo "mrugnięcie" pocisku w powietrzu przed wrogiem (koordynaty przed trafieniem), albo potencjalnie za wrogiem gdzieś (koordynaty przed trafieniem + prędkość*delta), albo odchylone od dotychczasowej ścieżki tylko po to, żeby wylądować w centrum masy wroga. Kombinowałem więc i kombinowałem i kombinowałem i kom-
BO SIĘ ZATKNĘ
-i wykombinowałem rzecz następującą. W momencie, gdy metoda shapecast wykrywa trafienie, wywoływana jest nowa funkcja: nazwijmy ją "znajdź_punkt_trafienia" - ZPT. ZPT jako parametr otrzymuje obecną wartość delta*prędkość, jest to maksymalny zasięg lotu pocisku. Wartość ta jest podzielona na 20 (może być mniej, może być więcej, jak kto woli precyzję). Wtedy ustalana jest pętelka odliczająca według takiego schematu ideowego:
for odliczanie in zasięg_lotu do 0, skok -(zasięg_lotu/20):
aktualizuj_shapecast (odliczanie)
if shapecast NOT trafił:
return (odliczanie + zasięg_lotu/20)
Jest to schemat bardzo uproszczony, funkcja ma parę zabezpieczeń na różne wypadki krawędziowe, ale generalnie chodzi o to, że wiemy, że shapecast trafia, czyli na drodze jest hitbox. Patrzymy więc coraz bliżej i bliżej punktu, w którym pocisk znajduje się obecnie. Prędzej czy później sprawdzany dystans będzie dostatecznie krótki, że shapecast wyląduje przed hitboxem i zgłosi że w nic nie trafił. Wtedy pętla zostaje zakończona - funkcja zwraca poprzedni sprawdzony dystans, czyli ostatni który jeszcze trafiał w hitbox wroga. W tym miejscu pojawi się mrugnięcie pocisku. Trochę testów później miałem pewność, że wszystko działa jak należy, niezależnie od kształtu przecienika.
ping
Dobra, przyszła pora zrobić jakiś ekran tytułowy - sprawić, żeby działał prawidłowo - menu główne, oraz jakieś przejście z tego menusa do oddania kontroli w ręce gracza. Co do ekranu tytułowego, coż, popatrzyłem co bym chciał zrobić a co bym umiał zrobić moimi obecnymi umiejętnościami popychania pikseli w aseprite/kiricie i stanęło na ekranie tytułowym na miarę nowej wielkiej produkcji Laboratoriów Komputerowych Avalon za jakieś sto tysięcy polskich złotych (albo może trochę mniej). Przy okazji oficjalnie zaklepałem nazwę którą pierwotnie nadałem folderowi ze sprajtami do gry. Potem zająłem się wprowadzeniem. Chodziło o to, ze miałem donacyjny kawałek muzyki 16 sekund, który brzmiał mi na dobre otwarcie pierwszego poziomu; myślałem o sklejeniu jakiejś dynamicznej animacji typu statek gracza wymija pociski, w tle jakaś wojna, coś w ten deseń. Szczególnie chodziło o to, że akcja byłaby zgrana z muzyką. Wysmażyłem zatem parę dodatkowych assetów i zacząłem dłubać w animation playerze, synchronizując rzeczy i budując w znoju animację. Kilka dni później już byłem koło ósmej sekundy, ale jednocześnie zwątpiłem w sens całego przedsięwzięcia, bo z jednej strony od cholery roboty przy komponowaniu takiej animacji, z drugiej efekt fajny ale znacząco wydłużał wejście do gry, które IMO w tego typu grach powinno być nie dłuższe niż 1-2 sekundy, a co dopiero 16. Niby można dodać jakiś skip, ale to trochę psuje sens animacji w tym konkretnym momencie. Przemyślałem chwilę i wpadł mi do głowy kompletnie nowy visual i plan rozegrania sytuacji: utwór służący za intro pierwszej planszy niestety poszedł do piachu, nie ma innego miejsca, do którego by się nadawał. Ekran tytułowy też poszedł do piachu. Teraz odpalenie pliku wykonywalnego z grą pokaże kilkunastosekundową animację, recyklującą już zrobione assety graficzne, która ukaże tytuł gry, a potem przejdzie w menu główne. Rozpoczęcie nowej gry uruchomi króciutką animację, a pierwszy poziom rozpoczenie się zwyczajnym fade-in. Animację tytułową można też skipnąć naciśnięciem enter/z/czegoś tam jeszcze. Proste? Jak drut kolczasty.
Prawidłowe sklejenie tych elementów w calość wymagało sporo grzebania. Core-skrypt zarządzający flow gry musi zacząć od wczytania sceny intra, uruchomienia jej, a gdy się ono skończy lub zostanie skipnięte, musi bezpiecznie całość wywalić i wrzucić scenę menusa - a musi to być płynnie, tak, aby użytkownik nie zauważył jakichś przestojów, znikania, pojawiania. Tak samo trzeba przejść z menusa do animacji do pozomu. Wymagało to poważniejszej nauki systemu sygnałów. Sygnały to całkiem fajny system godota, gdzie każdy obiekt w danej sytuacji może wyemitować sygnał do wszystkich podpiętych, słuchających obiektów. Wiele z gotowych obiektów ma wbudowany szereg pożytecznych sygnałów, np. AudioStreamPlayer2D ma m.in. sygnał, że dany plik dźwiękowy skończył odtwarzanie, użytkownicy mogą też zdefiniować dowolną ilość własnych sygnałów i emitować je gdy przyjdzie ochota - w skrypcie. Dotąd korzystałem z tego system na sztywno, przy korzystaniu z hitboxów w ramach jednej sceny, co pozwalało mi po prostu w GUI editora kliknąć connect, wybrać skrypt i funkcję która będzie słuchała i już. Teraz będzie trudniej bo rzeczy emitujące sygnały będą dodawane i usuwane, w związku z czym muszę pamiętać żeby po załadowaniu rzeczy nakazać podpięcie w kodzie. Zakasłałem rękawy i zabrałem się do grzebania.
Tworzę zatem nową scenę, Intro. W niej jest AnimationPlayer oraz wszystkie assety - sprajty, dźwięki, etc. potrzebne do odegrania animacji intra. W Animation Player komponuję animację o nazwie "Intro_anim" za pomocą klatek kluczowych (keyframes) zawierających pozycje obiektów w określonych momentach, Animation Player zaś zadba, aby płynnie (linowo lub wykładniczo) przesuwać obiekty ruchome z A do B na czas, albo zmienić kolory płynnie naczas, albo jeszcze co innego. Zapisuję scenę w pliku. W scenie core, która jest rdzeniem kontrolnym gry, daję w deklaracjach zmiennych preload właśnie zapisanej sceny intra, to znaczy scena będzie już załadowana i gotowa do akcji gdy wywołam ją w kodzie (rzeczy które robię są na tyle małe że w sumie nie potrzeba loading screenów zbytnio); w sekcji _ready()
, która jest odpalana, gdy wszystko w danej scenie jest załadowane i przygotowane do działania wywołuję procedurę rozpocznij_intro(). Procedura ta jest prosta: instantiate oraz self.add_child preloadowanego intra, następnie kazanie obiektowi o adresie $Intro/AnimationPlayer
aby odpalił animację "Intro_anim", oraz na koniec - połączenie sygnału animacja zakończona tego obiektu do następnej procedury, intro_zakończone(), o tak:
$Intro/AnimationPlayer.animation_finished.connect(intro_zakończone)
Ponieważ edytor płacze, że nie ma takiej funkcji, tworzę func intro_zakończone(argument)
, która - co ważne - musi przyjmować jeden argument, ponieważ sygnał animation_finished przekazuje jeden argument (nazwę skończonej animacji). Nie jest on nam potrzebny w tej chwili, ale być musi. Tworzę kolejną nową scenę, MainMenu. W stanie początkowym wygląda ona identycznie, jak ostatni kadr intra (prawie - GPUParticles2D robiące za starfield będzie zresetowane, ale zachowanie tego elementu w identycznym stanie to o wiele za dużo zachodzu IMO). W króciutkiej animacji robi się miejsce na menus i pojawia się - ten menus właśnie. W drugiej animacji statek odlatuje z centrum ekranu, ma to symbolizować rozpoczęcie rozgrywki. Menu główne - jego część interaktywna - jest stworzone za pomocą wbudowanego obiektu ItemList
, który jako sygnał zwraca m.in. element, który został aktywowany oraz element, który został kliknięty myszą. ItemList nawet samo nasłuchuje klawiszy zdefiniowanych w projekcie jako GUI up/down/etc więc nie muszę dodawać dodatkowych komend i warunków. Podpinam jeszcze skrypt, w którym, nie, nie będziemy czekać co będzie naciśnięte, skrypt będzie delikatnie ruszał obecnym na ekranie statkiem, nadając trochę dynamiki i urozmaicenia i - mam nadzieję - stylu. Ruch będzie losowy i nieskończony, co znaczy że nie mogę używać AnimationPlayer. Wracając do intro_zakończone
, tam instancjuję i wrzucam świeżo zapisaną scenę menusa, odpalam muzykę menu, odpalam animację rozsuwania i podpinam sygnał zakończenia animacji do kolejnej funkcji. Trochę się tych schodów sygnałowych zbiera, ale jesteśmy już blisko.
śledzenie sygnałów w średnio skomplikowanej grze be like
W funkcji nasłuchującej sygnału zakończenia animacji w scenie menu głównego tym razem sprawdzamy jaki dostaliśmy parametr. Jeśli skończona została animacja pokazywania menu, podpinamy sygnał wybrania opcji do funkcji-wybieraka. Jeśli skończona została animacja odlatywania, wywoływana jest funkcja new_game, gdzie w końcu, w końcu aktywowana jest scena pierwszego poziomu, statek gracza, oraz interfejs.
Po sprawdzeniu że wszystko działa jak należy i poprawieniu błędów i sprawdzeniu że wszystko działa jak należy i poprawieniu błędów wreszcie mogę zacząć pracę nad faktyczną konstrukcją pierwszego poziomu, nad aranżacją wrogów itd. W scrollowanych strzelankach proces ten jest dużo bardziej uciążliwy niż w innych gatunkach - w platformówkach czy fpsach czy rpgach wystarczy skleić planszę z np. kafli, wrzucić postać gracza gdzieś i oddać kontrolę, niech sobie zwierdza; w SHMUPach natomiast ekran jest generalnie statyczny, ale wrogowie i tło się ruszają, najczęściej jednolitym tempem, ale zawsze poza kontrolą gracza, który nie może wyjść poza ramy ekranu. Można to zaimplementować na parę sposobów: na przykład można ułożyć planszę, postawić gracza na jednym końcu i oprócz ruchu z klawiatury przesuwać kamerę oraz gracza cały czas w jedną stronę. Albo z drugiej strony - kamera stoi sztywno, ale mapa jest przesuwana w stronę przeciwną. Efekt końcowy z grubsza ten sam, wybór dotyczy tego, która część obiektów oberwie dodatkowym pokomplikowanem obliczeń. Ja, korzystając z tego, że pierwszy poziom jest w kosmosie, znacznie uprościłem sobie sprawę: kamera nieruchoma, mapa nie jest składana w edytorze, jest tylko statyczną planszą rozmiaru ekranu, na której jest przesuwany starfield z particle generatora symulujący ruch. Wrogowie nie istnieją w ramach gry dopóki nie przyjdzie ich pora, o czym decyduje Bardzo Długi Match (tm).
nie taki match
Działa to tak, że plansza zawiera skrypt (no ba). W tym skrypcie punktem centralnym jest funkcja _process(delta)
, która, przypominam, jest odpalana przy każdej aktualizacji stanu gry. Ponadto są dwie zmienne: czasomierz, który dostaje zawsze (prawie) +delta oraz kontrola spawnu (nazwijmy ją, well, kontrola_spawnu), która zaczyna na 0. Wewnątrz _process jest wspomniany Bardzo Długi Match (tm) sprawdzający wartość kontroli spawnu. To wybiera, jaka następna grupa wrogów będzie wrzucona na pole gry. Kiedy to nastąpi kontroluję natomiast Dodatkowym warunkiem if, porównującym czasomierz z ustaloną przeze mnie wartością sekundową. Jeśli warunek zostaje spełniony, aktywuję procedurę spawnującą konkretnych wrogów w wybranym przeze mnie miejscu i, w wybranych przypadkach dodatkowe rzeczy typu zatrzymanie czasomierza dopóki wróg nie zginie, albo konkretne wzory ruchu, czy inne rzeczy. Indywidualna sekcja wygląda zatem, na przykład, tak
czasomierz += delta
match kontrola_spawnu:
(...)
21:
if czasomierz > 65.0
spawnuj_skrzydła(Vector2(1300, 300), 5)
spawnuj_klatki(Vector2(1300, 500), 1)
kontrola_spawnu +=1
Powyższy kod działa zatem następująco: czasomierz zostaje zaktualizowany o czas jaki upłynął od poprzedniego wywołania. Kontrola_spawnu ma wartość 21. Sprawdzamy, czy od początku planszy upłynęło 65 sekund. Jeśli tak, wrzucamy 5 wrogów "skrzydło" nieco za prawą krawędzią ekranu (zaraz sami wlecą na pole gry), odrobinę powyżej połowy w pionie, oraz 1 wroga "klatkę", tak samo za prawą krawędzią ale w 2/3 ekranu w pionie. I co ważne - kontrola spawnu zostaje kopnięta o 1 dalej, w przeciwnym razie oba warunki (czasomierz i kontrola spawnu) byłyby prawidłowe w każdej jednej następnej klatce gry, więc w każdej jednej następnej klatce gry wrzucone byłoby kolejne 3 skrzydła i 1 klatka. Efekty wyglądają śmiesznie, ale już po paru sekundach komputer zaczyna się dławić od kilku tysięcy obiektów nagle obecnych na polu gry.
brrrt
Zatem teraz zostaje ni mniej ni więcej jak ułożyć utworzonych już wrogów w planszę, która będzie fajna. Bagatela, co nie? Na ten moment nie mam nawet pojęcia, jak będzie długa; szacuję, że potrwa "kilka minut jakoś", ale to zależy. Na razie mój cykl dniowy polega na dodaniu kolejnej grupy wrogów, odpaleniu gry, pograniu i pograniu i pograniu, a potem cyk może trochę więcej wrogów w tej czy tamtej fali, może wszystko przesunąć o kilka sekund tu czy tam, może wywalić sekcję i skleić od nowa. Zależy, ważne żeby było fajnie. Co jakiś czas ujawnia się kolejny magiczny bug, który przerywa pracę do naprawy - a to jedna z broni gracza nie wyświetla się prawidłowo, a to tarcza znika mimo że jeszcze 1 poziom pozostał, a to jeszcze co innego. Dzieje się?