Tworzenie gier w Javie – Tworzymy grę Hello Snake!

W tym rozdziale napiszemy razem kolejną grę – będzie to prosty wężyk. Zadaniem gracza będzie „zjadanie” jedzenia, które będzie losowo pojawiało się na ekranie, w wyniku czego wąż będzie wydłużał się o jeden segment. Gra zakończy się, gdy wąż wpadnie na samego siebie.

Gra prezentuje się następująco:

Ukończona gra Hello Snake
Ukończona gra Hello Snake!

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 Hello Snake!, to 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 Hello Snake! są następujące:

  • Dla każdego segmentu węża używamy takiej samej tekstury: Tekstura używana do reprezentacji segmentu węża
  • Na ekranie w losowym miejscu pojawia się jedzenie, po zjedzeniu którego wąż wydłuża się o jeden segment.
  • Tekstura dla jedzenia jest następująca: Tekstura jedzenia, które należy zbierać w grze
  • Rozmiar tekstury segmentu węża oraz jedzenia ma rozmiar 15 na 15 pikseli.
  • Rozmiar okna powinniśmy ustalić na wielokrotność rozmiaru segmentu węża – niech będzie to 420 na 225 pikseli. Będzie to oznaczało, że w szerokości zmieści się 28 segmentów węża, a w wysokości – 15. Reprezentacja miejsc w oknie gry, które może zajmować wąż bądź jedzenie, wygląda następująco:
    Podział okna gry na segmenty
    Podział okna gry na segmenty, które może zajmować wąż i jedzenie
  • Gracz nie powinien móc zmieniać rozmiaru okna.
  • Wąż będzie się automatycznie przesuwał w ustalonym przez gracza kierunku. Aby zmienić kierunek węża, gracz powinien skorzystać ze strzałek na klawiaturze.
  • Nie powinno być możliwe „zawrócenie” węża, tzn. zmiana kierunku o 180 stopni. Dla przykładu, gdy wąż porusza się w lewo, naciśnięcie strzałki w prawo nie powinno mieć żadnego efektu – w przeciwnym razie „głowa” węża wpadłaby od razu na poprzedni segment.
  • Wąż powinien poruszać się „skokowo”, tzn. o wartość równą rozmiarowi swojej tekstury. Co 100 milisekund, wąż przesunie się w ustalonym kierunku o 15 pikseli, bo taki jest rozmiar tekstury używanej do reprezentacji segmentu węża.
  • Gdy gra się rozpoczyna, wąż składa się z pięciu segmentów i porusza się w prawą stronę.
  • Gra się kończy, gdy wąż wpadnie na samego siebie. Gracz może rozpocząć nową grę naciskając klawisz F2.
  • Gdy wąż będzie przy krawędzi okna, powinien „wyjść” z drugiej strony.

W kolejnych podrozdział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. Pełny kod źródłowy znajdziesz w ostatnim podrozdziale.

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 void render () {
  snake.act(Gdx.graphics.getDeltaTime());

  if (snake.isCherryFound(cherry.getPosition())) {
    snake.extendSnake();
    cherry.randomizePosition();
  }

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

  batch.begin();

  cherry.draw(batch);
  snake.draw(batch);

  batch.end();
}

Różne fazy tworzenia gry w osobnych branchach

Grę Hello Snake! 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ę snake_01_opis, snake_02_opis itd., gdzie „opis” to krótka informacja o zawartości danego brancha.

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

D:\tworzenie-gier-w-javie-przyklady>git branch --list -a "*snake*" remotes/origin/snake_01_wstepny_projekt remotes/origin/snake_02_rozmiar_okna remotes/origin/snake_03_wstepna_wersja_klasy_snake remotes/origin/snake_04_obsluga_zmiany_kierunku remotes/origin/snake_05_wychodzenie_poza_krawedzie remotes/origin/snake_06_wyswietlanie_jedzenia remotes/origin/snake_07_zjadanie_jedzenia remotes/origin/snake_08_wykrywanie_kolizji remotes/origin/snake_09_restartowanie_gry remotes/origin/snake_10_osobne_tekstury_dla_glowy_i_ogona remotes/origin/snake_11_lepsze_losowanie_pozycji_jedzenia

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 snake_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.

Aby szybko uruchomić projekt Hello Snake!, bez potrzeby otwierania projektu w IntelliJ IDEA, możesz z linii poleceń wykonać komendę gradlew desktop:run w katalogu głównym projektu:

D:\tworzenie-gier-w-javie-przyklady\rozdzial-09\hello-snake>gradlew desktop:run

Wstępny projekt

W pierwszej kolejności wygenerujemy projekt LibGDX za pomocą aplikacji LibGDX Project Setup. Projekt nazwałem hello-snake, a klasę główną – HelloSnake.

Zajrzyj do rozdziału drugiego, jeżeli potrzebujesz sobie przypomnieć, jak wykonać obie te czynności. Szkielet projektu do gry Hello Snake! 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:

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\HelloSnake.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.g2d.SpriteBatch;

public class HelloSnake 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);

    batch.begin();
    batch.end();
  }

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

Klasa DesktopLauncher na razie pozostaje niezmieniona:

rozdzial-09\hello-snake\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.HelloSnake;

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

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

Ta wersja gry znajduje się w branchu snake_01_wstepny_projekt.

Ustawienie rozmiaru okna

Zgodnie z założeniami gry, ustawimy rozmiar okna na 420 x 225 pikseli. Liczby te są wielokrotnością szerokości i wysokości tekstury używanej do reprezentacji segmentu węża, dzięki czemu segmenty będą zawsze wyświetlane na ekranie w całości, także przy krawędziach okna.

Dodatkowo, uniemożliwimy zmianę rozmiaru okna gry, a także ustawimy jego tytuł.

Aby dokonać tych zmian, zmodyfikujemy klasę DesktopLauncher:

rozdzial-09\hello-snake\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.HelloSnake;

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

    config.resizable = false;
    config.width = 420;
    config.height = 225;
    config.title = "Hello Snake! https://kursjava.com";

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

Jest to ostateczna wersja klasy DesktopLauncher dla tego projektu. Okno gry powinno wyglądać następująco:

Wstępna wersja gry - ustawiony docelowy rozmiar okna
Wstępna wersja gry - ustawiony docelowy rozmiar okna

Ta wersja gry znajduje się w branchu snake_02_rozmiar_okna.

Podstawowa wersja klasy Snake

Utworzymy teraz podstawową wersję klasy o nazwie Snake, która będzie reprezentowała postać węża na ekranie.

Będzie ona zawierać teksturę segmentu węża, którą przekażemy do konstruktora. Ponadto, musimy także przechowywać informacje o segmentach węża.

Zauważ, że jedynymi danymi o każdym segmencie węża, jakie potrzebujemy, to pozycja lewego, dolnego wierzchołka w oknie gry, a także kolejność segmentu:

Segment opisuje jego kolejność oraz położenie jego lewego, dolnego wierzchołka na ekranie
Segment opisuje jego kolejność oraz położenie jego lewego, dolnego wierzchołka na ekranie

Pierwszy segment to zawsze głowa węża. Współrzędne położenia każdego segmentu znajdą się na liście obiektów typu Gridpoint2. Klasa ta udostępniana jest nam przez LibGDX – zawiera ona współrzędne x oraz y, które opiszą pozycję segmentu na ekranie. Kolejność na liście, którą nazwałem snakeSegments, będzie wyznaczała kolejność segmentów węża – pierwszy element listy będzie zawsze głową węża, a na koniec listy będziemy dodawać nowe segmenty, gdy wąż będzie zjadał jedzenie.

Dodatkowo, w konstruktorze dodamy na listę segmentów pięć elementów – będą to początkowe segmenty węża. Poza konstruktorem, w klasie znajdzie się metoda draw, której będziemy używać do rysowania węża na ekranie.

Klasa Snake wygląda na razie następująco:

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\Snake.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.math.GridPoint2;
import java.util.ArrayList;
import java.util.List;

public class Snake {
  private final Texture texture;
  private final List<GridPoint2> snakeSegments;

  public Snake(Texture texture) {
    this.texture = texture;

    snakeSegments = new ArrayList<>();

    snakeSegments.add(new GridPoint2(90, 30));
    snakeSegments.add(new GridPoint2(75, 30));
    snakeSegments.add(new GridPoint2(60, 30));
    snakeSegments.add(new GridPoint2(45, 30));
    snakeSegments.add(new GridPoint2(30, 30));
  }

  public void draw(Batch batch) {
    for (GridPoint2 pos : snakeSegments) {
      batch.draw(texture, pos.x, pos.y);
    }
  }
}

Rysowanie węża jest proste – wystarczy przejść przez tablicę snakeSegments i narysować teksturę węża w każdej z lokalizacji zapisanych w elementach tej tablicy.

Wczytamy teraz w klasie głównej teksturę segmentu węża oraz utworzymy obiektu klasy Snake. Do katalogu assets skopiowałem teksturę segmentu węża:

Tekstura używana do reprezentacji segmentu węża

Do metody render dodamy rysowanie węża, a do dispose – zwalnianie zasobu tekstury:

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\HelloSnake.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 HelloSnake extends ApplicationAdapter {
  private SpriteBatch batch;
  private Texture snakeImg;

  private Snake snake;

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

    snakeImg = new Texture("snake.png");

    snake = new Snake(snakeImg);
  }

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

    batch.begin();

    snake.draw(batch);

    batch.end();
  }

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

Jeżeli teraz uruchomimy grę, to zobaczymy na ekranie nieruchomego węża.

Do klasy Snake dodamy teraz metodę act, która będzie odpowiedzialna za przesuwanie węża. Aktualny kierunek węża określi wartość pola enum MovementDirection, którą zapiszemy w nowym polu direction, które dodamy do klasy Snake. Domyślnie wąż będzie poruszał się w prawo – taką wartość nadamy polu direction w konstruktorze:

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\Snake.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.math.GridPoint2;

import java.util.ArrayList;
import java.util.List;

enum MovementDirection { LEFT, UP, RIGHT, DOWN }

public class Snake {
  private final Texture texture;
  private final List<GridPoint2> snakeSegments;
  private MovementDirection direction;

  public Snake(Texture texture) {
    this.texture = texture;

    direction = MovementDirection.RIGHT;
    snakeSegments = new ArrayList<>();

    snakeSegments.add(new GridPoint2(90, 30));
    snakeSegments.add(new GridPoint2(75, 30));
    snakeSegments.add(new GridPoint2(60, 30));
    snakeSegments.add(new GridPoint2(45, 30));
    snakeSegments.add(new GridPoint2(30, 30));
  }

  public void act(float deltaTime) {
    // TODO implementacja
  }

  public void draw(Batch batch) {
    for (GridPoint2 pos : snakeSegments) {
      batch.draw(texture, pos.x, pos.y);
    }
  }
}

Jaka powinna być treść metody act, aby wąż się poruszał w oknie gry? Okazuje się, że jest to bardzo proste zadanie, ponieważ w każdym momencie znamy następne położenie każdego segmentu węża poza pierwszym (czyli głową węża): ostatni segment powinien zająć miejsce przed ostatniego, przed ostatni – przed przed ostatniego itd. Faktyczne przesunięcie musimy wykonać tylko w przypadku pierwszego segmentu węża, w zależności od kierunku, w jakim się on porusza:

Przesuwanie segmentów węża - każdy, poza pierwszym, przechodzi na pozycję poprzedniego segmentu
Przesuwanie segmentów węża - każdy, poza pierwszym, przechodzi na pozycję poprzedniego segmentu

Metoda act w klasie Snake będzie więc wyglądała następująco:

fragment klasy Snake
public void act() {
  for (int i = snakeSegments.size() - 1; i > 0; i--) { // 1
    snakeSegments.get(i).set(snakeSegments.get(i - 1)); // 2
  }

  GridPoint2 head = snakeSegments.get(0); // 3

  switch (direction) { // 4
    case LEFT:
      head.x -= texture.getWidth();
      break;
    case UP:
      head.y += texture.getHeight();
      break;
    case RIGHT:
      head.x += texture.getWidth();
      break;
    case DOWN:
      head.y -= texture.getHeight();
      break;
  }
}

Na początku metody act przechodzimy przez wszystkie segmenty węża poza pierwszym, od ostatniego do drugiego (1). W ciele pętli, ustawiamy pozycję segmentu na taką, jaką zajmuje obecnie następny segment w kolejności (2). Dzięki temu, osiągniemy efekt przesuwania się wszystkich segmentów węża poza pierwszym.

Pierwszy segment, czyli głowę węża (3), musimy przesunąć w inny sposób. Na podstawie wartości pola direction, zmienimy odpowiednią współrzędną głowy węża o wartość szerokości bądź wysokości jego tekstury – dzięki temu, głową węża przesunie się w odpowiednim kierunku.

Możemy teraz skorzystać z metody act na początku metody render klasy głównej:

fragment klasy HelloSnake
@Override
public void render () {
  snake.act();

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

  batch.begin();

  snake.draw(batch);

  batch.end();
}

Jeżeli teraz uruchomimy program, to zobaczymy, że wąż porusza się bardzo szybko i po krótkiej chwili znika z ekranu.

Zamiast przesuwać węża w każdym wywołaniu metody render, powinniśmy zmieniać położenie co pewien stały, określony czas – np. co 100 milisekund. Oznacza to, że w trakcie sekundy wąż przesunie się o 10 jednostek, gdzie jedna jednostka to długość jednego segmentu.

