This translation is community contributed and may not be up to date. We only maintain the English version of the documentation. Read this tutorial in English
W tym artykule przejdziemy przez implementację prostej dwuwymiarowej platformówki opartej na kafelkach w Defold. Poznasz ruch w lewo i w prawo, skakanie oraz spadanie.
Istnieje wiele różnych sposobów tworzenia platformówki. Rodrigo Monteiro napisał na ten temat bardzo obszerną analizę i więcej znajdziesz tutaj.
Zdecydowanie polecamy przeczytanie jej, jeśli dopiero zaczynasz tworzyć platformówki, ponieważ zawiera wiele cennych informacji. W tym artykule omówimy nieco bardziej szczegółowo kilka opisanych metod oraz to, jak zaimplementować je w Defold. Wszystko powinno jednak być łatwe do przeniesienia na inne platformy i języki (w Defold używamy Lua).
Zakładamy, że znasz choć trochę matematykę wektorową (algebrę liniową). Jeśli nie, warto się z nią zapoznać, bo jest niesamowicie przydatna w tworzeniu gier. David Rosen z Wolfire napisał bardzo dobrą serię na ten temat tutaj.
Jeśli już pracujesz w Defold, możesz utworzyć nowy projekt oparty na szablonie projektu Platformer i poeksperymentować z nim podczas czytania tego artykułu.
Niektórzy czytelnicy zwrócili uwagę, że proponowana przez nas metoda nie jest możliwa przy domyślnej implementacji Box2D. Wprowadziliśmy kilka modyfikacji do Box2D, aby to zadziałało:
Kolizje między obiektami kinematycznymi i statycznymi są ignorowane. Zmień sprawdzenia w b2Body::ShouldCollide i b2ContactManager::Collide.
Również odległość kontaktu (nazywana separation w Box2D) nie jest przekazywana do funkcji zwrotnej.
Dodaj pole distance do b2ManifoldPoint i upewnij się, że jest aktualizowane w funkcjach b2Collide*.
Wykrywanie kolizji jest potrzebne, aby gracz nie przechodził przez geometrię poziomu. Istnieje wiele sposobów rozwiązania tego problemu, zależnie od gry i jej konkretnych wymagań. Jednym z najprostszych rozwiązań, jeśli to możliwe, jest powierzenie tego silnikowi fizycznemu. W Defold używamy silnika fizycznego Box2D do gier 2D. Domyślna implementacja Box2D nie ma wszystkich potrzebnych funkcji, więc na końcu tego artykułu opisujemy, jak ją zmodyfikowaliśmy.
Silnik fizyczny przechowuje stany obiektów fizycznych wraz z ich kształtami, aby symulować zachowanie fizyczne. Podczas symulacji raportuje też kolizje, dzięki czemu gra może reagować na nie w momencie ich wystąpienia. W większości silników fizycznych istnieją trzy typy obiektów: statyczne, dynamiczne i kinematyczne (nazwy te mogą się różnić w innych silnikach fizycznych). Istnieją też inne typy obiektów, ale na razie je pomińmy.
W takiej grze chcemy uzyskać coś przypominającego fizyczne zachowanie z prawdziwego świata, ale znacznie ważniejsze są responsywne sterowanie i dobrze wyważone mechaniki. Skok, który daje dobre wrażenie, nie musi być fizycznie dokładny ani zachowywać się zgodnie z rzeczywistą grawitacją. Ta analiza pokazuje jednak, że grawitacja w grach z Mario zbliża się do 9,8 m/s2 w każdej wersji. :-)
Ważne jest, abyśmy mieli pełną kontrolę nad tym, co się dzieje, dzięki czemu możemy projektować i dostrajać mechaniki tak, aby osiągnąć zamierzony efekt. Dlatego modelujemy postać gracza jako obiekt kinematyczny. Dzięki temu możemy swobodnie poruszać postacią gracza, bez konieczności radzenia sobie z siłami fizycznymi. Oznacza to, że problem separacji między postacią a geometrią poziomu będziemy musieli rozwiązać sami (więcej o tym później), ale jest to kompromis, który jesteśmy gotowi zaakceptować. W świecie fizyki postać gracza będziemy reprezentować jako kształt pudełka.
Skoro zdecydowaliśmy, że postać gracza będzie reprezentowana przez obiekt kinematyczny, możemy swobodnie przemieszczać ją, ustawiając pozycję. Zacznijmy od ruchu w lewo i w prawo.
Ruch będzie oparty na przyspieszeniu, aby nadać postaci pewien ciężar. Podobnie jak w przypadku zwykłego pojazdu przyspieszenie określa, jak szybko postać gracza może osiągnąć maksymalną prędkość i zmienić kierunek. Przyspieszenie działa w trakcie kroku klatki - zwykle przekazywanego w parametrze dt (delta-t) - a następnie jest dodawane do prędkości. Analogicznie, prędkość działa w trakcie klatki, a wynikowe przemieszczenie jest dodawane do pozycji. W matematyce nazywa się to całkowaniem w czasie.

