Tworzenie gier w Javie – Pętla gry, upływ czasu i ruch obiektów

Ważnym elementem każdej gry jest tzw. pętla gry. Jest to pętla, w której wykonywane są aktualizacje świata gry oraz rysowanie wszystkich elementów tego świata na ekranie.

Istotnym pojęciem związanym z pętlą gry jest FPS, czy Frames Per Second. Jest to liczba klatek na sekundę, które gra jest w stanie narysować na ekranie. Innymi słowy – FPS oznacza ile razy w ciągu sekundy jesteśmy w stanie narysować na ekranie wszystkie elementy świata gry. Im większa liczba FPS, tym gra działa płynniej.

Gdy korzystamy z LibGDX, pętla gry jest przed nami ukryta i nie musimy się nią przejmować. LibGDX w każdym obiegu tej pętli wywołuje metodę render obiektu klasy głównej naszej gry, w której my aktualizujemy świat gry i rysujemy wszystkie obiekty.

Pytanie: ile razy w ciągu sekundy wywoływana jest metoda render? Tzn. jaki jest FPS gier korzystających z LibGDX?

To zależy. Domyślnie LibGDX stara się wywołać naszą metodę render 60 razy na sekundę, tzn. FPS powinien wynosić średnio 60.

Skąd stwierdzenie „stara się”? Może zdarzyć się, że aktualizacja świata gry i rysowanie go będzie na tyle czasochłonne, że nasza gra nie będzie w stanie narysować aż 60 klatek na sekundę. Wtedy metoda render będzie po prostu wywoływana rzadziej, ponieważ ponowne wykonanie metody render jest możliwe dopiero wtedy, gdy poprzednie jej wywołanie się zakończy. Wynika z tego, że na starszych komputerach gra może działać z mniejszą liczbą FPS niż na nowszych, szybszych komputerach.

Może też zajść odwrotna sytuacja. Jeżeli nasza gra będzie działała bardzo wydajnie i/lub komputer, na którym zostanie uruchomiona, będzie miał dużą moc obliczeniową, to LibGDX będzie automatycznie „usypiał” naszą grę na bardzo krótkie momenty czasu, aby FPS nie przekraczało 60. Wynika to z faktu, że 60 klatek na sekundę to wystarczająca liczba, aby gra działała bardzo płynnie i rysowanie większej liczby klatek nie miałoby sensu, a zabierałoby czas procesora. Dzięki krótkim "uśpieniom" naszej gry, procesor naszego komputera może wykonywać w tym czasie inne zadania.

W trakcie działania gry FPS może się zmieniać. FPS spadnie na przykład w momencie, gdy gra będzie potrzebowała większej mocy procesora. Może się tak stać np. gdy na ekranie pojawi się dużo postaci i trzeba będzie sprawdzać, czy nie zaszła pomiędzy nimi kolizja (wykrywanie kolizji to dość czasochłonna operacja).

Domyślną liczbę FPS można zmienić w klasie DesktopLauncher ustawiając wartość parametru foregroundFPS obiektu LWJGLApplicationConfiguration. Ta wartość określa FPS, gdy okno gry jest aktywne. Drugi parametr, backgroundFPS, określa liczbę FPS, gdy okno gry nie jest aktywne. Nie będziemy zmieniać wartości tych parametrów i pozostawimy domyślne ustawienia, które na moment pisania tego kursu wynoszą 60 dla obu parametrów.

W kolejnych dwóch rozdziałach opowiemy sobie o poruszaniu obiektów w grach. Zobaczymy, czym różni się ruch zależny i niezależny od FPS.

Ruch zależny od FPS

W większości gier występują obiekty, które zmieniają swoją pozycję na ekranie z upływem czasu. Dla przykładu, w grze przygodowej postać, którą gramy, porusza się po świecie gry, gdy naciskamy klawisze strzałek.

Z poprzedniego rozdziału wiemy, że gra może działać ze zmieniającą się liczbą klatek na sekundę. Jeżeli pozycję bohatera gry będziemy zmieniać w metodzie render o pewną stałą wartość, to prędkość poruszania się obiektu będzie uzależniona od tego, ile klatek na sekundę nasza gra jest w stanie narysować. Spowoduje to, że obiekty w grze będą poruszały się wolniej na wolniejszych komputerach, na których nasza gra nie będzie w stanie działać z oczekiwaną liczbą FPS.