Aby osiągnąć powyższe założenie, do klasy Snake dodamy pole timeElapsedSinceLastMove, które będzie akumulować czas, jaki minął od ostatniego przesunięcia:

fragment klasy Snake
public class Snake {
  private final Texture texture;
  private final List<GridPoint2> snakeSegments;
  private MovementDirection direction;
  private float timeElapsedSinceLastMove;

  public Snake(Texture texture) {
    this.texture = texture;
  // reszta kodu została pominięta

Metoda act będzie teraz przyjmowała argument deltaTime, który określi czas, jaki upłynął od jej poprzedniego wywołania. Gdy wartość timeElapsedSinceLastMove przekroczy ustaloną wartość 100 milisekund (czyli 0.1 sekundy), przesuniemy węża i wyzerujemy wartość timeElapsedSinceLastMove. Metoda act klasy Snake będzie wyglądała następująco:

fragment klasy Snake
public void act(float deltaTime) {
  timeElapsedSinceLastMove += deltaTime;

  if (timeElapsedSinceLastMove >= 0.1) {
    timeElapsedSinceLastMove = 0;

    for (int i = snakeSegments.size() - 1; i > 0; i--) {
      snakeSegments.get(i).set(snakeSegments.get(i - 1));
    }

    GridPoint2 head = snakeSegments.get(0);

    switch (direction) {
      case LEFT:
        head.x -= texture.getWidth();
        break;
      case UP:
        head.y += texture.getHeight();
        break;
      case RIGHT:
        head.x += texture.getWidth();
        break;
      case DOWN:
        head.y -= texture.getHeight();
        break;
    }
  }
}

Musimy jeszcze uaktualnić metodę render z klasy głównej, aby przekazała do metody act wartość deltaTime, korzystając z metody getDeltaTime obiektu graphics, który udostępnia nam LibGDX. Ta metoda (poznana w rozdziale Ruch zależny od upływu czasu) zwraca czas, jaki upłynął od jej ostatniego wywołania:

fragment klasy HelloSnake
@Override
public void render () {
  snake.act(Gdx.graphics.getDeltaTime());

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

  batch.begin();

  snake.draw(batch);

  batch.end();
}

Gdy teraz uruchomimy grę, zobaczymy, że wąż porusza się wolniej. Przesuwa się on o jeden segment co około 100 milisekund. Gra w tej chwili prezentuje się następująco:

Wstępna wersja gry, w której wąż porusza się w prawo
Wstępna wersja gry, w której wąż porusza się w prawo

Aktualny kod klasy Snake jest następujący:

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\Snake.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.math.GridPoint2;

import java.util.ArrayList;
import java.util.List;

enum MovementDirection { LEFT, UP, RIGHT, DOWN }

public class Snake {
  private final Texture texture;
  private final List<GridPoint2> snakeSegments;
  private MovementDirection direction;
  private float timeElapsedSinceLastMove;

  public Snake(Texture texture) {
    this.texture = texture;

    direction = MovementDirection.RIGHT;
    snakeSegments = new ArrayList<>();

    snakeSegments.add(new GridPoint2(90, 30));
    snakeSegments.add(new GridPoint2(75, 30));
    snakeSegments.add(new GridPoint2(60, 30));
    snakeSegments.add(new GridPoint2(45, 30));
    snakeSegments.add(new GridPoint2(30, 30));
  }

  public void act(float deltaTime) {
    timeElapsedSinceLastMove += deltaTime;

    if (timeElapsedSinceLastMove >= 0.1) {
      timeElapsedSinceLastMove = 0;

      for (int i = snakeSegments.size() - 1; i > 0; i--) {
        snakeSegments.get(i).set(snakeSegments.get(i - 1));
      }

      GridPoint2 head = snakeSegments.get(0);

      switch (direction) {
        case LEFT:
          head.x -= texture.getWidth();
          break;
        case UP:
          head.y += texture.getHeight();
          break;
        case RIGHT:
          head.x += texture.getWidth();
          break;
        case DOWN:
          head.y -= texture.getHeight();
          break;
      }
    }
  }

  public void draw(Batch batch) {
    for (GridPoint2 pos : snakeSegments) {
      batch.draw(texture, pos.x, pos.y);
    }
  }
}

Ta wersja gry znajduje się w branchu snake_03_wstepna_wersja_klasy_snake.

Zmiana kierunku poruszania się węża

Kolejny elementem, jaki dodamy do gry, będzie zmiana kierunku, w jakim porusza się wąż.

Do metody act klasy Snake dodamy wywołanie nowej metody o nazwie handleDirectionChange. Ta metoda będzie odpowiedzialna za sprawdzenie, czy gracz nacisnął strzałki na klawiaturze. Wydzielimy także kod odpowiedzialny za przesuwanie segmentów węża do osobnej metody o nazwie move:

fragment klasy Snake
public void act(float deltaTime) {
  handleDirectionChange(); // 1

  timeElapsedSinceLastMove += deltaTime;

  if (timeElapsedSinceLastMove >= 0.1) {
    timeElapsedSinceLastMove = 0;
    move(); // 2
  }
}

private void handleDirectionChange() {
  // TODO implementacja
}

private void move() { // 3
  for (int i = snakeSegments.size() - 1; i > 0; i--) {
    snakeSegments.get(i).set(snakeSegments.get(i - 1));
  }

  GridPoint2 head = snakeSegments.get(0);

  switch (direction) {
    case LEFT:
      head.x -= texture.getWidth();
      break;
    case UP:
      head.y += texture.getHeight();
      break;
    case RIGHT:
      head.x += texture.getWidth();
      break;
    case DOWN:
      head.y -= texture.getHeight();
      break;
  }
}

Kod do przesuwania węża, zawarty wcześniej w metodzie act, przeniosłem do metody move (3). Metodę tę wywołujemy, gdy nadejdzie czas, aby przesunąć węża (2). Na początku metody render (1) wywoływana jest nowa metoda o nazwie handleDirectionChange, którą teraz zaimplementujemy.

Aby zmienić kierunek, w jakim porusza się wąż, wystarczy sprawdzić, czy gracz nacisnął którąś ze strzałek na klawiaturze, i odpowiednio ustawić wartość pola direction. Musimy jednak wziąć pod uwagę jedno z założeń gry – wąż nie może zmienić kierunku o 180 stopni, ponieważ to skutkowałoby, że od razu wpadłby na samego siebie. Dlatego w metodzie handleDirectionChange będziemy także sprawdzać, czy próba zmiany kierunku węża jest dozwolona: jeżeli wąż idzie w prawo, to nie można zmienić jego kierunku na „lewo”, jeżeli idzie w lewo, nie można zmienić kierunku na „prawo” itd.:

fragment klasy Snake
private void handleDirectionChange() {
  if (Gdx.input.isKeyJustPressed(Input.Keys.LEFT) &&
      direction != MovementDirection.RIGHT) {
    direction = MovementDirection.LEFT;
  }

  if (Gdx.input.isKeyJustPressed(Input.Keys.RIGHT) &&
      direction != MovementDirection.LEFT) {
    direction = MovementDirection.RIGHT;
  }

  if (Gdx.input.isKeyJustPressed(Input.Keys.UP) &&
      direction != MovementDirection.DOWN) {
    direction = MovementDirection.UP;
  }

  if (Gdx.input.isKeyJustPressed(Input.Keys.DOWN) &&
      direction != MovementDirection.UP) {
    direction = MovementDirection.DOWN;
  }
}

Do sprawdzenia, czy użytkownik nacisnął którąś ze strzałek, korzystamy z metody isKeyJustPressed obiektu input, poznaną w rozdziale o obsłudze klawiatury i myszki. Zauważ, że poza użyciem isKeyJustPressed, sprawdzamy aktualny kierunek węża, aby uniemożliwić zwrot o 180 stopni. Przykładowo:

if (Gdx.input.isKeyJustPressed(Input.Keys.UP) &&
    direction != MovementDirection.DOWN) {
  direction = MovementDirection.UP;
}

W tej instrukcji warunkowej zezwalamy na zmianę kierunku na „góra” tylko w przypadku, gdy naciśnięto strzałkę w górę oraz wąż aktualnie nie porusza się w dół.

W tej chwili gra wygląda następująco:

Wersja gra z możliwością skręcania wężem
Wersja gra z możliwością skręcania wężem

Ta wersja gry znajduje się w branchu snake_04_obsluga_zmiany_kierunku.

Obsługa wychodzenia poza krawędzie okna

Zgodnie z założeniami gry, jeżeli wąż wyjdzie poza krawędź okna gry, to powinien wystąpić efekt „wyjścia” z przeciwległej krawędzi:

Wersja gry z możliwością przechodzenia przez krawędzie okna
Wersja gry z możliwością przechodzenia przez krawędzie okna

Aby osiągnąć to założenie, musimy przed przesunięciem głowy węża sprawdzić, czy jest on aktualnie przy krawędzi okna i porusza się w takim kierunku, który spowodowałby, że po wykonaniu ruchu głowa węża zniknęłaby z ekranu. W takim przypadku powinniśmy ustawić położenie węża tak, by był przy przeciwległej krawędzi okna gry.

Jeżeli wąż porusza się w lewo, to sprawdzanie, czy wyjdzie poza krawędź, sprowadza się do porównania współrzędnej x głowy węża do zera. Ustawimy w tym przypadku wartość x głowy węża na ostatnią widoczną pozycję w oknie gry, która graniczy z prawą krawędzią okna. Ta wartość to szerokość okna minus szerokość tekstury segmentu węża. Wyliczymy tę wartość w metodzie move klasy Snake:

int segmentWidth = texture.getWidth();

int lastWindowSegmentX = Gdx.graphics.getWidth() - segmentWidth;

Z wyliczonej wartości skorzystamy w instrukcji switch w metodzie move:

case LEFT:
  head.x = (head.x == 0) ? lastWindowSegmentX : head.x - segmentWidth;
  break;

Korzystając z trój-argumentowego operatora logicznego, sprawdzamy, czy aktualnie głowa węża znajduje się przy lewej krawędzi okna gry – współrzędna x ma wtedy wartość 0. Jeżeli tak, to ustawiamy tę współrzędną na wyliczoną wartość lastWindowSegmentX – dzięki temu, wąż zamiast zniknąć za lewą krawędzią okna, pojawi się przy przeciwległej krawędzi. Jeżeli aktualna wartość x jest inna od 0, to wykonamy „normalny” ruch, tzn. przesuniemy głowę węża w lewo o wartość równą szerokości segmentu węża.

Analogicznie należy postąpić dla ruchu węża w pozostałych trzech kierunkach. Różnić będą się tylko zmieniane współrzędne i wartości nadawane w momencie wyjścia poza krawędź okna. Finalna wersja metody move będzie wyglądać następująco:

fragment klasy Snake
private void move() {
  // przesuń wszystkie segmenty poza głową
  for (int i = snakeSegments.size() - 1; i > 0; i--) {
    snakeSegments.get(i).set(snakeSegments.get(i - 1));
  }

  // przesuń głowę
  int segmentWidth = texture.getWidth();
  int segmentHeight = texture.getWidth();

  // pozycje X, Y ostatniego segmentu
  // przed górną i prawą krawędzią okna
  int lastWindowSegmentX = Gdx.graphics.getWidth() - segmentWidth;
  int lastWindowSegmentY = Gdx.graphics.getHeight() - segmentHeight;

  GridPoint2 head = snakeSegments.get(0);

  switch (direction) {
    case LEFT:
      head.x = (head.x == 0) ? lastWindowSegmentX : head.x - segmentWidth;
      break;
    case UP:
      head.y = (head.y == lastWindowSegmentY) ? 0 : head.y + segmentHeight;
      break;
    case RIGHT:
      head.x = (head.x == lastWindowSegmentX) ? 0 : head.x + segmentWidth;
      break;
    case DOWN:
      head.y = (head.y == 0) ? lastWindowSegmentY : head.y - segmentHeight;
      break;
  }
}

W przypadku ruchu w dół i górę, potrzebujemy wartości lastWindowSegmentY, którą wyznaczamy w analogiczny sposób, jak lastWindowSegmentX. Dla ruchu w dół obsługa wychodzenia poza krawędź jest taka sama, jak dla ruchu w lewo, z tym, że działamy na współrzędnej y.

Dla ruchu w prawo i w górę warunek „wyjścia” z krawędzi jest odwrotny niż dla ruchu w lewo i w dół – sprawdzamy, czy głowa węża jest aktualnie przy prawej bądź górnej krawędzi, korzystając ze zmiennych lastWindowSegmentX oraz lastWindowSegmentY – jeżeli tak, to zmieniamy odpowiednią współrzędną na 0.

Ta wersja gry znajduje się w branchu snake_05_wychodzenie_poza_krawedzie.

Wyświetlanie jedzenia węża w losowym miejscu okna gry

Jedzenie węża, którego zbieranie będzie zadaniem gracza, będzie reprezentować nowa klasa o nazwie Cherry. Klasa ta będzie przechowywać teksturę skojarzoną z jedzeniem oraz położenie w oknie gry, w którym ma być ono rysowane, reprezentowane przez obiekt typu GridPoint2. Poza tym dwoma polami, do klasy dodamy także konstruktor i kilka metod.

Wstępna wersja tej klasy wygląda następująco:

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\Cherry.java
package com.kursjava.gamedev;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.math.GridPoint2;

public class Cherry {
  private final GridPoint2 position;
  private final Texture texture;

  public Cherry(Texture texture) {
    this.texture = texture;
    this.position = new GridPoint2();
  }
}

Obiekt na ekranie będziemy rysować za pomocą metody draw, którą także dodamy do klasy Cherry:

public void draw(Batch batch) {
  batch.draw(texture, position.x, position.y);
}

Musimy jeszcze wylosować miejsce w oknie gry, w którym ma pojawić się jedzenie. Losowanie pozycji na ekranie opakujemy w metodę randomizePosition, której będziemy wielokrotnie używać – za każdym razem, gdy wąż zje jedzenie, wylosujemy dla niego nowe położenie.

Metoda randomizePosition w klasie Cherry wygląda następująco:

public void randomizePosition() {
  int numberOfXPositions =
      Gdx.graphics.getWidth() / texture.getWidth();

  int numberOfYPositions =
      Gdx.graphics.getHeight() / texture.getHeight();

  this.position.set(
      (int) (Math.random() * numberOfXPositions) * texture.getWidth(),
      (int) (Math.random() * numberOfYPositions) * texture.getHeight()
  );
}

Zmienne pomocnicze numberOfXPositions oraz numberOfYPositions wyznaczają liczbę możliwych pozycji w oknie gry. Widzieliśmy podział okna na możliwe pozycje segmentów węża (bądź jedzenia) w jednym początkowych rozdziałów:

Podział okna gry na segmenty
Podział okna gry na segmenty, które może zajmować wąż i jedzenie

Aby wylosować pozycję jedzenia w oknie gry, losujemy dwie liczby z przedziału <0 do numberOfXPositions lub numberOfYPositions), a następnie przemnażamy każdą z tych wylosowanych wartości przez szerokość (bądź wysokość) tekstury jedzenia, dzięki czemu pozycja będzie odpowiednio dopasowana do „siatki” pozycji prezentowanych na powyższym obrazku.

Z metody randomizePosition skorzystamy od razu w konstruktorze klasy Cherry, aby nadać jedzeniu wstępną pozycję na ekranie. Do klasy dodałem także getter getPosition dla pola position – przyda się w kolejnym rozdziale. Poniżej znajduje się pełny kod źródłowy tej klasy:

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\Cherry.java
package com.kursjava.gamedev;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.math.GridPoint2;

public class Cherry {
  private final GridPoint2 position;
  private final Texture texture;