Dwie pionowe linie oznaczają początek i koniec klatki. Wysokość linii to prędkość, jaką postać gracza ma w tych dwóch punktach czasu. Nazwijmy te prędkości v0 i v1. v1 otrzymujemy, stosując przyspieszenie (nachylenie krzywej) dla kroku czasowego dt:

Pomarańczowy obszar to przemieszczenie, które powinniśmy zastosować do postaci gracza w bieżącej klatce. Geometrycznie możemy przybliżyć ten obszar jako:

W ten sposób całkujemy przyspieszenie i prędkość, aby poruszać postacią w pętli aktualizacji:
Oblicz zmianę prędkości w tej klatce (dv to skrót od delta-velocity), jak wyżej:
local dv = acceleration * dt
dv nie przekracza zamierzonej różnicy prędkości, i w razie potrzeby ogranicz ją.Zapisz bieżącą prędkość do późniejszego użycia (self.velocity, czyli aktualnie prędkość użyta w poprzedniej klatce):
local v0 = self.velocity
Oblicz nową prędkość, dodając zmianę prędkości:
self.velocity = self.velocity + dv
Oblicz przemieszczenie w osi x dla tej klatki, całkując prędkość, jak wyżej:
local dx = (v0 + self.velocity) * dt * 0.5
Jeśli nie wiesz, jak obsługiwać wejście w Defold, znajdziesz o tym poradnik tutaj.
Na tym etapie możemy poruszać postacią w lewo i w prawo, a sterowanie jest płynne i sprawia wrażenie wyraźnie cięższego. Teraz dodajmy grawitację!
Grawitacja także jest przyspieszeniem, ale działa na postać wzdłuż osi y. Oznacza to, że będzie stosowana w taki sam sposób jak opisane wyżej przyspieszenie ruchu. Jeśli po prostu zamienimy powyższe obliczenia na wektory i dopilnujemy, aby w kroku 3) uwzględnić grawitację w składowej y przyspieszenia, wszystko po prostu zadziała. Kochamy matematykę wektorową! :-)
Teraz nasza postać może się poruszać i spadać, więc czas przyjrzeć się reakcji na kolizje. Oczywiście musimy móc lądować na geometrii poziomu i przemieszczać się po niej. Użyjemy punktów kontaktu dostarczanych przez silnik fizyczny, aby mieć pewność, że nigdy niczego nie nakładamy.
Punkt kontaktu zawiera normalną kontaktu (wskazującą na zewnątrz obiektu, z którym się zderzamy, choć w innych silnikach może to wyglądać inaczej) oraz odległość, która mierzy, jak głęboko wniknęliśmy w drugi obiekt. To wszystko, czego potrzebujemy, aby oddzielić postać gracza od geometrii poziomu. Ponieważ używamy pudełka, możemy w jednej klatce otrzymać wiele punktów kontaktu. Dzieje się tak na przykład wtedy, gdy dwa rogi pudełka przecinają się z poziomym podłożem albo gdy gracz porusza się w stronę narożnika.