Przykład: jeżeli postać po naciśnięciu strzałki w prawo ma poruszać się o 2 piksele na jedno wykonanie metody render, to:

  • w grze, która będzie działała z FPS = 60, postać przejdzie 60 * 2 piksele = 120 pikseli w ciągu jednej sekundy, ponieważ metoda render zostanie wykonana 60 razy,
  • w grze, która będzie działała z FPS = 30 postać przejdzie 30 * 2 piksele = 60 pikseli w ciągu jednej sekundy.

Spójrz na poniższe animacje, które prezentują ten sam program, ale działający z inną liczbą FPS. W pierwszym przypadku jest to 60 klatek na sekundę, a w drugim – 30 klatek:

Animacja ruchu obrazu z ustawieniem 60 FPS
Animacja ruchu obrazu z ustawieniem 60 FPS
Animacja ruchu obrazu z ustawieniem 30 FPS
Animacja ruchu obrazu z ustawieniem 30 FPS

Klasa główna powyższego programu (pominąłem pakiet i importy):

public class FPSDependentMovement extends ApplicationAdapter {
  private SpriteBatch batch;
  private Texture img;

  private int x = 0;
 
  @Override
  public void create () {
    batch = new SpriteBatch();
    img = new Texture("arrow.png");
  }

  @Override
  public void render () {
    Gdx.gl.glClearColor(1, 1, 1, 1);
    Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

    batch.begin();
    batch.draw(img, x, 50);
    batch.end();

    x += 2;
 }
 
  @Override
  public void dispose () {
    batch.dispose();
    img.dispose();
  }
}

Aktualna pozycja tekstury przechowywana jest w polu x. Wartość tego pola zwiększana jest o 2 na końcu każdej metody render, co powoduje efekt przesuwania się obrazu w prawą stronę.

Ten przykład znajdziesz w katalogu rozdzial-06\ruch-zalezny-od-fps w repozytorium z przykładami na GitHubie. Możesz uruchomić ten przykład za pomocą dołączonych do niego skryptów run_60fps.cmd oraz run_30fps.cmd. Pierwszy uruchomi program z ustawieniem FPS na 60, a drugi – na 30. Możesz sam w ten sposób zobaczyć i porównać poruszanie się obiektu uzależnionego od liczby FPS.

W kolejnym rozdziale zobaczymy lepszy sposób na zmianę położenia obiektów, niezależną od FPS.

Ruch zależny od upływu czasu

Zamiast stosować ruch zależny od FPS, będziemy zmianę pozycji obiektów w naszych grach bazować na czasie, jaki upłynął od poprzedniego wywołania metody render.

W tym celu wykorzystamy metodę Gdx.graphics.getDeltaTime oraz trochę fizyki.

Metoda getDeltaTime zwraca ułamkową część sekundy, jaka upłynęła od poprzedniego wywołania metody render. Jeżeli nasza gra będzie działać z domyślną, średnią liczbą 60 klatek na sekundę, to metoda getDeltaTime będzie zwracać wartość zbliżoną do 1/60 sekundy, czyli około 0,01667.

Spójrz na kilka wartości zwróconych przez metodę getDeltaTime w kolejnych wywołaniach metody render. Wypisałem je na ekran linii poleceń dodając poniższą linię kodu do metody render z przykładu o ruchu zależnym od FPS:

@Override
public void render () {
  System.out.println(Gdx.graphics.getDeltaTime());

  Gdx.gl.glClearColor(1, 1, 1, 1);
  Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

  batch.begin();
  batch.draw(img, x, 50);
  batch.end();

  x += 2;
}

Uruchomiłem program dwa razy – najpierw z ustawieniem 60 FPS, a drugim razem z 30 FPS:

Gdx.graphics.getDeltaTime()
60 FPS 30 FPS
0.0102197 0.0088685
0.0173301 0.0552011
0.0169219 0.0105912
0.0142605 0.0331948
0.0179608 0.0327919
0.0169078 0.0328685
0.0149888 0.0339948
0.0179663 0.0338999
0.0159506 0.0332511
0.0169243 0.0381274

Dla ustawienia 60 FPS, wartość jest zbliżona do 1/60 sekundy, ponieważ pomiędzy kolejnymi wywołaniami metody render upływa mniej więcej tyle czasu. Z kolei dla 30 FPS, metoda render wywoływana jest dwa razy rzadziej – tylko 30 razy na sekundę. Dlatego czasy zwracane przez getDeltaTime są dwa razy większe, niż w przypadku 60 FPS, i są zbliżone do 1/30 sekundy.

