Tworzenie gier w Javie – Klasa Actor i wykrywanie kolizji

LibGDX udostępnia nam wiele klas do wykorzystania – tematem tego rozdziału jest klasa Actor, a kolejnego – klasa Stage. Poznamy także kilka dodatkowych klas, które przydają się podczas używania klasy Actor.

Pierwsze użycie klasy Actor

Klasa ta reprezentuje obiekt świata gry, któremu można zdefiniować kilka, lub wszystkie, z poniższych cech:

  • położenie w świecie gry,
  • rozmiar,
  • skalę,
  • rotację (obrót),
  • przypisane akcje, które są obiektami typu Action, za pomocą których możemy zmieniać m. in. stan, położenie, oraz rozmiar, obiektu.

Klasę tę wykorzystujemy, gdy nasza gra będzie składała się z wielu różnych elementów, postaci, i przedmiotów, posiadających własne zestawy tekstur oraz zachowań. Aby z niej skorzystać, tworzymy nową klasę, która ją rozszerza.

Klasa Actor posiada dwie metody, które powinniśmy wywoływać na rzecz obiektów tej klasy – są to metody draw oraz act. Pierwsza odpowiedzialna jest za rysowanie obiektu na ekranie, a druga za aktualizację jego stanu.

Spójrz na poniższy przykład wykorzystania klasy Actor:

rozdzial-10\uzycie-klasy-actor\core\src\com\kursjava\gamedev\MovingActor.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.scenes.scene2d.Actor;

public class MovingActor extends Actor {
  private final Texture texture;

  public MovingActor(Texture texture, float x, float y) {
    this.texture = texture;
    this.setPosition(x, y);
  }

  @Override
  public void act(float delta) {
    super.act(delta);
  }

  @Override
  public void draw(Batch batch, float parentAlpha) {
    super.draw(batch, parentAlpha);

    batch.draw(texture, getX(), getY());
  }
}

MovingActor rozszerza klasę Actor. Nadpisujemy dwie metody z klasy bazowej – act oraz draw. Dodałem także konstruktor, który oczekuje tekstury, która będzie skojarzona z tworzonym obiektem klasy MovingActor, oraz dwie wartości określające pozycję na ekranie:

public MovingActor(Texture texture, float x, float y) {
  this.texture = texture;
  this.setPosition(x, y);
}

Zauważ, że w konstruktorze korzystamy z metody setPosition – ustawia ona pola x i y, które dziedziczymy po klasie bazowej Actor. Metodę setPosition, jak i wiele innych metod, także dostajemy do użytku dzięki rozszerzeniu klasy Actor.

Bazowa metoda draw w klasie Actor jest pusta, więc musimy ją nadpisać, aby cokolwiek zostało narysowane na ekranie. W naszej implementacji tej metody wywołujemy metodę bazową (pomimo, że jest pusta – być może w nowszych wersjach LibGDX będzie tam umieszczony jakiś istotny kod), a następnie, za pomocą obiektu batch przekazanego jako argument, rysujemy teksturę skojarzoną z naszym obiektem:

@Override
public void draw(Batch batch, float parentAlpha) {
  super.draw(batch, parentAlpha);

  batch.draw(texture, getX(), getY());
}

Metody getX i getY to kolejne metody, które dziedziczmy po klasie Actor. Argument parentAlpha to wartość przezroczystości obiektu nadrzędnego – możemy dzięki temu przeliczyć przezroczystość naszego obiektu uwzględniając przezroczystość obiektu, nad którym się on znajduje.

Klasa MovingActor zawiera także metodę act, ale na razie jedynie wywołujemy w niej metodę bazową:

@Override
public void act(float delta) {
  super.act(delta);
}

Metoda act przyjmuje jako argument czas w sekundach, jaki upłynął od jej poprzedniego wywołania. Ten czas, jak wiemy z rozdziału o upływie czasu, zwraca metoda Gdx.graphics.getDeltaTime. W przeciwieństwie do metody draw, metoda act w klasie Actor nie jest pusta i wykonuje sporo operacji związanych z obsługą akcji, które możemy przypisywać do obiektów typu Actor (oraz typów pochodnych), aby wpływać m. in. na zmianę rozmiaru i rotacji obiektu. To zagadnienie przedstawię w rozdziale o animacjach.

Do klasy dodamy jeszcze kod odpowiedzialny za zmianę położenia obiektu – zmieniłem metodę act, by wyglądała następująco:

@Override
public void act(float delta) {
  super.act(delta);

  float deltaX = 0, deltaY = 0;

  if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
    deltaX = SPEED * delta;
  } else if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
    deltaX = -SPEED * delta;
  }

  if (Gdx.input.isKeyPressed(Input.Keys.UP)) {
    deltaY = SPEED * delta;
  } else if (Gdx.input.isKeyPressed(Input.Keys.DOWN)) {
    deltaY = -SPEED * delta;
  }

  moveBy(deltaX, deltaY);
}

W metodzie act sprawdzamy teraz, czy strzałki są naciśnięte – jeżeli tak, to wyznaczamy o ile mają się zmienić współrzędne x oraz y obiektu. Korzystamy z ruchu niezależnego od FPS, który poznaliśmy w rozdziale Ruch niezależny od FPS. Aby zmienić pozycję obiektu, korzystamy z metody moveBy, odziedziczonej z klasy bazowej. SPEED to stała, którą dodałem do klasy:

private final int SPEED = 200;

Musimy jeszcze zmodyfikować kod klasy głównej naszego programu, aby wykorzystać powyższą klasę (pominąłem pakiet i importy):

rozdzial-10\uzycie-klasy-actor\core\src\com\kursjava\gamedev\ActorExample.java
public class ActorExample extends ApplicationAdapter {
  private SpriteBatch batch;
  private Texture catImg;
  private MovingActor cat;

  @Override
  public void create () {
    batch = new SpriteBatch();
    catImg = new Texture("cat.png");

    cat = new MovingActor(catImg, 10, 10);
  }

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

    cat.act(Gdx.graphics.getDeltaTime());

    batch.begin();
    cat.draw(batch, 1);
    batch.end();
  }

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

W klasie głównej naszego programu, w metodzie create, tworzymy obiekt klasy MovingActor. W metodzie render wywołujemy metodę act naszego aktora, podając czas, jaki upłynął za pomocą poznanej już metody Gdx.graphics.getDeltaTime. Aby narysować nasz obiekt, wywołujemy jego metodę draw – przekazujemy jako argument obiekt typu SpriteBatch, za pomocą którego aktor narysuje się na ekranie. Zauważ, że nadal musimy wywołać metody begin i end na rzecz obiektu batch.

Po uruchomieniu tego przykładu będziemy mogli przesuwać za pomocą strzałek obiekt na ekranie:

Wyświetlanie obiektu na ekranie korzystając z klasy Actor
Wyświetlanie obiektu na ekranie korzystając z klasy Actor

Cechy obiektów typu Actor

Jak wspomniałem na początku rozdziału, typ Actor posiada kilka cech, z których można skorzystać, aby narysować na ekranie zmieniony obraz skojarzony z danym aktorem. Są to:

  • Rotacja, którą ustawiamy za pomocą metody setRotation, której przekazujemy o ile stopni aktor ma zostać obrócony. Aktor obracany jest wokół podanego punktu, zwanego z angielskiego origin. Aby obrócić aktora wokół jego środka, należy ustawić origin za pomocą metody setOrigin na połowę wysokości i szerokości tekstury skojarzonej z aktorem.
  • Kolor, którego możemy użyć, aby zmienić kolorystykę rysowanego obrazu i/lub ustawić jego częściową przezroczystość. Kolor ustawiamy za pomocą metody setColor, przekazując cztery składowe koloru RGBA: Red, Green, Blue, oraz Alpha (przezroczystość).
  • Skala, którą ustawiamy za pomocą metody setScale. Ustawiając skalę aktora na wartość 2 spowodujemy, że jego wymiary zwiększą się dwukrotnie, natomiast wartość 0.5 spowoduje, że szerokość i wysokość będą o połowę mniejsze.