Aby nie wykonywać tej samej korekty wielokrotnie, sumujemy korekty w wektorze, żeby nie skompensować zbyt mocno. W przeciwnym razie znaleźlibyśmy się za daleko od obiektu, z którym doszło do kolizji. Na powyższym obrazku widać, że mamy obecnie dwa punkty kontaktu, pokazane przez dwie strzałki (normalne). Odległość penetracji jest taka sama dla obu kontaktów, więc gdybyśmy bezrefleksyjnie stosowali ją za każdym razem, przesunęlibyśmy postać dwa razy bardziej, niż zamierzaliśmy.
Ważne jest, aby wyzerować skumulowane korekty w każdej klatce do wektora 0.
Na końcu funkcji update() umieść coś takiego:
self.corrections = vmath.vector3()
Zakładając, że istnieje funkcja zwrotna wywoływana dla każdego punktu kontaktu, tak wygląda oddzielanie obiektów w tej funkcji:
local proj = vmath.dot(self.correction, normal) -- <1>
local comp = (distance - proj) * normal -- <2>
self.correction = self.correction + comp -- <3>
go.set_position(go.get_position() + comp) -- <4>
Musimy też wyzerować tę część prędkości gracza, która kieruje się w stronę punktu kontaktu:
proj = vmath.dot(self.velocity, message.normal) -- <1>
if proj < 0 then
self.velocity = self.velocity - proj * message.normal -- <2>
end
Skoro możemy już biegać po geometrii poziomu i spadać, czas skakać! Skakanie w platformówkach można zrealizować na wiele różnych sposobów. W tej grze celujemy w coś podobnego do Super Mario Bros i Super Meat Boy. Podczas skoku postać gracza jest wypychana w górę impulsem, który jest w zasadzie stałą prędkością.
Grawitacja będzie nieustannie ściągać postać z powrotem w dół, tworząc przyjemną parabolę skoku. Będąc w powietrzu, gracz nadal może sterować postacią. Jeśli gracz puści przycisk skoku przed osiągnięciem szczytu paraboli, prędkość wznoszenia zostanie zmniejszona, aby przerwać skok wcześniej.
Gdy wejście zostanie wciśnięte, wykonaj:
-- jump_takeoff_speed to stała zdefiniowana w innym miejscu
self.velocity.y = jump_takeoff_speed
Należy to zrobić tylko wtedy, gdy wejście jest wciśnięte, a nie w każdej klatce, gdy pozostaje przytrzymane.
Gdy wejście zostanie zwolnione, wykonaj:
-- skróć skok, jeśli nadal poruszamy się w górę
if self.velocity.y > 0 then
-- zmniejsz prędkość wznoszenia
self.velocity.y = self.velocity.y * 0.5
end
ExciteMike przygotował kilka ciekawych wykresów trajektorii skoku w Super Mario Bros 3 i Super Meat Boy, które warto zobaczyć.
Geometria poziomu to kształty kolizyjne otoczenia, z którymi zderza się postać gracza (i ewentualnie inne obiekty). W Defold istnieją dwa sposoby tworzenia takiej geometrii.
Możesz albo tworzyć osobne kształty kolizji na wierzchu budowanych poziomów. Ta metoda jest bardzo elastyczna i pozwala precyzyjnie pozycjonować grafikę. Jest szczególnie przydatna, jeśli chcesz mieć łagodne nachylenia. Gra Braid używała takiego sposobu budowania poziomów i tak samo zbudowano poziom przykładowy w tym samouczku. Tak to wygląda w edytorze Defold:

Inną opcją jest budowanie poziomów z kafelków i pozwolenie edytorowi na automatyczne generowanie kształtów fizycznych na podstawie grafiki kafelków. Oznacza to, że geometria poziomu będzie aktualizowana automatycznie, gdy zmieniasz poziomy, co może być niezwykle przydatne.
Ułożone kafelki zostaną automatycznie połączone w jeden kształt, jeśli ich krawędzie będą do siebie pasować. Eliminuje to szczeliny, przez które postać gracza mogłaby się zatrzymywać lub podskakiwać podczas ślizgania się po kilku poziomych kafelkach. Osiąga się to przez zamianę wielokątów kafelków na kształty krawędzi w Box2D podczas wczytywania.

