Tworzenie gier w Javie – Tworzymy grę Puzzle

Wiemy już wystarczająco dużo na temat LibGDX, aby napisać naszą pierwszą grę.

W tym rozdziale stworzymy krok po kroku proste puzzle. Użytkownik będzie układał kwadratowe części obrazka na ekranie, przesuwając je za pomocą kursora myszki. Gra z założenia ma być nieskomplikowana. W ramach ćwiczenia będziesz mógł wzbogacić ją o dodatkowe funkcjonalności, które zaproponuję na końcu rozdziału.

Poniższa animacja przedstawia tę grę w akcji:

Przykład działania gry Puzzle
Przykład działania gry Puzzle
Jeżeli będziesz miał jakieś pytania bądź problemy, śmiało opisz je komentarzu lub napisz do mnie maila – mój adres e-mail znajdziesz na tej stronie. Jeżeli napiszesz własną wersję gry Puzzle, tym bardziej daj znać w komentarzu!

Założenia gry

Warto zawsze zdefiniować sobie założenia, jakie nasza gra ma spełniać, abyśmy wiedzieli, jakie funkcjonalności musimy wziąć pod uwagę jeszcze zanim zaczniemy pisać kod gry.

Główne założenia gry Puzzle, tworzonej w ramach tego rozdziału, są następujące:

  • użytkownik układa kwadratowe elementy, które powstają z podziału wczytanego obrazka,
  • zawsze układany jest ten sam obrazek, który ma rozmiar 600x300 pikseli,
  • szerokość i wysokość elementów wynosi 100x100 pikseli,
  • w oknie powinno być rysowane pole, w którym należy umieszczać elementy układanki,
  • okno gry powinno mieć większy rozmiar niż układany obrazek, aby użytkownik mógł porozkładać puzzle po bokach,
  • elementy są losowo rozrzucone po oknie gry,
  • element przemieszcza się klikając go lewym przyciskiem myszy i przesuwając myszkę – element przesuwany jest dopóki przycisk myszki jest wciśnięty,
  • po „upuszczeniu” elementu, sprawdzane jest jego położenie – jeżeli kursor myszki w momencie „upuszczenia” elementu znajduje się we właściwym dla tego elementu miejscu w układance, to element zostaje wpasowany do swojego miejsca,
  • elementy wstawione na odpowiednie miejsce nie mogą być przesuwane,
  • jeżeli kursor myszki wyjdzie poza ekran gry, to aktualnie przesuwany element nie będzie zmieniał swojej pozycji.

Dodatkowo, dodamy dwie funkcjonalności, dzięki którym układanie obrazka będzie wygodniejsze dla użytkownika:

  • elementy umieszczone na poprawnych miejscach powinny być zawsze rysowane „pod spodem” elementów jeszcze niedopasowanych,
  • aktualnie przesuwany element powinien być zawsze rysowany nad wszystkimi pozostałymi elementami.

Dlaczego potrzebujemy dwóch powyższych funkcjonalności? Gdy rysujemy tekstury, tekstura narysowana jako pierwsza jest zawsze „na spodzie”, tzn. każda tektura narysowana później, która będzie chociaż częściowo pokrywała się z poprzednio narysowaną teksturą, będzie ją zasłaniała. Nie chcemy w naszej grze aby elementy już dopasowane potencjalnie zasłaniały te jeszcze nie umieszczone w poprawnym miejscu w układance. Tak samo z elementem, który przesuwamy – żaden inny element układanki nie powinien go zasłaniać:

Kolejność rysowania elementów
Kolejność rysowania elementów

Powyższy obrazek prezentuje, że element dopasowany jest zawsze „na spodzie”. Element niedopasowany jest nad elementem dopasowanym, ale pod elementem aktualnie przesuwanym.

W kolejnych rozdziałach będę prezentował sporo kodu źródłowego – w większości przypadków będę pomijał pakiet oraz importy, aby skrócić listingi kodu, o ile nie będę dodawał do kodu nowych instrukcji import.

Ponadto, w prezentowanych listingach linie kodu, które zostały dodane bądź się zmieniły, będę zaznaczał podświetlając ich tło (dzięki temu będziesz wiedział, na których fragmentach się skupić), np.:

public class SimpleJigsawPuzzleGame extends ApplicationAdapter {
  public static final int WINDOW_WIDTH = 800;
  public static final int WINDOW_HEIGHT = 450;

  private Texture puzzleImg;
  private SpriteBatch batch;
  private int puzzleOriginX;
  private int puzzleOriginY;

Różne fazy tworzenia gry w osobnych branchach

Grę Puzzle będziemy tworzyć krok po kroku, dodając kolejne funkcjonalności pisząc i modyfikując kod źródłowy. Kolejne etapy (wersje) gry będą dostępne w osobnych branchach w repozytorium z przykładami do tego kursu. Te branche noszą nazwę puzzle_01_opis, puzzle_02_opis itd., gdzie „opis” to krótka informacja o zawartości danego brancha.

Aby sprawdzić, jakie branche są dostępne dla gry Puzzle, skorzystaj z następującej komendy Gita z poziomu linii poleceń:

D:\tworzenie-gier-w-javie-przyklady>git branch --list -a '*puzzle*' remotes/origin/puzzle_01_wstepny_projekt remotes/origin/puzzle_02_obraz_ustawienia_okna remotes/origin/puzzle_03_dzielenie_ukladanki remotes/origin/puzzle_04_mieszanie_elementow remotes/origin/puzzle_05_pole_ukladanki remotes/origin/puzzle_06_przesuwanie_elementow remotes/origin/puzzle_07_dopasowywanie_elementow remotes/origin/puzzle_08_obracanie_elementow

Aby przejść na konkretny branch, skorzystaj z komendy git checkout z podaniem argumentu – nazwy brancha bez prefixu remotes/origin/, np.:

D:\tworzenie-gier-w-javie-przyklady>git checkout puzzle_01_wstepny_projekt

Na końcu każdego z kolejnych podrozdziałów znajdziesz informację jak nazywa się branch z konkretnymi zmianami wykonanymi w tym podrozdziale.

Finalna wersja gry znajduje się w głównym branchu o nazwie master oraz w ostatnim podrozdziale.

Wstępny projekt

Tworzenie gry zaczynamy od wygenerowania szkieletu projektu za pomocą aplikacji LibGDX Project Setup, poznanej w rozdziale drugim, oraz zaimportowaniu jej do IntelliJ IDEA. Projekt nazwałem proste-puzzle, a klasę główną – SimpleJigsawPuzzleGame.

Zajrzyj do rozdziału drugiego, jeżeli potrzebujesz sobie przypomnieć jak wykonać obie te czynności. Szkielet projektu do gry Puzzle utworzyłem i zaimportowałem w ten sam sposób, jaki opisałem w rozdziale drugim.

Usunąłem z projektu przykładowy obrazek z katalogu assets, który zawsze dodawany jest do projektu generowanego za pomocą aplikacji LibGDX Project Setup. Ponadto, usunąłem pole img klasy głównej oraz ustawiłem tło okna na białe w metodzie render. Kod źródłowy klasy głównej gry wygląda w tej chwili następująco:

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.g2d.SpriteBatch;

public class SimpleJigsawPuzzleGame extends ApplicationAdapter {
  private SpriteBatch batch;

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

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

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

Klasa DesktopLauncher pozostaje na razie niezmieniona:

package com.kursjava.gamedev.desktop;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
import com.kursjava.gamedev.SimpleJigsawPuzzleGame;

public class DesktopLauncher {
  public static void main (String[] arg) {
    LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();
    new LwjglApplication(new SimpleJigsawPuzzleGame(), config);
  }
}

Powyższe klasy są punktem wyjścia do dalszego tworzenia gry.

Ta wersja gry znajduje się w branchu puzzle_01_wstepny_projekt.

Wczytywanie obrazu i ustawienia okna

W naszej grze będziemy układać następujący obrazek przedstawiający mojego kota:

Obraz układany w grze Puzzle
Obraz układany w grze Puzzle
Możesz użyć własnego obrazu, ale powinien on mieć rozmiar, najlepiej, 600x300 pikseli.

Powyższy obraz o nazwie kitty.png umieściłem w katalogu core/assets (możesz pobrać go klikając na powyższy obraz i wybierając z menu „Zapisz obraz jako...”). Następnie:

  • do klasy SimpleJigsawPuzzleGame dodamy pole typu Texture, do którego w metodzie create wczytamy powyższy obraz,
  • wczytaną teksturę wyświetlimy na ekranie za pomocą metody render,
  • dodamy zwalnianie pamięci zajmowanej przez teksturę w metodzie dispose.

Kod będzie wyglądał następująco (podświetliłem dodane linie kodu):

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 SimpleJigsawPuzzleGame extends ApplicationAdapter {
  private Texture puzzleImg;
  private SpriteBatch batch;

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

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

    batch.begin();
    batch.draw(puzzleImg, 0, 0);
    batch.end();
  }

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

Po uruchomieniu, okno gry wygląda następująco:

Obraz układanki narysowany w oknie
Obraz układanki narysowany w oknie

W kolejnym kroku wyśrodkujemy w oknie teksturę, a także ustawimy tytuł okna i uniemożliwimy zmianę jego rozmiaru. Ponadto, ustawimy inny rozmiar okna, aby lepiej odpowiadał wielkości tekstury.

Wymiary obrazu wynoszą 600x300 pikseli, więc rozmiar okna ustawimy np. na 800x450 pikseli, dzięki czemu na około obrazu będzie odpowiednio duży margines, aby użytkownik miał miejsce na porozkładanie puzzli. Jak wiemy z jednego z poprzednich rozdziałów, rozmiar okna i inne jego cechy ustawiamy w klasie DesktopLauncher. Zamiast podać rozmiar okna „na sztywno”, w głównej klasie gry dodamy dwie stałe o nazwach WINDOW_WIDTH oraz WINDOW_HEIGHT, z których będziemy korzystać.

Aby tekstura była wyśrodkowana w oknie, musimy wskazać odpowiedni punkt, od którego ma być rysowana w metodzie render. W rozdziale o teksturach dowiedzieliśmy się, że ten punkt określa jej lewy, dolny wierzchołek. Współrzędne tego punktu są następujące:

x = szerokość_okna / 2 - szerokość_tekstury / 2
y = wysokość_okna / 2 - wysokość_tekstury / 2

Wartości te wyliczymy w metodzie create po wczytaniu obrazka z pliku i zapiszemy je w polach o nazwach puzzleOriginX oraz puzzleOriginY:

public class SimpleJigsawPuzzleGame extends ApplicationAdapter {
  public static final int WINDOW_WIDTH = 800;
  public static final int WINDOW_HEIGHT = 450;

  private Texture puzzleImg;
  private SpriteBatch batch;
  private int puzzleOriginX;
  private int puzzleOriginY;

  @Override
  public void create () {
    puzzleImg = new Texture("kitty.png");
    puzzleOriginX = WINDOW_WIDTH / 2 - puzzleImg.getWidth() / 2;
    puzzleOriginY = WINDOW_HEIGHT / 2 - puzzleImg.getHeight() / 2;

    batch = new SpriteBatch();
  }

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

    batch.begin();
    batch.draw(puzzleImg, puzzleOriginX, puzzleOriginY);
    batch.end();
  }

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

W klasie DesktopLauncher zmieniłem rozmiar okna oraz jego tytuł, a także uniemożliwiłem zmianę rozmiaru okna. Wielkość okna definiują stałe WINDOW_WIDTH oraz WINDOW_HEIGHT, zdefiniowane wcześniej w klasie głównej:

package com.kursjava.gamedev.desktop;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
import com.kursjava.gamedev.SimpleJigsawPuzzleGame;

public class DesktopLauncher {
  public static void main (String[] arg) {
    LwjglApplicationConfiguration config = new
        LwjglApplicationConfiguration();

    config.resizable = false;
    config.width = SimpleJigsawPuzzleGame.WINDOW_WIDTH;
    config.height = SimpleJigsawPuzzleGame.WINDOW_HEIGHT;
    config.title = "Simple Puzzle Game - https://kursjava.com";

    new LwjglApplication(new SimpleJigsawPuzzleGame(), config);
  }
}

Jest to finalna wersja klasy DesktopLauncher – nie będzie się ona więcej zmieniać.

Po powyższych zmianach, ekran gry wygląda następująco:

Obraz układanki wyśrodkowany w oknie
Obraz układanki wyśrodkowany w oknie
Ta wersja gry znajduje się w branchu puzzle_02_obraz_ustawienia_okna.

Podzielenie obrazka na elementy

Kolejnym krokiem będzie stworzenie elementów układanki z wczytanego w poprzednim rozdziale obrazu. W tym celu wykorzystamy poznaną w rozdziale o teksturach klasę TextureRegion – dzięki niej będziemy mogli podzielić obrazek na mniejsze elementy o rozmiarach 100 na 100 pikseli.

Obraz każdego elementu układanki będzie opisywany przez obiekt typu TextureRegion. Ponadto, każdy element będzie miał swoją aktualną pozycję na ekranie. Z tego powodu utworzymy nową klasę o nazwie PuzzlePiece, która będzie przechowywała te dane. Kod klasy PuzzlePiece będzie następujący:

package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.GridPoint2;

class PuzzlePiece {
  private TextureRegion pieceImg;
  private GridPoint2 positionOnScreen;

  PuzzlePiece(
      TextureRegion pieceImg,
      GridPoint2 positionOnScreen) {

    this.pieceImg = pieceImg;
    this.positionOnScreen = new GridPoint2(positionOnScreen);
  }

  void draw(SpriteBatch batch) {
    batch.draw(pieceImg, positionOnScreen.x, positionOnScreen.y);
  }
}

Konstruktor tej prostej klasy oczekuje dwóch obiektów:

  • obiektu klasy TextureRegion, który przechowuje informacje o fragmencie obrazu, który ten element układanki przedstawia,
  • pozycji na ekranie, na której element ma być rysowany.

Do przechowywania punktu na ekranie używamy typu GridPoint2, który udostępnia nam biblioteka LibGDX. Klasa ta zawiera pola x oraz y, opisujące pozycję na ekranie. Moglibyśmy użyć dwóch osobnych pól typu int do przechowania pozycji x oraz y elementu układanki, ale użycie obiektu typu GridPoint2 jest wygodniejsze.

Dodatkowo, klasa PuzzlePiece zawiera metodę draw. Wywołanie tej metody powoduje narysowanie elementu układanki. Metoda ta oczekuje obiektu typu SpriteBatch za pomocą którego element narysuje się na ekranie.

Mając do dyspozycji klasę PuzzlePiece, możemy podzielić obraz układanki na małe elementy. Musimy je jednak gdzieś przechowywać – w tym celu skorzystamy z listy, a konkretniej z jej implementacji o nazwie LinkedList. Ten rodzaj listy udostępnia szybkie wstawianie i usuwanie elementów – ta cecha będzie dla nas przydatna, jak zobaczymy w jednym z kolejnych rozdziałów.

Zgodnie z założeniami gry, puzzle będą miały wymiary 100 na 100 pikseli. Te wartości będą nam potrzebne w kilku miejscach w kodzie źródłowym, więc zamiast wpisywać je na sztywno, utworzymy dwie stałe o nazwach PUZZLE_PIECE_WIDTH oraz PUZZLE_PIECE_HEIGHT.

Za przygotowanie elementów puzzli odpowiedzialna będzie metoda preparePuzzlePieces klasy głównej naszej gry. Metodę tę wywołamy na końcu metody create.

Zanim napiszemy ciało metody preparePuzzlePieces, zobaczmy, jak będzie wyglądała klasa główna po uwzględnieniu powyższych zmian:

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 java.util.LinkedList;
import java.util.List;

public class SimpleJigsawPuzzleGame extends ApplicationAdapter {
  public static final int WINDOW_WIDTH = 800;
  public static final int WINDOW_HEIGHT = 450;

  private static final int PUZZLE_PIECE_WIDTH = 100;
  private static final int PUZZLE_PIECE_HEIGHT = 100;

  private Texture puzzleImg;
  private SpriteBatch batch;
  private int puzzleOriginX;
  private int puzzleOriginY;

  private List<PuzzlePiece> puzzlePiecesLeft;

  @Override
  public void create () {
    puzzleImg = new Texture("kitty.png");
    puzzleOriginX = WINDOW_WIDTH / 2 - puzzleImg.getWidth() / 2;
    puzzleOriginY = WINDOW_HEIGHT / 2 - puzzleImg.getHeight() / 2;

    batch = new SpriteBatch();

    puzzlePiecesLeft = new LinkedList<>();

    preparePuzzlePieces();
  }

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