Nie wszystkie wartości są zbliżone do zakładanych, szczególnie te na początku działania programu. Jak już wspominałem w poprzednim rozdziale, gdy mówimy o FPS, zazwyczaj mamy na myśli średnią liczbę klatek na sekundę. Wartości zwracane przez getDeltaTime będą się różnić w różnych momentach działania naszej gry, czy to przez chwilowe większe zapotrzebowanie na czas procesora przez naszą grę czy też inne procesy działające w danym momencie w naszym systemie operacyjnym, które mogą spowodować chwilowy spadek FPS w naszej grze.

Jak wykorzystać getDeltaTime do poruszania obiektów gry, aby uniezależnić się od liczby klatek na sekundę?

Okazuje się, że jest to bardzo proste – wystarczy określić, jaką drogę (np. w pikselach) nasz obiekt powinien przebyć na ekranie w trakcie jednej sekundy, czyli jaka jest prędkość obiektu na sekundę.

Następnie, gdy będziemy chcieli przesunąć obiekt, przemnożymy tę prędkość przez czas, jaki upłynął od poprzedniej zmiany pozycji obiektu. Ten czas to wartość zwracana przez getDeltaTime, ponieważ oznacza czas, jak upłynął pomiędzy aktualnym i poprzednim wywołaniem metody render, a właśnie w metodzie render aktualizujemy świat gry i, m. in., zmieniamy położenie obiektów. Zastosujemy tutaj wzór znany z lekcji fizyki: s = v * t, gdzie s to droga, v to prędkość, a t to czas. Wyliczona wartość s będzie oznaczała o ile należy przesunąć nasz obiekt.

W metodzie render zastosujemy ten wzór w następujący sposób:

x = x + SPEED * Gdx.graphics.getDeltaTime();

gdzie SPEED to stała przechowująca prędkość obiektu.

Dla przykładu, załóżmy, że prędkość naszego obiektu wynosi 100 pikseli na sekundę. Zobaczmy, o ile zmieni się pozycja tego obiektu w jednym wykonaniu metody render dla dwóch przypadków:

  • dla FPS = 60, getDeltaTime zwróci około 1/60 sekundy, czyli 0,01667. Podstawiamy do wzoru:

100 * 0,01667 = 1,667 – obiekt przesunie się o trochę ponad półtora piksela,

  • dla FPS = 30, getDeltaTime zwróci około 1/30 sekundy, czyli 0,03333. Podstawiamy do wzoru:

100 * 0,03333 = 3,333 – obiekt przesunie się o niecałe trzy i pół piksela.

Możesz się teraz zastanowić – Gdzie ta niezależność od FPS? Przecież obiekt przesunął się o inną liczbę pikseli w zależności od liczby klatek na sekundę. To prawda, ale w przypadku uruchomienia programu z ustawieniem 60 FPS, obiekt przesunie się dwa razy o obliczoną liczbę pikseli, podczas gdy w tym samym czasie w programie z 30 FPS obiekt przesunie się tylko raz, o 3,33 piksela.

Innymi słowy – w programie z 60 FPS obiekt przesunie się 60 razy o 1,667 pikseli, czyli o 100,02 pikseli. W programie z 30 FPS, obiekt przesunie się 30 razy o 3,333 pikseli, czyli o 99,99 pikseli. W przybliżeniu, w obu przypadkach, niezależnie od liczby FPS, obiekt przesunie się na taką samą odległość – 100 pikseli. Osiągnęliśmy w ten sposób poruszanie się obiektów uniezależnione od prędkości, z jaką na danym komputerze i w danym momencie działa nasza gra.

Piksel na ekranie jest niepodzielny. Powyżej obliczyliśmy, że dla programu działającego ze średnią 30 FPS, obiekt o prędkości 100 pikseli na sekundę przesunie się o 3,333 pikseli w jednym wykonaniu metody render. Obiekty na ekranie są zawsze wyświetlane z dokładnością do jednego piksela – nie można przesunąć obiektu o np. pół piksela lub o 3,333 pikseli – pozycja na ekranie jest zaokrąglana do pełnych pikseli.

Z tego powodu, jako współrzędne obiektów stosujemy zmienne o typie rzeczywistym, tzn. przechowującym część ułamkową – w tym przypadku stosujemy typ float. Dzięki temu nie stracimy informacji o części ułamkowej, o jaką obiekt powinien zostać przesunięty.

