Béton brut

Dobre rzeczy, gorzej: Jajeczka w 302 liniach Lua

2020-07-25

W mojej przygodzie z programowaniem jest wiele rzeczy, których nie próbowałem. Umościłem się wygodnie na dnie stosu i ciężko jest mi się wygrzebać z tego barłogu, gdzie ciepło, a wszystkie kąty są znane. Cierpię też na przypadłość, która trapi wielu programistów: mój zmysł estetyczny jest przytłumiony. Jeśli coś wygląda paskudnie, a działa doskonale to znaczy, że jest piękne. Dlatego też podchodziłem do programowania gier z wielką nieufnością. Trzeba się piekielnie napracować żeby coś dobrze wyglądało. Żeby grający wiedział, co dzieje się na ekranie.

Do tego trzeba mieć pomysł. Najlepiej dobry pomysł.

Dlatego latami czytałem sobie rozmaite „jak to się robi”, ale nigdy nawet nie wyszedłem poza kompilację załączonych przykładów do tej czy innej biblioteki, mądre potrząsanie głową i mówienie „Acha!”.

Kilka dni temu zamknąłem dzień w pracy straszną awanturą. Jedną z tych, która jak śniegowa kula zaczyna się gdzieś od płatka, a kończy się groźbą rozpłaszczenia nas wszystkich. Potrzebowałem zająć się czymś do końca dnia żeby nie usiąść i nie zacząć komponować e-maila z kategorii „jak ja to widzę”.

Potrzebowałem czegoś absorbującego uwagę, więc najlepiej czegoś, czego nigdy nie robiłem. Wiem, napiszę grę. Problem braku pomysłu i jakości rozwiązałem w iście holiudzkim stylu. Zrobię to, co już było, ale gorzej!

Mój wybór padł na kultową grę elektroniczną zwaną kolokwialnie „Jajeczkami”. Zasady są proste, grafika jest prosta. Powinienem sobie dać radę. W temacie narzędzi padło na Luę i Love2d. Nie znałem jednego ani drugiego, więc idealnie wpasowywały się w mój plan zajęcia głowy nowymi problemami.

Po godzinie udało mi się uzyskać kontrolę nad środowiskiem na tyle, że patrząc na biały prostokąt rozpierała mnie duma i wiara we własne możliwości. Postanowiłem udokumentować ten cały proces i podzielić się nim z Wami. Na początku wymyśliłem, że będzie to doskonały tekst dla początkujących, ale im bardziej piętrzyły się przede mną problemy tym bardziej traciłem wiarę, że dam radę.

Powstały więc zapiski chaotyczne, które ani nie uczą, ani nie tłumaczą. Przynajmniej powstrzyma mnie to od napisania „Re: jak ja to widzę”.

„Jajeczka”

Wpierw o samej grzej. „Jajeczka” to klon Mickey Mouse, gry elektronicznej wydanej w 1984 przez Nintendo. Postacie zostały wymienione na mniej reakcjonalne maskotki z kreskówki „Ну, погоди!”. Wcielamy się w postać Wilka, który w kreskówce przedstawiany jest jako bumelant i opryszek. Tym razem zajmuje się pracą na kurzej fermie, najwidoczniej w wyniku resocjalizacji.

Na czterech grzędach siedzą cztery kury, które znoszą jajka, a te pod wpływem grawitacji toczą się rynnami w dół. Naszym zadaniem jest złapanie ich do koszyka. Trzeba też nadmienić, że kury te są niesamowicie produktywne, Trofim byłby dumny.

Wyświetlacz to segmentowane LCD. Znaczy to, że wszystkie możliwe elementy są predefiniowane, a ruch symulowany jest przez zapalanie odpowiednich kształtów. Znacząco ułatwia mi to kradzież, gdyż nie muszę się przejmować skomplikowanymi fazami ruchu.

Wyświetlacz LCD ze wszystkimi komponentami

Postanowiłem wyciąć wszystkie elementy, które pozostają w ruchu i potem poukładać je samemu w grze.

Mamy więc:

  1. Wilka patrzącego w lewo i prawo
  2. Ręce Wilka sięgające do dolnej i górej grzędy dla lewego i prawego kierunku
  3. Pięć faz animacji toczącego się jajka dla każdej rynienki
  4. Cztery fazy animacji „stłuczonego jajka”
  5. Wskaźnik liczba „stłuczonych jajek”