  public Cherry(Texture texture) {
    this.texture = texture;
    this.position = new GridPoint2();

    randomizePosition();
  }

  public void draw(Batch batch) {
    batch.draw(texture, position.x, position.y);
  }

  public void randomizePosition() {
    int numberOfXPositions =
        Gdx.graphics.getWidth() / texture.getWidth();

    int numberOfYPositions =
        Gdx.graphics.getHeight() / texture.getHeight();

    this.position.set(
        (int) (Math.random() * numberOfXPositions) * texture.getWidth(),
        (int) (Math.random() * numberOfYPositions) * texture.getHeight()
    );
  }

  public GridPoint2 getPosition() {
    return this.position;
  }
}

Musimy jeszcze użyć klasy Cherry w klasie głównej gry:

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\HelloSnake.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 HelloSnake extends ApplicationAdapter {
  private SpriteBatch batch;
  private Texture snakeImg;
  private Texture cherryImg;

  private Snake snake;
  private Cherry cherry;

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

    snakeImg = new Texture("snake.png");
    cherryImg = new Texture("cherry.png");

    snake = new Snake(snakeImg);
    cherry = new Cherry(cherryImg);
  }

  @Override
  public void render () {
    snake.act(Gdx.graphics.getDeltaTime());

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

    batch.begin();

    cherry.draw(batch);
    snake.draw(batch);

    batch.end();
  }

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

Pozostaje jeszcze tylko dodać teksturę jedzenia do katalogu core\assets:

Tekstura jedzenia, które należy zbierać w grze

Gra po uruchomieniu prezentuje się teraz następująco:

Wersja gry z wyświetlaniem jedzenia węża
Wersja gry z wyświetlaniem jedzenia węża

Ta wersja gry znajduje się w branchu snake_06_wyswietlanie_jedzenia.

Zjadanie jedzenia

Zaimplementujemy teraz sprawdzanie, czy wąż zjadł jedzenie. Do klasy Snake dodamy metodę isCherryFound, która przyjmie jako argument położenie jedzenia. Jeżeli głowa węża znajduje się w tym samym miejscu, co jedzenie, to metoda zwróci wartość true. W takim przypadku wydłużymy węża o jeden segment – będzie za to odpowiedzialna nowa metoda extendSnake.

Metody te wyglądają następująco:

fragment klasy Snake
public boolean isCherryFound(GridPoint2 cherryPosition) {
  return snakeSegments.get(0).equals(cherryPosition);
}

public void extendSnake() {
  snakeSegments.add(
      new GridPoint2(snakeSegments.get(snakeSegments.size() - 1))
  );
}

Pierwsza metoda sprawdza, czy pierwszy segment węża (czyli głowa) znajduje się w tej samej pozycji, co przekazany jako argument punkt cherryPosition, określający położenie jedzenia na ekranie.

Druga metoda dodaje na listę segmentów węża nowy segment. Jego pozycja jest początkowo ustawiana na pozycję aktualnie ostatniego segmentu. Po pierwszy przesunięciu się węża ten nowy element będzie widoczny na ekranie.

W metodzie isCherryFound, a także w napisanej już metodzie move, odnosimy się do pierwszego segmentu węża, czyli jego głowy, w następujący sposób:

snakeSegments.get(0)

Zmienimy ten fragment na wywołanie nowej metody, którą dodamy do klasy Snake. Metodę tę nazwiemy head. Będzie ona zwracać pozycję głowy węża, dzięki czemu nasz kod źródłowy będzie czytelniejszy. Metoda head, dodana do klasy Snake, wygląda następująco:

fragment klasy Snake
private GridPoint2 head() {
  return snakeSegments.get(0);
}

Zmodyfikowałem jeszcze metody isCherryFound oraz move, aby korzystały z tej nowej metody:

fragment klasy Snake
public boolean isCherryFound(GridPoint2 cherryPosition) {
  return head().equals(cherryPosition);
}

// kod pozostałych metod został pominięty

private void move() {
  // przesuń wszystkie segmenty poza głową
  for (int i = snakeSegments.size() - 1; i > 0; i--) {
    snakeSegments.get(i).set(snakeSegments.get(i - 1));
  }

  // przesuń głowę
  int segmentWidth = texture.getWidth();
  int segmentHeight = texture.getWidth();

  // pozycje X, Y ostatniego segmentu
  // przed górną i prawą krawędzią okna
  int lastWindowSegmentX = Gdx.graphics.getWidth() - segmentWidth;
  int lastWindowSegmentY = Gdx.graphics.getHeight() - segmentHeight;

  GridPoint2 head = head();
  switch (direction) {
    case LEFT:
      head.x = (head.x == 0) ? lastWindowSegmentX : head.x - segmentWidth;
      break;
    case UP:
      head.y = (head.y == lastWindowSegmentY) ? 0 : head.y + segmentHeight;
      break;
    case RIGHT:
      head.x = (head.x == lastWindowSegmentX) ? 0 : head.x + segmentWidth;
      break;
    case DOWN:
      head.y = (head.y == 0) ? lastWindowSegmentY : head.y - segmentHeight;
      break;
  }
}

Z metod isCherryFound oraz extendSnake skorzystamy w metodzie render klasy głównej gry:

fragment klasy HelloSnake
@Override
public void render () {
  snake.act(Gdx.graphics.getDeltaTime());

  if (snake.isCherryFound(cherry.getPosition())) {
    snake.extendSnake();
    cherry.randomizePosition();
  }

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

  batch.begin();

  cherry.draw(batch);
  snake.draw(batch);

  batch.end();
}

Jeżeli metoda isCherryFound zwróci true, to skorzystamy z metody extendSnake do dodania nowego segmentu do węża, a także wylosujemy nową pozycję na ekranie dla jedzenia za pomocą metody randomizePosition obiektu cherry.

Gra wygląda teraz następująco:

Wersja gry, w której wąż wydłuża się po zjedzeniu jedzenia
Wersja gry, w której wąż wydłuża się po zjedzeniu jedzenia

Ta wersja gry znajduje się w branchu snake_07_zjadanie_jedzenia.

Wykrywanie kolizji

W tej chwili wpadnięcie węża na samego siebie nie ma żadnego efektu. Do klasy Snake dodamy metodę hasHitHimself, która odpowie na pytanie, czy głowa węża uderzyła w jeden z jego pozostałych segmentów – jeżeli tak, to gra powinna się zakończyć. Metoda ta wygląda następująco:

public boolean hasHitHimself() {
  for (int i = 1; i < snakeSegments.size(); i++) {
    if (snakeSegments.get(i).equals(head())) {
      return true;
    }
  }
  return false;
}

Metoda po kolei porównuje położenie każdego z segmentów węża do położenia jego głowy, czyli pierwszego segmentu. Jeżeli którykolwiek z segmentów znajduje się w tym samym miejscu, co głowa węża, oznacza to, że wąż wpadł na siebie samego – w takim przypadku zwrócimy wartość true.

Skorzystamy z powyższej metody w klasie głównej gry. Jeżeli ta metoda zwróci true, to ustawimy wartość nowego pola gameOver na true. Wszelkie aktualizacje świata gry będą uzależnione od wartości tej zmiennej – jeżeli będzie miała wartość true, to przestaniemy wywoływać metodę act obiektu snake, oraz pozostałe metody.

fragment klasy HelloSnake
private Snake snake;
private Cherry cherry;
private boolean gameOver;

Kod odpowiedzialny za obsługę logiki gry przeniosłem do osobnej metody updateGame. Metoda ta jest teraz wywoływana na początku metody render:

fragment klasy HelloSnake
@Override
public void render () {
  updateGame();

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

  batch.begin();

  cherry.draw(batch);
  snake.draw(batch);

  batch.end();
}

private void updateGame() {
  if (!gameOver) { // 1
    snake.act(Gdx.graphics.getDeltaTime());

    if (snake.isCherryFound(cherry.getPosition())) {
      snake.extendSnake();
      cherry.randomizePosition();
    }

    if (snake.hasHitHimself()) {
      gameOver = true;
    }
  }
}

Fragment kodu z metody updateGame, który nie jest podświetlony, to kod, który do tej pory znajdował się na początku metody render. Jeżeli metoda hasHitHimself zwróci wartość true, to ustawimy wartość pola gameOver, także na true. Aktualizacja świata gry będzie wykonywana tylko w przypadku, gdy wartość gameOver jest równa false (1). Jeżeli gracz wpadnie postacią węża na jeden z jego segmentów, to nie będzie już mógł zmieniać kierunku węża, a wąż przestanie się poruszać.

Gra jest już prawie ukończona – wąż zjada jedzenie, co powoduje dodawanie nowych segmentów, przechodzi przez krawędzie okna, oraz nie może wpaść na siebie, bo w przeciwnym razie powoduje to koniec gry. Jednakże, wprowadzenie wykrywania kolizji węża doprowadziło do wykrycia niechcianej funkcjonalności: jeżeli gracz wystarczająco szybko naciśnie na klawiaturze dwie strzałki, które spowodują zwrot węża o 180 stopni, a wąż nie zdąży pomiędzy zmianami kierunku wykonać co najmniej jednego ruchu (przesunięcia o jeden segment), to wąż wpadnie na siebie samego, ponieważ zmieni swój kierunek o 180 stopni.

Dla przykładu, gdy wąż porusza się do góry, jeżeli bardzo szybko naciśniesz strzałkę w np. w prawo i w dół, to wąż nie zdąży przejść o jeden segment w prawo, a zamiast tego będzie próbował „cofnąć się” o jeden segment w dół, co spowoduje przegraną, ponieważ wąż od razu wpadnie na swój poprzedni segment.

Aby uniemożliwić zajście takiej sytuacji, po zmianie kierunku poruszania się węża, uniemożliwimy kolejną zmianę kierunku, dopóki wąż nie przesunie się co najmniej o jeden segment w tym kierunku. Do klasy Snake dodamy nowe pole canChangeDirection, które będzie wyznaczało, czy możliwa jest aktualnie zmiana kierunku, czy nie:

fragment klasy Snake
public class Snake {
  private final Texture texture;
  private final List<GridPoint2> snakeSegments;
  private MovementDirection direction;
  private float timeElapsedSinceLastMove;
  private boolean canChangeDirection;

W metodzie act wywołamy metodę handleDirectionChange tylko wtedy, gdy pole canChangeDirection będzie miało wartość true. Wartość true dla pola canChangeDirection będziemy ustawiać po wykonaniu ruchu przez węża:

fragment klasy Snake
public void act(float deltaTime) {
  if (canChangeDirection) {
    handleDirectionChange();
  }

  timeElapsedSinceLastMove += deltaTime;

  if (timeElapsedSinceLastMove >= 0.1) {
    timeElapsedSinceLastMove = 0;
    canChangeDirection = true;
    move();
  }
}

Pozostaje nam jeszcze ustawić wartość pola canChangeDirection na false w odpowiednim momencie, czyli wtedy, gdy nastąpi zmiana kierunku w metodzie handleDirectionChange:

private void handleDirectionChange() {
  MovementDirection newDirection = direction;

  if (Gdx.input.isKeyJustPressed(Input.Keys.LEFT) &&
      direction != MovementDirection.RIGHT) {
    newDirection = MovementDirection.LEFT;
  }

  if (Gdx.input.isKeyJustPressed(Input.Keys.RIGHT) &&
      direction != MovementDirection.LEFT) {
    newDirection = MovementDirection.RIGHT;
  }

  if (Gdx.input.isKeyJustPressed(Input.Keys.UP) &&
      direction != MovementDirection.DOWN) {
    newDirection = MovementDirection.UP;
  }

  if (Gdx.input.isKeyJustPressed(Input.Keys.DOWN) &&
      direction != MovementDirection.UP) {
    newDirection = MovementDirection.DOWN;
  }

  if (direction != newDirection) {
    direction = newDirection;
    canChangeDirection = false;
  }
}

W nowej wersji metody handleDirectionChange nie przypisujemy do pola direction nowego kierunku bezpośrednio, lecz przypisujemy wartość (potencjalnie) nowego kierunku do pomocniczej zmiennej newDirection. Jeżeli kierunek faktycznie się zmienił, ustawimy wartość pola direction, a także przypiszemy polu canChangeDirection wartość false. Dzięki temu, gracz nie będzie w stanie zmienić w krótkim czasie kierunku węża o 180 stopni, powodując wpadnięcie węża na siebie samego. Dopiero, gdy wąż przesunie się o jeden segment w nowym kierunku, gracz będzie mógł ponownie zmienić kierunek węża.

Powyższe zmiany są wystarczające, aby rozwiązać napotkany problem. Pozostaje nam jeszcze umożliwić użytkownikowi rozpoczęcie gry od nowa.

Ta wersja gry znajduje się w branchu snake_08_wykrywanie_kolizji.

Ponowne uruchamianie gry

Gra jest prawie gotowa – pozostaje nam obsługa naciśnięcia klawisza F2, aby gra rozpoczęła się ponownie po przegranej.

Rozpoczęcie nowej gry sprowadza się do wykonania trzech kroków:

  • przywrócenie segmentów węża i jego kierunku do początkowego stanu,
  • wylosowania nowej pozycji dla jedzenia węża,
  • ustawienia wartości pola gameOver na false.

Aby wykonać pierwszy z powyższych punktów, zmodyfikujemy klasę Snake, aby wszelkie instrukcje związane z inicjalizacją węża zawarte były metodzie, którą będziemy mogli wywołać, aby rozpocząć grę od nowa.

Do klasy Snake dodałem nową metodę initialize, która zawiera część kodu z konstruktora tej klasy:

fragment klasy Snake
public Snake(Texture texture) {
  this.texture = texture;
  snakeSegments = new ArrayList<>();
}

public void initialize() {
  timeElapsedSinceLastMove = 0;
  direction = MovementDirection.RIGHT;

  snakeSegments.clear();
  snakeSegments.add(new GridPoint2(90, 30));
  snakeSegments.add(new GridPoint2(75, 30));
  snakeSegments.add(new GridPoint2(60, 30));
  snakeSegments.add(new GridPoint2(45, 30));
  snakeSegments.add(new GridPoint2(30, 30));
}

Ciało metody initialize stanowi w większości kod przeniesiony z konstruktora klasy Snake. Dodałem jeszcze instrukcję czyszczącą listę snakeSegments oraz zerowanie zmiennej timeElapsedSinceLastMove – obie te linie podświetliłem powyżej. Możemy teraz dodać do klasy głównej użycie tej metody. Dodamy do niej również nową metodę, o nazwie initializeNewGame, która wykona trzy kroki opisane na początku tego rozdziału. Wywołamy ją w metodzie create:

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

