Devlog lamera remonstered saga: nowe perspektywy
Witam witam. Lata temu pisałem trochę o dłubaniu w tworzeniu własnych giereczek z perspektywy kompletnego dyletanta, który miał jedynie doświadczenie w BASIC-podobnych językach. FreeBASIC zaczął jednak już nie wystarczać do moich potrzeb jeszcze zanim zacząłem próby implementacji drzewek BSP, więc po pewnych deliberacjach zdecydowałem się na ogarnięcie C++. Co też zrobiłem... yyy, znaczy, przeczytałem bardzo uważnie najnowszy podwówczas C++ Primer kilka razy, robiąc przykłady i zadania w vstudio. Zobaczywszy jednak ile użerania się trzeba na zrobienie w C++ czegokolwiek bardziej złożonego niż pasjans czy inny tetris dałem się, muszę przyznać, pokonać czystemu lenistwu. Mimo to do aktywnego dłubania w kodzie koniec końców wróciłem i chciałbym tym wpisem-luźną gawędą przekonać was, że naprawdę nie ma się czego bać i niezależnie od doświadczenia w temacie czy talentu każdy może coś skleić fajnego.
Prehistoria
Rok 2020. Przyjrzałem się Unity. Zacząłem tłuc tutoriale w unity, większość podstaw była ogarnięta już chyba circa 2021. Ze stworzenia jednak czegoś konkretnego w U nie wyszło nic, głównie z braku konkretnego pomysłu do zrealizowania. Wiedziałem niby, że chciałbym zrobić jakąś 2D strzelankę z gatunku kosmicznych, ale to o wiele za mało żeby można było skleić coś konkretnego; zacząłem od d...ziwnej strony, to jest zacząłem klepać sprajty w programie voxelowym, zakładając że pozwoli mi to łatwo wygenerować rotacje sprajtów bez potrzeby od razu ogarniania całego blendera
Well, efekty były tragiczne wizualnie. Zgarnąłem jeszcze picoCAD na podobne potrzeby, ale picoCAD raczej słabo nadaje się do robienia czegoś więcej jak śmieszne wirujące lores gify pojedynczego obiektu. W międzyczasie jeszcze wychodziła nowa wersja Unity, gdzie paru rzeczy trzeba było się nauczyć od nowa, plus Unity jednak jest raczej zorientowane bardziej na rzeczy 3D niż 2D, przynajmniej takie odnoszę wrażenie.
Rok 2023. Unity odwaliło to, co odwaliło, stawiając przyszłość platformy pod ogromnym znakiem zapytania. Rozpuściłem wici googlując i pytając się tu i tam, co w zasadzie dostępnego normalnym ludziom jeszcze nadaje się do robienia giereczek a nie jest małym U(nity) albo dużym U(nreal). Padło na Godot.
Pierwsze iskry inspiracji
Godot powierzchownie nie różni się wybitnie od Unity, ot takie IDE zorientowane na giereczki. Ma własny bardzo prosty do ogarnięcia język skryptowy gdscript (alternatywnie C# też można używać, jeśli ktoś woli), od niedawna też nawet coraz bardziej umie 3D. Mnie zaintrygował paradygm tworzenia rzeczy oparty na nodach i scenach. Działa to tak, że każda jedna cegiełka tworząca grę, czyli sprajt, model, hitbox, dźwięk, nawet ścieżka to "Node". Nody organizowane są w tzw. drzewka. Ot, chociażby wrogi statek można złożyć tak:
Taki układ mówi silnikowi, że mamy do czynienia z pewnym obszarem w przestrzeni 2D, który posiada własną teksturę, kształt do wykrywania kolizji, oraz może odtwarzać jakiś dźwięk modyfikowany aktualną pozycją obszaru w oknie gry (tj jak jest bliżej prawej krawędzi, odtwarzany jest bardziej w prawym głośniku itp.). Do każdej z nich (albo i żadnej) można podpiąć plik skryptu dyktujący parametry, zachowania i reakcje. I teraz fajna rzecz: dowolną grupę nod zorganizowaną w drzewko można zapisać jako Scenę. Zapisaną scenę można wykorzystać w dowolnym innym miejscu projektu, dowolną ilość razy. Na przykład, klejąc drzewko jednej misji można podpiąć do niego (z edytora lub skryptem) pięć razy scenę myśliwca, co wyniknie pięcioma indywidualnymi - niezależnymi od siebie myśliwcami obecnymi na polu gry, każdy z nich posiadający własny Area, Sprite, CollisionShape, Audio. System ten jest bardzo ciekawy (dla mnie) i pozwala na kreatywne sztuczki z organizacją i granulacją logiki gry (choć ja aktualnie raczej nie będę potrzebował mocno kombinować).
Klepałem sobie zatem tutoriale różne i zaznajamiałem się z powierzchownymi przynajmniej cechami tego silnika, jednocześnie patrząc i regularnie gadając przez discorda ze znajomym, który sobie regularnie dla samej radości klepania tworzy w gamemakerze 2hu (i nie tylko) gierki na różne game jamy i patrząc co on i inni wrzucają w kanale #gamedev jego serwera. Aż pewnego dnia w połowie lutego 2024 doznałem olśnienia. Dosłownie efekt gromu z jasnego nieba; położyłem się do łóżka, po czym zaraz wstałem, wziąłem zeszytonotatnik i zacząłem pisać, rysować, szkicować, planować, układać. Następnego dnia miałem może nie zaraz jakiś wielki design doc, ale dostatecznie dużo informacji, żeby na poważnie zabrać do tworzenia gierki. Nawet rzuciłem wspomnianemu znajomemu że "planuję coś szybkiego zrobić, ze dwa tygodnie zejdzie i gotowe". To było cztery miesiące temu, a ja jeszcze nie mam stage 1.
heh
Projekt: wstęp: intro: początek
Co to zatem za gierka? SHMUP skrollowany w poziomie. Gracz z lewej, wrogowie z prawej. Trochę dziedzictwa R-Type, choć nieprzesadnie wiele, ale bliżej tej serii niż Gradiusowi czy Tyrianowi; nie jest to danmaku. Cztery plansze, każda w innym środowisku, ze swoimi 4-5 rozdajami wrogów i specjalnymi problemami. Pięć broni gracza, każda z 3 poziomami mocy.
Od strony konstrukcyjnej: silnik - Godot. Grafika w 99% rysowana przeze mnie w Aseprite, za wyjątkiem czcionki oraz animacji eksplozji, które pobrałem z zasobów udostępnionych przez różnych twórców w ramach Creative Commons. Dźwięki z początku sporo pobranych z freesound, również z preferencją CC, ale coraz więcej z nich jest stworzone znowuż przeze mnie w jsfxr albo Jeskola Buzz. Muzykę planowałem przekopać różne stare archiwa trackerowe, ale znajomy zaoferował że skomponuje kawałki, więc problem z głowy. W momencie zaczynania pracy mam nie wiem, kilka godzin praktycznego doświadczenia w godocie, głównie robienia tutorialów online, w tym tych dostarczonych przez godot i może parę godzin więcej czytania generalnie o gdscripcie. Zero doświadczenia w reszcie programów. Rysowanie myszką. Na projekt poświęcam koło godziny dziennie, życie.
Pierwsze kroki
Najgorsze są pierwsze kroki tworzenia gierki. Znaczy późniejsze też nie są za dobre, ale tutaj trzeba sporo roboty zanim będzie cokolwiek nadającego się do testowania i działania. W godocie przynajmniej prototypowanie idzie nawet względnie szybko, dzięki systemowi scen i drzewek. Najpierw co prawda trzeba by jakoś rozplanować stukturę gry, ale u mnie w miarę postępów wykrystalizowała się ona następująco:
- na szczycie (u korzeni?) drzewka noda rdzenna - nazwijmy ją Core. Noda ta zawiaduje generalną logiką gry i zawiera rzeczy których ciągłość w toku gry jest kluczowa - punktację, pancerz gracza, aktualne uzbrojenie. Również HUD, a gdy dojdę do tego momentu, menusy
- do Core po rozpoczęciu rozgrywki zostaje podpięta komendą w skrypcie sterującym scena z poziomem (pierwszym, a po ukończeniu - wypięta i zastąpiona następnym itd). Ta noda zawiera skrypt logiki poziomu: spawnowanie wrogów, wrogowie będą do niej konkretnie podpinani, odtwarzanie muzyki, manipulacja tłem, manipulacja dekoracjami.
- dodatkowe pytanie - shmup z reguły zawiera skrolowanie, jeśli gracz nie ma na nie wpływu, to jak to lepiej zorganizować - czy ruszać statek gracza w prawo o stałą ilość co aktualizację stanu gry, czy ruszać kamerę, a statek gracza przykuć do tej kamery, czy może zostawić oba nieruchome, a ruszać wrogów i dekoracje w lewo? Każde z tych rozwiązań dość skomplikuje kod w którymś miejscu i nie ma tu opcji idealnej. Wydaje mi się, że w ramach gry mam dostateczny framework żeby wybrać dowolną opcję zależnie od potrzeb; stage 1, osadzony w kosmosie, poradzi sobie statycznym graczem i kamerą, jedynie puszczjąc wszystko inne w lewo dla symulacji ruchu
- do sceny z poziomem po załadowaniu podpinana jest noda statku gracza. Noda ta przemieszcza się zgodnie z sygnałami odbieranymi z Core, zawiera hitbox, zawiera sprajty, efekty dźwiękowe powiązane z graczem, oraz zawiera punkt w którym generowane będą pociski gracza
Zanim jednak taki framework będzie obecny i będzie działał, potrzebne będzie sporo roboty w kanalizacji. A żeby kanalizację zacząć, trzeba mieć z czym pracować - mieć jakieś proto assety, choćby jakieś abstrakcyjne. Zatem spędziłem jakiś czas rysując i rysując i rysując aż wyrysowałem akceptowalny sprajt statku gracza oraz kompletny placeholder wroga w postaci bardzo grubej rakiety-cygara. Można było dłubać.
Ruszanie statkiem gracza jest trywialnie proste. W opcjach projektu trzeba zdefiniować klawisze odpowiadające czterem kierunkom, nadać im aliasy (np. "ruch_dol". W skrypcie - chwilowo w skrypcie podpiętym pod sam statek - definiujemy 2-wymiarowy wektor-zmienną zawierającą kierunek ruchu. W głównym procesie dajemy szereg ifów - "If [alias] jest wciśnięty", które dodają do wektoru kierunku 1 albo -1 w osi X albo Y. Mała normalizacja, żeby ruch po przekątnej nie był szybszy od ruchu w osi. Wtedy można do obecnej pozycji dodać śmieszną formułkę zwaną "kierunek * delta * stała". Kierunek - no to nasz właśnie uzyskany wektor ruchu. Stała - to jest pożądana szybkość ruchu statku w pikselach na sekundę.
Delta - to zmienna specjalna. W dużym uproszczeniu chodzi o to, że godot cały czas wywołuje funkcje _process oraz _physics_process w każdym czynnym skrypcie, któy je posiada. _physics_process jest wykonywane stałą (w miarę możliwości procesora), równomiernie rozłożoną ilość razy na sekundę - nie jest na szywno przypięty do kalkulacji fizyki, po prostu pakuje się tam rzeczy, co do których chcemy mieć pewność że będą wykonane dokładnie X razy każdej sekundy; _process jest wywoływany tak często, jak jest to możliwe między kolejnymi wywołaniami i chyba też dyktuje odświeżanie rysowania pola gry. Obie te funkcje otrzymują od silnika parametr zwany delta, który zawiera czas w sekundach (no, drobnych ułamkach sekundy) od ostatniego wywołania. Delta jest bardzo pomocna w każdej sytuacji, gdy chcemy, żeby coś się działo w stałym tempie, niezależnie od tego na jak mocnym albo słabym komputerze program zostanie uruchomiony. Wrzucenie jej do formułki powyżej sprawia, że niezależnie czy będziemy mieli 20, 40, czy 500 klatek na sekundę, statek będzie się ruszał tak samo szybko.
Zatem jest Scena statku gracza: Area2D ze skryptem nasłuchującym kliknięć strzalek na klawiaturze. Można ją od razu przetestować: run scene w edytorze i pokazuje się okno rozmiaru ustawionego w opcjach programu (domyśłnie 1280x720 bodaj), jednolite szare poza sprajtem gracza w kącie ekranu (pozycja 0,0). Wciskam strzałki, statek rusza się, super.Niestety wylatuje sobie spokojnie poza okno, a tego nie chcemy. W skrypcie pobieramy rozmiar ekranu przy uruchomieniu statku (bo może się jeszcze zmienić, a co) i zakładamy klampy (clamp) żeby pozycja nigdy poza rozmiar ekranu nie wykroczyła. Jeszcze trochę tuningowania parametru szybkości, kwestia gustu. Statek wydaje się trochę za duży na okno, nie będę przerysowywał, klikam root, w parametrach po prawej stronie zmieniam skalę z 1 na 0.8. Super. Kilkanaście minut stukania i już coś działa!
Teraz placeholder jakiegoś wroga. W shmupach generalnie AI nie potrzeba żadnego, więc nasza rakieta-cygaro będzie po prostu leciała w lewo aż wyleci poza ekran. Nowa scena, Area2D, sprajt, kształt kolizji dopasowany w miarę (ale nieprzesadnie) do sprajta. Skrypt pod korzeń sceny (Area2D). W skrypcie zero nasłuchiwania, po prostu każdego _process przesuwamy w lewo (-transform.x) razy delta razy prędkość. Żeby ułatwić sobie tuningowanie przy deklaracji zmiennej prędkości zapisuję @export, co sprawia, że mogę ją sobie zmieniać z poziomu edytora zamiast za każdym razem szukać w skrypcie. Czy działa?
Żeby to przetestować muszę mieć jakieś wspólne pole, na którym gracz i wróg byliby równorzędni; w przeciwnym razie ruch gracza przesuwałby również rakietę, albo na odwrót. Nowa scena, test_stage aka pole testowe. Przeciągam plik sceny gracza na pole gry widoczne w edytorze, gdzieś po lewej. Pojawia się mój statek. Przeciągam plik sceny wroga na pole gry, gdzieś po prawej. Pojawia się rakieta. Run scene. Gracz lata, rakieta też, oczywiście jak się zetkną, nic się nie dzieje, bo i nie powiedziałem silnikowi co ma się dziać. Jeszcze zauważyłem, że jak rakieta wyleci poza ekran, dalej jest kalkulowana i leci sobie w nieskończoność. Nie mogę jej zamknąć w oknie bo się po prostu stuknie o lewą krawędź okna i tam zostanie. Tutaj z pomocą przychodzi specjalna noda, która emituje "sygnał" tam gdzie jej wskażę, gdy dany obiekt wejdzie i/lub wyjdzie z ekranu. Daję tę nodę do drzewka rakiety, podpinam sygnał "wyszedłem z ekranu" do skryptu rakiety, daję queue_free(), to jest uniwersalną komendę wyrzucającą daną nodę i wszystko co jej podlega przy najbliższej okazji.
Dobra, ale co z kolizjami? Tworzę dwie warstwy kolizji, player i enemy. Noda gracza jest layer-Player i mask-Enemy. Noda wroga - na odwrót. Na konsolę ma wyjść wiadomość debugowa, że kolizja została wykryta. Odpalam pole testowe, wlatuję graczem na rakietę, patrzę na konsolę, nic się nie dzieje. Hm. Hm. Googlam robienie kolizji w godocie następne parę godzin i moja konfuzja jedynie rośnie, ale zrozumiałem (?) że to dlatego, że obie konstrukcje powinny być nie obszarami, a ciałami (Rigid/KinematicBody) a w ogóle to wszystko panie nie w _process a w _physics_process i nie tak. No to przeklejam kod do _p_p, podpinam sygnały, nie position +/- a move_and_collide, zeruję grawitację żeby mi na ziemię nie spadło, odpalam i...
Well... to jest zdecydowanie i kategorycznie... wynik. Ale nie ten, którego szukałem.
C.D.N.