To powinno wystarczyć za część graficzną. Teraz pozostaje zebrać całość do kupy. Ale zanim zaczniemy, kilka słów dla czytelników, którzy nigdy nie interesowali się jak to się dzieje, że rzeczy się ruszają i są w odpowiednich miejscach. Choćby amatorsko doświadczeni w sztuce będą wiedzieli, że te definicje nie są idealne, ale to neofita tłumaczy ludziom krok za nim.

Ekran

Możecie myśleć o ekranie komputera jak o kartce papieru w kratkę, gdzie każdy segment to pojedynczy piksel, który można zaświecić na dowolny kolor z dostępnej dla komputera palety. Pozycję na ekranie wyrażamy jako koordynaty X i Y.

W przypadku Love2d, lewy górny róg ekranu znajduje się pod pozycją (0, 0). Więc (10, 0) znaczy jedenaście pikseli (liczymy od zera) w prawo od brzegu w pierwszej linii. Idąc dalej tym tropem (4, 5) znajdzie się w szóstej linii od góry, pięć pikseli od lewego brzegu ekranu.

W większości przypadków nie będziemy rysować grafiki piksel po pikselu. Zwykle wczytujemy gotowy element graficzny, który umieszczamy na naszej płaszczyźnie. Sam wczytany obiekt ma własną, wewnętrzną siatkę koordynat, ale umieszczamy go wskazując miejsce, gdzie znajdzie się jego (0, 0).

Więc jeśli chcemy wczytać niezmiernie estetyczną żółtą twarz i umieścić ją gdzieś na ekranie, powiemy, że pod (9, 5) to wyląduje tam lewy górny bok, reprezentowany wewnętrznie przez (0, 0)

Animacja pokazująca siatkę

Myślę, że to wszystko czego potrzebujemy na tę chwilę.

Stan

Stan to wiedza o wszystkich właściwościach świata stworzonego w grze. Co jest stanem w Jajeczkach? W którą stronę obrócony jest wilk i jak ma ułożone ręce. Ile razy przegraliśmy. W jakiej pozycji znajdują się jajka i ile ich jest na ekranie. Punktacja. Wszystkie elementy, które tworzą grę są odzwierciedlone przez zmienną, która jest potem interpretowana przez nas.

Zmienna score w consts.lua trzyma naszą punktację, miss liczbę skuch, w main.lua hands i face reprezentują pozycję wilka.

W Love2d stan jest globalny, tj. wszystkie komponenty mają bezpośredni dostęp do zmiennych. Jest to uważane za bardzo zły pomysł™, gdyż niepowiązane komponenty mogą sobie stawać na palcach i doprowadzać do nieokreślonego stanu, ale myślę, że obniża to też znacząco wysokość pierwszego stopnia, który potencjalny twórca musi pokonać.

Pętla

Istnienie czasu to problem, który spędzał sens z powiek wielu filozofom. Czy to rzeczywiście taka prosta linia z przeszłości w przyszłość? Czy może cały czas istnieje na raz, a nasza świadomość odbiera tylko echa takiej projekcji. Być może zmiany są w ogóle niemożliwe?

Jakakolwiek jest rzeczywistość skryta przed naszymi oczami, zmechanizowaliśmy czas, pocięliśmy go na plasterki sekundową wskazówką zegarka i empirycznie jest nam z tym dobrze.

W grze zmiana i czas muszą być przez nas odtworzone, nikt nie znajdzie przyjemności patrząc w statyczny obraz na ekranie kiedy miał zamiar grać.

Love2d posiada dwie funkcje, które są wywoływane na okrągło, w nieskończonej pętli. To w nich implementujemy zmianę (co się rysuje) i czas (zmiany stanu gry). Są to odpowiednio love.draw() i love.update(dt).

Może zauważyliście, że w przypadku update mamy parametr dt — jest to zmienna, która mówi ile czasu minęło od ostatniego jej wywołania (Δ czasu). Pozwala nam to kontrolować czas w którym rzeczy się dzieją. Przykładowo chciałem aby można było poruszać Wilkiem w każdej chwili, ale ruch spadających jajek musi zachować jednolite tempo, niepowiązane z ruchem postaci (inaczej gra nie była by uczciwa: gdyby Wilk poruszał się w tempie spadających jajek, sięgnięcie trzech będących na granicy rynny byłoby niemożliwe).