    batch.begin();
    batch.draw(puzzleImg, puzzleOriginX, puzzleOriginY);
    batch.end();
  }

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

  private void preparePuzzlePieces() {
    // TODO - implementacja
  }
}

Listę z elementami układanki nazwałem puzzlePiecesLeft. Tworzymy ją w metodzie create, po czym wywołujemy metodę preparePuzzlePieces. W metodzie tej musimy przejść przez wszystkie rzędy i kolumny układanki, aby podzielić ją na równe części, które będą opisane przez obiekty typu TextureRegion.

Liczba rzędów i kolumn układanki zależna jest od jej rozmiaru oraz rozmiaru jej elementów. W przypadku naszego obrazu, który ma rozmiar 600x300 pikseli, układanka posiada trzy rzędy elementów oraz sześć kolumn, zakładając, że elementy mają mieć rozmiar 100 na 100 pikseli:

Zaznaczone elementy, na jakie obraz zostanie podzielony
Zaznaczone elementy, na jakie obraz zostanie podzielony

Skorzystamy z zagnieżdżonej pętli, aby przejść po kolei przez wszystkie rzędy i kolumny układanki:

private void preparePuzzlePieces() {
  int numberOfPuzzleRows =
      puzzleImg.getHeight() / PUZZLE_PIECE_HEIGHT;
  int numberOfPuzzleColumns =
      puzzleImg.getWidth() / PUZZLE_PIECE_WIDTH;

  for (int row = 0; row < numberOfPuzzleRows; row++) {
    for (int col = 0; col < numberOfPuzzleColumns; col++) {
      // TODO - implementacja
    }
  }
}

Liczbę rzędów i kolumn układanki wyliczyłem na początku metody i zapisałem w pomocniczych zmiennych numberOfPuzzleRows oraz numberOfPuzzleColumns. Dzięki tym wartościom wiemy ile iteracji musimy przeprowadzić w każdej pętli.

Następnie, utworzymy obiekt TextureRegion opisujący element wyznaczony przez aktualne wartości zmiennych row oraz col:

private void preparePuzzlePieces() {
  int numberOfPuzzleRows =
      puzzleImg.getHeight() / PUZZLE_PIECE_HEIGHT;
  int numberOfPuzzleColumns =
      puzzleImg.getWidth() / PUZZLE_PIECE_WIDTH;

  for (int row = 0; row < numberOfPuzzleRows; row++) {
    for (int col = 0; col < numberOfPuzzleColumns; col++) {

      TextureRegion puzzlePieceImg = new TextureRegion();

      puzzlePieceImg.setTexture(puzzleImg);
      puzzlePieceImg.setRegion(
          col * PUZZLE_PIECE_WIDTH,
          row * PUZZLE_PIECE_HEIGHT,
          PUZZLE_PIECE_WIDTH,
          PUZZLE_PIECE_HEIGHT
      );
    }
  }
}

W tworzonym obiekcie klasy TextureRegion o nazwie puzzlePieceImg najpierw ustawiamy skojarzoną z nim teksturę, przechowywaną w obiekcie puzzleImg, a następnie ustawiamy region.

Aktualny element układanki znajduje się w rzędzie o numerze przechowywanym w zmiennej row oraz kolumnie o wartości zmiennej col. Aby wyznaczyć fragment obrazu układanki, musimy wskazać początek tego elementu oraz podać jego wysokość i szerokość. Pierwszy element znajduje się w lewym, górnym rogu tekstury. Wtedy wartości zmiennych row i col mają wartości 0, więc pierwszy element zostanie utworzony z następującymi argumentami:

puzzlePieceImg.setRegion(
    0 * PUZZLE_PIECE_WIDTH,
    0 * PUZZLE_PIECE_HEIGHT,
    PUZZLE_PIECE_WIDTH,
    PUZZLE_PIECE_HEIGHT
);

W następnym obiegu wewnętrznej pętli, zmienna col będzie miała wartość 1, więc utworzony zostanie następujący region:

puzzlePieceImg.setRegion(
    1 * PUZZLE_PIECE_WIDTH,
    0 * PUZZLE_PIECE_HEIGHT,
    PUZZLE_PIECE_WIDTH,
    PUZZLE_PIECE_HEIGHT
);

I tak dalej – zmieniające się wartości zmiennych pętli spowodują, że „potniemy” układankę element po elemencie idąc kolumna po kolumnie, rząd po rzędzie.

Pozostaje nam jeszcze utworzyć obiekt typu PuzzlePiece, z którym skojarzymy utworzony region, oraz ustawić jego lokalizację – na razie będzie to punkt 0, 0. W kolejnym rozdziale zajmiemy się ustawieniem losowej pozycji w oknie dla każdego elementu, aby „pomieszać” elementy układanki.

Utworzony obiekt typu PuzzlePiece dodamy na listę elementów układanki puzzlePiecesLeft, utworzoną wcześniej:

private void preparePuzzlePieces() {
  int numberOfPuzzleRows =
      puzzleImg.getHeight() / PUZZLE_PIECE_HEIGHT;
  int numberOfPuzzleColumns =
      puzzleImg.getWidth() / PUZZLE_PIECE_WIDTH;

  for (int row = 0; row < numberOfPuzzleRows; row++) {
    for (int col = 0; col < numberOfPuzzleColumns; col++) {

      TextureRegion puzzlePieceImg = new TextureRegion();

      puzzlePieceImg.setTexture(puzzleImg);
      puzzlePieceImg.setRegion(
          col * PUZZLE_PIECE_WIDTH,
          row * PUZZLE_PIECE_HEIGHT,
          PUZZLE_PIECE_WIDTH,
          PUZZLE_PIECE_HEIGHT
      );

      GridPoint2 positionOnScreen = new GridPoint2(0, 0);

      PuzzlePiece piece = new PuzzlePiece(
          puzzlePieceImg,
          positionOnScreen
      );

      puzzlePiecesLeft.add(piece);
    }
  }
}

Układanka jest podzielona na elementy, ale wszystkie mają tę samą lokalizację w oknie – punktu 0, 0. Zajmiemy się teraz losowaniem pozycji elementów oknie gry.

Ta wersja gry znajduje się w branchu puzzle_03_dzielenie_ukladanki.

Losowanie pozycji elementów układanki

W poprzednim rozdziale podzieliliśmy układankę na elementy, ale każdemu z nich przypisaliśmy lokalizację w punkcie 0, 0. W tym rozdziale „rozrzucimy” puzzle po oknie gry, losując ich położenie.

Aby wylosować położenie elementu, skorzystamy z metody random klasy Math ze standardowej biblioteki Java. Metoda ta zwraca losową liczbę większą równą 0 i mniejszą od 1, czyli z zakresu <0, 1). Skorzystamy z poniższej metody pomocniczej do losowania liczby z przedziału 0 do podanej jako argumentu liczby:

private int randomIntMax(int maxValue) {
  return (int) (Math.random() * (maxValue + 1));
}

Ponieważ metoda random zwraca ułamek z zakresu <0 ,1), przemnożenie go przez maksymalną liczbę, jaką chcemy otrzymać, spowoduje, że otrzymamy liczbę z zakresu <0, maxValue). Do maxValue dodajemy 1, aby potencjalnie liczba maxValue także została wylosowana. Całość rzutujemy na typ int, aby pozbyć się części ułamkowej.

Możemy teraz napisać kolejną metodę pomocniczą, która zwróci losowy punkt w oknie gry, który będzie stanowił położenie elementu:

private GridPoint2 randomizePuzzlePiecePosition() {
  return new GridPoint2(
      randomIntMax(WINDOW_WIDTH - PUZZLE_PIECE_WIDTH),
      randomIntMax(WINDOW_HEIGHT - PUZZLE_PIECE_HEIGHT)
  );
}

Metoda randomizePuzzlePiecePosition korzysta dwukrotnie z metody randomIntMax, aby wylosować współrzędną X oraz Y elementu układanki w oknie. Musimy od szerokości i wysokości okna odjąć szerokość i wysokość elementu – w przeciwnym razie niektóre elementy mogłyby wyjść poza prawą i górną krawędź okna (pamiętajmy, że położenie obiektów to ich lewy, dolny wierzchołek):

Elementy wychodzące poza okno gry
Elementy wychodzące poza okno gry

Możemy teraz zmodyfikować metodę preparePuzzlePieces, aby przypisywała tworzonym elementom układanki losowe pozycje na ekranie:

private void preparePuzzlePieces() {
  int numberOfPuzzleRows =
      puzzleImg.getHeight() / PUZZLE_PIECE_HEIGHT;
  int numberOfPuzzleColumns =
      puzzleImg.getWidth() / PUZZLE_PIECE_WIDTH;

  for (int row = 0; row < numberOfPuzzleRows; row++) {
    for (int col = 0; col < numberOfPuzzleColumns; col++) {

      TextureRegion puzzlePieceImg = new TextureRegion();

      puzzlePieceImg.setTexture(puzzleImg);
      puzzlePieceImg.setRegion(
          col * PUZZLE_PIECE_WIDTH,
          row * PUZZLE_PIECE_HEIGHT,
          PUZZLE_PIECE_WIDTH,
          PUZZLE_PIECE_HEIGHT
      );

      GridPoint2 positionOnScreen =
          randomizePuzzlePiecePosition();

      PuzzlePiece piece = new PuzzlePiece(
          puzzlePieceImg,
          positionOnScreen
      );

      puzzlePiecesLeft.add(piece);
    }
  }
}

Czas narysować rozrzucone elementy układanki na ekranie. Ponadto, usuniemy rysowanie całego układanego obrazu na ekranie:

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

  batch.begin();

  puzzlePiecesLeft.forEach(piece -> piece.draw(batch));

  batch.end();
}

W metodzie render przechodzimy przez całą listę z elementami układanki i każemy im się narysować na ekranie. Efekt jest następujący:

Puzzle rozrzucone losowo w oknie gry
Puzzle rozrzucone losowo w oknie gry
Ta wersja gry znajduje się w branchu puzzle_04_mieszanie_elementow.

Rysowanie pól elementów

Elementy są gotowe do układania, ale użytkownik nie wie, gdzie dokładnie powinien próbować je układać. Narysujemy w oknie pomocnicze pole, które będzie wyznaczało zakres elementów układanki. Zastosujemy w tym celu poniższy obraz:

Zarys pól elementów układanki
Zarys pól elementów układanki

Ta tekstura ma te same wymiary, co układany obraz. Skopiowałem plik puzzle_outline.png do katalogu core/assets gry Puzzle. Pole typu Texture, do którego wczytamy w metodzie create ten obraz, nazwiemy puzzleOutlineImg. W metodzie render narysujemy tę teksturę – jej pozycję wyznaczają wyliczone w jednym z poprzednim rozdziałów wartości pól puzzleOriginX oraz puzzleOriginY. Dodatkowo, musimy też dodać zwalnianie obiektu puzzleOutlineImg w metodzie dispose.

Kod źródłowy klasy głównej, po uwzględnieniu powyższych zmian, jest następujący:

public class SimpleJigsawPuzzleGame extends ApplicationAdapter {
  public static final int WINDOW_WIDTH = 800;
  public static final int WINDOW_HEIGHT = 450;

  private static final int PUZZLE_PIECE_WIDTH = 100;
  private static final int PUZZLE_PIECE_HEIGHT = 100;

  private Texture puzzleImg;
  private Texture puzzleOutlineImg;
  private SpriteBatch batch;
  private int puzzleOriginX;
  private int puzzleOriginY;

  private List<PuzzlePiece> puzzlePiecesLeft;

  @Override
  public void create () {
    puzzleImg = new Texture("kitty.png");
    puzzleOriginX = WINDOW_WIDTH / 2 - puzzleImg.getWidth() / 2;
    puzzleOriginY = WINDOW_HEIGHT / 2 - puzzleImg.getHeight() / 2;

    puzzleOutlineImg = new Texture("puzzle_outline.png");
    batch = new SpriteBatch();

    puzzlePiecesLeft = new LinkedList<>();

    preparePuzzlePieces();
  }

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

    batch.begin();

    batch.draw(puzzleOutlineImg, puzzleOriginX, puzzleOriginY);

    puzzlePiecesLeft.forEach(piece -> piece.draw(batch));

