[PL] IGK 2013 Compo - o naszej grze

May 5, 2013, 2 a.m.

(collaborative post by Gynvael, oshogbo & xa)
W Siedlcach odbyła się co roczna konferencja Inżynierii Gier Komputerowych (IGK) - tym razem była to jubileuszowa, 10siąta, edycja. I jak co roku było teamowe compo - 6.5h (ostatecznie przedłużone o jeszcze godzinę) na zrobienie gry na zadany temat w maksymalnie 4 osoby. Tematem było "Artillery Game" (więcej poniżej), a alians Vexillium + Dragon Sector (reprezentowany przez 6 osób na IGK) wystawił dwa teamy po 3 osoby (pod nazwami "Bad Sectors" oraz "Dragons"). Ostatecznie obu naszym teamom udało się stworzyć w pełni działające i ukoączone gry (bugi przemilczmy ;p), i zając pierwsze oraz piąte miejsce na 12 drużyn. W niniejszym poście chcieliśmy coś napisać o grze naszego teamu z pierwszego miejsca (w składzie: code: gynvael+oshogbo, gfx: xa), a także udostępnić grę w wersji post-compo (razem ze źródłami na MIT i grafiką na CC) oraz port gry na HTML5 który zrobił Xa w ciągu ostatnich paru wieczorów. Miłej lektury (tak, są obrazki) :)

TL;DR

Wersja post-compo Xeno Invasion (win32 bin + src; działa też pod GNU/Linux, patrz readme):
xenoinv_postcompo.zip (5.5 MB)

Port Xeno Invasion na HTML5 by Xa:
Click to play :)
Projekt na GitHub.

The Game

(by oshogbo)

Tematem compo było "Artillery Game" z kilkoma punktami na które jury miało zwracać uwagę podczas oceniania:

- Tryb multiplayer.
- System achievementów.
- Niszczenie terenu na wiele różnych sposobów.
- Jakaś inna forma "energii" oprócz HP.

Postanowiliśmy zaimplementować multiplayer w formie co-op (a więc inaczej niż w klasycznych grach typu "artillery") - gracze mają wspólnego wroga, którego muszą (w miarę szybko) eliminować. Tak właśnie narodził się główny cel naszej gry ("Xeno Invasion") którym jest przeżycie jak najdłużej na planecie atakowanej przez dziwne stworki.



Nie przypadkowo gra kojarzy Ci się z takimi tytułami jak:
- (wersja Gynvaela) Scorched Earth czy Scorched Tanks
- (wersja oshogbo)  Liero, Wormsami czy z Lemingami
bo właśnie na tych grach się wzorowaliśmy.

W ręce graczy oddaliśmy dwa działa które mają służyć im do obronny. Działa posiadają wspólną ilość amunicji, która regeneruje się co pewien okres czasu (jeden nabój co dwie sekundy), z której gracze powinni roztropnie korzystać bo łatwo jest ją zużyć całą. Co kilka strzałów nad bazą pojawia się ikonka sygnalizująca specjalny strzał. I tak wyróżniamy:
- normalny pocisk
- "silny" pocisk
- cluster bomb
- "bardzo silny" pocisk (w kodzie oznaczony jako SHOT_FIRE)
- carpet bomb (w kodzie oznaczony jako SHOT_LASER)
- oraz jeszcze jeden typ, których niech zostanie niespodzianką dla wytrwałych testerów



Nasza gra jest wyposażona w "bardzo rozbudowany" system achivmentów (są ich aż cztery), jednakże pozostawimy Wam przyjemność odkrycia ich.



Najważniejszym ficzerem gry są wrogowie (alieni) i ich logika. Stworzyliśmy świat w którym nasze stworki przekształcają się w element podłoża aby ułatwić swoim kompanom dojście do ich ostatecznego celu. Kolejną ciekawostką na temat alienów jest to że zostają po nich szczątki jeżeli nie zniszczyło się ich całkowicie.
Jest zmienny wiatr który znacząco wpływa na tor lotu pocisków.