Popatrzmy w kod:

function love.update(dt)

    require('wolf_movement') 
    wolf_movement()

    time_since_movement = time_since_movement + dt
    if(time_since_movement >= ms_to_roll) then
        time_since_movement = 0
        -- a jednak się rusza!
    end
end

Mamy zmienną time_since_movement w której składujemy wszystkie mikrosekundy, które do nas przyszły w wyniku wykonywania funkcji. Jeśli ta suma jest większa niż zdefiniowana w ms_to_roll ruszamy spadającymi jajkami. Funkcja odpowiedzialna za ruch wilka znajduje się w wolf_movement i jest poza blokiem, pozwalając mu ruszać się w każdej chwili.

Po tym jak Love2d skończy wykonywać ciało funkcji love.update(dt) wywoływane jest love.draw(), gdzie nasza grafika jest rysowana zgodnie ze stanem ustawionym wcześniej.

Elementy graficzne

Każda klatka animacji znajduje się w osobnym pliku i jest wczytywana podczas inicjalizacji programu. Wymyśliłem sobie, że będę je wszystkie trzymał w tabeli (nomenklatura Lua znacząca listę/hashmapę), dzięki czemu animowanie będzie polegało tylko na zwiększaniu indeksu o jeden.

W przypadku Wilka musiałem zastosować nieco inną metodę. Ponieważ rusza się on niezależnie i jest sterowany przez nas, możemy jego fragmenty pochować pod ładnymi nazwami

-- pozycje wilka
wolf_left = {
    171, 134, -- koordynaty
    love.graphics.newImage('assets/wolf_left.png'), -- obrazek
    'left' -- nazwa
}
wolf_right = {
    250, 134,
    love.graphics.newImage('assets/wolf_right.png'),
    'right'
}
left_up = {
    124, 134,
    love.graphics.newImage('assets/left_up.png'),
    'up'
}
left_down = {
    116, 193,
    love.graphics.newImage('assets/left_down.png'),
    'down'
}
right_up = {
    309, 138,
    love.graphics.newImage('assets/right_up.png'),
    'up'
}
right_down = {
    300, 198,
    love.graphics.newImage('assets/right_down.png'),
    'down'
}

Każdy fragment Wilka składa się z czteroelementowej tablicy, która zawiera: koordynaty, reprezentację wczytanego obrazka, a następnie string. Ten ostatni dodałem nie mogąc wymyślić jak zaimplementować sytuację, gdy Wilk patrzy w jedną stronę i zmieniamy go na drugą. Logika mówi, że jeśli trzymał ręce w górze w lewo, po zmianie na prawo ta pozycja będzie zachowana. Niestety, nieznajomość Lua spowodowała, że nie mogłem wymyślić lepszego pomysłu niż wulgarne nazwanie kierunku.

Ruch jajek miał dużo mniej problemów merytorycznych.

-- ruch jajek
eggs_pos = {
    {
        {37, 86, love.graphics.newImage('assets/left_up_egg_1.png')},
        {54, 99, love.graphics.newImage('assets/left_up_egg_2.png')},
        {75, 104, love.graphics.newImage('assets/left_up_egg_3.png')},
        {92, 117, love.graphics.newImage('assets/left_up_egg_4.png')},
        {107, 137, love.graphics.newImage('assets/left_up_egg_5.png')}
    },
    {
        {39, 156, love.graphics.newImage('assets/left_down_egg_1.png')},
        {55, 164, love.graphics.newImage('assets/left_down_egg_2.png')},
        {71, 176, love.graphics.newImage('assets/left_down_egg_3.png')},
        {92, 183, love.graphics.newImage('assets/left_down_egg_4.png')},
        {105, 204, love.graphics.newImage('assets/left_down_egg_5.png')},
    },
    {
        {416, 87, love.graphics.newImage('assets/right_up_egg_1.png')},
        {399, 95, love.graphics.newImage('assets/right_up_egg_2.png')},
        {381, 106, love.graphics.newImage('assets/right_up_egg_3.png')},
        {359, 117, love.graphics.newImage('assets/right_up_egg_4.png')},
        {346, 128, love.graphics.newImage('assets/right_up_egg_5.png')},
    },
    {
        {418, 159, love.graphics.newImage('assets/right_down_egg_1.png')},
        {399, 164, love.graphics.newImage('assets/right_down_egg_2.png')},
        {380, 174, love.graphics.newImage('assets/right_down_egg_3.png')},
        {364, 184, love.graphics.newImage('assets/right_down_egg_4.png')},
        {343, 200, love.graphics.newImage('assets/right_down_egg_5.png')},
    }

}