Zarówno skalowanie, jak i obroty, wymagają znajomości rozmiaru aktora. W tym celu należy wywołać metodę setSize, za pomocą której ustawimy szerokość i wysokość.

Jeżeli utworzymy teraz klasę dziedziczącą po klasie Actor i skorzystamy z powyższych metod do ustawienia różnych cech obiektów-aktorów, to w wyniku ich narysowania nie zobaczymy żadnych zmian. Stanie się tak, ponieważ do tej pory korzystaliśmy z najprostszej formy rysowania tekstur na ekranie:

batch.draw(img, 0, 0);

Aby „cechy” aktora zostały wzięte pod uwagę, skorzystamy z przeładowanej wersji metody draw obiektu batch, która przyjmuje o wiele więcej argumentów:

batch.draw(
    textureRegion,  // tekstura skojarzona z aktorem
    getX(),          // polozenie X na ekranie
    getY(),          // polozenie Y na ekranie
    getOriginX(),    // wspolrzedna X srodka obrotu
    getOriginY(),    // wspolrzedna Y srodka obrotu
    getWidth(),      // szerokosc tekstury
    getHeight(),     // wysokosc tekstury
    getScaleX(),     // skala szerokosci
    getScaleY(),     // skala wysokosci
    getRotation()    // rotacja w stopniach
);

Za pomocą powyższej wersji metody draw będziemy rysować aktora na ekranie – wszystkie wywoływane metody, które widzisz powyżej, dziedziczmy z klasy Actor. Zwracają one różne cechy aktora, które zostaną wzięte pod uwagę podczas rysowania jego tekstury na ekranie. Ta wersja metody draw wymaga jako pierwszego argumentu obiektu typu TextureRegion, więc taki obiekt utworzymy w konstruktorze.

Jak widzisz, powyższa metoda nie ustawia nigdzie koloru, który ma zmodyfikować sposób rysowania tekstury. Aby ten kolor ustawić, musimy przed wywołaniem metody batch.draw, wywołać inną metodę – batch.setColor:

batch.setColor(getColor());

Metoda getColor to kolejna metoda dziedziczona z klasy Actor.

Czas na przykład – w poniższej klasie utworzyłem kilka obiektów klasy MyActor, która rozszerza klasę Actor, aby zaprezentować, jak można wpłynąć na rysowany obraz za pomocą opisanych wyżej cech klasy Actor. Przykład składa się z klasy głównej oraz klasy MyActor.

Klasa MyActor wygląda następująco:

rozdzial-10\wyswietlanie-aktorow\core\src\com\kursjava\gamedev\MyActor.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.scenes.scene2d.Actor;

public class MyActor extends Actor {
  private TextureRegion textureRegion;

  public MyActor(Texture texture, float x, float y) {
    this.textureRegion = new TextureRegion(texture);
    this.setPosition(x, y);
    this.setSize(texture.getWidth(), texture.getHeight());
  }

  @Override
  public void draw(Batch batch, float parentAlpha) {
    super.draw(batch, parentAlpha);

    batch.setColor(getColor());
    batch.draw(
        textureRegion,  // tekstura skojarzona z aktorem
        getX(),         // polozenie X na ekranie
        getY(),         // polozenie Y na ekranie
        getOriginX(),   // wspolrzedna X srodka obrotu
        getOriginY(),   // wspolrzedna Y srodka obrotu
        getWidth(),     // szerokosc tekstury
        getHeight(),    // wysokosc tekstury
        getScaleX(),    // skala szerokosci
        getScaleY(),    // skala wysokosci
        getRotation()   // rotacja w stopniach
    );
  }
}

W konstruktorze tworzymy obiekt typu TextureRegion, którego oczekuje rozszerzona wersja metody batch.draw, której używamy w metodzie draw naszej klasy MyActor.

W konstruktorze ustawiamy także pozycję na ekranie za pomocą metody setPosition, a także wymiary aktora za pomocą metody setSize – rozmiar aktora jest wymagany, aby poprawnie go obracać i skalować. W naszym przypadku rozmiar aktora to po prostu rozmiar skojarzonej z nim tekstury.

Kod klasy głównej, która korzysta z powyższej klasy MyActor, jest następujący:

rozdzial-10\wyswietlanie-aktorow\core\src\com\kursjava\gamedev\DrawingActorsExample.java
package com.kursjava.gamedev;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;

public class DrawingActorsExample extends ApplicationAdapter {
  private SpriteBatch batch;
  private Texture catImg;

  private MyActor normalActor;
  private MyActor transparentActor;
  private MyActor greenTintedActor;
  private MyActor biggerActor;
  private MyActor smallerActor;
  private MyActor rotatedActorLeftBottomOrigin;
  private MyActor rotatedActorCenter;

  @Override
  public void create () {
    batch = new SpriteBatch();
    catImg = new Texture("cat.png");

    normalActor = new MyActor(catImg, 25, 100);

    transparentActor = new MyActor(catImg, 85, 100);
    transparentActor.setColor(1, 1, 1, 0.5f);

    greenTintedActor = new MyActor(catImg, 145, 100);
    greenTintedActor.setColor(0, 1, 0, 1);

    biggerActor = new MyActor(catImg, 205, 100);
    biggerActor.setScale(2);

    smallerActor = new MyActor(catImg, 315, 100);
    smallerActor.setScale(0.5f);

    rotatedActorLeftBottomOrigin = new MyActor(catImg, 400, 100);
    rotatedActorLeftBottomOrigin.setRotation(180);

    rotatedActorCenter = new MyActor(catImg, 410, 100);
    rotatedActorCenter.setRotation(180);
    rotatedActorCenter.setOrigin(
        rotatedActorCenter.getWidth() / 2,
        rotatedActorCenter.getHeight() / 2
    );
  }

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

    batch.begin();

    normalActor.draw(batch, 1);
    transparentActor.draw(batch, 1);
    greenTintedActor.draw(batch, 1);
    biggerActor.draw(batch, 1);
    smallerActor.draw(batch, 1);
    rotatedActorLeftBottomOrigin.draw(batch, 1);
    rotatedActorCenter.draw(batch, 1);

    batch.end();
  }

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