Niestety wielu ficzerów, które sobie zaplanowalismy, nie udało się nam zrealizować w tak krótkim czasie są to między innymi:
- ożywające potworki (te, które wcześniej zmorfowały się w element podłoża)
- latające elementy
- sklep z bronią (zamiast tego różne rodzaje amunicji są przydzielane wg. pewnego schematu)
- dodatkowe bazy przyśpieszające przyrost amunicji
- dodatkowe mapy

Wydaje nam się że stworzyliśmy ciekawą grę, której niestety wciąż brakuje balansu (tutaj podziękowania dla Krzyska K za testy i rady), dla starszych i młodszych graczy :). Zapraszamy do testowania i komentowania.



oshogbo's point of view

Podział pracy w naszym zespole był dość prosty Gynvael odpowiadał za część logiki, Xa jako grafik nie potrzebował specjalnego przydziału zadaą, a ja odpowiadałem za część "przezentacyjną" (dźwięk, GUI, obsługę eventów, etc.), czyli za tą żmudniejszą część. Siłą rzeczy moja opowieść nie będzie tak techniczna jak Gyna.

Z ciekawostek mogę przytoczyć natomiast zdarzenie które spotkało nas w przed wieczór COMPO - mianowicie podczas konfiguracji prostego frameworka napisanego przez Gyna na potrzeby innego COMPO (można przeczytać o nim troszkę niżej), postanowiliśmy przejść z rzutowania perspektywicznego na rzutowanie ortogonalne. Postanowiliśmy także że będziemy pisać na system Windows - ja na co dzieą jestem użytkownikiem GNU/Linux"™a więc wymagało to ode mnie konfiguracji kilku narzędzi.
Po otrzymaniu frameworku od Gyna zmieniłem funkcję odpowiedzialną za konfigurację OpenGL i dodałem wyświetlenie testowego kwadratu. W koącu kompiluje, widzę jak make pracuje i w koącu odpalam program a tu... czarne okno. Niby nic strasznego, więc wracam do części odpowiedzialnej za tworzenie okna patrzę, szukam, zaglądam do dokumentacji wygląda że wszystkie parametry są dobre. Naturalnym krokiem pewnie każdego programisty teraz było by dodanie jakiś debug stringów, i taki też był mój pomysł. Dla mniej doświadczonych czytelników przedstawię poglądowy kod:
int
main()
{
 ...
 create_window();
 puts("1");

 while(1) {
   ...
   puts("2");
   draw_quad();
   ...
 }
 ...
}

Kolejna kompilacja, make pracuje, czarny ekran, pustka w konsoli.

Muszę przyznać że problem zajął nam dłuższą chwile (dobrze że to nie trwało podczas COMPO). Drogi Czytelniku czy Ty już wiesz co było problemem? No cóż zagadka jest niestety z serii "dziadek ma 2 wnucząt, ile lat ma babcia?", bo problemem był zegar systemowy. Przechodząc z GNU/Linux"™a na Windows"™a nie zauważyłem że zegar systemowy przestawił się o 1h wstecz. Gyn miał poprawnie ustalony czas więc posiadałem exe"™ka "z przyszłości", przez co make kompilował zmieniane pliki ale ich nie linkował do exe"™ka (bo ten był nowszy niż wynikowe pliki obiektowe).
Różnice czasowe wynikają z tego że GNU/Linux przechowuje czas w UTC natomiast Windows przechowuje czas lokalny. Oba systemy zapisują czas na płytę główną do mikroprocesora (przyjmijmy dla uproszczenia że jest to mikroprocesor) podtrzymywanego przez małą baterie, aby po wyłączeniu komputera czas był dalej odliczany. Po włączeniu komputera system pobiera z chipu zapisany czas. GNU/Linux zapisał czas w UTC Windows go potraktował jako czas CEST. I ot cała zagadka.