Mamy więc tabelę z czterema elementami, które odwzorowują cztery rynny. A w nich pięć faz animacji spadającego jajka: koordynaty i plik graficzny. Czyli eggs_pos[1][1] to pierwsza rynna, pierwsza pozycja jajka. I tak, zanim zapytacie: tabele w Lua zaczynają indeks od 1. Miałem przynajmniej trzy błędy związane z moim — najwidoczniej optymistycznym — założeniem, że liczymy od zera!

Podobnie wygląda definicja animacji skuchy.

I to byłoby na tyle w temacie przygotowania elementów graficznych.

Wilk lewy, Wilk prawy

Miałem wielką ambicję zrobienia jakiejś dobrej abstrakcji w temacie ruchu naszym protagonistą. Nauczyłem się jednak, że zła abstrakcja boli całe życie. Zwłaszcza, gdy robi się ją kompletnie bez znajomości idiomów języka. Postanowiłem, że prosty if() nikogo nie zabił, nawet jeśli powtarzam się powtarzam się przy tym przy tym.

Czytanie klawiatury w Love2d jest trywialne, nie trzeba znać nawet kodów klawiszy.

function wolf_movement()
    if love.keyboard.isDown("up") then 
        if face[4] == 'left' then
            hands = left_up 
        else
            hands = right_up
        end
    end
    if love.keyboard.isDown("down") then 
        if face[4] == 'left' then
            hands = left_down 
        else
            hands = right_down
        end
    end
    if love.keyboard.isDown("right") then
        face = wolf_right
        if hands[4] == 'up' then
            hands = right_up
        else
            hands = right_down
        end
    end
    if love.keyboard.isDown("left") then
        face = wolf_left
        if hands[4] == 'up' then
            hands = left_up
        else
            hands = left_down
        end
    end
end

Zmienne face i hands przechowują obecną pozycję Wilka. Widzimy tu moje tchórzliwe użycie czwartego elementu do zachowania stanu rąk. Teraz trzeba wrócić do love.draw i powiedzieć mu, co rysować.

love.graphics.draw(face[3], face[1], face[2])
love.graphics.draw(hands[3], hands[1], hands[2])

I to wszystko! Już można hasać po ekranie. Programowanie gier to czysta przyjemność, gdy nie przejmujesz się detalami!

Demonstracja ruchu

Jajka się toczą

Nabuzowany tym sukcesem postanowiłem powoli zabrać się za następny element, ruch spadających jajek. Pierwsza wersja przyszła szybko.

Piersze jajka za płoty

Mina mi jednak szybko zrzedła. Okazało się, że zapomniałem o grze w grze. Moja pierwsza implementacja nie mogła działać. Muszę mieć możliwość losowego spuszczania jajek dowolną rynną oraz dodawania nowych. Dodatkowo jest też zwiększający się poziom trudności, zaczynamy od jednego jajka, a po uzyskaniu jakiejś liczby punktów muszą być dwa. I nie mogą zaczynać w tym samym momencie. Dobrą chwilę patrzyłem w kod nie potrafiąc się wyrazić wystarczająco jasno w Lua.

Na przykład zmienną trzymającą stan rynien normalnie ująłbym używając binarnej reprezentacji. 0x1010 i wiemy, że jajko toczy się po rynnie 1 i 3. Ale Lua nie ma w standardowej bibliotece odpowiednich narzędzi, nie chciałem utknąć w trybie „jak się instaluje biblioteki”. Zrobiłem więc następującą rzecz, wiedząc już, że moje zapiski będą bezużyteczne dla absolutnie początkujących: dodałem zmienne eggs = {0, 0, 0, 0} i roll_on = {0, 0, 0, 0} reprezentujące odpowiednio klatkę animacji na danej rynience i to, czy coś się po niej toczy. Następnie napisałem okropne sum(), które przyjmuje tabelę i sumuje elementy. Aplikując to na roll_on wiem ile rynienek jest zajętych. Teraz tylko sprawdzić, czy chcę mieć więcej jajek niż jest widoczne, zapisać indeksy tych, które są puste (==0) i losowo wcisnąć jajko.

