Java RPG Game Programmierung Tutorial 2 – Das Spielfeld

Bevor wir uns an die Programmierung unseres Game-Characters machen, zeigen wir erst einmal das Spielfeld an. So können wir beim Programmieren unseres Avatars vielleicht schon etwas sehen, und das ist doch hilfreicher beim Debuggen als nur mit Konsolenausgaben. Die hier vorgestellte Screen Klasse könnt ihr auch einfach übernehmen ohne viel darüber nachzudenken, denn wir verwenden das alte „Advanced Widget Toolkit“ AWT. Die ist in der Java-Sprache mit drin, wurde aber inzwischen von der JavaFX Spracherweiterung seit Java 7 praktisch abgelöst. Für kleine Aufgaben ist AWT aber immer noch gut geeignet, zudem wir kaum mehr als den „Canvas“, also die Zeichenfläche, verwenden. Hier schon einmal die vollständige Screen Klasse, um unser Gamefenster anzuzeigen:

import java.awt.Canvas;
import java.awt.Dimension;
import javax.swing.JFrame;

public class Screen {

  private JFrame frame;
  private Canvas canvas;

  private String title;
  private int width, height;

  public Screen(String title, int width, int height){
    this.title = title;
    this.width = width;
    this.height = height;

    frame = new JFrame(title);
    frame.setSize(width, height);
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setResizable(false);
    frame.setLocationRelativeTo(null);
    frame.setVisible(true);

    canvas = new Canvas();
    canvas.setPreferredSize(new Dimension(width, height));
    canvas.setMaximumSize(new Dimension(width, height));
    canvas.setMinimumSize(new Dimension(width, height));
    canvas.setFocusable(false);

    frame.add(canvas);
    frame.pack();
  }

  public Canvas getCanvas(){
    return canvas;
  }

  public JFrame getFrame(){
    return frame;
  }
}

Um ein mit den Standard-Windows Kontrollelementen ausgestattetes Fenster auf dem Desktop zu bekommen, verwenden wir der Einfachheit halber JFrame aus der Java Swing-Bibliothek. Welche Technik wir zur Fenstererstellung verwenden, ist letztlich egal. Diese Java-Klasse kann einfach gegen eine andere, die vielleicht JavaFX verwendet, ausgetauscht werden. Nur die Verwendung des AWT Canvas zieht sich durch alle Kapitel des Tutorials und sollte erst einmal beibehalten werden.

Erwähnenswert ist noch der Konstruktor dieser Klasse, in dem wir die beiden Parameter width und height finden. Das ist die fest eingestellte Größe unseres Fensters auf das Spielfeld und wird zur Erzeugung sowohl von JFrame als auch Canvas benötigt. Folglich sind die Größen unseres Fensters und der Zeichenfläche identisch. Diese beiden Konstanten und die Instanziierung unserer neuen Screen Klasse müssen noch in unsere Game-Klasse eingebaut werden:

public class Game implements Runnable {
  public static final int FPS = 60;
  public static final long maxLoopTime = 1000 / FPS;
  public static final int SCREEN_WIDTH = 640;
  public static final int SCREEN_HEIGHT = 640;

  public static void main(String[] arg) {
    Game game = new Game();
    new Screen("Game", SCREEN_WIDTH, SCREEN_HEIGHT);
    new Thread(game).start();
  }