Co do samego konkursu to, w tym roku był na bardzo wysokim poziomie. W konkursie wzięło udział 12 zespołów, a zróżnicowanie gier było zaskakujące: od "krzykaczy do telefonu" (via warsztat.gd - http://www.youtube.com/watch?v=n1aUMDdeBT4) poprzez gry 2D w (pseudo)konsoli, gry 2D sterowane kilkoma urządzeniami (2 myszki, 2 komórki z Windows Mobile Phone) po gry 2D z modelami 3D. Wiele drużyn postanowiło użyć silnika fizycznego box2D, wydaje mi się że to spowodowało że ich gry "zgubiły" się w tłumie. Bardzo ciężko było podjąć decyzje która gra była najlepsza i chyba wiele osób, tak jak ja, przetrzymywało kartkę zastanawiając na co zagłosować (publiczność tez miała swój udział w ocenianiu).

Niestety poza miłymi aspektami imprezy były też pewnie małe wpadki techniczne. Na przykład prezentacja gier w tym roku nie przebiegała zbyt sprawnie (rzutniki trochę nie chciały współpracować, a potem, podczas głosowania, zamiast pełnych screenshotów z gier były wyświetlane ich miniaturki; dop. Gyn).

Gynvael's point of view

Czyli studium przypadku jednego błędu, "hybrydowe" konstruowanie obrazu, oraz warstwa ognia.

Pisanie gry w 7h godzin rządzi się własnymi prawami - nie ma tu miejsca na tworzenie pięknego, elastycznego kodu, unit testów, stosowanie optymalnych rozwiązaą czy korzystanie ze wszystkich nam znanych dobrych praktyk tworzenia oprogramowania. Zamiast tego implementuje się to, co można zaimplementować najszybciej i będzie wystarczająco dobre.
Z drugiej strony nie można też popełnić za dużo błędów - każda minuta spędzona nad debuggerem to minuta podczas której nie można zaimplementować jakiegoś ficzera, lub, co gorsza, dokoączyć jakiegoś kluczowego fragmentu kodu.
W tym roku szczęście nam dopisało i udało się błędów popełnić zadziwiająco mało, a te które popełniliśmy (i które poprawiliśmy w wersji post-compo) albo nie objawiły się podczas prezentacji, albo nie były na tyle krytyczne by znacząco psuły jakość produkcji.

W zasadzie w mojej części kodu był tylko jeden błąd któremu musiałem poświęcić więcej uwagi niż szybki rzut okiem na objaw + fix. Zanim przejdę do samego błędu zacznę od tego jak się objawiał:
Całą gra działała OK do momentu wystrzelenia pocisku typu "cluster bomb" (to te pociski rozrzucające inne pociski podczas lotu). Po jego wystrzeleniu i wyrzuceniu kilku bomb obcy nagle, jak na komendę, zatrzymywali się w miejscu i przestawali w ogóle się ruszać. Czasem po jakimś czasie gra się w ogóle crashowała w jakimś dziwnym miejscu.

Jak się okazało problem leżał w... zwiększaniu rozmiaru wektora (std::vector) zawierającego pociski. Ale po kolei..

Wszystkie istniejące w danym momencie pociski były w kontenerze typu std::vector<mob_bullet*> nazwanym po prostu bullets. W każdej klatce w funkcji gynvael_BulletsLogic następowało (m.in) przejście po całym wektorze, przesunięcie pocisku do nowej pozycji, sprawdzenie kolizji, ale również wywołanie ewentualnej dodatkowej funkcji (via pointer na funkcje) iterrate na danym konkretnym pocisku. Każdy pocisk typu cluster korzystał w tym miejscu z funkcji bulletproc_cluster która sprawdzała czy czas już wyrzucić kolejny wybuchowy odłamek. Oczywiście jeśli FPS był niski być może trzeba wyrzucić kilka bomb od razu, wiec pojawił się tam również while który miał tego dopilnować. Funkcja wyglądała mniej więcej tak:

void bulletproc_cluster(Mob_Bullet *n, float t) {
 (void)t;

 // n->userf[0] to moment wystrzelenia pocisku
 // (w sekundach od rozpoczęcia gry)
 int diff = (game_time - n->userf[0]) * 10;

 // n->useri[1] to ilość wyrzuconych bomb przez pocisk
 while(n->useri[1] < diff) {
   n->useri[1]++;

   Mob_Bullet *b = new Mob_Bullet;
   ...
   b->x = n->x;
   b->y = n->y;
   ...
   bullets.push_back(b);
 }
}

Å»eby zobaczyć błąd trzeba zdać sobie sprawę jak działa std::vector - jest to po prostu opakowana w klasę, dynamicznie zaalokowana tablica danego typu. Jeśli koączy się w niej miejsce na nowe elementy, to alokowana jest nowa tablica, stare elementy są do niej kopiowane, po czym stara tablica jest uwalniana (typowe realloc).
W przypadku powyższego kodu oznacza to mniej więcej tyle, że po wywołaniu bullets.push_back(b) pointer na główny pocisk n może przestać być poprawny - tj. będzie nadal wskazywał na pamięć "starej" tablicy związanej z wektorem, która przez push_back (i wyczerpanie miejsca) została zwolniona i zastąpiona nową (wspomniany realloc)..
Tak więc w kolejnym obiegu tej pętli odwołania do pól useri[1] oraz x i y będą po prostu nieprawidłowe (typowe use-after-free). Do tego wewnątrz pętli jest new (alokacja), która może wskoczyć akurat w miejsce "starej", zwolnionej, tablicy - czyli n->useri[1]++ może zaczął pisać po obiekcie lub wręcz po strukturach heapu, a to się oczywiście nie może dobrze skoączyć :)
Szczęśliwie bug udało mi się zlokalizować bardzo szybko (dla ciekawych - użyłem metody comment out).

