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.

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

Mamy więc:
- Wilka patrzącego w lewo i prawo
- Ręce Wilka sięgające do dolnej i górej grzędy dla lewego i prawego kierunku
- Pięć faz animacji toczącego się jajka dla każdej rynienki
- Cztery fazy animacji „stłuczonego jajka”
- 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)

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!

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

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.
-
I 2h pisząc to, a za 7h muszę przejechać na rowerze 60Km, proszę o współczucie. ↩