  boolean running = true;
  @Override
  public void run() {
    long timestamp;
    long oldTimestamp;
    while(running) {
      oldTimestamp = System.currentTimeMillis();
      update();
      timestamp = System.currentTimeMillis();
      if(timestamp-oldTimestamp > maxLoopTime) {
        System.out.println("Wir sind zu spät!");
        continue;
      }
      render();
      timestamp = System.currentTimeMillis();
      System.out.println(maxLoopTime + " : " + (timestamp-oldTimestamp));
      if(timestamp-oldTimestamp <= maxLoopTime) {
        try {
          Thread.sleep(maxLoopTime - (timestamp-oldTimestamp) );
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    }
  }

  void update() { }
  void render() { }
}

Jetzt stellt sich natürlich die Frage, was wir nun auf dem Canvas zeichnen wollen. Zuallererst ist das der Untergrund, d.h. der Boden, bestehend aus Gras, Sand, Felsen, Steppe oder Wasser, alles Dinge, die unverrückbar an ihrem Platz stehen. Dies steht im Gegensatz zu Dingen, die zwar auf dem Spielfeld erscheinen, dort aber wieder verschwinden können, wie z.B. Gegner oder Gegenstände. Darauf werden wir im nächsten Kapitel eingehen. Wir wollen die Spielfläche aus Kacheln (Tiles) zusammensetzen, weil uns das erlaubt, Spielfelder für unterschiedlichste Gegenden (Regenwald, Steppe, Dungeons, …) mit relativ geringem Aufwand zu konstruieren. Feste Bilder lassen sich nur umständlich ändern und wenn wir zum Thema Layering vordringen, wird schnell klar, warum wir mit Kacheln arbeiten.

Wir setzen unser Spielfeld also aus unterschiedlichen Typen von Kacheln zusammen. Hier das Beispiel für eine (stark vergrößerte) Gras-Kachel:

grass

In diesem Tutorial verwenden wir eine Kachelgröße von 64 x 64 Pixeln. Unser erstes Game-Level soll eine Größe von 20 Kacheln horizontal und 20 Kacheln vertikal haben, also deutlich größer (20 x 64px = 1280px) als unser Fenster auf die Spielfläche, das ja nur 640 x 640 Pixel groß ist. Folglich müssen wir unser Spiel so programmieren, dass sich unser Fensterausschnitt auf das Spielfeld verschiebt, wenn wir unsere Spielfigur bewegen. Diese Thematik behandeln wir aber erst im Kapitel 6 „Die Kamera“.

Wir könnten alle verwendeten Kacheln aus einzelnen Bilddateien laden. Das ist bei großen Projekten mit hunderten oder gar tausenden von verschiedenen Kacheln nicht effektiv. Statt dessen verwenden wir sogenannte Tilesets. Hier ein Beispiel für ein einfaches Tileset:

rpg

Unsere Graskachel befindet sich in Zeile 1, Spalte 10, jeweils von der Basis 0 ausgehend, wir fangen also mit der 0 an zu zählen. Bemerkenswert an diesem Tileset ist, dass dort auch die Übergänge von einer Bodenformation auf eine andere enthalten ist. Wenn wir beispielweise ein Gras-Tile direkt neben einen Sand-Tile legen würden, sähe das doch ziemlich billig aus. Statt dessen verwenden wir Übergangs-Tiles, z.B. von Gras auf Sand. Schließt beispielsweise eine Grasfläche ein Sandfläche ein, verwenden wir die Tiles an Position 7 – 8 in den Zeile 0, sowie 7 – 8 in Zeile 1. Wird die Grasfläche allerdings von einer Sandfläche umgeben, verwenden wir 9 und 11 in Zeile 0, sowie 9 und 11 in Zeile 2. Die Kacheln an der Position 10 in Zeile 0, Position 9 und 11 in Zeile 1, sowie 10 in Zeile 2 lassen sich für beide Übergänge verwenden.

Beim Links-Click auf das obige Bild wird es in voller Größe angezeigt und kann dann mit Rechts-Click gespeichert werden. WO es gespeichert werden muss, wird jetzt erklärt !

Bevor wir dieses Tileset in unserem Java-Projekt verwenden können, wollen wir dass es Teil unseres Programms wird und wir es dadurch zur Laufzeit lesen können. Dazu erstellen wir in unserem Java-Projekt mit Eclipse ein weiteres Verzeichnis : In Eclipse klicken wir im Package Explorer mit der rechten Maustaste auf das Projekt und wählen “New” und dann “Folder” und geben dann “res” als Namen ein. In diesem Verzeichnis speichern wir alle Spielresourcen ab, also Bilddateien, Konfigurationsdateien, Textdateien, und so weiter. Um die Dateien besser zu organisieren, erstellen wir zusätzliche Unterorderner in res, und zwar „tiles“ und „level„. Wenn ihr damit fertig seid, dann links-klickt auf das Tileset-Bild und speichert das dann in Originalgröße angezeigte Bild mit einem Rechts-Klick in den res/tiles Ordner. Damit res aber als Programm-Ressource betrachtet wird, müsst ihr noch in Eclipse mit der rechten Maustaste darauf klicken und “Build Path” und dann “Use as Source Folder” auswählen.

Im Source-Code werden Pfade auf Resource-Dateien verwendet, die im res-Folder angelegt sein müssen, Viele Leser machen hier den am häufigsten auftretenden Fehler und machen entweder den res-Folder nicht zu einem Source Folder, oder die darunter liegende Verzeichnisse und Dateien sind falsch geordnet.

Zurück zu unserem Tileset. Um aus dieser Sammlung aller Kacheln ein Spielfeld zu bauen, benötigen wir eine Liste von Zahlen mit den Nummern der Kacheln, aus denen sich unser Spielfeld zusammensetzt. Bei einem 20 x 20 Kacheln großen Spielfeld ist diese Liste 400 Einträge groß. Der Einfachheit halber schreiben wir die Zahlen in eine Textdatei, die wir zur Laufzeit lesen und aus den daraus gelesenen Zahlen das Spielfeld mit Kacheln versehen. Bevor wir also mit der Programmierung einer TileSet Klasse beginnen, schreiben wir erst mal diese Textdatei. Da diese Textdatei ein komplettes Spielfeld beschreibt, und wir davon unzählige in unserem Programm haben können, nennen wir die Datei „Level1.txt“ und speichern sie in unserem Resource-Folder ab:

20 20
00 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 02
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
12 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 22 14
24 25 25 25 25 25 25 25 25 25 25 25 25 25 25 25 25 25 25 26

Die beiden ersten Zahlen referenzieren keine Kachel, sondern sie beziffern die Größe des nachfolgend beschriebenen Spielfeldes, da aus der Anzahl der Zahlen nicht auf die Ausdehnung des Spielfeldes in X und Y Richtung geschlossen werden kann. Es ist zweckmäßig, alle nachfolgenden Zahlen Spielfeldzeilenweise und mit führenden Nullen zu schreiben. Sobald ihr die obigen Zahlen in eine Datei kopiert habt, speichert diese Datei in den res Folder, wie schon oben beim Tileset beschrieben.

Die Arrays, mit denen wir arbeiten werden, können nicht mit Zahlenstrings angesteuert werden, sondern benötigen Integer-Werte. Folglich brauchen wir eine Programmfunktion, um aus den Textzahlen in der Level1.txt-Datei echte Java Integer-Werte zu machen. Typischerweise werden solche allgemeingültigen Konvertierungsfunktionen nicht in die Klasse hinein programmiert, die sie als erste benötigt hat, sondern sie werden in sogenannten Utility- oder Helper-Klassen gebündelt. Wer sich für diese Thematik interessiert, sollte einen Blick in diesen Artikel werfen. Im weiteren Verlauf des Tutorials werden wir weitere Methoden kennenlernen, die wir in einer solchen Utility Klasse unterbringen. Hier unsere Utility Klasse mit der ersten Hilfsfunktion parseInt():

public class Utils {
  public static int parseInt(String number){
    try {
      return Integer.parseInt(number);
    } catch(NumberFormatException e){
      e.printStackTrace();
      return -1;
    }
  }
}

Jetzt endlich sind wir bei unserer TileSet Klasse. Die grundlegende Aufgabe dieser Klasse ist, das Tileset aus einer Datei in ein Array einzulesen. Dazu muss sie noch in der Lage sein, jedes gewünschte Tile an eine vorgegebene Position unseres Spielfeldes zu zeichnen (rendern). Für die Profi-Version unseres Spiels könnten wir noch bestimmte Tiles animieren. Eine Wasseroberfläche würde sich anbieten oder eine Grassteppe, über die der Wind bläst.

import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.io.IOException;
import javax.imageio.ImageIO;

public class TileSet {

public static final int TILEWIDTH = 64, TILEHEIGHT = 64;

  private BufferedImage[] tiles;

  public TileSet(String path, int sizeX, int sizeY) {
    tiles = new BufferedImage[sizeX * sizeY];
    BufferedImage tileSet;
    try {
      tileSet = ImageIO.read(TileSet.class.getResource(path));
    } catch (IOException e) {
      e.printStackTrace();
      return;
    }
    int i = 0;
    for(int y = 0; y < sizeY; y++) {
      for(int x = 0; x < sizeX; x++) {
        tiles[i++] = tileSet.getSubimage(x * (TILEWIDTH + 3), y * (TILEHEIGHT + 3),
              TILEWIDTH, TILEHEIGHT);
      }
    }
  }
  public void renderTile(Graphics g, int tileNum, int x, int y){
    g.drawImage(tiles[tileNum], x, y, TILEWIDTH, TILEHEIGHT, null);
  }
}

Im Konstruktor dieser Klasse wird das Tileset in ein BufferedImage eingelesen. Man könnte meinen, diese BufferedImage Klasse wäre allein zu dem Zweck der Programmierung von Kachel-basierenden Spielprogrammen entwickelt worden, denn sie bietet genau das, was wir für unsere Zwecke benötigen. Insbesondere ist das die Methode getSubImage(), die uns wahlfrei rechteckige Stücke aus dem BufferedImage rauskopiert und dabei wieder als BufferedImage zur Verfügung stellt. In den beiden for-Schleifen machen wir genau das; wir holen aus jeder Zeile y unseres Tilesets jede einzelne Kachel x (12 Stück) . 12 Zeilen á 12 Kacheln macht 144 Tiles. Die Gras-Kachel ist die 22te Kachel in dem Tileset und 22 entspricht auch der Zählweise in unserer Level-Textdatei. Unsere Gras-Kachel landet in dem tiles-Array also an Position tiles[22].

Jetzt wo wir das gesamte Tileset zur Verfügung haben, können wir beginnen, das Spielfeld zu zeichnen. Zur Erinnerung: Die Nummern der Kacheln, aus denen die Spielfläche besteht, stehen in der Textdatei Level1.txt. Wir ergänzen erst unsere Utility Klasse um die Methode loadFileAsString(), die den Text aus der Datei Level1.txt in einen langen String konvertiert und zurückgibt:

public static String loadFileAsString(String path){
  StringBuilder builder = new StringBuilder();

  //Get file from resources folder
  FileReader file = null;
  try {
    file = new FileReader(Utils.class.getResource(path).getFile());
  } catch (FileNotFoundException e1) {
    e1.printStackTrace();
  }
  if(file != null) {
    try {
      BufferedReader br = new BufferedReader(file);
      String line;
      while((line = br.readLine()) != null) {
        builder.append(line + "\n");
      }
      br.close();
    } catch(IOException e) {
      e.printStackTrace();
    }
  }
  return builder.toString();
}

Jetzt brauchen wir noch die Klasse, die das Spielfeld zusammenbaut. Wir nennen sie Level. Im Konstruktor konvertieren wir erst den von loadFileAsString() übernommenen String mittels der split()-Methode der String Klasse in ein 1-dimensionales Array. Danach setzten wir die Zahlen in das 2-dimensionales Array tileMap um. Damit entsprechen die Positionen y, x in unserer tileMap genau den Positionen der Kacheln auf der Spielfläche, und die Zahlen darin entsprechen den Kacheln im Tileset. Beispiel: In tileMap[2][3] befindet sich die Zahl 22. Auf der Spielfläche in Zeile 2 Position 3 soll also eine Graskachel angezeigt werden.

import java.awt.Graphics;

public class Level {
  private TileSet ts;
  private int sizeX, sizeY;
  private int[][] tileMap;

  public Level(String path, TileSet ts) {
    this.ts = ts;
    String file = Utils.loadFileAsString(path);
    String[] tokens = file.split("\\s+");
    sizeX = Utils.parseInt(tokens[0]);
    sizeY = Utils.parseInt(tokens[1]);
    tileMap = new int[sizeX][sizeY];
    int i = 2;
    for(int y = 0; y < sizeY; y++){
      for(int x = 0; x < sizeX; x++){
        tileMap[x][y] = Utils.parseInt(tokens[i++]);
      }
    }
  }

  public void renderMap(Graphics g){
    for(int tileY = 0; tileY < sizeY; tileY++){
      for(int tileX = 0; tileX < sizeX; tileX++){
        ts.renderTile(g, tileMap[tileX][tileY], tileX * TileSet.TILEWIDTH, tileY * TileSet.TILEHEIGHT);
      }
    }
  }
}

Beachtet, dass wir mit der Anweisung

int i = 2

einen Offset von zwei Einträgen in dem tokens Array machen, denn die ersten beiden Zahlen gehörten ja nicht zu unserem Spielfeld.

Jetzt sind wir bald fertig. Was nur noch fehlt, ist die Anzeige des Spielfeldes, von dem unser Fenster ja nur einen Ausschnitt anzeigen wird. Zu Beginn des Spiels ist das die linke obere Ecke des Spielfeldes.

Das Spielfeld kann durch Bewegung der Spielfigur oder Animationen bestimmter Kacheln sehr dynamisch werden. Der einzig mögliche Platz im Programm zum Rendern der Spielfläche ist also die Game-Loop. Unsere Game Klasse muss erst die TileSet Klasse und die Level Klasse instanziieren, bevor es mit der Game-Loop los geht. Die in der Game-Loop aufgerufene render() Methode muss sich dann den Canvas holen und die BufferStrategy initialisieren, bevor die renderMap() Methode der Level Klasse aufgerufen wird, wo dann tatsächlich die gesamte Spielfläche einmal gerendert wird. Wichtig zu wissen ist, dass wir vorerst immer das gesamte Spielfeld rendern und nicht nur den Ausschnitt, der im Fenster dargestellt wird. Hier der aktuelle Code der Game Klasse:

import java.awt.Canvas;
import java.awt.Graphics;
import java.awt.image.BufferStrategy;
import java.awt.image.BufferedImage;

public class Game implements Runnable {
  public static final int FPS = 60;
  public static final long maxLoopTime = 1000 / FPS;
  public static final int SCREEN_WIDTH = 640;
  public static final int SCREEN_HEIGHT = 640;

  public Screen screen;

  public static void main(String[] arg) {
    Game game = new Game();
    new Thread(game).start();
  }
  Level level;
  @Override
  public void run() {
    long timestamp;
    long oldTimestamp;
    BufferedImage playerImages;
    screen = new Screen("Game", SCREEN_WIDTH, SCREEN_HEIGHT);

    TileSet tileSet = new TileSet("/tiles/rpg.png", 12, 12);
    level = new Level("/level/level1.txt", tileSet);

    while(true) {
      oldTimestamp = System.currentTimeMillis();
      update();
      timestamp = System.currentTimeMillis();
      if(timestamp-oldTimestamp > maxLoopTime) {
        System.out.println("Wir sind zu spät!");
        continue;
      }
      render();
      timestamp = System.currentTimeMillis();
      System.out.println(maxLoopTime + " : " + (timestamp-oldTimestamp));
      if(timestamp-oldTimestamp <= maxLoopTime) {
        try {
          Thread.sleep(maxLoopTime - (timestamp-oldTimestamp) );
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    }
  }

  void update() {
    try {
      Thread.sleep(15);
    } catch (InterruptedException e) {
      e.printStackTrace();
    };
  }
  BufferStrategy bs;
  Graphics g;
  void render() {
    Canvas c = screen.getCanvas();
    // c.setBackground(Color.blue);
    bs = c.getBufferStrategy();
    if(bs == null){
      screen.getCanvas().createBufferStrategy(3);
      return;
    }
    g = bs.getDrawGraphics();
    //Clear Screen
    g.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
    level.renderMap(g);
    bs.show();
    g.dispose();
  }
}

Die BufferStrategy ist eine Technik, mit der Speicherbereiche (Buffer) reserviert werden, in die Bildschirmdaten geschrieben werden, bevor sie in den nächsten Zwischenspeicher übertragen werden, bis sie schließlich im Bildschirmspeicher landen. Damit werden Probleme vermieden, wenn wenn wir direkt in den Bildschirmspeicher schreiben würden. Die Zahl 3 gibt an, das wir mit 3 Zwischenspeichern arbeiten wollen. Schlagt in Google nach, wenn ihr Genaueres zur BufferStrategy wissen wollt. Wenn ihr das Programm nun mit diesem Stand startet, sollte folgendes Bild zu sehen sein:

Kapitel2

Wer Probleme mit seiner Programmversion hat, kann auch den bisher erreichten Programmstand hier bei GitHub herunterladen.

Weiter geht es mit dem Kapitel Java RPG Game Programmierung Tutorial 3 – Die Spielfigur

3 Gedanken zu „Java RPG Game Programmierung Tutorial 2 – Das Spielfeld

  1. Daniel Wirtz

    Hallo Stephan,
    ich finde es toll das du diese Seite eingerichtet hast!
    Als ich sie beim stöbern im Netzt gefunden habe dachte ich sofort: „Geil ich kann endlich meine Idee von einem eigenen Spiel umsetzten“.
    Ich bin also alle Schritte durch gegangen und habe alles fein Säuberlich ab getippt. Doch ich bin immer wieder hier in Kapitel 2 an einer Fehlermeldung hängen geblieben. Also habe ich zunächst anderen Projekten zugewant. Als ich mir dann ein Buch über Java gekauft habe und ich mir dadurch ein deutlich weiteres wissen aneignen konnte habe ich mich nochmal an deine Seite gewannt und erneut angefangen und konnte nun den Fehler erkennen. Du hast dich in der Level Klasse verschrieben. Du hast aus versehen die Deklination von „sizeX“ in das Splitargument geschrieben.

    Mit freundlichen Grüßen und mit viel Respekt vor deiner Arbeit,
    Daniel Wirtz

    Like

    Antwort
    1. Werner Zimmermann

      Hallo, den Fehler bezüglich „sizeX“ kann ich nicht nachvollziehen. Eventuell gab es ja eine Korrektur vom Source Code.
      Allerdings läuft der aktuellen Source code nicht mit Eclipse 2022-06 bzw. 2022-12.
      Fehler in Utils beim Laden vom Level: Exception in thread „Thread-0“ java.lang.NullPointerException: Cannot invoke „java.net.URL.getFile()“ because the return value of „java.lang.Class.getResource(String)“ is null

      Ursache ist folgende Zeile in Utils:
      file = new FileReader(Utils.class.getClass().getResource(path).getFile());

      Lösung: Wenn man .getClass() löscht – ähnlich wie bei den Tiles – funktioniert alles.
      file = new FileReader(Utils.class.getResource(path).getFile());

      Viele Grüße und vielen Dank für das tolle Tutorial! Ist einfacher und nicht so hardwarenah wie der Kurs von TheCherno.
      Werner Zimmermann

      Like

      Antwort

Hinterlasse einen Kommentar