Jeśli chodzi o sam fix, to wygląda on następująco:

void bulletproc_cluster(Mob_Bullet *n, float t) {
 (void)t;

 int diff = (game_time - n->userf[0]) * 10;

 int counter = n->useri[1];
 int nx = n->x;
 int ny = n->y;

 n->useri[1] = diff;

 // DONT'T ADD ANYTHING n-> HERE!!!

 while(counter < diff) {
   counter++;

   Mob_Bullet *b = new Mob_Bullet;
   ...
   b->x = nx;
   b->y = ny;
   ...
   bullets.push_back(b);
 }
}

Pewnie część z was skrzywi się widząc powyższy komentarz (i samą metodę), ale jak wspominałem na początku - compo rządzi się własnymi prawami :)

Poza bugami chciałem jeszcze wspomnieć o podejściu do konstruowania obrazu które zastosowaliśmy już drugie compo z rzędu - czyli o hybrydzie "old schoolowej" grafiki pixelowej/bitmapowej z "wektorowym" podejściem znanym z OpenGL (jak wiecie, lubię łączyć zalety różnych rozwiązaą).

Pomysł jest prosty - finalny obraz jest składany z layerów, z których niektóre są bitmapami i rysuje się po nich per-pixel, a niektóre są po prostu zestawami prymitywów OpenGL'owych (zazwyczaj quadów). Oczywiście te pierwsze przed samym wyświetleniem są konwertowane (co klatkę) do tekstury, która następnie jest nakładana na quad o wielkości całego ekranu.

Layery bitmapowe umożliwiają robienie kolizji per-pixel, uszkadzanie terenu (które u nas de facto sprowadziło się do ustawienia kanału przezroczystości (alpha) na 0 dla zniszczonych pikseli), czy uzyskiwanie pewnych efektów do których normalnie trzeba pixel/fragment shader zaprzęgnąć.

Natomiast OpenGL daje "gratis" skalowanie i rotacje, oraz doskonale nadaje się do wszelkich (animowanych) backgroundów i GUI.

Jeśli chodzi o naszą grę, to finalny obraz jest składany mniej więcej w następujący sposób (click to zoom):



Jedną z rzeczy na powyższym grafie, na którą chciałem zwrócić szczególną uwagę, jest mechanizm ochrzczony przeze mnie mianem "fire plane" (warstwa ognia).

Jest to w zasadzie (mój ulubiony) automat komórkowy na płaszczyźnie o wielkości obrazu, z zaimplementowanym efektem płomienia (w uproszczeniu: k[x,y] = avg(k[x,y], k[x-1,y+1], k[x, y+1], k[x+1, y+1]), dla k[x,y] będącego liczbą naturalną od 0 do 255), który jest iterowany do 20 razy na sekundę (w wersji compo było to co klatkę). Następnie co klatkę "obraz" z automatu jest renderowany używając palety ognia (RGB + alpha, która jest tym niższa im niższa wartość komórki).