Powyżej znajduje się przykład, w którym utworzyliśmy pięć sąsiadujących kafelków z fragmentu grafiki platformówki. Na obrazku widać, jak ułożone kafelki (u góry) odpowiadają jednemu kształtowi, który został połączony w jeden (na dole, szary kontur).
Więcej informacji znajdziesz w naszych poradnikach o fizyce i kafelkach.
Jeśli chcesz dowiedzieć się więcej o mechanice platformówek, tutaj znajdziesz imponująco dużą ilość informacji o fizyce w Sonicu.
Jeśli wypróbujesz nasz projekt szablonowy na urządzeniu z iOS lub przy użyciu myszy, skok może wydawać się bardzo niezręczny. To tylko nasza nieśmiała próba zaimplementowania platformowania z użyciem wejścia jednoprzyciskowego. :-)
Nie wspomnieliśmy o tym, jak obsłużyliśmy animacje w tej grze. Możesz zobaczyć, jak to działa, sprawdzając poniższy plik player.script i szukając funkcji update_animations().
Mamy nadzieję, że te informacje były przydatne! Stwórzcie świetną platformówkę, żebyśmy wszyscy mogli w nią zagrać! <3
Oto zawartość pliku player.script:
-- player.script
-- to są ustawienia dostrajające mechanikę; możesz je zmienić, żeby uzyskać inne odczucie
-- przyspieszenie ruchu w lewo i w prawo
local move_acceleration = 3500
-- współczynnik przyspieszenia używany w powietrzu
local air_acceleration_factor = 0.8
-- maksymalna prędkość w lewo i w prawo
local max_speed = 450
-- grawitacja ściągająca gracza w dół, wyrażona w pikselach
local gravity = -1000
-- prędkość wybicia przy skoku, wyrażona w pikselach
local jump_takeoff_speed = 550
-- maksymalny odstęp czasu dla podwójnego dotknięcia, aby został uznany za skok (używane tylko przy sterowaniu myszą i dotykiem)
local touch_jump_timeout = 0.2
-- wstępne haszowanie identyfikatorów poprawia wydajność
local msg_contact_point_response = hash("contact_point_response")
local msg_animation_done = hash("animation_done")
local group_obstacle = hash("obstacle")
local input_left = hash("left")
local input_right = hash("right")
local input_jump = hash("jump")
local input_touch = hash("touch")
local anim_run = hash("run")
local anim_idle = hash("idle")
local anim_jump = hash("jump")
local anim_fall = hash("fall")
function init(self)
-- dzięki temu możemy obsługiwać wejście w tym skrypcie
msg.post(".", "acquire_input_focus")
-- początkowa prędkość gracza
self.velocity = vmath.vector3(0, 0, 0)
-- zmienna pomocnicza do śledzenia kolizji i separacji
self.correction = vmath.vector3()
-- czy gracz stoi na ziemi
self.ground_contact = false
-- wejście ruchu w zakresie [-1,1]
self.move_input = 0
-- aktualnie odtwarzana animacja
self.anim = nil
-- licznik czasu sterujący oknem skoku przy sterowaniu myszą i dotykiem
self.touch_jump_timer = 0
end
local function play_animation(self, anim)
-- odtwarzaj tylko animacje, które nie są już aktywne
if self.anim ~= anim then
-- każ sprite'owi odtworzyć animację
sprite.play_flipbook("#sprite", anim)
-- zapamiętaj, która animacja jest odtwarzana
self.anim = anim
end
end
local function update_animations(self)
-- dopilnuj, aby postać była zwrócona we właściwą stronę
sprite.set_hflip("#sprite", self.move_input < 0)
-- dopilnuj, aby odtwarzana była właściwa animacja
if self.ground_contact then
if self.velocity.x == 0 then
play_animation(self, anim_idle)
else
play_animation(self, anim_run)
end
else
if self.velocity.y > 0 then
play_animation(self, anim_jump)
else
play_animation(self, anim_fall)
end
end
end
function update(self, dt)
-- wyznacz docelową prędkość na podstawie wejścia
local target_speed = self.move_input * max_speed
-- oblicz różnicę między bieżącą a docelową prędkością
local speed_diff = target_speed - self.velocity.x
-- pełne przyspieszenie, które całkujemy w tej klatce
local acceleration = vmath.vector3(0, gravity, 0)
if speed_diff ~= 0 then
-- ustaw przyspieszenie tak, aby działało w kierunku tej różnicy
if speed_diff < 0 then
acceleration.x = -move_acceleration
else
acceleration.x = move_acceleration
end
-- zmniejsz przyspieszenie w powietrzu, aby ruch wydawał się wolniejszy
if not self.ground_contact then
acceleration.x = air_acceleration_factor * acceleration.x
end
end
-- oblicz zmianę prędkości w tej klatce (dv to skrót od delta-velocity)
local dv = acceleration * dt
-- sprawdź, czy dv przekracza zamierzoną różnicę prędkości, i w razie potrzeby ją ogranicz
if math.abs(dv.x) > math.abs(speed_diff) then
dv.x = speed_diff
end
-- zapisz bieżącą prędkość do późniejszego użycia
-- (self.velocity to w tym momencie prędkość użyta w poprzedniej klatce)
local v0 = self.velocity
-- oblicz nową prędkość przez dodanie zmiany prędkości
self.velocity = self.velocity + dv
-- oblicz przemieszczenie w tej klatce przez całkowanie prędkości
local dp = (v0 + self.velocity) * dt * 0.5
-- zastosuj je do postaci gracza
go.set_position(go.get_position() + dp)
-- zaktualizuj licznik czasu skoku
if self.touch_jump_timer > 0 then
self.touch_jump_timer = self.touch_jump_timer - dt
end
update_animations(self)
-- zresetuj stan chwilowy
self.correction = vmath.vector3()
self.move_input = 0
self.ground_contact = false
end
local function handle_obstacle_contact(self, normal, distance)
-- zrzutuj wektor korekty na normalną kontaktu
-- (dla pierwszego punktu kontaktu wektor korekty jest wektorem zerowym)
local proj = vmath.dot(self.correction, normal)
-- oblicz kompensację potrzebną dla tego punktu kontaktu
local comp = (distance - proj) * normal
-- dodaj ją do wektora korekty
self.correction = self.correction + comp
-- zastosuj kompensację do postaci gracza
go.set_position(go.get_position() + comp)
-- sprawdź, czy normalna jest wystarczająco skierowana w górę, by uznać gracza za stojącego na ziemi
-- (0.7 odpowiada w przybliżeniu odchyleniu o 45 stopni od kierunku pionowego)
if normal.y > 0.7 then
self.ground_contact = true
end
-- zrzutuj prędkość na normalną
proj = vmath.dot(self.velocity, normal)
-- jeśli rzut jest ujemny, część prędkości jest skierowana w stronę punktu kontaktu
if proj < 0 then
-- usuń w takim przypadku tę składową
self.velocity = self.velocity - proj * normal
end
end
function on_message(self, message_id, message, sender)
-- sprawdź, czy otrzymaliśmy wiadomość o punkcie kontaktu
if message_id == msg_contact_point_response then
-- sprawdź, czy obiekt jest czymś, co uznajemy za przeszkodę
if message.group == group_obstacle then
handle_obstacle_contact(self, message.normal, message.distance)
end
end
end
local function jump(self)
-- pozwól skakać tylko z ziemi
-- (rozszerz to o licznik, jeśli chcesz dodać na przykład podwójny skok)
if self.ground_contact then
-- ustaw prędkość wybicia
self.velocity.y = jump_takeoff_speed
-- odtwórz animację
play_animation(self, anim_jump)
end
end
local function abort_jump(self)
-- skróć skok, jeśli nadal poruszamy się w górę
if self.velocity.y > 0 then
-- zmniejsz prędkość wznoszenia
self.velocity.y = self.velocity.y * 0.5
end
end
function on_input(self, action_id, action)
if action_id == input_left then
self.move_input = -action.value
elseif action_id == input_right then
self.move_input = action.value
elseif action_id == input_jump then
if action.pressed then
jump(self)
elseif action.released then
abort_jump(self)
end
elseif action_id == input_touch then
-- poruszaj się w stronę punktu dotknięcia
local diff = action.x - go.get_position().x
-- przekazuj wejście tylko wtedy, gdy punkt jest dostatecznie daleko (ponad 10 pikseli)
if math.abs(diff) > 10 then
-- zwalniaj, gdy odległość jest mniejsza niż 100 pikseli
self.move_input = diff / 100
-- ogranicz wejście do zakresu [-1,1]
self.move_input = math.min(1, math.max(-1, self.move_input))
end
if action.released then
-- zacznij odmierzać czas od ostatniego puszczenia, aby sprawdzić, czy zaraz nastąpi skok
self.touch_jump_timer = touch_jump_timeout
elseif action.pressed then
-- wykonaj skok przy podwójnym dotknięciu
if self.touch_jump_timer > 0 then
jump(self)
end
end
end
end