  snakeImg = new Texture("snake.png");
  cherryImg = new Texture("cherry.png");

  snake = new Snake(snakeImg);
  cherry = new Cherry(cherryImg);

  initializeNewGame();
}

private void initializeNewGame() {
  snake.initialize();
  cherry.randomizePosition();
  gameOver = false;
}

W nowej metodzie initializeNewGame wywołujemy metodę initialize obiektu Snake, którą przed chwilą dodaliśmy do tej klasy. Z racji tego, że wywołujemy na początku każdej gry metodę randomizePosition obiektu cherry, możemy usunąć wywoływanie tej metody z konstruktora klasy Cherry:

fragment klasy Cherry
public Cherry(Texture texture) {
  this.texture = texture;
  this.position = new GridPoint2();
}

Ostatnią zmianą do kodu źródłowego gry Hello Snake! jest dodanie obsług naciśnięcia klawisza F2 na klawiaturze, aby rozpocząć nową grę po przegranej. Dodamy kod odpowiedzialny za tę funkcjonalność to metody updateGame klasy HellloSnake:

fragment klasy HelloSnake
private void updateGame() {
  if (!gameOver) {
    snake.act(Gdx.graphics.getDeltaTime());

    if (snake.isCherryFound(cherry.getPosition())) {
      snake.extendSnake();
      cherry.randomizePosition();
    }

    if (snake.hasHitHimself()) {
      gameOver = true;
    }
  } else {
    if (Gdx.input.isKeyJustPressed(Input.Keys.F2)) {
      initializeNewGame();
    }
  }
}

Jeżeli gra się zakończyła, a gracz nacisnął klawisz F2, to wywołujemy metodę initializeNewGame, która spowoduje rozpoczęcie gry od nowa.

To już ostatnia zmiana – gra Hello Snake! jest ukończona!

Ta wersja gry znajduje się w branchu snake_09_restart_gry.

Zadania

Do gry Hello Snake! możesz wprowadzić, w ramach ćwiczeń, kilka nowych funkcjonalności. Pomysły, które możesz wykorzystać:

  • Obsługa osobnych tekstur dla głowy i końca węża. Przygotowałem obraz z teksturami, z których możesz skorzystać:
    Osobne tekstury dla głowy, ogona, i tułowia węża
    Osobne tekstury dla głowy, ogona, i tułowia węża
  • W tej chwili może wystąpić sytuacja, w której jedzenie pojawi się w miejscu, gdzie znajduje się segment węża. Możesz usprawnić metodę randomizeCherryPosition, aby uniemożliwiała taką sytuację.
  • Dodanie ścian, które powodują przegraną, jeżeli gracz na nie wpadnie.
  • Poziomy trudności – dla przykładu, po zebraniu co dziesiątego jedzenia, wąż może poruszać się coraz szybciej.
  • Drugi wąż i obsługa gry dla dwóch osób. Tekstura dla drugiego gracza, z której możesz skorzystać:
    Osobne tekstury dla głowy, ogona, i tułowia węża drugiego gracza
    Osobne tekstury dla głowy, ogona, i tułowia węża drugiego gracza

Rozwiązania do dwóch pierwszych zadań znajdziesz w następujących branchach w repozytorium z przykładami do kursu:

  • snake_10_osobne_tekstury_dla_glowy_i_ogona
  • snake_11_lepsze_losowanie_pozycji_jedzenia

Jeżeli napiszesz własną wersję gry Hello Snake!, daj znać!

Pełny kod źródłowy gry Hello Snake!

Poniżej znajdziesz pełny kod źródłowy czterech klas, z których składa się gra Hello Snake! 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-09\hello-snake.

HelloSnake.java

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\HelloSnake.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 HelloSnake extends ApplicationAdapter {
  private SpriteBatch batch;
  private Texture snakeImg;
  private Texture cherryImg;

  private Snake snake;
  private Cherry cherry;
  private boolean gameOver;

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

    snakeImg = new Texture("snake.png");
    cherryImg = new Texture("cherry.png");

    snake = new Snake(snakeImg);
    cherry = new Cherry(cherryImg);

    initializeNewGame();
  }

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

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