Mechanizm ten daje "gratis" całkiem zgrabne wybuchy - wystarczy "narysować" cokolwiek na płaszczyźnie automatu (technicznie nie różni się to niczym od rysowania po layerze/canvasie/surface/zwał-jak-zwał typu 8-bit grayscale) a to elegancko spłonie.

Poniżej znajduje się przykład z kolejnymi iteracjami (stanem początkowym było koło o promieniu 12 pixeli):



Oraz w formie animacji (po lewej fire plane, po prawej po nałożeniu palety):



I w sumie tyle :)

P.S. z uwagi na pewien bug (tj. zapomniałem czyścić ostatniego wiersza warstwy ognia) można było na stałe "podpalić" podłoże ;p (co zresztą widać na jednym ze screenów wyżej).

P.S.2. Warstwę ognia używaliśmy też w "Escape" - naszym compo-entry z poprzedniego IGK.

Xa's point of view

Na compo miałem jedno i tylko jedno zadanie, - stworzyć grafikę do naszej gry w kilka godzin. Natomiast po samym compo zabrałem się za portowanie naszej gry do HTML5, ale o tym później.

Grafika musiała być prosta by mogła powstać w jak najkrótszym czasie. Padło na pixelart (dobry oldshool nie jest zły), co też przy okazji ułatwiło tworzenie animacji. Do stworzenia grafik posłużył mało znany ale za to świetny program graficzny do pixelartów - aseprite http://www.aseprite.org/. Interfejs wygląda jak by aplikacja nie była rozwijana od czasów rewolucji francuskiej, ale to tylko pozory, gdyż jest to zabieg celowy a sam program jest rozwijany od 2001 aż po dzisiejszy dzieą.

Menu gry, oraz część grafik z HUD zostały stworzone w Inkscape. Natomiast tam gdzie było trzeba podciągnąć kolory czy poprawić kontrast poszedł w ruch znany i lubiany GIMP.



Cała gra składa się z kilku warstw:

Warstwa nieba - jest na niej rysowane jedynie statyczne tło nieba.
Bazy z kolumna - Składa się z dwóch elementów samej bazy, oraz podstawy, która jest niewidoczna aż do momentu zniszczenia terenu bezpośrednio pod bazą.
Teren - Skrawek ziemi, który możemy dowolnie niszczyć.
Twarda skała - na te pixele nie ma mocnych.
HUD - Informacje o stanie gry.

(warstw od strony technicznej jest więcej, ale o tym więcej w poście Gyna)

Pierwszym etapem tworzenia grafiki było przygotowanie placeholderów - grafik, które miały identyczną wielkość jak docelowe grafiki. Na placeholderach zazwyczaj znajduje się jakaś drobna graficzna informacja np. numer klatki i spartaąski zarys budowli składający się z kilku linii. Głównym zadaniem pleceholderów było dostarczenie materiału do pracy programistom, lub jak kto woli - spokój dla mnie od częstych "xaaa zrób mi grafikę X", "xaaa na kiedy zrobisz grafikę X?", "xaaa, co z grafiką X?", itd.



Mając wszystko rozplanowane, z gotowymi placeholderami, robienie grafik po za "xa to jest brzydkie", było dość monotonne, no może po za jednym kawałkiem:
Gyn poprosił mnie pod koniec compo o grafikę silników odrzutowych, które miałyby być przyczepione do spodu bazy, której bronił gracz. Grafika ta miał na celu ukrycie faktu iż było można zniszczyć ziemię na której baza stała a tym samym niechcący "zletwitować" całą bazę. Dumny ze swoich silników odrzutowych wrzuciłem je w repo, Gyn podpiął je do gry i zaczął testować. Trochę miny nam zrzedły gdy okazało się że co prawda baza teraz miała dobry pretekst by unosić się w powietrzu, ale za to stworki atakujące bazy nie miały żadnego usprawiedliwienia by atakować niewidzialną kolumnę znajdującą się idealnie pod latającą bazą. Przyczyną tego była kolizja stworków na osi X która nie uwzględniała położenia bazy na osi Y, więc z punktu widzenia stworków baza miała wysokość całego ekranu. Czasu było mało a rozwiązanie dość oczywiste, niewidzialną kolumnę trzeba było zrobić widzialną również dla gracza - zamiast silników trzeba była narysować słusznych rozmiarów kolumnę. Pare minut później do repozytorium poszedł zaktualizowany plik o wdzięcznej nazwie "engines.png" - znak zażegnanego kryzysu.