Najważniejsze w tym przykładzie jest utworzenie obiektów typu MyActor w metodzie create oraz przypisanie im różnych cech:

  • obiekt normalActor to „zwykły” aktor, który zostanie po prostu narysowany na ekranie bez żadnych zmian:
    normalActor = new MyActor(catImg, 25, 100);
    
  • obiekt transparentActor zostanie narysowany z 50% przezroczystością, ponieważ ustawiliśmy dla niego kolor 1, 1, 1, 0.5f – ostatnia wartość wyznacza właśnie przezroczystość. Pierwsze trzy składowe ustawione na 1 spowodują, że składowa czerwona, zielona, oraz niebieska, kolorów tekstury aktora nie zostaną zmienione:
    transparentActor = new MyActor(catImg, 85, 100);
    transparentActor.setColor(1, 1, 1, 0.5f);
  • obiekt greenTintedActor zostanie narysowany w odcieniach zieleni – ustawiliśmy dla niego kolor, który ma wyzerowaną składową czerwoną oraz niebieską – spowoduje to, że wszystkie kolory tekstury „utracą” swoje czerwone i niebieskie składowe:
    greenTintedActor = new MyActor(catImg, 145, 100);
    greenTintedActor.setColor(0, 1, 0, 1);
  • obiekty biggerActor i smallerActor zostaną, odpowiednio, narysowane w powiększeniu i pomniejszeniu, ponieważ ustawiliśmy dla nich skalę:
    biggerActor = new MyActor(catImg, 205, 100);
    biggerActor.setScale(2);
    
    smallerActor = new MyActor(catImg, 315, 100);
    smallerActor.setScale(0.5f);
  • obiekt rotatedActorLeftBottomOrigin ma ustawioną wartość rotacji (obrotu) na 180 stopni – obrót zostanie wykonany wokół lewego, dolnego wierzchołka tekstury, ponieważ jest to domyślny punkt obrotu (origin):
    rotatedActorLeftBottomOrigin = new MyActor(catImg, 400, 100);
    rotatedActorLeftBottomOrigin.setRotation(180);
  • ostatni obiekt, rotatedActorCenter, zostanie także obrócony o 180 stopni, ale obrót zostanie wykonany wokół środka aktora, ponieważ punkt obrotu został ustawiony na środek za pomocą metody setOrigin:
    rotatedActorCenter = new MyActor(catImg, 410, 100);
    rotatedActorCenter.setRotation(180);
    rotatedActorCenter.setOrigin(
        rotatedActorCenter.getWidth() / 2,
        rotatedActorCenter.getHeight() / 2
    );

W metodzie render powyższego programu rysujemy na ekranie wszystkich aktorów. Program po uruchomieniu wyświetla następujące obrazy:

Prezentacja możliwości wyświetlania aktorów za pomocą metody draw
Prezentacja możliwości wyświetlania aktorów za pomocą metody draw

Pierwszy od lewej to „zwykły” aktor. Kolejny to aktor z 50% przezroczystością, a następny to aktor, którego kolory „utraciły” czerwoną i niebieską składową, więc jego tekstura zawiera jedynie odcienie zieleni.

Czwarty od lewej jest powiększony aktor, a za nim – pomniejszony.

Dwa ostatnie obrazy należą do obróconych aktorów. Zauważ, że pierwszy jest poniżej linii wszystkich pozostałych aktorów – stało się tak dlatego, ponieważ został on obrócony względem swojego lewego, dolnego wierzchołka. Drugi z obróconych aktorów został obrócony wokół swojego środka, ponieważ tak ustawiliśmy, za pomocą metody setOrigin, punkt obrotu.

Rysowanie aktorów w ten sposób daj nam możliwości tworzenia różnych efektów i animacji. Manipulowanie skalą, rotacją, położeniem itd. można łatwo osiągnąć za pomocą akcji (obiektów typu Action), które omówię w jednym z kolejnych rozdziałów.

Ruch pod określonym kątem

W grach często mamy potrzebę przesunąć obiekt pod pewnym kątem. Aby to zrobić, musimy odpowiednio wyznaczyć przesunięcie względem współrzędnej x oraz y, zakładając ruch z określoną szybkością.

Dla przykładu, aby przesunąć obiekt o 10 pikseli pod kątem 90 stopni (odmierzając kąt od początku osi x, w kierunku przeciwnym do ruchu wskazówek zegara), powinniśmy zmienić współrzędną y tego obiektu o 10 pikseli, a współrzędną x o 0 pikseli:

Ruch obiektu pod kątem 90 stopni
Ruch obiektu pod kątem 90 stopni

Czarne strzałki, podpisane jako x i y, to osie układu współrzędnych. Czerwona linia wyznacza wektor przesunięcia obiektu. Czarna kropkowana strzałka zaznacza kąt, pod jakim obiekt się porusza. Kąt ten zaczynamy liczyć od początku osi x i kontynuujemy w kierunku przeciwnym do ruchu wskazówek zegara. Na rysunku po prawej stronie zaprezentowane jest przesunięcie obiektu – kropkowany fragment wyznacza poprzednią pozycję obiektu (przed przesunięciem).

Analogicznie możemy zaprezentować przesunięcie obiektu, który porusza się pod kątem 120 stopni:

Ruch obiektu pod kątem 120 stopni
Ruch obiektu pod kątem 120 stopni

Zauważ, że tym razem zmieniły się obie współrzędne, ale nie o taką samą wartość – obiekt przesunął się „bardziej” w górę, niż w lewo.

Analogicznie moglibyśmy wyznaczyć przesunięcie dla np. kąta 180 stopni – wtedy obiekt przesuwałby się w lewo po osi x, a wartość jego współrzędnej y by się nie zmieniała. Podobnie dla kąta 0 stopni, tylko w tym przypadku obiekt przesuwałby się w prawo.

Aby wyznaczyć o ile współrzędne x oraz y obiektu powinny się zmienić, gdy porusza się on pod pewnym kątem, powinniśmy zastosować funkcje trygonometryczne sinus oraz cosinus. Jest to jednak tak często spotykane zagadnienie w grach, że LibGDX udostępnia dla naszego wygody klasę Vector2, z której możemy w tym celu skorzystać.

Klasa ta reprezentuje wektor – może ona nam posłużyć za wektor określający przesunięcie obiektu. Wektor definiowany jest za pomocą dwóch współrzędnych x oraz y. Możemy obrócić taki wektor o podany kąt, a jego współrzędne x oraz y zostaną dla nas automatycznie przeliczone.

Aby określić wektor, który ma służyć do wyznaczania przesunięcia współrzędnych x oraz y pewnego obiektu pod określonym kątem i na określoną odległość, możemy utworzyć obiekt typu Vector2 podając tylko wartość współrzędnej x. Jej wartość ustawimy na wartość „prędkości” naszego obiektu. Następnie, obrócimy wektor o kąt, pod jakim nasz obiekt ma się poruszać. Klasa Vector2 sama przeliczy nowe wartości dla swoich współrzędnych x oraz y po obrocie. Tak uaktualniony wektor będzie nam służył do przesuwania naszego obiektu.

Dla przykładu, poniższa definicja obiektu typu Vector2 będzie nam służyła do przesuwania naszego obiektu o 50 pikseli na sekundę pod kątem 180 stopni:

goLeftVector = new Vector2(50, 0).setAngle(180);

Z tego wektora będziemy korzystać w następujący sposób:

batch.draw(
    img,
    imgX + goLeftVector.x * timeElapsed,
    imgY + goLeftVector.y * timeElapsed
);

Do współrzędnych x oraz y obrazu (reprezentowanych powyżej przez pola imgX oraz imgY) dodajemy wartości x i y wektora, przemnożone przez czas, jak upłynął od początku uruchomienia programu. Dzięki temu, wraz z upływem czasu, obraz będzie przesuwał się zgodnie z kierunkiem wyznaczanym przez kąt wektora z szybkością przekazaną do konstruktora w momencie jego tworzenia. Moglibyśmy zmieniać kąt wektora, aby symulować wykonywanie przez obiektu skrętów.

Poniższy prosty przykład pokazuje zastosowanie kilku wektorów do przesunięcia obrazu, który rysujemy na ekranie:

rozdzial-10\poruszanie-pod-katem\core\src\com\kursjava\gamedev\AngleMovementExample.java
package com.kursjava.gamedev;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.Vector2;

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

  private int imgX = 225, imgY = 175;

  private Vector2 goLeftVector, goRightVector;
  private Vector2 goUpVector, goDownVector;
  private Vector2 go30DegreesAngleVector;
  private Vector2 go135DegreesAngleVector;

  private float timeElapsed;

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

    goLeftVector = new Vector2(50, 0).setAngle(180);
    goRightVector = new Vector2(50, 0).setAngle(0);
    goUpVector = new Vector2(25, 0).setAngle(90);
    goDownVector = new Vector2(25, 0).setAngle(270);
    go30DegreesAngleVector = new Vector2(75, 0).setAngle(30);
    go135DegreesAngleVector = new Vector2(75, 0).setAngle(135);
  }

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

    timeElapsed += Gdx.graphics.getDeltaTime();

    batch.begin();

    // statyczny obraz
    batch.draw(img, imgX, imgY);

    // obraz poruszajacy sie w lewo
    batch.draw(
        img,
        imgX + goLeftVector.x * timeElapsed,
        imgY + goLeftVector.y * timeElapsed
    );

    // obraz poruszajacy sie w prawo
    batch.draw(
        img,
        imgX + goRightVector.x * timeElapsed,
        imgY + goRightVector.y * timeElapsed
    );

    // obraz poruszajacy sie w gore
    batch.draw(
        img,
        imgX + goUpVector.x * timeElapsed,
        imgY + goUpVector.y * timeElapsed
    );

    // obraz poruszajacy sie w dol
    batch.draw(
        img,
        imgX + goDownVector.x * timeElapsed,
        imgY + goDownVector.y * timeElapsed
    );

    // obraz poruszajacy sie pod katem 30 stopni
    batch.draw(
        img,
        imgX + go30DegreesAngleVector.x * timeElapsed,
        imgY + go30DegreesAngleVector.y * timeElapsed
    );

    // obraz poruszajacy sie pod katem 120 stopni
    batch.draw(
        img,
        imgX + go135DegreesAngleVector.x * timeElapsed,
        imgY + go135DegreesAngleVector.y * timeElapsed
    );

    batch.end();
  }

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

W metodzie create tworzonych jest kilka wektorów, aby zaprezentować ruch pod różnym kątem. Pierwszy argument to „prędkość”, jaką chcemy nadać obiektowi – zakładamy, że jest to liczba pikseli na sekundę. Wartość ta różni się pomiędzy różnymi wektorami poniżej, aby przesuwane za ich pomocą obiekty poruszały się z różną szybkością:

goLeftVector = new Vector2(50, 0).setAngle(180);
goRightVector = new Vector2(50, 0).setAngle(0);
goUpVector = new Vector2(25, 0).setAngle(90);
goDownVector = new Vector2(25, 0).setAngle(270);
go30DegreesAngleVector = new Vector2(75, 0).setAngle(30);
go135DegreesAngleVector = new Vector2(75, 0).setAngle(135);

W metodzie render rysowany jest tam sam obraz kilkukrotnie w różnych lokalizacjach z przesunięciem dzięki zastosowaniu utworzonych wcześniej wektorów:

// obraz poruszajacy sie w prawo
batch.draw(
    img,
    imgX + goRightVector.x * timeElapsed,
    imgY + goRightVector.y * timeElapsed
);

Pole timeElapsed zawiera czas w sekundach, jaki upłynął od początku działania programu, więc im dłużej program będzie działał, tym bardziej obrazy będą oddalać się od początkowej pozycji określanej przez pola imgX oraz imgY, które ustawiłem na takie wartości, aby statyczny obraz, rysowane na początku metody draw, był wyśrodkowany w oknie:

private int imgX = 225, imgY = 175;

Poniższa animacja przedstawia działanie powyższego programu:

Ruch obiektu pod różnymi kątami
Ruch obiektu pod różnymi kątami

Obraz znajdujący się w środku jest nieruchomy – wszystkie pozostałe obrazy oddalają się od niego, przesuwane za pomocą wyliczenia przesunięć współrzędnych x oraz y z zastosowaniem wektorów.

Wykrywanie kolizji

Gdy w grze występują obiekty, które mogą się poruszać, często zachodzi potrzeba sprawdzenia, czy ich pozycje częściowo się nie pokrywają, tzn. czy występuje kolizja tych obiektów:

Brak kolizji/kolizja obiektów
Brak kolizji/kolizja obiektów

Wykrywanie kolizji to niełatwe zadanie, szczególnie, gdy obiekty naszej gry mają nieregularne kształty. W najprostszym przypadku, możemy założyć, że obiekty będą opisywane przez prostokątne obszary – łatwo wtedy wykryć pomiędzy nimi kolizję sprawdzając położenie ich wierzchołków względem siebie. W tym prostym przypadku zakładamy, że obiekty nie mogą się obracać, lub po obrocie prostokąt, którego używamy do wykrywania kolizji, nie jest obracany.

LibGDX udostępnia także bardziej zaawansowaną funkcjonalność do wykrywania kolizji obiektów opisanych przez zestaw wierzchołków. Obiekty te mogą być także obrócone.

Spójrzmy na przykłady wykorzystania obu tych funkcjonalności.

Wykrywanie kolizji pomiędzy prostokątnymi obszarami

LibGDX udostępnia nam klasę Rectangle, która opisuje prostokątny obszar. Możemy ustawić jego lewy, dolny wierzchołek, a także szerokość i wysokość. Klasa ta zawiera metodę overlaps, której możemy przekazać inny obiekt typu Rectangle – jeżeli obszary opisane przez te obiekty nachodzą na siebie, to metoda zwróci wartość true.

Poniższa klasa CollisionActor zostanie użyta w przykładzie, w którym gracz będzie poruszał postacią, która nie będzie mogła przejść przez obiekty symbolizujące ściany bądź inne przeszkody:

rozdzial-10\proste-kolizje\core\src\com\kursjava\gamedev\CollisionActor.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.scenes.scene2d.Actor;

public class CollisionActor extends Actor {
  private final Texture texture;
  private final Rectangle collisionRectangle; // 1

  public CollisionActor(Texture texture, float x, float y) {
    this.texture = texture;
    this.setBounds(x, y, texture.getWidth(), texture.getHeight());
    this.collisionRectangle = new Rectangle(x, y, getWidth(), getHeight()); // 2
  }

  @Override
  public void draw(Batch batch, float parentAlpha) {
    super.draw(batch, parentAlpha);

    batch.draw(texture, (int) getX(), (int) getY());
  }

  @Override
  public void moveBy(float x, float y) {
    super.moveBy(x, y);

    collisionRectangle.setPosition((int) getX(), (int) getY());
  }

  public boolean checkCollision(CollisionActor other) {
    return collisionRectangle.overlaps(
        other.collisionRectangle
    );
  }
}

Obiekty klasy CollisionActor zawierają pole collisionRectangle (1) typu Rectangle, które będzie opisywać obszar zajmowany w świecie gry przez dany obiekt. Początkowe współrzędne i rozmiar tego obszaru ustawiamy w konstruktorze (2). Ustawiamy w nim także, za pomocą metody setBounds, współrzędne obiektu na ekranie oraz jego szerokość i wysokość. Zamiast tej metody moglibyśmy wywołać metody setPosition i setSize, ale skorzystanie z pojedynczej metody jest wygodniejsze.

Gdy obiekt się porusza, musimy zaktualizować nie tylko jego współrzędne, ale także położenie obszaru opisywanego przez obiekt collisionRectangle. Zajmuje się tym przeładowana wersja metody moveBy:

@Override
public void moveBy(float x, float y) {
  super.moveBy(x, y);

  collisionRectangle.setPosition((int) getX(), (int) getY());
}

Sprawdzanie kolizji pomiędzy dwoma obiektami odbywa się metodzie checkCollision. Korzysta ona z metody overlaps klasy Rectangle, o której wspominałem na początku rozdziału:

public boolean checkCollision(CollisionActor other) {
  return collisionRectangle.overlaps(
      other.collisionRectangle
  );
}