Jeżeli zmienna przechowująca pozycję obiektu byłaby zawsze liczbą całkowitą, to za każdym razem liczba 3,333, o jaką chcielibyśmy przesunąć obiekt, byłaby zaokrąglana w dół do liczby 3. Po trzech przesunięciach obiektu w przypadku zmiennej typu całkowitego obiekt przesunąłby się o 9 pikseli. Gdybyśmy zamiast tego stosowali typ rzeczywisty, to po trzech przesunięciach obiekt powędrowałby 9,999 pikseli, więc w zaokrągleniu 10, i ta liczba przebytych pikseli byłaby bardziej zgodna z rzeczywistą prędkością, jaką określiliśmy dla naszego obiektu. Dlatego właśnie stosujemy typ float, by opisywać położenie obiektów, gdy istnieje potencjał utracenia części danych ze względu na część ułamkową drogi, jaką ma przebyć obiekt.

Spójrz na poniższą animację – prezentuje ona różnicę pomiędzy ruchem zależnym od FPS, a ruchem niezależnym. Dwa obrazy znajdujące się w górnej części animacji działają z ustawieniem FPS wynoszącym 60. Dwa obrazy poniżej – z FPS wynoszącym 30.

Pozycja pierwszego i trzeciego obrazu, patrząc od góry okna, zmienia się o stałą wartość, więc ich ruch jest zależny od liczby FPS. Obrazy drugi i czwarty, patrząc od góry, poruszają się korzystając ze wzoru poznanego w tym rozdziale, wykorzystując metodę getDeltaTime. Zauważ, że obrazy pierwszy i trzeci poruszają się z różną szybkością, bo są uzależnione od liczby klatek na sekundę, ale obrazy drugi i czwarty przesuwają się z taką samą prędkością, niezależnie od FPS:

Animacja prezentująca różnicę w ruchu zależnym i niezależnym od FPS
Animacja prezentująca różnicę w ruchu zależnym i niezależnym od FPS

Klasa główna przykładowego programu korzystającego z getDeltaTime do przesuwania obiektu wygląda następująco (pominąłem pakiet i importy):

public class FPSIndependentMovement extends ApplicationAdapter {
  private SpriteBatch batch;
  private Texture img;

  private static final int SPEED = 100;
  private float xFPSDependent;
  private float xFPSIndependent;

  @Override
  public void create () {
    batch = new SpriteBatch();
    img = new Texture("arrow.png");
  }

  @Override
  public void render () {
    Gdx.gl.glClearColor(1, 1, 1, 1);
    Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

    batch.begin();
    batch.draw(img, xFPSDependent, 80);
    batch.draw(img, xFPSIndependent, 0);
    batch.end();

    // Ruch nie zalezny od FPS, lecz od czasu, jaki uplynal
    // od poprzedniego wywolania metody render().
    xFPSIndependent += SPEED * Gdx.graphics.getDeltaTime();
    // Ruch zalezny od FPS.
    xFPSDependent += 2;
  }

  @Override
  public void dispose () {
    batch.dispose();
    img.dispose();
  }
}

W podświetlonej linii kodu stosujemy wzór na wyliczenie drogi, jaką obraz ma przebyć w tym wywołaniu metody render. Pozycję obrazu poruszającego się z prędkością niezależną od FPS przechowujemy w zmiennej o nazwie xFPSIndependent. W tym przykładzie dodałem także dla porównania obraz poruszający się z prędkością zależną od liczby FPS – jego pozycję określa zmienna xFPSDependent.

Ten przykład znajdziesz w katalogu rozdzial-06\ruch-niezalezny-od-fps na GitHubie. W katalogu z projektem dodałem dwa skrypty: run_30fps.cmd oraz run_60fps.cmd, którymi możesz uruchomić ten przykład z ustawieniem różnej wartości FPS. Dolny obraz powinien poruszać się z prędkością niezależną od liczby FPS. Możesz uruchomić ten przykład oboma skryptami na raz, aby samemu zobaczyć to zagadnienie w akcji.


W następnym rozdziale dowiesz się jak reagować na klawisze naciskane przez użytkownika na klawiaturze oraz jak odczytywać pozycję kursora myszy i sprawdzać, czy naciśnięto przycisk myszy. To wystarczy, aby napisać naszą pierwszą grę! W następującym rozdziale krok po kroku zobaczysz, jak napisać proste puzzle korzystając z LibGDX.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Nie musisz podawać swojego imienia, e-mailu, ani strony www, aby opublikować komentarz. Komentarze muszą zostać zatwierdzone, aby były widoczne na stronie.