A co z opóźnieniami? Okazało się, że Lua jest cierpliwe i zaakceptowało mój szalony pomysł: ustawię pozycję na ujemną!

I jakimś cudem zadziałało, uff…

function gutters_logic()
    if sum(roll_on) == 0 then -- nic się nie toczy
        -- znieś jajko byle gdzie
        roll_on[math.random(1,4)] = 1
    end

    if sum(roll_on) < eggs_on_screen then
        -- jest mniej jajek niż chcemy
        print("Sum(roll_on) ", sum(roll_on))
        print("eggs_on_screen ", eggs_on_screen)
        pick = {}
        how_many_we_need = eggs_on_screen - sum(roll_on)
        print('how_many_we_need ', how_many_we_need)
        for i=1, table.getn(roll_on) do
            -- wybieramy puste rynienki
            if roll_on[i] == 0 then
                table.insert(pick, i)
            end
        end

        for i=1, how_many_we_need do
            -- losowa rynna z dostępnych
            choice = math.random(1, table.getn(pick)) 
            if roll_on[pick[choice]] == 1 then
            else
                roll_on[pick[choice]] = 1
                -- ileś „tyknięć nim się pojawi”
                eggs[pick[choice]] = math.random(1, 4) * -1
            end
        end
    end
end

Jak Doktor Frankenstein byłem zafascynowany i przerażony, że to żyje!

Rysowanie poszło dużo łatwiej.

for i=1, table.getn(roll_on) do
    if eggs[i] > 0 then
        current_egg = eggs_pos[i][eggs[i]]
        love.graphics.draw(current_egg[3], current_egg[1], current_egg[2])
    end
end

Goń i łap

Nie pozostało nic innego jak zacząć łapać te jajka. Po raz kolejny zawiesiłem wszystko na choince if()ów.

function success_or_failure(i)
    if i == 1 and face == wolf_left and hands == left_up then return true end
    if i == 2 and face == wolf_left and hands == left_down then return true end
    if i == 3 and face == wolf_right and hands == right_up then return true end
    if i == 4 and face == wolf_right and hands == right_down then return true end
    return false
end

Należało to jeszcze wpiąć w love.update(dt)

for i=1, table.getn(roll_on) do
    -- jeśli jajko się tu toczy
    if roll_on[i] == 1 then
        -- daj następną klatkę animacji
        eggs[i] = eggs[i] + 1
        if eggs[i] == fail_at then
            -- jajko jest na brzegu? A gdzie patrzy wilk?
            if(success_or_failure(i)) then
                score = score + 1
                -- agresywnie zwiększam ilość jaj dla debugu
                if score == 2 then
                    eggs_on_screen = 2
                end
                if score == 5 then
                    eggs_on_screen = 3
                end
            else
                miss = miss + 1
                if i == 1 or i == 2 then
                    failure_left = 1
                else
                    failure_right = 1
                end
                print("Bams", i, failure_left, failure_right)
            end
            -- jajko znika, komora losowania jest pusta
            eggs[i] = 0
            roll_on[i] = 0
        end
    end
end

I tak, krok po kroku, całość zaczęła przypominać pierwowzór. Osiągnąłem swój cel główny: zmarnowałem prawie 5h1. Cel poboczny, tj. zrobić coś, czego nigdy nie robiłem, uznaję też za osiągnięty.

Teraz myślę nad zmianą kariery, napędzany zadufaniem początkującego.

Kod dostępny jest w repozytorium wraz z półproduktami.

PS. jeśli na serio chcecie się czegoś dowiedzieć o projektowaniu gier polecam Wam książki wydane przez moich przyjaciół z „Inżynierii Wszechświetności”, choćby Level design.


  1. I 2h pisząc to, a za 7h muszę przejechać na rowerze 60Km, proszę o współczucie. 

QR for Dobre rzeczy, gorzej: Jajeczka w 302 liniach&nbsp;Lua