Zarówno w metodzie draw, jak i podczas aktualizacji położenia obiektu collisionRectangle w metodzie moveBy, korzystamy z rzutowania współrzędnych na typ int. Ma to na celu wyeliminowanie potencjalnych problemów z częścią ułamkową pozycji, która może spowodować, że kolizja będzie wykrywana przedwcześnie.

Klasa CollisionActor, używana w klasie głównej przykładu, reprezentuje zarówno obiekt, którym porusza gracz, jak i nieprzekraczalne ściany. Klasa główna wygląda następująco (pominąłem ciała dwóch ostatnich metod – opiszę je za moment):

rozdzial-10\proste-kolizje\core\src\com\kursjava\gamedev\SimpleCollisionsExample.java
package com.kursjava.gamedev;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;

public class SimpleCollisionsExample extends ApplicationAdapter {
  private static final int PLAYER_SPEED = 50;
  private SpriteBatch batch;
  private Texture catImg;
  private Texture blockImg;

  private CollisionActor player;
  private CollisionActor[] blocks;
  
  @Override
  public void create () {
    batch = new SpriteBatch();
    catImg = new Texture("cat.png");
    blockImg = new Texture("block.png");

    player = new CollisionActor(catImg, 0, 0);

    blocks = new CollisionActor[3];
    blocks[0] = new CollisionActor(blockImg, 40, 60);
    blocks[1] = new CollisionActor(blockImg, 40, 180);
    blocks[2] = new CollisionActor(blockImg, 230, 160);
  }

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

    handleKeyboard();

    batch.begin();

    for (CollisionActor block : blocks) {
      block.draw(batch, 1);
    }
    player.draw(batch, 1);

    batch.end();
  }

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

  private void handleKeyboard() {
      // ...
  }

  private void movePlayer(float deltaX, float deltaY) {
      // ...
  }
}

W klasie głównej wczytujemy obrazy w metodzie create oraz tworzymy cztery obiekty: obiekt gracza player oraz trzy „bloki” (symbolizujące nieprzekraczalne ściany). Wszystkie te obiekty są typu CollisionActor.

W metodzie draw wywołujemy metodę handleKeyboard obsługującą klawiaturę, która będzie miała za zadanie sprawdzać, czy gracz naciska strzałki na klawiaturze. Poza tym, w metodzie draw rysujemy wszystkie obiekty.

Spójrzmy teraz na implementację dwóch pozostałych metod: handleKeyboard oraz movePlayer:

private void handleKeyboard() {
  float deltaTime = Gdx.graphics.getDeltaTime();

  float deltaX = 0, deltaY = 0;

  if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
    deltaX = PLAYER_SPEED * deltaTime;
  } else if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
    deltaX = -PLAYER_SPEED * deltaTime;
  }

  if (Gdx.input.isKeyPressed(Input.Keys.UP)) {
    deltaY = PLAYER_SPEED * deltaTime;
  } else if (Gdx.input.isKeyPressed(Input.Keys.DOWN)) {
    deltaY = -PLAYER_SPEED * deltaTime;
  }

  if (deltaX != 0 || deltaY != 0) { // 1
    movePlayer(deltaX, deltaY);
  }
}

private void movePlayer(float deltaX, float deltaY) {
  player.moveBy(deltaX, deltaY);

  for (CollisionActor block : blocks) {
    if (player.checkCollision(block)) { // 3
      player.moveBy(-deltaX, -deltaY); // 4
      break;
    }
  }
}

W metodzie handleKeyboard sprawdzamy, czy gracz nacisnął strzałki na klawiaturze – jeżeli tak, to wyliczamy przesunięcie w odpowiednim kierunku. Na końcu tej metody wywołujemy metodę movePlayer, jeżeli faktycznie powinniśmy przesunąć postać gracza (1).

Wykrywanie kolizji pomiędzy obiektami odbywa się w metodzie movePlayer. Zauważ, że najpierw przesuwamy postać gracza (2), a później w pętli sprawdzamy kolizje pomiędzy nim a wszystkimi „blokami”. Jeżeli metoda checkCollision (3) zwróci true, oznacza to, iż gracz „wpadł” na nieprzekraczalny blok. W tym przypadku musimy „wycofać” postać gracza, tzn. cofnąć wykonany przez niego ruch – w przeciwnym razie, fragment obrazu postaci gracza znajdzie się na nieprzekraczalnym bloku. Aby to zrobić, ponownie wywołujemy metodę moveBy, ale przekazuje przeciwne wartości (4), niż poprzednio, dzięki czemu postać gracza wróci na swoje miejsce przed kolizją.

W wyniku działania tego programu możemy poruszać postacią na ekranie, ale nie możemy przekraczać pewnych obszarów:

Prezentacja programu, w którym stosowane jest proste wykrywanie kolizji
Prezentacja programu, w którym stosowane jest proste wykrywanie kolizji

Złożone kolizje

Poza kolizjami korzystającymi z prostokątnych obszarów, LibGDX udostępnia także skomplikowane, dokładne wyznaczanie kolizji pomiędzy obiektami o nieregularnych kształtach, z wzięciem pod uwagę, że obiekty te mogą być obrócone o pewien kąt.

Dodatkowo, LibGDX może dla nas wyznaczać wektor minimalnego przesunięcia, z którego możemy skorzystać, aby przesunąć nasz obiekt, by „stykał się” z obiektem, z którym zaszła kolizja, dając efekt dojścia do nieprzekraczalnej przeszkody, bez efektu wchodzenia tekstury jednego obiektu na drugi:

Dwa rodzaje kolizji: obiekty zachodzą na siebie lub stykają się krawędziami
Dwa rodzaje kolizji: obiekty zachodzą na siebie lub stykają się krawędziami

Na powyższym obrazie, po lewej stronie, widzimy przykład dwóch obiektów, które w wyniku kolizji „weszły” na siebie. Często w grach mamy potrzebę uniknięcia takich przypadków. Dla przykładu, nie chcemy, aby tekstura reprezentująca postać, którą porusza gracz, mogła „wejść” np. na ścianę. Na obrazie po prawej stronie widzimy przykład, gdy kolidujące ze sobą obiekty „stykają się” swoimi teksturami. Aby osiągnąć ten drugi efekt możemy skorzystać właśnie z wektora minimalnego przesunięcia, który może dla nas wyznaczyć LibGDX.

Klasą, którą będziemy stosować do wyznaczenia obszaru zajmowanego przez nasze obiekty, jest klasa Polygon z biblioteki LibGDX. Gdy tworzymy obiekt klasy Polygon, powinniśmy przekazać jako argument do konstruktora tablicę współrzędnych – każda para tych współrzędnych wyznacza punkt obszaru obiektu. Zbiór takich punktów wyznaczy obszar, który dany obiekt zajmuje, i który ma być brany pod uwagę podczas sprawdzania kolizji. Współrzędne punktów podajemy względem lewego, dolnego wierzchołka obiektu, zakładając, że punkt ten ma współrzędne 0, 0.

Dla przykładu, w poniższy sposób można utworzyć obiekt typu Polygon opisujący prostokąt o szerokości w oraz wysokości h:

collisionPolygon = new Polygon(
    new float[] {
    0, 0,  // lewy dolny wierzcholek
    w, 0,  // prawy dolny wierzcholek
    w, h,  // prawy gorny wierzcholek
    0, h   // lewy gorny wierzcholek
  }
);

Jako argument konstruktora przekazałem tablicę – każde dwa elementy tej tablicy wyznaczają jeden punkt opisujący obszar – pierwsza wartość każdej pary to wartość X, a druga to wartość Y. Graficznie powyższy obszar można przedstawić następująco:

Współrzędne wierzchołków prostokąta
Współrzędne wierzchołków prostokąta

W przykładowym programie do tego rozdziału będziemy wykrywali kolizje pomiędzy obiektami o prostokątnym kształcie, a także o kształcie rombu:

Obrazek rombu stosowany w przykładzie

Aby wyznaczyć punkty, które opiszą obszar tego rombu, wystarczy zauważyć, że każdy z jego wierzchołków znajduje się w połowie wysokości bądź szerokości prostokąta, w którym się on znajduje:

Współrzędne wierzchołków rombu
Współrzędne wierzchołków rombu

Gdzie w i h to, odpowiednio, wysokość i szerokość obszaru, w którym romb się znajduje, zaznaczonego na powyższy obrazku za pomocą szarych, przerywanych linii.

Obiekty klasy Polygon, poza nieregularnym kształtem, mogą być także obrócone bądź przeskalowane. Postać, którą w przykładowym programie będzie kierował gracz, będzie mogła się obracać w dowolną stronę, więc zanim dokonamy sprawdzenia, czy nie zaszły kolizje, zaktualizujemy obiekt typu Polygon, nadając mu odpowiednią rotację.

Przykładowy program składa się z klasy StationaryActor, PlayerActor, oraz klasy głównej ActorCollisionsExample. Spójrzmy najpierw na klasę StationaryActor, która będzie reprezentować różne obiekty na ekranie – pomiędzy nimi a graczem będziemy wykrywać kolizje.

rozdzial-10\kolizje-aktorow\core\src\com\kursjava\gamedev\StationaryActor.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.math.Polygon;
import com.badlogic.gdx.scenes.scene2d.Actor;

public class StationaryActor extends Actor {
  private final Texture texture;
  private final boolean canBeCollected;
  private final Polygon collisionPolygon;

  public StationaryActor(
      Texture texture,
      boolean canBeCollected,
      float x,
      float y,
      float[] collisionVertices) {

    this.texture = texture;
    this.canBeCollected = canBeCollected;
    this.setBounds(x, y, texture.getWidth(), getHeight());
    this.collisionPolygon = new Polygon(collisionVertices); // 1
    this.collisionPolygon.setPosition(x, y); // 2
  }

  @Override
  public void draw(Batch batch, float parentAlpha) {
    super.draw(batch, parentAlpha);

    if (isVisible()) { // 3
      batch.draw(texture, getX(), getY());
    }
  }

  public Polygon getCollisionPolygon() { // 4
    return collisionPolygon;
  }

  public boolean canBeCollected() {
    return canBeCollected;
  }
}

Ta prosta klasa zawiera teksturę oraz obiekt typu Polygon (1), który wyznaczy jej obszar podczas wykrywania kolizji. Punkty opisujące obszar obiektu zostaną przekazane jako argument konstruktora. Musimy jeszcze ustawić położenie obszaru collisionPolygon na ekranie (2).

Klasa posiada także pole canBeCollected. Jeżeli zajdzie kolizja pomiędzy graczem a obiektem, który można zbierać, to ustawimy pole visible takiego obiektu na false, aby przestał być rysowany na ekranie (3).

Metoda getCollisionPolygon (4) zwraca obszar zajmowany przez obiekt. Jeżeli obiekty opisywane przez klasę StationaryActor mogłyby się poruszać w świecie gry, to przed zwróceniem obiektu collisionPolygon powinniśmy uaktualnić jego położenie na ekranie. Są to jednak nieruchome obiekty, więc raz ustawione położenie w konstruktorze (2) wystarczy.

Kolejna klasa, PlayerActor, reprezentuje postać, którą porusza gracz. Początek klasy jest następujący:

rozdzial-10\kolizje-aktorow\core\src\com\kursjava\gamedev\PlayerActor.java
public class PlayerActor extends Actor {
  private final static int ROTATION_SPEED = 180;

  private final TextureRegion textureRegion;
  private final Polygon collisionPolygon;
  private final Vector2 movementVector;

  public PlayerActor(
      Texture texture,
      float x,
      float y,
      float[] collisionVertices) {

    textureRegion = new TextureRegion(texture);
    collisionPolygon = new Polygon(collisionVertices);
    movementVector = new Vector2(100, 0).setAngle(90); // 1

    this.setRotation(90); // 2
    this.setBounds(x, y, texture.getWidth(), texture.getHeight());
    // punkt na obszarze tekstury gracza, wokół którego
    // gracz powinien być obracany
    this.setOrigin(getWidth() / 2, getHeight() / 2); // 3
  }

Poza teksturą, klasa zawiera pole collisionPolygon do wykrywania kolizji, oraz obiekt movementVector, którego będziemy używać to wyznaczania przesunięcia postaci gracza względem współrzędnych x oraz y pod określonym kątem. Początkowo ustawiamy kąt poruszania się na 90 stopni (1). Ustawiamy także rotację aktora na 90 stopni (2). W linii (3) ustawiamy środek aktora jak punkt, wokół którego powinien on być obracany.

Kolejna metoda to act:

@Override
public void act(float delta) {
  super.act(delta);

  if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) { // 1
    rotateBy(-ROTATION_SPEED * delta); // 2
    movementVector.setAngle(getRotation());
  } else if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) { // 3
    rotateBy(ROTATION_SPEED * delta); // 4
    movementVector.setAngle(getRotation());
  }

  if (Gdx.input.isKeyPressed(Input.Keys.UP)) { // 5
    moveBy(movementVector.x * delta, movementVector.y * delta); // 6
  } else if (Gdx.input.isKeyPressed(Input.Keys.DOWN)) { // 7
    moveBy(-movementVector.x * delta, -movementVector.y * delta); // 8
  }
}

Za pomocą strzałek , gracz może obracać w lewo i prawo postać gracza. W liniach (1) (3) obsługujemy te klawisze i obracamy aktora oraz wektor jego przesunięcia o wyliczoną wartość obrotu. Wartość obrotu jest niezależna od FPS, ponieważ ROTATION_SPEED przemnażamy przez czas, jaki upłynął od ostatniego wywołania tej metody (2) (4). Wartość ROTATION_SPEED ustaliliśmy na 180 na początku klasy PlayerActor, więc postać gracza będzie się obracać z prędkością 180 stopni na sekundę.

Do poruszania się do przodu i do tyłu należy użyć strzałek (5) (7) Do wyliczenia przesunięcia postaci gracza korzystamy z wektora przesunięcia movementVector (6) (8).

Ponieważ postać gracza może się obracać, do jej narysowania używamy rozszerzonej wersji metody draw obiektu typu Batch. Korzystaliśmy już niej w poprzednich rozdziałach:

@Override
public void draw(Batch batch, float parentAlpha) {
  super.draw(batch, parentAlpha);

  batch.draw(
      textureRegion,  // tekstura skojarzona z aktorem
      getX(),         // polozenie X na ekranie
      getY(),         // polozenie Y na ekranie
      getOriginX(),   // wspolrzedna X srodka obrotu
      getOriginY(),   // wspolrzedna Y srodka obrotu
      getWidth(),     // szerokosc tekstury
      getHeight(),    // wysokosc tekstury
      getScaleX(),    // skala szerokosci
      getScaleY(),    // skala wysokosci
      getRotation()   // rotacja w stopniach
  );
}

Ostatnią metodą jest checkCollision:

public void checkCollision(StationaryActor obj) {
  Polygon otherPolygon = obj.getCollisionPolygon(); // 1
  // 2
  Polygon thisPolygon = collisionPolygon;
  thisPolygon.setPosition(getX(), getY());
  thisPolygon.setOrigin(this.getOriginX(), this.getOriginY());
  thisPolygon.setRotation(this.getRotation());

  // 3
  if (!thisPolygon.getBoundingRectangle().overlaps(
      otherPolygon.getBoundingRectangle())
  ) {
    return;
  }

  Intersector.MinimumTranslationVector mtv = // 4
      new Intersector.MinimumTranslationVector();

  if (Intersector.overlapConvexPolygons( // 5
      collisionPolygon,
      obj.getCollisionPolygon(),
      mtv)
  ) {
    if (obj.canBeCollected()) {
      obj.setVisible(false);
    } else {
      this.moveBy(
          mtv.normal.x * mtv.depth,
          mtv.normal.y * mtv.depth
      );
    }
  }
}

Na początku metody przypisujemy do pomocniczych zmiennych dwa obiekty typu Polygon – ten z przekazanego jako argument obiektu typu StationaryActor (1), a także ten opisujący obiekt PlayerActor (2). Jako, że postać gracza może się poruszać i obracać, musimy ustawić pozycję oraz rotację obiektu typu Polygon, którego używamy do wykrywania kolizji dla tego obiektu.

W linii (3) wykonujemy początkowe sprawdzenie, czy prostokąty, w których zawarte są obydwa obiekty typu Polygon, nachodzą na siebie. Takie wczesne sprawdzenie pozwala szybko wyeliminować sytuację, w której na pewno nie mogłoby dojść do kolizji obiektów:

Wczesne sprawdzanie, czy kolizja jest w ogóle możliwa
Wczesne sprawdzanie, czy kolizja jest w ogóle możliwa

Złożone wykrywanie kolizji jest kosztownym procesem i najlepiej ograniczyć jego wykonywanie do minimum. Taki jest właśnie cel sprawdzenia wykonywanego w linii (3).

W linii (4) tworzymy obiekt typu MinimumTranslationVector, do którego LibGDX zapisze minimalne przesunięcie obiektu w przypadku wykrycia kolizji. Ten wektor będzie pozwalał na takie przesunięcie obiektu, aby był on maksymalnie „dosunięty” do drugiego obiektu, z którym nastąpiła kolizja.

Do wykrywanie złożonych kolizji pomiędzy dwoma obiektami typu Polygon, korzystamy z klasy Intersector, którą udostępnia nam LibGDX. Korzystamy z metody overlapConvexPolygons klasy Intersector, która przyjmuje jako argument dwa obiekty typu Polygon i obiekt typu MinimumTranslationVector. Jeżeli zachodzi kolizja pomiędzy obszarami opisanymi przez oba obiekty typu Polygon, metoda ta zwraca true oraz wypełnia obiekt typu MinimumTranslationVector, który przekazujemy jej jako argument:

if (Intersector.overlapConvexPolygons( // 5
    collisionPolygon,
    obj.getCollisionPolygon(),
    mtv)
)

Jeżeli zachodzi kolizja i obiekt, z którym zaszła kolizja, możesz zostać „zjedzony”, to ustawimy jego wartość visible na false, aby nie był już rysowany. W przeciwnym razie, przesuwamy postać gracza korzystając z obiektu mtv klasy MinimumTranslationVector, który został wypełniony w metodzie overlapConvexPolygons, aby obiekt gracza znalazł się przy krawędzi obiektu, z którym zaszła kolizja. Do przesunięcia korzystamy z metody moveBy, którą dziedziczymy z klasy Actor:

if (obj.canBeCollected()) {
  obj.setVisible(false);
} else {
  this.moveBy(
      mtv.normal.x * mtv.depth,
      mtv.normal.y * mtv.depth
  );
}

Pełny kod tej klasy jest następujący:

rozdzial-10\kolizje-aktorow\core\src\com\kursjava\gamedev\PlayerActor.java
package com.kursjava.gamedev;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Polygon;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.scenes.scene2d.Actor;

public class PlayerActor extends Actor {
  private final static int ROTATION_SPEED = 180;

  private final TextureRegion textureRegion;
  private final Polygon collisionPolygon;
  private final Vector2 movementVector;

  public PlayerActor(
      Texture texture,
      float x,
      float y,
      float[] collisionVertices) {

    textureRegion = new TextureRegion(texture);
    collisionPolygon = new Polygon(collisionVertices);
    movementVector = new Vector2(100, 0).setAngle(90);

    this.setRotation(90); // wstępna rotacja postaci gracza
    this.setBounds(x, y, texture.getWidth(), texture.getHeight());
    // punkt na obszarze tekstury gracza, wokół którego
    // gracz powinien być obracany
    this.setOrigin(getWidth() / 2, getHeight() / 2);
  }

  @Override
  public void act(float delta) {
    super.act(delta);

    if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
      rotateBy(-ROTATION_SPEED * delta);
      movementVector.setAngle(getRotation());
    } else if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
      rotateBy(ROTATION_SPEED * delta);
      movementVector.setAngle(getRotation());
    }

    if (Gdx.input.isKeyPressed(Input.Keys.UP)) {
      moveBy(movementVector.x * delta, movementVector.y * delta);
    } else if (Gdx.input.isKeyPressed(Input.Keys.DOWN)) {
      moveBy(-movementVector.x * delta, -movementVector.y * delta);
    }
  }

  @Override
  public void draw(Batch batch, float parentAlpha) {
    super.draw(batch, parentAlpha);

    batch.draw(
        textureRegion,  // tekstura skojarzona z aktorem
        getX(),         // polozenie X na ekranie
        getY(),         // polozenie Y na ekranie
        getOriginX(),   // wspolrzedna X srodka obrotu
        getOriginY(),   // wspolrzedna Y srodka obrotu
        getWidth(),     // szerokosc tekstury
        getHeight(),    // wysokosc tekstury
        getScaleX(),    // skala szerokosci
        getScaleY(),    // skala wysokosci
        getRotation()   // rotacja w stopniach
    );
  }

  public void checkCollision(StationaryActor obj) {
    Polygon otherPolygon = obj.getCollisionPolygon();
    Polygon thisPolygon = collisionPolygon;
    thisPolygon.setPosition(getX(), getY());
    thisPolygon.setOrigin(this.getOriginX(), this.getOriginY());
    thisPolygon.setRotation(this.getRotation());

    if (!thisPolygon.getBoundingRectangle().overlaps(
        otherPolygon.getBoundingRectangle())
    ) {
      return;
    }

    Intersector.MinimumTranslationVector mtv =
        new Intersector.MinimumTranslationVector();

    if (Intersector.overlapConvexPolygons(
        collisionPolygon,
        obj.getCollisionPolygon(),
        mtv)
    ) {
      if (obj.canBeCollected()) {
        obj.setVisible(false);
      } else {
        this.moveBy(
            mtv.normal.x * mtv.depth,
            mtv.normal.y * mtv.depth
        );
      }
    }
  }
}

Pozostaje nam użyć dwóch powyższych klas w klasie głównej gry. Spójrzmy na kilka najważniejszych metod z tej klasy. Na końcu rozdziału znajdziesz pełny kod źródłowy klasy głównej ActorCollisionExample.

Postać gracza przechowujemy w polu player, a wszystkie pozostałe obiekty świata w tablicy gameObjects:

private PlayerActor player;
private StationaryActor[] gameObjects;

W metodzie create wczytujemy tekstury oraz tworzymy obiekty, które będzie widać na ekranie:

@Override
public void create () {
  batch = new SpriteBatch();

  cat = new Texture("cat.png");
  box = new Texture("wall.png");
  diamond = new Texture("diamond.png");
  food = new Texture("food.png");

  player = new PlayerActor( // 1
      cat,
      10,
      10,
      getRectangularVertices(cat.getWidth(), cat.getHeight())
  );

  gameObjects = new StationaryActor[15];
  gameObjects[0] = createBox(10, 80);
  gameObjects[1] = createBox(120, 80);
  gameObjects[2] = createBox(230, 80);
  gameObjects[3] = createBox(230, 150);
  gameObjects[4] = createBox(10, 200);
  gameObjects[5] = createFood(95, 90);

  gameObjects[6] = createFood(225, 125);
  gameObjects[7] = createFood(285, 125);
  gameObjects[8] = createFood(160, 250);
  gameObjects[9] = createFood(205, 250);
  gameObjects[10] = createFood(205, 90);

  gameObjects[11] = createDiamond(135, 140);
  gameObjects[12] = createDiamond(30, 140);
  gameObjects[13] = createDiamond(30, 245);
  gameObjects[14] = createDiamond(300, 205);
}

Tworząc obiekt typu PlayerActor (1), przekazujemy jako ostatni argument tablicę wierzchołków opisującą prostokątny obszar, który będzie używany do wykrywania kolizji pomiędzy graczem a innymi obiektami. Zaraz zobaczymy, jak działa wykorzystywana w tym celu metoda getRectangularVertices.

Obiekty świata gry tworzymy korzystając z trzech pomocniczych metod, które zwracają obiekty typu StationyActor z odpowiednio ustawionymi polami:

private StationaryActor createBox(float x, float y) {
  return new StationaryActor(
      box,
      false,
      x,
      y,
      getRectangularVertices(
          box.getWidth(),
          box.getHeight()
      )
  );
}

private StationaryActor createDiamond(float x, float y) {
  return new StationaryActor(
      diamond,
      true,
      x,
      y,
      getDiamondVertices(
          diamond.getWidth(),
          diamond.getHeight()
      )
  );
}

private StationaryActor createFood(float x, float y) {
  return new StationaryActor(
      food,
      true,
      x,
      y,
      getRectangularVertices(
          food.getWidth(),
          food.getHeight()
      )
  );
}

Metody te tworzą obiekty typu StationaryActor z odpowiednią teksturą, wartością true/false dla pola canBeCollected, oraz wierzchołkami opisującymi obszar służący do wykrywania kolizji.

Tablice z wierzchołkami zwracają dwie pomocnicze metody getRectangularVertices oraz getDiamonVertices:

private float[] getRectangularVertices(int width, int height) {
  return new float[] {
      0, 0,           // lewy dolny wierzcholek
      width, 0,       // prawy dolny wierzcholek
      width, height,  // prawy gorny wierzcholek
      0, height       // lewy gorny wierzcholek
  };
}

private float[] getDiamondVertices(int width, int height) {
  return new float[] {
      width / 2.0f, 0,      // środek dolnej krawędzi
      width, height / 2.0f, // środek prawej krawędzi
      width / 2.0f, height, // środek górnej krawędzi
      0, height / 2.0f      // środek lewej krawędzi
  };
}

Metody te wyznaczają cztery wierzchołki dla obszarów, odpowiednio, prostokątnego oraz w kształcie rombu. Na początku rozdziału prezentowałem, dlaczego te wartości wyznaczane są w taki właśnie sposób.

Rysowanie obiektów i wykrywanie kolizji odbywa się w metodzie render. W pętli rysujemy kolejne obiekty oraz sprawdzamy kolizję pomiędzy graczem a konkretnym obiektem. Na końcu rysujemy postać gracza:

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

  player.act(Gdx.graphics.getDeltaTime());

  batch.begin();

  for (StationaryActor gameObject : gameObjects) {
    gameObject.draw(batch, 1);
    player.checkCollision(gameObject);
  }

  player.draw(batch, 1);

  batch.end();
}

Po uruchomieniu przykładu z tego rozdziału, na ekranie zobaczymy:

Przykład programu stosującego złożone wykrywanie kolizji
Przykład programu stosującego złożone wykrywanie kolizji

Zauważ, że postać gracza znajduje się w obrębie prostokąta, w którym znajduje się niebieski romb, ale kolizja nie jest na razie wykryta – dzieje się tak dzięki złożonemu wykrywaniu kolizji, jakie zastosowaliśmy. Dopiero po zetknięciu się gracza z teksturą rombu, wykryta zostanie kolizja, a romb zniknie.

Pełny kod źródłowy klasy głównej jest następujący:

rozdzial-10\kolizje-aktorow\core\src\com\kursjava\gamedev\ActorCollisionExample.java
package com.kursjava.gamedev;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;

public class ActorCollisionExample extends ApplicationAdapter {
  private SpriteBatch batch;
  private Texture cat;
  private Texture box;
  private Texture diamond;
  private Texture food;

  private PlayerActor player;
  private StationaryActor[] gameObjects;

  @Override
  public void create () {
    batch = new SpriteBatch();

    cat = new Texture("cat.png");
    box = new Texture("wall.png");
    diamond = new Texture("diamond.png");
    food = new Texture("food.png");

    player = new PlayerActor(
        cat,
        10,
        10,
        getRectangularVertices(cat.getWidth(), cat.getHeight())
    );

    gameObjects = new StationaryActor[15];
    gameObjects[0] = createBox(10, 80);
    gameObjects[1] = createBox(120, 80);
    gameObjects[2] = createBox(230, 80);
    gameObjects[3] = createBox(230, 150);
    gameObjects[4] = createBox(10, 200);

    gameObjects[5] = createFood(95, 90);
    gameObjects[6] = createFood(225, 125);
    gameObjects[7] = createFood(285, 125);
    gameObjects[8] = createFood(160, 250);
    gameObjects[9] = createFood(205, 250);
    gameObjects[10] = createFood(205, 90);

    gameObjects[11] = createDiamond(135, 140);
    gameObjects[12] = createDiamond(30, 140);
    gameObjects[13] = createDiamond(30, 245);
    gameObjects[14] = createDiamond(300, 205);
  }

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

    player.act(Gdx.graphics.getDeltaTime());

    batch.begin();

    for (StationaryActor gameObject : gameObjects) {
      gameObject.draw(batch, 1);
      player.checkCollision(gameObject);
    }

    player.draw(batch, 1);

    batch.end();
  }

  @Override
  public void dispose () {
    batch.dispose();
    cat.dispose();
    box.dispose();
    diamond.dispose();
    food.dispose();
  }

  private StationaryActor createBox(float x, float y) {
    return new StationaryActor(
        box,
        false,
        x,
        y,
        getRectangularVertices(
            box.getWidth(),
            box.getHeight()
        )
    );
  }

  private StationaryActor createDiamond(float x, float y) {
    return new StationaryActor(
        diamond,
        true,
        x,
        y,
        getDiamondVertices(
            diamond.getWidth(),
            diamond.getHeight()
        )
    );
  }
  private StationaryActor createFood(float x, float y) {
    return new StationaryActor(
        food,
        true,
        x,
        y,
        getRectangularVertices(
            food.getWidth(),
            food.getHeight()
        )
    );
  }

  private float[] getRectangularVertices(int width, int height) {
    return new float[] {
        0, 0,           // lewy dolny wierzcholek
        width, 0,       // prawy dolny wierzcholek
        width, height,  // prawy gorny wierzcholek
        0, height       // lewy gorny wierzcholek
    };
  }

  private float[] getDiamondVertices(int width, int height) {
    return new float[] {
        width / 2.0f, 0,      // srodek dolnej krawedzi
        width, height / 2.0f, // srodek prawej krawedzi
        width / 2.0f, height, // srodek gornej krawedzi
        0, height / 2.0f      // srodek lewej krawedzi
    };
  }
}

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.