    batch.end();
  }

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

  private void preparePuzzlePieces() {
    // pozostała część kodu została pominięta

Nasza gra po uruchomieniu wygląda teraz następująco:

Rysowanie zarysu pól elementów układanki
Rysowanie zarysu pól elementów układanki

Pod elementami rysowany jest zarys elementów do ułożenia.

Ta wersja gry znajduje się w branchu puzzle_05_pole_ukladanki.

Przesuwanie elementów układanki za pomocą myszki

Mając przygotowane elementy układanki, dodamy kod odpowiedzialny za ich przesuwanie w oknie gry.

Jak wiemy z rozdziału o obsłudze myszki, układ współrzędnych kursora myszki zaczyna się w lewym, górnym rogu okna, a układ współrzędnych obiektów gry – w lewym, dolnym rogu. Pobierając aktualne położenie kursora myszki będziemy to musieli wziąć pod uwagę.

Obsługą myszki zajmiemy się w metodzie handleMouse, którą będziemy wywoływać na początku metody render:

@Override
public void render () {
  handleMouse();

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

  batch.begin();

  batch.draw(puzzleOutlineImg, puzzleOriginX, puzzleOriginY);

  puzzlePiecesLeft.forEach(piece -> piece.draw(batch));

  batch.end();
}

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

private void handleMouse() {
  // TODO implementacja
}

Metoda handleMouse musi obsłużyć trzy sytuacje:

  • użytkownik klika lewym przyciskiem myszki – jeżeli kliknie na element układanki, element ten powinniśmy w jakiś sposób oznaczyć w naszym kodzie, abyśmy wiedzieli, że ten element ma być przesuwany,
  • użytkownik przesuwa kursor z wciśniętym lewym przyciskiem myszki – jeżeli kliknięty został wcześniej element układanki, to powinniśmy ten element przesuwać wraz z ruchem kursora myszki,
  • użytkownik nie trzyma lewego przyciski myszki – jeżeli wcześniej przesuwał jeden z elementów układanki, to ten element powinien zostać „upuszczony”.

Łapanie elementu

Kliknięty element układanki przypiszemy do nowego pola o nazwie selectedPiece typu PuzzlePiece. Dzięki temu będziemy mieli łatwy dostęp do elementu, który jest aktualnie przesuwany przez użytkownika.

Ponadto, w celu sprawdzania o ile kursor myszki się przesunął, musimy znać jego poprzednie położenie. Będziemy je zapisywać w nowym polu o nazwie lastMousePosition:

public class SimpleJigsawPuzzleGame extends ApplicationAdapter {
  public static final int WINDOW_WIDTH = 800;
  public static final int WINDOW_HEIGHT = 450;

  private static final int PUZZLE_PIECE_WIDTH = 100;
  private static final int PUZZLE_PIECE_HEIGHT = 100;

  private Texture puzzleImg;
  private Texture puzzleOutlineImg;
  private SpriteBatch batch;
  private int puzzleOriginX;
  private int puzzleOriginY;

  private List<PuzzlePiece> puzzlePiecesLeft;
  private PuzzlePiece selectedPiece;
  private GridPoint2 lastMousePosition = new GridPoint2();

  // pozostały kod został pominięty

Zajmiemy się teraz implementacją metody handleMouse. W pierwszej kolejności obsłużymy klikanie na element układanki.

Aby sprawdzić, czy użytkownik właśnie kliknął lewy przycisk myszki, skorzystamy z metody isButtonJustPressed poznanej w rozdziale o obsłudze myszki i klawiatury. Jak jednak sprawdzić, czy i który element układanki został kliknięty? Jeżeli znamy położenie kursora myszki i elementów układanki na ekranie, to możemy sprawdzić, czy pozycja kursora zawarta jest w obszarze jednego z elementów.

Elementy układanki przechowujemy na liście puzzlePiecesLeft. Za pomocą pętli przejdziemy przez tę listę i sprawdzimy, czy kliknięto na jeden z nich. Elementy będziemy sprawdzać od ostatniego do pierwszego. Dlaczego w takiej kolejności? Elementy rysowane później przykrywają elementy rysowane przed nimi na ekranie. Sprawdzając elementy od końca będziemy mieć pewność, że użytkownik „złapie” element „z wierzchu”:

Łapany powinien być zawsze element "na wierzchu"
Łapany powinien być zawsze element „na wierzchu”

Na powyższym obrazku element „pod spodem” znajduje się na liście puzzlePiecesLeft przed elementem, który jest nad nim. Element pod spodem jest rysowany jako pierwszy. Jeżeli będziemy sprawdzać kliknięcie elementu zaczynając od początku listy, to w powyższym przypadku użytkownik po kliknięciu zaznaczy dolny element zamiast górnego.

Potrzebujemy jeszcze dwóch rzeczy: aktualnej pozycji myszki oraz kodu odpowiedzialnego za sprawdzenie, czy myszka znajduje się w obrębie elementu układanki.

Pozycję myszki będziemy pobierać za pomocą poniższej pomocniczej metody:

private GridPoint2 getMousePosMappedToScreenPos() {
  return new GridPoint2(
      Gdx.input.getX(),
      WINDOW_HEIGHT - 1 - Gdx.input.getY()
  );
}

Mapuje ona współrzędną Y kursora myszki na układ współrzędnych ekranu, który ma swój początek w lewym, dolnym rogu ekranu.

Z tej metody skorzystamy na początku metody handleMouse. Dodałem poniżej także kod odpowiedzialny za sprawdzanie, czy użytkownik kliknął lewy przycisk myszki oraz pętlę, która za pomocą iteratora przechodzi od końca do początku listy puzzlePiecesLeft:

private void handleMouse() {
  GridPoint2 mousePosition = getMousePosMappedToScreenPos();

  if (Gdx.input.isButtonJustPressed(Input.Buttons.LEFT)) {
    ListIterator<PuzzlePiece> listIterator =
        puzzlePiecesLeft.listIterator(puzzlePiecesLeft.size());

    while (listIterator.hasPrevious()) {
      PuzzlePiece puzzlePiece = listIterator.previous();
      
      // TODO implementacja
    }
  }
}

Powyższy kod wymagał dodania jeszcze dwóch poniższych importów:

import com.badlogic.gdx.Input;
import java.util.List;

Pozostaje nam jeszcze sprawdzić, czy kliknięto w obszar jednego z elementów. Dodamy nową metodę do klasy PuzzlePiece, która będzie przyjmowała punkt i odpowiadała na pytanie, czy jest on zawarty w elemencie układanki:

class PuzzlePiece {
  private TextureRegion pieceImg;
  private GridPoint2 positionOnScreen;

  PuzzlePiece(
      TextureRegion pieceImg,
      GridPoint2 positionOnScreen) {

    this.pieceImg = pieceImg;
    this.positionOnScreen = new GridPoint2(positionOnScreen);
  }

  void draw(SpriteBatch batch) {
    batch.draw(pieceImg, positionOnScreen.x, positionOnScreen.y);
  }

  boolean isMouseIn(GridPoint2 mousePos) {
    return
        mousePos.x >= positionOnScreen.x &&
        mousePos.y >= positionOnScreen.y &&
        mousePos.x <
            positionOnScreen.x + pieceImg.getRegionWidth() &&
        mousePos.y <
            positionOnScreen.y + pieceImg.getRegionHeight();
  }
}

Metoda sprawdza, czy punkt przekazany jako argument znajduje się w prostokącie opisanym przez jego lewy, dolny wierzchołek oraz prawy, górny wierzchołek. Możemy teraz skorzystać z tej metody w handleMouse. Jeżeli isMouseIn zwróci true, powinniśmy przerwać pętlę, ponieważ znaleźliśmy element, który został kliknięty. Zapamiętamy go w utworzonym wcześniej polu selectedPiece oraz usuniemy go z listy puzzlePiecesLeft – będziemy ten element rysować osobno.

Pozostaje nam jeszcze zapamiętać aktualną pozycję kursora myszki – będziemy z niej korzystać gdy będziemy sprawdzać, o ile kursor został przesunięty, i o tyle samo będziemy przesuwać trzymany przez użytkownika element. Poprzednią pozycję myszki zapiszemy w polu lastMousePosition.

Obie powyższe zmiany wyglądają następująco:

private void handleMouse() {
  GridPoint2 mousePosition = getMousePosMappedToScreenPos();

  if (Gdx.input.isButtonJustPressed(Input.Buttons.LEFT)) {
    ListIterator<PuzzlePiece> listIterator =
        puzzlePiecesLeft.listIterator(puzzlePiecesLeft.size());

    while (listIterator.hasPrevious()) {
      PuzzlePiece puzzlePiece = listIterator.previous();

      if (puzzlePiece.isMouseIn(mousePosition)) {
        selectedPiece = puzzlePiece;
        listIterator.remove();
        break;
      }
    }

    lastMousePosition.set(mousePosition);
  }
}

Przesuwanie elementu

Mając gotowy kod do „łapania” elementów układanki, możemy napisać dalszą cześć metody handleMouse odpowiedzialną za przesuwanie złapanego elementu. Element chcemy przesuwać wtedy, gdy został on kliknięty, tzn. selectedPiece != null oraz jest aktualnie wciśnięty lewy przycisk myszki:

if (Gdx.input.isButtonPressed(Input.Buttons.LEFT) &&
    selectedPiece != null) {
  // TODO implementacja
}

Znając poprzednie i aktualne położenie kursora myszy, łatwo możemy policzyć, o ile się on przesunął, odejmując oba położenia od siebie. Do klasy PuzzlePiece dodamy nową metodę moveBy, która będzie odpowiedzialna za aktualizację położenia elementu:

class PuzzlePiece {
  private TextureRegion pieceImg;
  private GridPoint2 positionOnScreen;

  PuzzlePiece(
      TextureRegion pieceImg,
      GridPoint2 positionOnScreen) {

    this.pieceImg = pieceImg;
    this.positionOnScreen = new GridPoint2(positionOnScreen);
  }

  void draw(SpriteBatch batch) {
    batch.draw(pieceImg, positionOnScreen.x, positionOnScreen.y);
  }

  boolean isMouseIn(GridPoint2 mousePos) {
    return
        mousePos.x >= positionOnScreen.x &&
        mousePos.y >= positionOnScreen.y &&
        mousePos.x <
            positionOnScreen.x + pieceImg.getRegionWidth() &&
        mousePos.y <
            positionOnScreen.y + pieceImg.getRegionHeight();
  }

  void moveBy(int x, int y) {
    positionOnScreen.x += x;
    positionOnScreen.y += y;
  }
}

Możemy teraz skorzystać z tej metody w handleMouse:

if (Gdx.input.isButtonPressed(Input.Buttons.LEFT) &&
    selectedPiece != null) {

  selectedPiece.moveBy(
      mousePosition.x - lastMousePosition.x,
      mousePosition.y - lastMousePosition.y
  );

  lastMousePosition.set(mousePosition);
}

Dodałem także aktualizację pola lastMousePosition. Jego wartość musi być cały czas aktualizowana gdy przesuwamy kursor myszki trzymając element układanki, abyśmy w kolejnym wywołaniu handleMouse mogli policzyć, o ile kursor się przesunął.

Dodamy do powyższego kodu jeszcze jedno sprawdzenie – zgodnie z założeniami gry nie chcemy przesuwać elementu układanki jeżeli kursor myszki wyjdzie poza ekran gry. Napiszemy metodę, którą będzie wykonywała to sprawdzenie:

private boolean isMouseInsideGameWindow() {
  GridPoint2 mousePosition = getMousePosMappedToScreenPos();
  return
      mousePosition.x >= 0 &&
      mousePosition.y >= 0 &&
      mousePosition.x < WINDOW_WIDTH &&
      mousePosition.y < WINDOW_HEIGHT;
}

Metoda sprawdza, czy kursor myszki nie wyszedł poza krawędzie okna. Dodamy użycie tej metody do handleMouse:

if (Gdx.input.isButtonPressed(Input.Buttons.LEFT) &&
    selectedPiece != null) {

  if (isMouseInsideGameWindow()) {
    selectedPiece.moveBy(
        mousePosition.x - lastMousePosition.x,
        mousePosition.y - lastMousePosition.y
    );
    lastMousePosition.set(mousePosition);
  }
}

Dzięki powyższemu użyciu isMouseInsideGameWindow, trzymany element będzie przesuwany tylko wtedy, gdy kursor myszki będzie w oknie gry.

Upuszczanie elementu

Pozostaje nam jeszcze obsłużyć przypadek, gdy użytkownik przestaje trzymać przycisk myszki. Jeżeli użytkownik trzymał element, to powinniśmy dodać go z powrotem na listę puzzlePiecesLeft. Element ten dodamy na jej koniec, dzięki czemu będzie rysowany jako ostatni. W ten sposób osiągniemy efekt „upuszczania” tego elementu na pozostałe elementy, tzn. przesuwany element po upuszczeniu będzie zawsze rysowany „na wierzchu”. Ponadto, musimy także ustawić pole selectedPiece na null, ponieważ element nie jest już przesuwany:

if (Gdx.input.isButtonPressed(Input.Buttons.LEFT) &&
    selectedPiece != null) {

  if (isMouseInsideGameWindow()) {
    selectedPiece.moveBy(
        mousePosition.x - lastMousePosition.x,
        mousePosition.y - lastMousePosition.y
    );
    lastMousePosition.set(mousePosition);
  }
} else if (selectedPiece != null) {
  puzzlePiecesLeft.add(selectedPiece);

  selectedPiece = null;
}

Powyższy fragment kodu będziemy jeszcze modyfikować w jednym z kolejnych podrozdziałów – musimy jeszcze sprawdzić, czy element jest na swoim miejscu po upuszczeniu.

W tej chwili cała metoda handleMouse wygląda następująco:

private void handleMouse() {
  GridPoint2 mousePosition = getMousePosMappedToScreenPos();

  if (Gdx.input.isButtonJustPressed(Input.Buttons.LEFT)) {
    ListIterator<PuzzlePiece> listIterator =
        puzzlePiecesLeft.listIterator(puzzlePiecesLeft.size());

    while (listIterator.hasPrevious()) {
      PuzzlePiece puzzlePiece = listIterator.previous();

      if (puzzlePiece.isMouseIn(mousePosition)) {
        selectedPiece = puzzlePiece;
        listIterator.remove();
        break;
      }
    }

    lastMousePosition.set(mousePosition);
  }

  if (Gdx.input.isButtonPressed(Input.Buttons.LEFT) &&
      selectedPiece != null) {

    if (isMouseInsideGameWindow()) {
      selectedPiece.moveBy(
          mousePosition.x - lastMousePosition.x,
          mousePosition.y - lastMousePosition.y
      );
      lastMousePosition.set(mousePosition);
    }
  } else if (selectedPiece != null) {
    puzzlePiecesLeft.add(selectedPiece);

    selectedPiece = null;
  }
}

Rysowanie przesuwanego elementu

Przesuwanie złapanego elementu układanki już działa, ale musimy go jeszcze narysować! W obsłudze łapania elementu w handleMouse usuwamy ten element tymczasowo z listy puzzlePiecesLeft i zapisujemy go w polu selectedPiece. Dodamy do metody render rysowanie tego złapanego elementu:

@Override
public void render () {
  handleMouse();

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

  batch.begin();

  batch.draw(puzzleOutlineImg, puzzleOriginX, puzzleOriginY);

  puzzlePiecesLeft.forEach(piece -> piece.draw(batch));
  if (selectedPiece != null) {
    selectedPiece.draw(batch);
  }

  batch.end();
}
Ta wersja gry znajduje się w branchu puzzle_06_przesuwanie_elementow.

Sprawdzanie, czy element układanki jest na swoim miejscu

Ostatnią rzeczą, którą musimy zaimplementować w naszej grze, to sprawdzanie, czy element układanki po upuszczeniu jest na swoim miejscu.

Docelowe miejsce elementu

Do klasy PuzzlePiece dodamy nowe pole, które będziemy ustawiać w momencie tworzenia elementów układanki. Nazwiemy je positionInPuzzle – będzie ono wyznaczało lewy, dolny wierzchołek miejsca danego elementu w układance. Będziemy mogli z niego skorzystać w celu sprawdzenia, czy kursor myszki w momencie „upuszczenia” elementu był w odpowiednim miejscu, tzn. czy element został upuszczony w prawidłowym miejscu. Do klasy PuzzlePiece dodamy metodę isPositionRight, która sprawdzi, czy położenie myszki jest w regionie docelowym danego elementu układanki:

class PuzzlePiece {
  private TextureRegion pieceImg;
  private GridPoint2 positionOnScreen;
  private GridPoint2 positionInPuzzle;

  PuzzlePiece(
      TextureRegion pieceImg,
      GridPoint2 positionOnScreen,
      GridPoint2 positionInPuzzle) {

    this.pieceImg = pieceImg;
    this.positionOnScreen = new GridPoint2(positionOnScreen);
    this.positionInPuzzle = new GridPoint2(positionInPuzzle);
  }

  void draw(SpriteBatch batch) {
    batch.draw(pieceImg, positionOnScreen.x, positionOnScreen.y);
  }

  boolean isMouseIn(GridPoint2 mousePos) {
    return
        mousePos.x >= positionOnScreen.x &&
        mousePos.y >= positionOnScreen.y &&
        mousePos.x <
            positionOnScreen.x + pieceImg.getRegionWidth() &&
        mousePos.y <
            positionOnScreen.y + pieceImg.getRegionHeight();
  }

  void moveBy(int x, int y) {
    positionOnScreen.x += x;
    positionOnScreen.y += y;
  }

  boolean isPositionRight(GridPoint2 dropPosition) {
    return
        dropPosition.x >= positionInPuzzle.x &&
        dropPosition.y >= positionInPuzzle.y &&
        dropPosition.x <
            positionInPuzzle.x + pieceImg.getRegionWidth() &&
        dropPosition.y <
            positionInPuzzle.y + pieceImg.getRegionHeight();
  }
}

Metoda isPositionRight sprawdza, czy punkt przekazany jako argument jest zawarty w prostokącie opisanym przez docelowe położenie danego elementu układanki.

Dodaliśmy do konstruktora argument positionInPuzzle – musimy jeszcze zmodyfikować metodę preparePuzzlePieces z klasy głównej, aby przekazać tę wartość do konstruktora:

private void preparePuzzlePieces() {
  int numberOfPuzzleRows =
      puzzleImg.getHeight() / PUZZLE_PIECE_HEIGHT;
  int numberOfPuzzleColumns =
      puzzleImg.getWidth() / PUZZLE_PIECE_WIDTH;

  for (int row = 0; row < numberOfPuzzleRows; row++) {
    for (int col = 0; col < numberOfPuzzleColumns; col++) {

      TextureRegion puzzlePieceImg = new TextureRegion();

      puzzlePieceImg.setTexture(puzzleImg);
      puzzlePieceImg.setRegion(
          col * PUZZLE_PIECE_WIDTH,
          row * PUZZLE_PIECE_HEIGHT,
          PUZZLE_PIECE_WIDTH,
          PUZZLE_PIECE_HEIGHT
      );

      GridPoint2 positionOnScreen =
          randomizePuzzlePiecePosition();

      GridPoint2 positionInPuzzle =
          new GridPoint2(
              puzzleOriginX + col * PUZZLE_PIECE_WIDTH,
              puzzleOriginY +
                (numberOfPuzzleRows - 1 - row) * PUZZLE_PIECE_HEIGHT
          );

      PuzzlePiece piece = new PuzzlePiece(
          puzzlePieceImg,
          positionOnScreen,
          positionInPuzzle
      );

      puzzlePiecesLeft.add(piece);
    }
  }
}

Docelową pozycję X elementu wyliczamy jako położenie w teksturze przemnożone razy szerokość elementu. Musimy też uwzględnić margines układanki, który przechowuje pole puzzleOriginX:

puzzleOriginX + col * PUZZLE_PIECE_WIDTH,

Z docelową współrzędną Y jest trudniej – musimy wziąć pod uwagę, że współrzędne w teksturze mają (podobnie jak kursor myszki) układ współrzędnych zaczynający się w lewym, górnym rogu. Musimy to uwzględnić i zmapować docelowe położenie Y elementu układanki stosując poniższy wzór:

puzzleOriginY +
    (numberOfPuzzleRows - 1 - row) * PUZZLE_PIECE_HEIGHT

Spójrz na poniższy obrazek:

Docelowa współrzędna Y elementów z pierwszej kolumny układanki
Docelowa współrzędna Y elementów z pierwszej kolumny układanki

Współrzędna Y wszystkich dolnych elementów, z uwzględnieniem marginesu okna, jest równa 75 (tyle wynosi dolny margines okna gry). Gdy w metodzie preparePuzzlePieces tworzymy wszystkie elementy układanki, zmienna row dla dolnego rzędu wynosi 2. Poniższy wzór:

puzzleOriginY +
    (numberOfPuzzleRows - 1 - row) * PUZZLE_PIECE_HEIGHT

daje nam dla ostatniego rzędu wynik:

75 + (3 - 1 - 2 ) * 100 = 75 + 0 * 100 = 75

Dla rzędu środkowego i pierwszego współrzędna Y będzie odpowiednio przesunięta, ponieważ wartość zmiennej row będzie, odpowiednio, miała wartość 1 i 0. Tak zapisany wzór da nam w rezultacie poprawnie wyliczoną współrzędną Y lewego, dolnego wierzchołka każdego elementu układanki.

Sprawdzanie miejsca upuszczenia elementu

Możemy teraz w handleMouse, w momencie „upuszczania” elementu, skorzystać z metody isPositionRight dodanej do klasy PuzzlePiece. Przekażemy do niej aktualne położenie kursora myszy. Jeżeli okaże się, że element jest we właściwym miejscu, to umieścimy go na nowej liście elementów już ułożonych. Ta lista będzie zawierała tylko te elementy, który zostały wstawione na właściwe miejsce.

Dzięki skorzystaniu z osobnej listy na elementy ułożone, będziemy mogli je łatwo rysować przed elementami jeszcze nieułożonymi – dzięki temu elementy ułożone będą zawsze „pod spodem” wszystkich nieułożonych elementów. W przeciwnym razie mogłyby one przykryć elementy, które jeszcze nie zostały dopasowane. Dodatkowo, takiego ułożonego elementu nie będziemy już brali pod uwagę podczas sprawdzania, czy jeden z elementów został kliknięty, ponieważ nie będzie go na liście puzzlePiecesLeft.

Nową listę z elementami ułożonymi nazwałem puzzlePiecesInPlace:

public class SimpleJigsawPuzzleGame extends ApplicationAdapter {
  public static final int WINDOW_WIDTH = 800;
  public static final int WINDOW_HEIGHT = 450;

  private static final int PUZZLE_PIECE_WIDTH = 100;
  private static final int PUZZLE_PIECE_HEIGHT = 100;

  private Texture puzzleImg;
  private Texture puzzleOutlineImg;
  private SpriteBatch batch;
  private int puzzleOriginX;
  private int puzzleOriginY;

  private List<PuzzlePiece> puzzlePiecesLeft;
  private List<PuzzlePiece> puzzlePiecesInPlace;
  private PuzzlePiece selectedPiece;
  private GridPoint2 lastMousePosition = new GridPoint2();

  @Override
  public void create () {
    puzzleImg = new Texture("kitty.png");
    puzzleOriginX = WINDOW_WIDTH / 2 - puzzleImg.getWidth() / 2;
    puzzleOriginY = WINDOW_HEIGHT / 2 - puzzleImg.getHeight() / 2;

    puzzleOutlineImg = new Texture("puzzle_outline.png");
    batch = new SpriteBatch();

    puzzlePiecesLeft = new LinkedList<>();
    puzzlePiecesInPlace = new LinkedList<>();

    preparePuzzlePieces();
  }

  @Override
  public void render () {
    handleMouse();

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

    batch.begin();

    batch.draw(puzzleOutlineImg, puzzleOriginX, puzzleOriginY);

    puzzlePiecesInPlace.forEach(piece -> piece.draw(batch));
    puzzlePiecesLeft.forEach(piece -> piece.draw(batch));
    if (selectedPiece != null) {
      selectedPiece.draw(batch);
    }

    batch.end();
  }

  // pozostała część kodu została pominięta

Jeżeli element jest na swoim miejscu po upuszczeniu, to dodamy go do listy puzzlePiecesInPlace. W przeciwnym razie wstawimy go z powrotem do listy puzzlePiecesLeft:

private void handleMouse() {
  // początek metody został pominięty...

  if (Gdx.input.isButtonPressed(Input.Buttons.LEFT) &&
      selectedPiece != null) {

    if (isMouseInsideGameWindow()) {
      selectedPiece.moveBy(
          mousePosition.x - lastMousePosition.x,
          mousePosition.y - lastMousePosition.y
      );
      lastMousePosition.set(mousePosition);
    }
  } else if (selectedPiece != null) {
    if (selectedPiece.isPositionRight(mousePosition)) {
      puzzlePiecesInPlace.add(selectedPiece);
    } else {
      puzzlePiecesLeft.add(selectedPiece);
    }

    selectedPiece = null;
  }
}

Nasza gra jest prawie gotowa. Jeżeli uruchomisz ją w aktualnej wersji, to zauważysz, że elementy „upuszczone” na właściwe miejsce nie są do nich dopasowywane. Brakuje jeszcze tej ostatniej funkcjonalności – „upuszczony” element powinien zostać dosunięty (wyrównany) do jego pola.

Jest to łatwe do osiągnięcia, ponieważ znamy docelowe miejsce każdego elementu – przechowujemy je w polu positionInPuzzle w obiektach klasy PuzzlePiece. Wystarczy w momencie „upuszczenia” elementu we właściwym miejscu ustawić pole positionOnScreen danego elementu na tę samą wartość, co pole positionInPuzzle. Do klasy PuzzlePiece dodamy nową metodę, która będzie za to odpowiedzialna:

void snapToGrid() {
  positionOnScreen.set(positionInPuzzle);
}

Tę metodę wywołamy w handleMouse w momencie, gdy okaże się, że użytkownik „upuścił” element na właściwym miejscu:

private void handleMouse() {
  // początek metody został pominięty...

  if (Gdx.input.isButtonPressed(Input.Buttons.LEFT) &&
      selectedPiece != null) {

    if (isMouseInsideGameWindow()) {
      selectedPiece.moveBy(
          mousePosition.x - lastMousePosition.x,
          mousePosition.y - lastMousePosition.y
      );
      lastMousePosition.set(mousePosition);
    }
  } else if (selectedPiece != null) {
    if (selectedPiece.isPositionRight(mousePosition)) {
      selectedPiece.snapToGrid();
      puzzlePiecesInPlace.add(selectedPiece);
    } else {
      puzzlePiecesLeft.add(selectedPiece);
    }

    selectedPiece = null;
  }
}

W ten sposób zakończyliśmy implementację gry Puzzle, zgodną z założeniami z początku rozdziału. Pełny kod źródłowy znajdziesz na końcu rozdziału.

Ta wersja gry znajduje się w branchu puzzle_07_dopasowywanie_elementow.

Ćwiczenia

Do gry puzzle możesz dodać w ramach ćwiczeń nowe funkcjonalności, na przykład:

  • elementy układanki nie powinny wychodzić poza ekran gry, gdy użytkownik przysunie je do jednej z krawędzi okna,
  • elementy powinny być losowo obrócone o 0, 90, 180, lub 270 stopni – użytkownik powinien móc obracać je o 90 stopni klikając na nie prawym przyciskiem myszki – elementy po upuszczeniu powinny być „dopasowane” tylko wtedy, gdy są na swoim miejscu i są poprawnie obrócone,
  • użytkownik powinien móc wybrać więcej niż jeden obraz do ułożenia np. za pomocą przycisków 1, 2, 3 na klawiaturze (jeżeli byłyby dostępne trzy obrazy),
  • użytkownik powinien móc wybrać rozmiar puzzli z np. dwóch lub trzech dostępnych, zdefiniowanych rozmiarów. Dla obrazków o rozmiarze 600x300 pikseli mogłyby to być wymiary np. 50x50, 100x100, oraz 150x150.

Obracanie rysowanej tekstury można łatwo osiągnąć za pomocą jednej z przeładowanych metod draw obiektu SpriteBatch. Metoda draw klasy PuzzlePiece mogłaby wyglądać następująco:

void draw(SpriteBatch batch) {
  batch.draw(
      pieceImg,
      positionOnScreen.x,
      positionOnScreen.y,
      pieceImg.getRegionWidth() / 2,
      pieceImg.getRegionHeight() / 2,
      pieceImg.getRegionWidth(),
      pieceImg.getRegionHeight(),
      1,
      1,
      rotation
  );
}

Ta przeładowana wersja metody draw klasy SpriteBatch przyjmuje wiele argumentów. Czwarty i piąty argument to punkt, wokół którego tekstura ma być obracana – podanie połowy szerokości i wysokości tekstury powoduje, że będzie ona obracana wokół jej środka. Ostatni argument to kąt obrotu w stopniach.

Jeżeli będziesz miał jakieś pytania bądź problemy, śmiało opisz je komentarzu lub napisz do mnie maila – mój adres e-mail znajdziesz na tej stronie. Jeżeli napiszesz własną wersję gry Puzzle, tym bardziej daj znać w komentarzu!

Przygotowałem wersję gry Puzzle, w której elementy układanki są losowo obracane. Znajdziesz ją w branchu puzzle_08_obracanie_elementow.

Pełny kod źródłowy gry Puzzle

Poniżej znajdziesz pełny kod źródłowy trzech klas, z których składa się gra Puzzle stworzona w ramach tego rozdziału. Poniższy kod źródłowy, wraz z konfiguracją Gradle oraz obrazkami używanymi w grze, znajdziesz także na GitHubie w katalogu rozdzial-08\proste-puzzle.

rozdzial-08\proste-puzzle\core\src\com\kursjava\gamedev\SimpleJigsawPuzzleGame.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;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.GridPoint2;

import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;

public class SimpleJigsawPuzzleGame extends ApplicationAdapter {
  public static final int WINDOW_WIDTH = 800;
  public static final int WINDOW_HEIGHT = 450;

  private static final int PUZZLE_PIECE_WIDTH = 100;
  private static final int PUZZLE_PIECE_HEIGHT = 100;

  private Texture puzzleImg;
  private Texture puzzleOutlineImg;
  private SpriteBatch batch;
  private int puzzleOriginX;
  private int puzzleOriginY;

  private List<PuzzlePiece> puzzlePiecesLeft;
  private List<PuzzlePiece> puzzlePiecesInPlace;
  private PuzzlePiece selectedPiece;
  private GridPoint2 lastMousePosition = new GridPoint2();

  @Override
  public void create () {
    puzzleImg = new Texture("kitty.png");
    puzzleOriginX = WINDOW_WIDTH / 2 - puzzleImg.getWidth() / 2;
    puzzleOriginY = WINDOW_HEIGHT / 2 - puzzleImg.getHeight() / 2;

    puzzleOutlineImg = new Texture("puzzle_outline.png");
    batch = new SpriteBatch();

    puzzlePiecesLeft = new LinkedList<>();
    puzzlePiecesInPlace = new LinkedList<>();

    preparePuzzlePieces();
  }

  @Override
  public void render () {
    handleMouse();

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

    batch.begin();

    batch.draw(puzzleOutlineImg, puzzleOriginX, puzzleOriginY);

    puzzlePiecesInPlace.forEach(piece -> piece.draw(batch));
    puzzlePiecesLeft.forEach(piece -> piece.draw(batch));
    if (selectedPiece != null) {
      selectedPiece.draw(batch);
    }

    batch.end();
  }

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

  private void handleMouse() {
    GridPoint2 mousePosition = getMousePosMappedToScreenPos();

    if (Gdx.input.isButtonJustPressed(Input.Buttons.LEFT)) {
      ListIterator<PuzzlePiece> listIterator =
          puzzlePiecesLeft.listIterator(puzzlePiecesLeft.size());

      while (listIterator.hasPrevious()) {
        PuzzlePiece puzzlePiece = listIterator.previous();

        if (puzzlePiece.isMouseIn(mousePosition)) {
          selectedPiece = puzzlePiece;
          listIterator.remove();
          break;
        }
      }

      lastMousePosition.set(mousePosition);
    }

    if (Gdx.input.isButtonPressed(Input.Buttons.LEFT) &&
        selectedPiece != null) {

      if (isMouseInsideGameWindow()) {
        selectedPiece.moveBy(
            mousePosition.x - lastMousePosition.x,
            mousePosition.y - lastMousePosition.y
        );
        lastMousePosition.set(mousePosition);
      }
    } else if (selectedPiece != null) {
      if (selectedPiece.isPositionRight(mousePosition)) {
        selectedPiece.snapToGrid();
        puzzlePiecesInPlace.add(selectedPiece);
      } else {
        puzzlePiecesLeft.add(selectedPiece);
      }

      selectedPiece = null;
    }
  }

  private void preparePuzzlePieces() {
    int numberOfPuzzleRows =
        puzzleImg.getHeight() / PUZZLE_PIECE_HEIGHT;
    int numberOfPuzzleColumns =
        puzzleImg.getWidth() / PUZZLE_PIECE_WIDTH;

    for (int row = 0; row < numberOfPuzzleRows; row++) {
      for (int col = 0; col < numberOfPuzzleColumns; col++) {

        TextureRegion puzzlePieceImg = new TextureRegion();

        puzzlePieceImg.setTexture(puzzleImg);
        puzzlePieceImg.setRegion(
            col * PUZZLE_PIECE_WIDTH,
            row * PUZZLE_PIECE_HEIGHT,
            PUZZLE_PIECE_WIDTH,
            PUZZLE_PIECE_HEIGHT
        );

        GridPoint2 positionOnScreen =
            randomizePuzzlePiecePosition();

        GridPoint2 positionInPuzzle =
            new GridPoint2(
                puzzleOriginX + col * PUZZLE_PIECE_WIDTH,
                puzzleOriginY +
                 (numberOfPuzzleRows - 1 - row) * PUZZLE_PIECE_HEIGHT
            );

        PuzzlePiece piece = new PuzzlePiece(
            puzzlePieceImg,
            positionOnScreen,
            positionInPuzzle
        );

        puzzlePiecesLeft.add(piece);
      }
    }
  }

  private GridPoint2 randomizePuzzlePiecePosition() {
    return new GridPoint2(
        randomIntMax(WINDOW_WIDTH - PUZZLE_PIECE_WIDTH),
        randomIntMax(WINDOW_HEIGHT - PUZZLE_PIECE_HEIGHT)
    );
  }

  private int randomIntMax(int maxValue) {
    return (int) (Math.random() * (maxValue + 1));
  }

  private GridPoint2 getMousePosMappedToScreenPos() {
    return new GridPoint2(
        Gdx.input.getX(),
        WINDOW_HEIGHT - 1 - Gdx.input.getY()
    );
  }

  private boolean isMouseInsideGameWindow() {
    GridPoint2 mousePosition = getMousePosMappedToScreenPos();
    return
        mousePosition.x >= 0 &&
        mousePosition.y >= 0 &&
        mousePosition.x < WINDOW_WIDTH &&
        mousePosition.y < WINDOW_HEIGHT;
  }
}
rozdzial-08\proste-puzzle\core\src\com\kursjava\gamedev\PuzzlePiece.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.GridPoint2;

class PuzzlePiece {
  private TextureRegion pieceImg;
  private GridPoint2 positionOnScreen;
  private GridPoint2 positionInPuzzle;

  PuzzlePiece(
      TextureRegion pieceImg,
      GridPoint2 positionOnScreen,
      GridPoint2 positionInPuzzle) {

    this.pieceImg = pieceImg;
    this.positionOnScreen = new GridPoint2(positionOnScreen);
    this.positionInPuzzle = new GridPoint2(positionInPuzzle);
  }

  void draw(SpriteBatch batch) {
    batch.draw(pieceImg, positionOnScreen.x, positionOnScreen.y);
  }

  boolean isMouseIn(GridPoint2 mousePos) {
    return
        mousePos.x >= positionOnScreen.x &&
        mousePos.y >= positionOnScreen.y &&
        mousePos.x <
            positionOnScreen.x + pieceImg.getRegionWidth() &&
        mousePos.y <
            positionOnScreen.y + pieceImg.getRegionHeight();
  }

  void moveBy(int x, int y) {
    positionOnScreen.x += x;
    positionOnScreen.y += y;
  }

  boolean isPositionRight(GridPoint2 dropPosition) {
    return
        dropPosition.x >= positionInPuzzle.x &&
            dropPosition.y >= positionInPuzzle.y &&
            dropPosition.x <
                positionInPuzzle.x + pieceImg.getRegionWidth() &&
            dropPosition.y <
                positionInPuzzle.y + pieceImg.getRegionHeight();
  }

  void snapToGrid() {
    positionOnScreen.set(positionInPuzzle);
  }
}
rozdzial-08\proste-puzzle\desktop\src\com\kursjava\gamedev\desktop\DesktopLauncher.java
package com.kursjava.gamedev.desktop;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
import com.kursjava.gamedev.SimpleJigsawPuzzleGame;

public class DesktopLauncher {
  public static void main (String[] arg) {
    LwjglApplicationConfiguration config =
        new LwjglApplicationConfiguration();

    config.resizable = false;
    config.width = SimpleJigsawPuzzleGame.WINDOW_WIDTH;
    config.height = SimpleJigsawPuzzleGame.WINDOW_HEIGHT;
    config.title = "Simple Puzzle Game - https://kursjava.com";

    new LwjglApplication(new SimpleJigsawPuzzleGame(), config);
  }
}

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.