Port do HTML5

Podczas wieczoru poprzedzającego compo, gdy już ustaliliśmy mniej więcej jaką grę będziemy tworzyć, z ciekawości napisałem w JS bardzo proste demko z poruszaniem się prostokąta po nierównym terenie. Pomimo faktu że nigdy wcześniej nie robiłem nic z kolizjami na pikselach, chodziło to całkiem znośnie. Po zwycięskim compo zabrałem się więc do portowania gry na HTML5. Nie obeszło się bez niespodzianek w różnicach implementacji obsługi elementu Canvas pomiędzy przeglądarkami.

Grę pisałem pod Google Chrome, gdzie wydajność była na poziomie 60 FPS. Gdy gra była już bliska ukoączenia, okazało się że u Gyna na Firefoksie wydajność była w okolicach 15 FPS natomiast u mnie 2-4 FPS. Winowajcą okazała się implementacja getImageData (metoda służąca do pobierania wartości zbioru pikseli w formacie RGBA), której używałem do sprawdzania wartości kanału alpha z którym sprawdzałem kolizje. Zatem jeżeli chciałem sprawdzić kolizję z podłożem dla każdego creepera, musiałem pobrać wartości wszystkich pixeli na obszarze który zajmował każdy crepper. Najprostszym rozwiązaniem było zrezygnowanie z kontekstu elementu Canvas jako źródła kanału alpha i stworzenie w tym celu osobnej tablicy. W skrócie zmiany wyglądały następująco:

"¢ W tablicy trzymam jedynie kanał alpha, więc z RGBA ostało się jedynie A.
"¢ getImageData jest używane tylko raz podczas inicjalizacji gry do wypełnienia tablicy wartościami pixeli warstwy z podłożem.
"¢ By tablica zgadzała się z tym co jest widoczne na ekranie muszę aktualizować część tablicy za każdym razem gdy rysuje coś na warstwie z podłożem.

Po tym prostym zabiegu wydajność gry polepszyła się diametralnie pod Firefoksem do poziomu 60 klatek na sekundę.

Innym problemem na jaki natrafiłem był odczuwalny lag (pod Firefoksem, a jakże) podczas odtwarzania dźwięku wybuchu. Okazuje się że tworzenie nowej instancji elementu Audio jednak jest dość powolne (nawet gdy dany plik znajduje się już w pamięci a sam element nie został podpięty do drzewa DOM). Rozwiązaniem na to było utworzenie zbioru instancji elementu Audio dla każdego dźwięku już podczas inicjalizacji gry by nie trzeba było ich tworzyć w locie. Czyli np. dźwięk wybuchu posiadał 5 instancji podczas rozgrywki, co dawało dodatkowy plus w postaci ograniczenie kakofonii jaka mogła powstać gdy na ekranie na raz pojawiło się np. 20 wybuchów. Co ciekawe podczas gry nie w sposób odczuć że nie każdy wybuch rysowany na ekranie posiada swoje odzwierciedlenie w odpowiednim odgłosie.

Sama gra różni się kilkoma znaczącymi elementami w stosunku do wersji SDL:
- Brak achivements - według mnie były one trochę wymuszone, więc ich nie implementowałem
- Lawa - teraz gdy zniszczymy teren i "dokopiemy" się do dna to wypłynie lawa, która po zastygnięciu zresetuje plansze (niszcząc wszelkie creepery przy okazji), wydłuża to rozgrywkę dzięki czemu można teoretycznie grać w nieskoączoność.
- Pauza - gdy focus nie znajduje się bezpośrednio na zakładce/oknie z grą, gra zostanie zapauzowana.
- Możliwość wyciszenia dźwięków

Można by się jeszcze w przyszłości pokusić o dodanie takich bajerów jak np. fullscreen, highscores czy nawet prawdziwy multiplayer / singleplayer.

Podsumowanie

(by Gynvael)

I tyle jeśli chodzi o technikalia. Na koniec dwie fotki: tablica wyników oraz, no cóż, my :)


(ta druga fotka jest stąd)