    batch.begin();

    cherry.draw(batch);
    snake.draw(batch);

    batch.end();
  }

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

  private void initializeNewGame() {
    snake.initialize();
    cherry.randomizePosition();
    gameOver = false;
  }

  private void updateGame() {
    if (!gameOver) {
      snake.act(Gdx.graphics.getDeltaTime());

      if (snake.isCherryFound(cherry.getPosition())) {
        snake.extendSnake();
        cherry.randomizePosition();
      }

      if (snake.hasHitHimself()) {
        gameOver = true;
      }
    } else {
      if (Gdx.input.isKeyJustPressed(Input.Keys.F2)) {
        initializeNewGame();
      }
    }
  }
}

Snake.java

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\Snake.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.math.GridPoint2;

import java.util.ArrayList;
import java.util.List;

enum MovementDirection { LEFT, UP, RIGHT, DOWN }

public class Snake {
  private final Texture texture;
  private final List<GridPoint2> snakeSegments;
  private MovementDirection direction;
  private float timeElapsedSinceLastMove;
  private boolean canChangeDirection;

  public Snake(Texture texture) {
    this.texture = texture;
    snakeSegments = new ArrayList<>();
  }

  public void initialize() {
    timeElapsedSinceLastMove = 0;
    direction = MovementDirection.RIGHT;

    snakeSegments.clear();
    snakeSegments.add(new GridPoint2(90, 30));
    snakeSegments.add(new GridPoint2(75, 30));
    snakeSegments.add(new GridPoint2(60, 30));
    snakeSegments.add(new GridPoint2(45, 30));
    snakeSegments.add(new GridPoint2(30, 30));
  }

  public void act(float deltaTime) {
    if (canChangeDirection) {
      handleDirectionChange();
    }

    timeElapsedSinceLastMove += deltaTime;

    if (timeElapsedSinceLastMove >= 0.1) {
      timeElapsedSinceLastMove = 0;
      canChangeDirection = true;
      move();
    }
  }

  public boolean isCherryFound(GridPoint2 cherryPosition) {
    return head().equals(cherryPosition);
  }

  public void extendSnake() {
    snakeSegments.add(
        new GridPoint2(snakeSegments.get(snakeSegments.size() - 1))
    );
  }

  public boolean hasHitHimself() {
    for (int i = 1; i < snakeSegments.size(); i++) {
      if (snakeSegments.get(i).equals(head())) {
        return true;
      }
    }
    return false;
  }

  public void draw(Batch batch) {
    for (GridPoint2 pos : snakeSegments) {
      batch.draw(texture, pos.x, pos.y);
    }
  }

  private void handleDirectionChange() {
    MovementDirection newDirection = direction;

    if (Gdx.input.isKeyJustPressed(Input.Keys.LEFT) &&
        direction != MovementDirection.RIGHT) {
      newDirection = MovementDirection.LEFT;
    }

    if (Gdx.input.isKeyJustPressed(Input.Keys.RIGHT) &&
        direction != MovementDirection.LEFT) {
      newDirection = MovementDirection.RIGHT;
    }

    if (Gdx.input.isKeyJustPressed(Input.Keys.UP) &&
        direction != MovementDirection.DOWN) {
      newDirection = MovementDirection.UP;
    }

    if (Gdx.input.isKeyJustPressed(Input.Keys.DOWN) &&
        direction != MovementDirection.UP) {
      newDirection = MovementDirection.DOWN;
    }

    if (direction != newDirection) {
      direction = newDirection;
      canChangeDirection = false;
    }
  }

  private void move() {
    // przesuń wszystkie segmenty poza głową
    for (int i = snakeSegments.size() - 1; i > 0; i--) {
      snakeSegments.get(i).set(snakeSegments.get(i - 1));
    }

    // przesuń głowę
    int segmentWidth = texture.getWidth();
    int segmentHeight = texture.getWidth();

    // pozycje X, Y ostatniego segmentu
    // przed górną i prawą krawędzią okna
    int lastWindowSegmentX =
        Gdx.graphics.getWidth() - segmentWidth;
    int lastWindowSegmentY =
        Gdx.graphics.getHeight() - segmentHeight;

    GridPoint2 head = head();

    switch (direction) {
      case LEFT:
        head.x = (head.x == 0) ?
            lastWindowSegmentX : head.x - segmentWidth;
        break;
      case UP:
        head.y = (head.y == lastWindowSegmentY) ?
            0 : head.y + segmentHeight;
        break;
      case RIGHT:
        head.x = (head.x == lastWindowSegmentX) ?
            0 : head.x + segmentWidth;
        break;
      case DOWN:
        head.y = (head.y == 0) ?
            lastWindowSegmentY : head.y - segmentHeight;
        break;
    }
  }

  private GridPoint2 head() {
    return snakeSegments.get(0);
  }
}

Cherry.java

rozdzial-09\hello-snake\core\src\com\kursjava\gamedev\Cherry.java
package com.kursjava.gamedev;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.math.GridPoint2;

public class Cherry {
  private final GridPoint2 position;
  private final Texture texture;

  public Cherry(Texture texture) {
    this.texture = texture;
    this.position = new GridPoint2();
  }

  public void draw(Batch batch) {
    batch.draw(texture, position.x, position.y);
  }

  public void randomizePosition() {
    int numberOfXPositions =
        Gdx.graphics.getWidth() / texture.getWidth();

    int numberOfYPositions =
        Gdx.graphics.getHeight() / texture.getHeight();

    this.position.set(
        (int) (Math.random() * numberOfXPositions) *
            texture.getWidth(),
        (int) (Math.random() * numberOfYPositions) *
            texture.getHeight()
    );
  }

  public GridPoint2 getPosition() {
    return this.position;
  }
}

DesktopLauncher.java

rozdzial-09\hello-snake\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.HelloSnake;

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

    config.resizable = false;
    config.width = 420;
    config.height = 225;
    config.title = "Hello Snake! https://kursjava.com";

    new LwjglApplication(new HelloSnake(), 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.