Wie man einfache Spiele programmieren kann

Diskutiere Wie man einfache Spiele programmieren kann im Spiele- und Multimedia-Programmierung Bereich.
mihe7

mihe7

Da grundsätzliche Fragen zum Thema Spiele bzw. UI-Programmierung immer wieder mal kommen, möchte ich mal die Gelegenheit nutzen, ein paar Dinge anhand eines konkreten Beispiels in einem Thread zusammenzufassen.

Es geht dabei mehr um den Entwurf als um das Spiel (oder dessen Darstellung) selbst. Der vorgestellte Ansatz ist nicht für alle Spiele gleichermaßen geeignet.

Ausgangspunkt war der Post https://www.java-forum.org/thema/programmieren-eines-spieles.189397, bei dem es darum geht, ein Spiel zu programmieren, bei dem ein Spieler sich durch Labyrinth bewegen muss, um ein Zielfeld zu erreichen.

Ein mögliches Vorgehen (es gibt viele und womöglich auch bessere) werde ich hier in mehreren Posts beschreiben. Man kann das als eine Art "Tagebuch" verstehen, da ich kaum Vorbereitungen getroffen habe, sondern versuche, die Überlegungen, Code und Erklärungen so direkt wie möglich runterzuschreiben. Sicher ist, dass am Ende kein fix und fertiges Spiel entstehen wird - zumindest nicht von meiner Seite. Wer will, kann natürlich gerne mitschreiben :)
 
mihe7

mihe7

Fangen wir mal ganz einfach an. Im Labyrinth kommen verschiedenartige Felder vor: Wände, Wege und das Ziel. Dafür können wir mal einen Typ einführen:

Java:
public enum FieldType { WALL, DESERT, FINISH }
So ein Labyrinth besteht nun aus vielen gleichgroßen Feldern, die rechteckig angeordnet sind. Es besitzt somit eine Breite und eine Höhe (angegeben in der Einheit "Feld", also z. B. 30 Felder breit und 20 Felder hoch).

Hierfür bauen wir uns eine Klasse:

Java:
public class Maze {
    private final FieldType[][] fields;

    public Maze(FieldType[][] fields) {
        int height = fields.length;
        int width = height == 0 ? 0 : fields[0].length;
        this.fields = new FieldType[height][width];

        for (int y = 0; y < height; y++) {
            for (int x = 0; x < width; x++) {
                this.fields[y][x] = fields[y][x];
            }
        }
    }

    public int getHeight() { return fields.length; }
    public int getWidth() { return getHeight() == 0 ? 0 : fields[0].length; }   
    public FieldType get(int x, int y) { return fields[y][x]; }            
}
Wie zu sehen ist, macht die Klasse nicht besonders viel, bietet aber die essentiellen Methoden zum Zugriff auf die Felder des Labyrinths an. Mehr muss sie nicht können. Wozu das Ganze, wenn wir doch an anderer Stelle ebenso gut direkt ein Array verwenden könnten? In der Objektorientierung geht es darum, "Konzepte" und somit Abstraktionen zu finden. Die Klasse abstrahiert also vom Array, schließlich wollen wir ein Labyrinth und kein Array. Wichtig dabei ist nur, wie das Labyrinth verwendet werden kann, also die drei Methoden, so dass man auch ein Interface schreiben hätte können. Wie der Spaß intern implementiert wird, ist für die "Außenwelt" uninteressant. Statt eines zweidimensionalen Arrays hätte man z. B. auch ein eindimensionales oder Listen verwenden können.

Der Konstruktor kopiert das angegebene Array. Erstens wird so verhindert, dass das Labyrinth "von außerhalb" nachträglich verändert werden kann und zweitens wird dadurch zugleich sichergestellt, dass nur kompatible Arrays verwendet werden können, damit am Ende ein "rechteckiges" Array entsteht. Hintergrund ist, dass in Java ein mehrdimensionales Array nicht rechteckig sein muss. Ein zweidimensionales Array ist in Java einfach in Array von Arrays und jedes Array kann eine unterschiedliche Kapazität aufweisen. Für Breite und Höhe wurden hier mal keine Instanzvariablen verwendet (könnte man natürlich machen), um deutlich zu zeigen, dass es um das Verhalten des Objekts gegenüber seiner Außenwelt geht und nicht um die Implementierung.

Die Klasse kann prinzipiell Labyrinthe beliebiger Größe (im Rahmen der Wertebreiche, versteht sich) darstellen, so dass wir nicht auf eine Größe beschränkt sind.

Mit der Klasse könnte man also bereits arbeiten, es soll aber eine Labyrinth aus einer Datei geladen werden. Dazu muss man sich überlegen, wie ein Labyrinth in einer Datei dargestellt wird. Konkret soll ein Textdatei verwendet werden, bei der jedes Zeichen einem Feldtyp im Programm entspricht. Wir haben also ein Textformat, in dem das Labyrinth gespeichert wird.

Für dieses Format legen wir wieder eine Klasse an:

Java:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;

import java.util.List;
import java.util.stream.Collectors;

public class MazeTextFormat {

    public Maze read(Reader reader) throws IOException {
        List<String> rows = readLines(reader);
        return createMaze(rows);
    }

    private List<String> readLines(Reader reader) throws IOException {
        try(BufferedReader buffered = new BufferedReader(reader)) {
            return buffered.lines().collect(Collectors.toList());
        }
    }

    private Maze createMaze(List<String> rows) {
        int height = rows.size();

        if (height == 0) { return new Maze(new FieldType[0][0]); }

        FieldType[][] fields = new FieldType[height][rows.get(0).length()];

        for (int y = 0; y <height; y++) {
            char[] chars = rows.get(y).toCharArray();
            for (int x = 0; x < chars.length; x++) {
                fields[y][x] = getFieldType(chars[x]);
            }                
        }

        return new Maze(fields);
    }

    private FieldType getFieldType(char ch) {
        switch(ch) {
            case 'w': return FieldType.WALL;
            case 'd': return FieldType.DESERT;
            case 'f': return FieldType.FINISH;
            default:
                throw new IllegalArgumentException(ch + " is not a known field type.");
        }
    }
}
Die Klasse liest aus einem Reader alle Zeilen in eine Liste ein, damit wir die Höhe des Labyrinths in Erfahrung bringen können. Anschleßend wird aus der eingelesenen Liste ein Labyrinth erstellt, wobei jedes Zeichen einer jeden Zeile in einen Feldtyp übersetzt wird. Das ist ggf. schon mehr als man in einer Klasse haben möchte. Die Konvertierung von Zeichen in Feldtypen könnte man in eine eigene Klasse ausgliedern, die mögliche Konvertierungen dynamisch verwaltet. Damit hätte man später die Möglichkeit, neue Feldtypen zu berücksichtigen, ohne den Code ändern zu müssen. Das sparen wir uns aber im Moment.

Für's Erste wäre es schön zu sehen, ob der Spaß auch funktioniert. Dazu bauen wir uns mal eine kleine Testklasse...

Java:
import java.io.StringReader;
import java.util.StringJoiner;

public class Test1 {
    private final Maze maze;

    public Test1(Maze maze) {
        this.maze = maze;
    }

    public void showMap() {
        System.out.println(mapToString());
    }

    private String mapToString() {
        StringJoiner sj = new StringJoiner("\n");

        for (int y = 0, n = maze.getHeight(); y < n; y++) {
            StringBuilder line = new StringBuilder();
            for (int x = 0, m = maze.getWidth(); x < m; x++) {
                line.append(getTypeChar(maze.get(x,y)));
            }
            sj.add(line.toString());
        }
        return sj.toString();
    }

    private char getTypeChar(FieldType type) {
        switch(type) {
            case WALL: return 'W';
            case DESERT: return '.';
            case FINISH: return 'F';
            default: return '?';
        }
    }


    public static void main(String[] args) throws Exception {
        String text = 
            "wwwwwww\n" +
            "wfddddw\n" +
            "wdddddw\n" +
            "wdddddw\n" +
            "wdddddw\n" +
            "wwwwwww\n";
             
        MazeTextFormat fmt = new MazeTextFormat();
        Maze maze = fmt.read(new StringReader(text));
        Test1 app = new Test1(maze);
        app.showMap();
    }
}
Im Programmeinstiegspunkt main wird zunächst ein String aufgebaut, der den Inhalt einer imaginären Textdatei darstellt. So können wir unser Textformat testen, ohne wirklich aus einer Datei lesen zu müssen. Die Voraussetzung dafür ist gegeben, das die read-Methode von MazeTextFormat lediglich einen Reader benötigt und wir daher einen StringReader verwenden können, um aus dem zuvor aufgebauten String wie aus einer Datei lesen zu können.

Mit der so eingelesenen MazeMap erstellen wir ein neues Test1-Objekt und verwenden deren showMap-Methode, um uns die eingelesene Karte auf der Konsole anzeigen zu lassen. Im Code werden Großbuchstaben für die Ausgabe verwendet, um klar zu machen, dass es an dieser Stelle um die Darstellung geht, die sich von der Eingabe unterscheiden kann.

Lässt man das laufen, erhält man

Code:
WWWWWWW
WF....W
W.....W
W.....W
W.....W
WWWWWWW
Scheint also zu funktionieren.
 
mihe7

mihe7

Das Labyrinth ist aber nicht das einzige, was es in unserer "Welt" gibt, schließlich soll sich ja ein Spieler im Labyrinth bewegen können. In der Spielwelt existiert also neben einem Labyrinth auch ein Spieler, wobei wir nur an dessen Position interessiert sind.

Apropos Position... eine solche ist die Kombination aus x- und y-Koordinate. Sollten wir irgendwie darstellen können:
Java:
public class Position {
    public final int x;
    public final int y;

    public Position(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public Position move(int dx, int dy) {
        return new Position(x + dx, y + dy);
    }

    public Position move(Position delta) {
        return new Position(x + delta.x, y + delta.y);
    }

    @Override
    public boolean equals(Object o) {
        if (o == null || o == this || !(o instanceof Position)) {
            return o == this;
        }

        Position p = (Position) o;
        return x == p.x && y == p.y;
    }

    @Override
    public int hashCode() {
        return 5*x+13*y;
    }
}
Die Position ist hier unveränderlich (immutable) realisiert. Gezeigt wird auch, dass man in solchen Fällen durchaus auch mal direkten Zugrif auf die Felder erlauben darf. Ebenso wurden equals und hashCode implementiert, da man Positionen gerne miteinander vergleicht. Die move-Methoden erzeugen jeweils ein neues Position-Objekt mit den neuen Koordinaten.

Jetzt können wir uns um die Spielwelt kümmern. Wir haben also ein Labyrinth, einen Spieler und wollen den Spieler in der Welt bewegen können. Dabei ist darauf zu achten, dass der Spieler sich nur innerhalb der Labyrinth-Grenzen bewegen und dabei keine Wände "betreten" darf.

Java:
public class MazeWorld {
    private Maze maze;
    private Position player;

    public MazeWorld(Maze maze, Position player) {
        this.maze = maze;
        this.player = player;
    }

    public Maze getMaze() { return maze; }
    public Position getPlayer() { return position; }

    public void move(int dx, int dy) {
        Position newPos = player.move(dx, dy);
        if (isLegalMove(newPos)) {
            player = newPos;
        }
    }
    
    private boolean isLegalMove(Position pos) {
        if (pos.x < 0 || pos.x >= maze.getWidth() ||
                pos.y < 0 || pos.y >= maze.getHeight()) {
            return false;
        }

        return maze.get(pos.x, pos.y) != FieldType.WALL;
    }  
}
Der Code von isLegalMove lässt sich noch ein wenig verbessern: die Antwort auf die Frage, ob eine Position innerhalb der Labyrinth-Grenzen liegt, könnte nämlich das Labyrinth selbst liefern.

Spendieren wir der Klasse Maze eine entsprechende Methode:
Java:
    public boolean contains(Position pos) {
        return pos.x >= 0 && pos.x < getWidth() &&
                pos.y >= 0 && pos.y < getHeight();
    }
Damit können wir isLegalMove nun schreiben als
Java:
    private boolean isLegalMove(Position pos) {
        return maze.contains(pos) &&
                maze.get(pos.x, pos.y) != FieldType.WALL;
    }
Das war einfach. Mal eine neue Testklasse bauen:
Java:
import java.io.StringReader;
import java.util.Scanner;
import java.util.StringJoiner;

public class Test2 {
    private final MazeWorld world;

    public Test2(MazeWorld world) {
        this.world = world;
    }

    public void run() {
        Scanner sc = new Scanner(System.in);
        boolean done = false;
        while (!done) {
            showMap();
            String line = sc.nextLine();
            char move = 0;
            if (!line.isEmpty()) {
                move = line.charAt(0);
            }

            switch(move) {
                case 'u': world.movePlayer(0, -1); break;
                case 'd': world.movePlayer(0, 1); break;
                case 'l': world.movePlayer(-1, 0); break;
                case 'r': world.movePlayer(1, 0); break;
                case 'q': done = true; break;
                default:
                    System.out.println("Bitte nur u/d/l/r verwenden. Ende mit q.");
            }
        }
    }

    public void showMap() {
        System.out.println(mapToString());
    }

    private String mapToString() {
        Maze maze = world.getMaze();
        Position player = world.getPlayer();

        StringJoiner sj = new StringJoiner("\n");

        for (int y = 0, n = maze.getHeight(); y < n; y++) {
            StringBuilder line = new StringBuilder();
            for (int x = 0, m = maze.getWidth(); x < m; x++) {
                if (player.x == x && player.y == y) {
                    line.append('*');
                } else {
                    line.append(getTypeChar(maze.get(x,y)));
                }
            }
            sj.add(line.toString());
        }
        return sj.toString();
    }

    private char getTypeChar(FieldType type) {
        switch(type) {
            case WALL: return 'W';
            case DESERT: return '.';
            case FINISH: return 'F';
            default: return '?';
        }
    }


    public static void main(String[] args) throws Exception {
        String text = 
            "wwwwwdw\n" +
            "wfddddw\n" +
            "wdddddd\n" +
            "ddddddw\n" +
            "wdddddw\n" +
            "wwwwwdw\n";
             
        MazeTextFormat fmt = new MazeTextFormat();
        Maze maze = fmt.read(new StringReader(text));
        MazeWorld world = new MazeWorld(maze, new Position(4, 4));
        Test2 app = new Test2(world);
        app.run();
    }
}
Mit u/d/l/r können wir den Spieler nun auf dem Feld bewegen. Das Labyrinth ist so aufgebaut, dass getestet werden kann, ob es Probleme bzgl. der Labyrinth-Grenzen gibt. Mit einem q lässt sich das Programm beenden.

Als nächstes kümmern wir uns um das Spiel selbst. Das besteht aus

a) der Spielwelt
b) zusammen mit einer Menge von Aktionen und Regeln

Die Welt haben wir schon, so dass wir uns um b) kümmern können. Tatsächlich halten sich die Aktionen und Regeln in Grenzen. Man kann den Spieler in der Welt bewegen, vorausgesetzt, das Spiel ist noch nicht vorbei. Das Spiel ist vorbei, wenn der Spieler ein FINISH-Feld erreicht hat.

Java:
public class MazeGame {
    private MazeWorld world;

    public MazeGame(MazeWorld world) {
        this.world = world;
    }

    public MazeWorld getWorld() { return world; }

    public boolean isWin() {
        Maze maze = world.getMaze();
        Position player = world.getPlayer();
        return maze.get(player.x, player.y) == FieldType.FINISH;
    }

    public void movePlayer(int dx, int dy) {
        if (isWin()) {
            return;
        }
        world.movePlayer(dx, dy);
    }
}
Hm... die isWin()-Methode ist nicht schön. Wir wollen eigentlich nur wissen, ob der Spieler auf einem Feld eines bestimmten Typs steht. Das kann man in Klasse MazeWorld prüfen:

Java:
    public boolean isPlayerOn(FieldType type) {
        return maze.get(player.x, player.y) == type;
    }
Damit lässt sich die Methode isWin in Klasse MazeGame schöner schreiben:
Java:
    public boolean isWin() {
        return world.isPlayerOn(FieldType.FINISH);
    }
So, nun lässt sich das ganze Spiel wunderbar auf der Konsole testen:

Java:
import java.io.StringReader;
import java.util.Scanner;
import java.util.StringJoiner;

public class Test3 {
    private final MazeGame game;

    public Test3(MazeGame game) {
        this.game = game;
    }

    public void run() {
        Scanner sc = new Scanner(System.in);
        while (!game.isWin()) {
            showMap();
            String line = sc.nextLine();
            char move = 0;
            if (!line.isEmpty()) {
                move = line.charAt(0);
            }

            switch(move) {
                case 'u': game.movePlayer(0, -1); break;
                case 'd': game.movePlayer(0, 1); break;
                case 'l': game.movePlayer(-1, 0); break;
                case 'r': game.movePlayer(1, 0); break;
                default:
                    System.out.println("Bitte nur u/d/l/r verwenden.");
            }
        }
        showMap();
        System.out.println("Ziel erreicht");
    }

    public void showMap() {
        System.out.println(mapToString());
    }

    private String mapToString() {
        MazeWorld world = game.getWorld();
        Maze maze = world.getMaze();
        Position player = world.getPlayer();

        StringJoiner sj = new StringJoiner("\n");

        for (int y = 0, n = maze.getHeight(); y < n; y++) {
            StringBuilder line = new StringBuilder();
            for (int x = 0, m = maze.getWidth(); x < m; x++) {
                if (player.x == x && player.y == y) {
                    line.append('*');
                } else {
                    line.append(getTypeChar(maze.get(x,y)));
                }
            }
            sj.add(line.toString());
        }
        return sj.toString();
    }

    private char getTypeChar(FieldType type) {
        switch(type) {
            case WALL: return 'W';
            case DESERT: return '.';
            case FINISH: return 'F';
            default: return '?';
        }
    }


    public static void main(String[] args) throws Exception {
        String text = 
            "wwwwwdw\n" +
            "wfddddw\n" +
            "wdddddd\n" +
            "ddddddw\n" +
            "wdddddw\n" +
            "wwwwwdw\n";
             
        MazeTextFormat fmt = new MazeTextFormat();
        Maze maze = fmt.read(new StringReader(text));
        MazeWorld world = new MazeWorld(maze, new Position(4, 4));
        Test3 app = new Test3(new MazeGame(world));
        app.run();
    }
}
Das Spiel läuft also schon mal auf der Konsole. Sehen wir von den Testklassen ab, ist das, was wir bis jetzt geschrieben haben, der Kern bzw. die Logik des Spiels. Nebenbei haben wir sogar schon ein User Interface in Form der Konsole. Wir können das Spiel also bereits testen und spielen.

Alles, was uns zu unserem Glück noch fehlt, ist eine grafische Oberfläche. Dazu später mehr.
 
mihe7

mihe7

Die grafische Oberfläche soll ebenfalls schlicht gehalten werden, ähnlich wie das Text-UI (Konsole).

Zunächst einmal brauchen wir eine Komponente, die uns einen Blick auf unsere Spieltwelt ermöglicht. Was aber heißt das genau? Soll die Komponente das Labyrinth zeichen oder mit Hilfe von kleinen, gleichgroßen Bildern darstellen? Soll die Komponente das ganze Labyrinth darstellen oder nur einen Ausschnitt? Wie soll der Spieler positioniert werden? Und so weiter, und so fort.

Es gibt also eine ganze Reihe von Entwurfsentscheidungen, wir machen es mal verhältnismäßig einfach und wollen das gesamte Labyrinth mit Kachel(bilder)n darstellen. Der Spieler wird dann entsprechend seiner Position dargestellt.

Also, fangen wir mal an und nennen unsere Komponente - ganz bescheiden - WorldView:

Java:
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Insets;
import javax.swing.JComponent;

import java.util.EnumMap;
import java.util.Map;

public class WorldView extends JComponent {
    private final int tileWidth;
    private final int tileHeight;

    private final Map<FieldType, Image> images = new EnumMap<>(FieldType.class);
    private Image player;

    private MazeWorld world;
    
    public WorldView(MazeWorld world, int tileWidth, int tileHeight) {
        this.world = world;
        this.tileWidth = tileWidth;
        this.tileHeight = tileHeight;
    }

    @Override
    public Dimension getPreferredSize() {
        if (isPreferredSizeSet()) {
            return super.getPreferredSize();
        }
        Insets insets = getInsets();
        int w = insets.left + insets.right + 
                tileWidth * world.getMaze().getWidth();
        int h = insets.top + insets.bottom + 
                tileHeight * world.getMaze().getHeight();
        return new Dimension(w, h);
    }

    public void changeWorld(MazeWorld world) {
        this.world = world;
        repaint();
    }

    public void setImage(FieldType type, Image image) {
        images.put(type, requireCompatibleImage(image));
        repaint();
    }

    public void setPlayer(Image image) {
        player = requireCompatibleImage(image);
        repaint();
    }

    private Image requireCompatibleImage(Image image) {
        if (image.getWidth(null) != tileWidth || image.getHeight(null) != tileHeight) {
            String fmt = "Given image doesn't match tile size (%d, %d)";
            String msg = String.format(fmt, tileWidth, tileHeight);
            throw new IllegalArgumentException(msg);
        }
        return image;
    }

    @Override
    protected void paintComponent(Graphics g) {
        paintMap(g);
        paintPlayer(g);
    }

    private void paintMap(Graphics g) {
        Maze maze = world.getMaze();
        for (int y = 0, n = maze.getHeight(); y < n; y++) {
            for (int x = 0, m = maze.getWidth(); x < m; x++) {
                Image img = images.get(maze.get(x, y));
                if (img != null) {
                    g.drawImage(img, x * tileWidth, y * tileHeight, null);
                }
            }
        }
    }

    private void paintPlayer(Graphics g) {
        if (player == null) {
            return;
        }

        Position pos = world.getPlayer();
        g.drawImage(player, pos.x * tileWidth, pos.y * tileHeight, null);
    }
}
Die Komponente ist so gehalten, dass sie unabhängig von den existierenden Feldtypen funktioniert. Will man später z. B. einen weitern Feldtyp hinzufügen, muss an dem Code nichts verändert werden. Erreicht wird das durch eine java.util.Map, die beliebige Feldtypen auf Bilder abbildet. Da FieldType eine Aufzählung ist, bietet sich als Implementierung EnumMap an. Die hat den Vorteil, dass sie extrem schnell ist.

Feldtypen und die dazugehörigen Bilder müssen der Komponente lediglich mit Hile eines setImage-Aufrufs bekanntgegeben werden.

Dem Konstruktor geben wir neben der darzustellenden Welt auch die Größe der Kacheln mit. Einerseits brauchen wir die Kachelgröße, um die Größe der Komponente zu berechnen (s. getPreferredSize), andererseits kann dadurch einfach sichergstellt werden, ob die übergebenen Bilder dieser Größe entsprechen (s. requireCompatibleImage).

Der Rest des Codes sollte selbsterklärend sein: wann immer sich etwas ändert, wird die Komponente neu gezeichnet. Beim Zeichnen wird zuerst das Labyrinth gemalt, anschließend der Spieler draufgesetzt. Das hat zur Folge, dass die Spielerkachel Transparenz enthalten und somit grafisch über beliebige Felder gesetzt werden kann.

In paintMap wird mit maze.get(x, y) der Feldtyp an Position (x, y) bestimmt und anschließend das zum Typ zugehörige Bild aus der Map images ermittelt. War dies erfolgreich, gilt img != null und das Bild wird an die passende Position gezeichnet. Ansonsten bleibt die Position einfach leer.

Um den Spaß testen zu können, brauchen wir eine Anwendung, die alles integriert. Wo kommen aber die Bilder her?

Die Bilder sind Ressourcen der Anwendung, sind also unmittelbar mit der Anwendung verbunden. Wenn ein Java-Archiv (JAR) erstellt wird, sollen keine externen Bilddateien benötigt werden. Es gibt somit zwei Möglichkeiten: entweder erstellen wir Bilder im Programm oder wir nutzen die Möglichkeit von Java, Ressourcen über den Classpath zu laden. Wir werden letzteres tun.

Dazu verwenden wir einen "Ressourcen-Ordner" namens images, in dem wir die Bilder ablegen (Beispielbilder im Anhang).

Achtung:

Wo dieser Ordner anzulegen ist, hängt vom verwenden Build-System ab. Baut man einfach per Befehlszeile, kann man den Ordner direkt in dem Verzeichnis erstellen, in dem sich auch die Class-Files befinden. Die "IDE-eigenen" Build-Systeme funktionieren in der Regel ähnlich, so dass der Ordner einfach bei den Quelldateien angelegt werden kann (hat man z. B. src/Test1.java, dann kann man den Ordner images unter src anlegen). Maven und Gradle haben eine definierte Projektstruktur, dort ist der Ordner unter src/main/resources zu erstellen.

Um die Bilder einzulesen und die View passend zu konfigurieren, schreiben wir uns eine abstrakte Klasse, die nur statische Methoden besitzt (sog. Utility-Class). Das ist hier in Ordnung, weil die Bilder Konstanten sind.

Java:
import java.awt.Image;
import java.io.IOException;
import javax.imageio.ImageIO;

public abstract class WorldViewDefaults {
    public static final Image DESERT;
    public static final Image WALL;
    public static final Image FINISH;
    public static final Image PLAYER;

    static {
        DESERT = getImage("desert.png");
        WALL = getImage("wall.png");
        FINISH = getImage("finish.png");
        PLAYER = getImage("player.png");
    }

    private static Image getImage(String name) {
        try {
            return ImageIO.read(WorldViewDefaults.class
                    .getResourceAsStream("/images/" + name));
        } catch (IOException ex) {
            throw new ExceptionInInitializerError(ex);
        }
    }

    public static void configure(WorldView view) {
        view.setImage(FieldType.WALL, WALL);
        view.setImage(FieldType.DESERT, DESERT);
        view.setImage(FieldType.FINISH, FINISH);
        view.setPlayer(PLAYER);
    }
}
Oben werden die vier Konstanten definiert, die anschließend in einem static-Initialisierungsblock initialisiert werden. Dieser Block wird ausgeführt, sobald die Java die Klasse initialisiert, was automatisch geschieht. Mit getImage werden die angegebenen Ressourcen aus dem images-Ordner geladen, wie dem Code einfach zu entnehmen ist. Sollte beim Laden einer Ressource ein Fehler auftreten, wird ein Error ausgelöst und das Programm wird erst gar nicht starten.

Die Methode configure ist einfache eine Convenience-Methode, die uns die Arbeit abnimmt, die View zu konfigurieren.

So, jetzt brauchen wir nur noch eine Swing-Anwendung, um den Spaß zu testen:
Java:
import javax.swing.*;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;

public class App1 {

    public void run() {
        WorldView worldView = new WorldView(createWorld(), 32, 32);
        WorldViewDefaults.configure(worldView);
        JFrame frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.add(worldView);
        frame.pack();
        frame.setVisible(true);
    }

    private MazeWorld createWorld() {
        String text = 
            "wwwwwdw\n" +
            "wfddddw\n" +
            "wdddddd\n" +
            "ddddddw\n" +
            "wdddddw\n" +
            "wwwwwdw\n";
             
        MazeTextFormat fmt = new MazeTextFormat();
        try {
            Maze maze = fmt.read(new StringReader(text));
            return new MazeWorld(maze, new Position(4, 4));
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new App1().run());
    }
}
Wir erstellen in run() erst einmal eine Spielwelt. Der Code der dabei verwendeten Methode createWorld ist fast identisch zur main-Methode von Test3.java zuvor. Unterschied ist lediglich, dass Ausnahmen "behandelt" werden und die erstellte Welt zurückgegeben wird. Mit der so erzeugten Welt wird unsere Komponente initialisiert. Dabei geben wir an, dass unsere Kacheln eine Größe von 32x32 haben.

Im nächsten Schritt verwenden wir unsere Utility-Klasse, um die Bilder zu initialisieren. Der Rest besteht einfach darin, einen Frame zu erstellen, die Komponente hinzuzufügen und das Fenster anzuzeigen.

Ausgabe:
App1.png

Passt. Später mehr...
 

Anhänge

mrBrown

mrBrown

Den Beitrag sollte man vielleicht mal anpinnen

Ein paar kleine Dinge die mir aufgefallen sind oder die ich anders machen würde:

  • Position move(Position delta) würde ich weglassen (wenn ich das richtig sehe wird sie auch nicht benutzt). Und wenn man es doch braucht, würde ich strikt trennen zwischen Punkt und Vektor, mit Möglichkeiten das eine in das andere umzuwandeln (uU auch beides als Interface und eine Klasse implementiert beide).
  • Maze hat (wenn ich das richtig sehe) eine Methode die x und x direkt nimmt, und eine, dir Position nimmt? (Vermutlich dem geschuldet, dass Position erst einen Schritt nach Maze eingeführt wurde) Würde ich vereinheitlichen.
  • Höhe und Breite würde ich auch zusammenfassen, genauso wie bei Position
  • Etwas in der Art maze.get(player.x, player.y) == type; kommt glaub ich zwei mal vor, und auch unabhängig von der Doppelung würde ich das in Maze ziehen, etwa hasTypeAt(FieldType, Position)

  • WorldViewDefaults würde ich final mit privatem Konstruktor machen, abstrakte Klassen dafür nutzen ist für mich immer ein „Antipattern“
  • Statt dem static-inizializer in WorldViewDefaults Würde ich die Konstanten direkt initialisieren, find ich persönlich sauberer
 
mihe7

mihe7

Position move(Position delta) würde ich weglassen (wenn ich das richtig sehe wird sie auch nicht benutzt)
Oh, muss ich mal schauen, warum das drin ist...

Maze hat (wenn ich das richtig sehe) eine Methode die x und x direkt nimmt, und eine, dir Position nimmt? (Vermutlich dem geschuldet, dass Position erst einen Schritt nach Maze eingeführt wurde)
Ja, das ist den Iterationen geschuldet. "I will refactor this later..." :)

EDIT: Korrektur (gerade nachgeschaut): Maze hat nur eine contains-Methode, die eine Position nimmt und eine get-Methode, die x und y direkt nimmt.

Höhe und Breite würde ich auch zusammenfassen, genauso wie bei Position
Gute Idee.

Etwas in der Art maze.get(player.x, player.y) == type; kommt glaub ich zwei mal vor, und auch unabhängig von der Doppelung würde ich das in Maze ziehen, etwa hasTypeAt(FieldType, Position)
Richtig. Das stand sogar schon auf dem gedanklichen Zettel.

WorldViewDefaults würde ich final mit privatem Konstruktor machen, abstrakte Klassen dafür nutzen ist für mich immer ein „Antipattern“
Kann man machen.

Statt dem static-inizializer in WorldViewDefaults Würde ich die Konstanten direkt initialisieren, find ich persönlich sauberer
Ah, das ist tatsächlich ein Überbleibsel (bevor es bei mir getImage gab...)

Danke für das Feedback, das baue ich in die Geschichte ein :)
 
Zuletzt bearbeitet:
L

LimDul

Wäre es auch nicht eine Idee das in ein Github Repository zu packen? Insebsondere kann man über die Commit History die Änderungen nachvollziehen.
 
mihe7

mihe7

So, nachdem ich wieder ein wenig dazu gekommen bin, hier weiterzumachen, habe ich zuerst einmal die Idee von @LimDul aufgegriffen und den Spaß der Reihe nach in ein git-Repository eingepflegt. Dabei sind mir zwei Fehler in MazeWorld aufgefallen, die sich hier eingeschlichen haben:

1. getPlayer() gibt position zurück. Eine Variable, die gar nicht existiert. Muss natürlich player heißen.
2. die move-Methode muss movePlayer heißen.

Das Repo findet man unter https://github.com/mihe7/maze - die Formatierung der Quelltexte entspricht der hier eingefügten, d. h. teilweise sehr kompakt mit kompletten Methoden auf einer Zeile. Ggf. werde ich später neu formatieren lassen. Das ist mir zu viel Action, ständig hin und her zu formatieren.
 
mihe7

mihe7

Durch das Review von @mrBrown hat der ein oder andere gleich mal einen schönen Einblick davon bekommen, wie das in der Praxis abläuft. Auch, wenn es die Möglichkeit einer "Fremdbegutachtung" allein im stillen Kämmerlein nicht gibt, wollen wir das gleich mal korrigieren. Erstens findet sich immer mal jemand, der über den Code schaut und zweitens ist es auch nicht ungewöhnlich, dass einem selbst Dinge zu einem späteren Zeitpunkt auffallen - vor allem, wenn man mit etwas zeitlichem Abstand mal wieder seinen Code liest.

In Position.java fliegt die Methode
Java:
    public Position move(Position delta) {
        return new Position(x + delta.x, y + delta.y);
    }
erst einmal ersatzlos raus. Evtl. greifen wir später den Vorschlag auf, einen Vektor einzuführen, für den Moment wollen wir es aber mal dabei belassen.

Der nächste Punkt betrifft Höhe und Breite oder kurz: Größenangaben. Hier kann man eine Klasse einführen, die analog zu Position aufgebaut sein kann.

Java:
public class Size {
    public final int width;
    public final int height;

    public Size(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public boolean equals(Object o) {
        if (o == null || o == this || !(o instanceof Size)) {
            return o == this;
        }

        Size s = (Size) o;
        return width == s.width && height == s.height;
    }

    @Override
    public int hashCode() {
        return 7*width + 53*height;
    }
}
Wenn ich es richtig sehe, kann Size in unserem bisherigen Modell in zwei Klassen verwendet werden: Maze und WorldView. Maze kann statt (oder zusätzlich zu) Höhe und Breite auch einfach die Größe zurückgeben. Als Convenience-Methoden sind getHeight() und getWidth() nicht unpraktisch, daher entfernen wir die nicht. Wir könnten jetzt einfach getSize() in Maze einfügen, die unter Verwendung von getWidth() und getHeight() ein Size-Objekt liefert. Allerdings würde dabei immer ein neues Size-Objekt erzeugt, weswegen wir Maze nun so umbauen, dass die Größe in einer Instanzvariable gehalten wird. Dementsprechend ändern sich auch die Methoden getWidth() und getHeight().

Java:
public class Maze {
    private final FieldType[][] fields;
    private final Size size;

    public Maze(FieldType[][] fields) {
        int height = fields.length;
        int width = height == 0 ? 0 : fields[0].length;
        this.fields = new FieldType[height][width];

        for (int y = 0; y < height; y++) {
            for (int x = 0; x < width; x++) {
                this.fields[y][x] = fields[y][x];
            }
        }

        size = new Size(width, height);
    }

    public int getHeight() { return size.height; }
    public int getWidth() { return size.width; }
    public Size getSize() { return size; }
    public FieldType get(int x, int y) { return fields[y][x]; }           

    public boolean contains(Position pos) {
        return pos.x >= 0 && pos.x < size.width &&
                pos.y >= 0 && pos.y < size.height;
    }
}
In WorldView entfernen wir dagegen die Variablen tileWidth und tileHeight und führen dafür tileSize ein. Das führt zu einigen Änderungen. Außerdem können wir jetzt die Methode getSize() von Maze verwenden, wodurch der Code an einigen Stellen schöner wird (vgl. die for-Schleifen).

Bei der Gelegenheit ist aufgefallen, dass Code zum Zeichnen der Bilder (g.drawImage) fast identisch doppelt vorhanden ist. Das lagern wir auch gleich in eine Methode paintImage aus, die ein Bild an gegebene "Kachelkoordinaten" zeichnet.

Java:
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Insets;
import javax.swing.JComponent;

import java.util.EnumMap;
import java.util.Map;

public class WorldView extends JComponent {
    private final Size tileSize;

    private final Map<FieldType, Image> images = new EnumMap<>(FieldType.class);
    private Image player;

    private MazeWorld world;
   
    public WorldView(MazeWorld world, int tileWidth, int tileHeight) {
        this(world, new Size(tileWidth, tileHeight));
    }

    public WorldView(MazeWorld world, Size tileSize) {
        this.world = world;
        this.tileSize = tileSize;
    }

    @Override
    public Dimension getPreferredSize() {
        if (isPreferredSizeSet()) {
            return super.getPreferredSize();
        }
        Insets insets = getInsets();
        Size mazeSize = world.getMaze().getSize();
        int w = insets.left + insets.right +
                tileSize.width * mazeSize.width;
        int h = insets.top + insets.bottom +
                tileSize.height * mazeSize.height;
        return new Dimension(w, h);
    }

    public void changeWorld(MazeWorld world) {
        this.world = world;
        repaint();
    }

    public void setImage(FieldType type, Image image) {
        images.put(type, requireCompatibleImage(image));
        repaint();
    }

    public void setPlayer(Image image) {
        player = requireCompatibleImage(image);
        repaint();
    }

    private Image requireCompatibleImage(Image image) {
        if (image.getWidth(null) != tileSize.width ||
                image.getHeight(null) != tileSize.height) {
            String fmt = "Given image doesn't match tile size (%d, %d)";
            String msg = String.format(fmt, tileSize.width, tileSize.height);
            throw new IllegalArgumentException(msg);
        }
        return image;
    }

    @Override
    protected void paintComponent(Graphics g) {
        paintMap(g);
        paintPlayer(g);
    }

    private void paintMap(Graphics g) {
        Maze maze = world.getMaze();
        Size mazeSize = maze.getSize();
        for (int y = 0; y < mazeSize.height; y++) {
            for (int x = 0; x < mazeSize.width; x++) {
                Image img = images.get(maze.get(x, y));
                if (img != null) {
                    paintImage(g, img, x, y);
                }
            }
        }
    }

    private void paintPlayer(Graphics g) {
        if (player == null) {
            return;
        }

        Position pos = world.getPlayer();
        paintImage(g, player, pos.x, pos.y);
    }

    private void paintImage(Graphics g, Image img, int xTile, int yTile) {
        g.drawImage(img, xTile * tileSize.width, yTile * tileSize.height, null);
    }
}
Tatsächlich stellt sich die Frage, warum an manchen Stellen mit x- und y-Koordinaten statt mit Position-Objekten gearbeitet wird. Das liegt einfach daran, dass die Schleife über x- und y-Koordinaten iteriert und somit für jedes Feld in der Schleife ein Position-Objekt erstellt werden müsste. Das würde bedeuten, dass man ein Koordinatenpaar nimmt, daraus ein Position-Objekt erzeugt, dieses an eine Methode übergibt, die ihrerseits wieder auf das Koordinatenpaar der Position zugreift. Da kann ich persönlich keinen echten Mehrwert erkennen.

Eine Idee wäre es, über Positionen (ggf. in Kombination mit dem jeweiligen Feldtyp) des Labyrinths iterieren zu können. Dazu habe ich aber gerade keine Lust, daher an der Stelle nur ein möglicher Ansatz (ungetester Code, kann Fehler enthalten):

Java:
public class MazeField {
    public final FieldType type;
    public final Position position;
    // Konstruktor, equals, hashCode
}
Damit ließe sich in Maze schreiben:
Java:
public class Maze implements Iterable<MazeField> {
    // ...

    @Override
    public Iterator<MazeField> iterator() {
        return new Iterator<MazeField>() {
            Position pos = new Position(0, 0);

            @Override
            public boolean hasNext() {
                return contains(pos);
            }

            @Override
            public MazeField next() {
                if (!hasNext()) {
                    throw new NoSuchElementException();
                }
                MazeField result = new MazeField(pos, get(pos));
                int index = pos.x + pos.y * size.width + 1;
                pos = new Position(index % size.width, index / size.width);
                return result;
            }
        };
    }
}
Solch ein Vorgehen hat den Charme, dass man Maze eine klein wenig andere Bedeutung zuschreiben kann. Bislang sind wir davon ausgegangen, dass die Felder eines Labyrinths immer die Fläche eines Rechtecks bilden. Wenn man ein Labyrinth aber als Sammlung von MazeField-Objekten versteht, dann können die Felder innerhalb eines rechteckigen Bereichs beliebig angeordnet sein, folglich auch Lücken enthalten.

Durch die Implementierung des Interfaces Iterable kann über ein Maze-Objekt iteriert werden. In WorldView könnte dann etwa geschrieben werden:
Java:
    private void paintMap(Graphics g) {
        for (MazeField field : world.getMaze()) {
            Image img = images.get(field.type);
            if (img != null) {
                paintImage(g, img, field.position);
            }
        }
    }
Vielleicht komme ich darauf später noch einmal zurück - oder es gibt zwischenzeitlich noch andere Ideen.

Schauen wir uns den vierten Punkt auf mrBrowns Liste an. In MazeWorld findet man zwei Zeilen:
Java:
maze.get(pos.x, pos.y) != FieldType.WALL;
// und
maze.get(player.x, player.y) == type
Die Idee war, in Maze eine Methode anzubieten, die den Typ eines Felds prüft (hasTypeAt(FieldType, Position)). Interessant ist jetzt, dass die beiden Parameter im Zusammenhang mit dem vorangegangenen Punkt bereits aufgetaucht sind, nämlich in Form von MazeField. Wir könnten die Prüfung also auch derart verstehen, dass wir das Labyrinth nach einem bestimmtes Feld (= Position und Feldtyp) fragen. Da wir das aber nicht umgesetzt haben, bleiben wir bei der Methode und implementieren in Maze

Java:
    public boolean hasTypeAt(FieldType type, Position pos) {
        return contains(pos) && get(pos.x, pos.y) == type;
    }
und schreiben die beiden Methoden in MazeWorld um:

Java:
    private boolean isLegalMove(Position pos) {
        return maze.contains(pos) &&
                !maze.hasTypeAt(FieldType.WALL, pos);
    }

    public boolean isPlayerOn(FieldType type) {
        return maze.hasTypeAt(type, player);
    }
Für die letzten beiden Punkte passen wir noch WorldViewDefaults am:
Java:
import java.awt.Image;
import java.io.IOException;
import javax.imageio.ImageIO;

public final class WorldViewDefaults {
    public static final Image DESERT = getImage("desert.png");
    public static final Image WALL = getImage("wall.png");
    public static final Image FINISH = getImage("finish.png");
    public static final Image PLAYER = getImage("player.png");

    private WorldViewDefaults() {
    }

    private static Image getImage(String name) {
        try {
            return ImageIO.read(WorldViewDefaults.class
                    .getResourceAsStream("/images/" + name));
        } catch (IOException ex) {
            throw new ExceptionInInitializerError(ex);
        }
    }

    public static void configure(WorldView view) {
        view.setImage(FieldType.WALL, WALL);
        view.setImage(FieldType.DESERT, DESERT);
        view.setImage(FieldType.FINISH, FINISH);
        view.setPlayer(PLAYER);
    }
}
Sieht doch gleich viel aufgeräumter aus.

Ach, ja: natürlich empfiehlt es sich bei solchen Änderungen anhand der Testklassen immer wieder zu überprüfen, ob das Programm noch tut, was es soll (normalerweise setzt man dazu automatisierte Tests ein, das führt hier aber zu weit).

Verbesserungsvorschläge sind wie immer willkommen.
 
Zuletzt bearbeitet von einem Moderator:
B

BestGoalkeeper

Der Faden gefällt mir. Für Anfänger, welche ein Spiel programmieren möchten, sicher ein willkommener Einstiegspunkt! Aber man braucht auch etwas Zeit, um deinen Gedankengängen zu folgen. 😊
 
mihe7

mihe7

Aber man braucht auch etwas Zeit, um deinen Gedankengängen zu folgen. 😊
Das will ich hoffen, für alle anderen gibts ein Pong-Tutorial :p Aber mal ernsthaft: geht es um den Anfang oder um das Refactoring? Bis Kommentar #4, würde ich sagen, ist das alles straight-forward. Bei #9 wird es etwas holprig, weil man einiges am Code ändern muss und ich noch ein paar andere Gedanken habe einfließen lassen. Ich hoffe aber mal, dass es trotzdem noch halbwegs nachvollziehbar ist. Der Code liegt ja nun auch im Repository, da kann man sich notfalls nur durchhangeln, das Forum wäre auch noch da und wenn alle Stricke reißen, kann man den Spaß ja nochmal in einem Thread zusammenfassen.
 
H

httpdigest

Ich habe gehört, dass Leute heutzutage sowieso eher via Youtube Videos lernen.
Wenn Mihe einen Kanal hast, würde ich den sofort abonnieren. :)
 
mihe7

mihe7

Nach den vorangegangenen Korrekturen können wir nun weitermachen. Da es zwischenzeitlich etwas unübersichtlich wurde, nochmal ein kurzer Überblick über unser Modell:
  • FieldType -> Aufzählung der Feldtypen
  • Position -> Positionsangabe (x,y)
  • Size -> Größenangabe (Breite und Höhe)
  • Maze -> Labyrinth
  • MazeWorld -> stellt einen Spieler in einem Labyrinth dar
  • MazeGame -> Spiellogik
Daneben haben wir, sozusagen als Add-On, die Klasse MazeTextFormat, mit der sich ein Labyrinth aus einer textuellen Beschreibung über einen Reader einlesen lässt.

Für die grafische Oberfläche haben wir bislang nur die Komponente WorldView, die eine Spielwelt darstellt. Außerdem WorldViewDefaults, die der Konfigurieren dieser Komponente dient sowie eine Beispielanwendung App1.

Als nächstes brauchen wir eine Steuerung für den Benutzer, der die Spielfigur mit den Pfeiltasten auf dem Labyrinth bewegen können soll. Es gibt in Swing verschiedene Möglichkeiten, so etwas zu realisieren. Wir setzen einfach mal auf eine Implementierung des KeyListener-Interface, die wir bei unserer WorldView-Komponente registrieren können.

Um eine Bewegung durchzuführen, sind zwei Schritte erforderlich: zunächst muss der Spieler in der Spielwelt auf eine neue Position verschoben werden, anschließend muss dem Anwender der so entstandene neue Zustand der Spielwelt angezeigt werden. Auf der Konsole haben wir letzteres erreicht, indem wir nach jedem Schritt eine textuelle Darstellung der Spielwelt ausgegeben haben. In der graphischen Oberfläche muss dafür gesorgt werden, dass die WorldView-Komponente neu gezeichnet wird.

Für den Anfang machen wir uns die Sache wieder sehr einfach und bauen uns eine App2.java, die nichts anderes als eine leicht erweiterte bzw. angepasste Version von App1.java ist. Daduch wird die Analogie zur Konsolenvariante Test2.java hoffentlich sehr deutlich. Später werden wir das noch ändern.

Der Code ist alles andere als aufregend:

Java:
import java.awt.event.KeyAdapter;
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
import javax.swing.*;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;

public class App2 {

    private MazeWorld world;
    private WorldView worldView;

    private KeyListener keyboardController = new KeyAdapter() {
        @Override
        public void keyPressed(KeyEvent e) {
            int dx = 0, dy = 0;

            switch (e.getKeyCode()) {
                case KeyEvent.VK_LEFT:  dx--; break;
                case KeyEvent.VK_RIGHT: dx++; break;
                case KeyEvent.VK_UP:    dy--; break;
                case KeyEvent.VK_DOWN:  dy++; break;
                default:
                    return;
            }

            world.movePlayer(dx, dy);
            worldView.repaint();
        }
    };

    public void run() {
        world = createWorld();
        worldView = new WorldView(world, 32, 32);
        WorldViewDefaults.configure(worldView);

        worldView.setFocusable(true);
        worldView.addKeyListener(keyboardController);

        JFrame frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.add(worldView);
        frame.pack();
        frame.setVisible(true);
    }

    private MazeWorld createWorld() {
        String text = 
            "wwwwwdw\n" +
            "wfddddw\n" +
            "wdddddd\n" +
            "ddddddw\n" +
            "wdddddw\n" +
            "wwwwwdw\n";
             
        MazeTextFormat fmt = new MazeTextFormat();
        try {
            Maze maze = fmt.read(new StringReader(text));
            return new MazeWorld(maze, new Position(4, 4));
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new App2().run());
    }
}
Natürlich hätte man im switch-Block auch direkt die passenden Werte setzen können, aber die Increment- bzw. Decrement-Operatoren zeigen die Intention schöner an (nach rechts bewegen -> x-Koordinate erhöhen). Man merkt allmählich, dass es ohne Vektoren hässlich wird...

Ausprobieren:
maze_move.gif
 
mihe7

mihe7

Um den Stand von Test3.java mit einer GUI zu erreichen, müssen wir lediglich zwei Dinge ändern:
  1. Die Anwendung muss ein MazeGame statt einer MazeWorld verwalten.
  2. Nach einer Bewegung muss ermittelt werden, ob das Ziel erreicht wurde, damit eine Gewinnmitteilung erfolgen kann.

Das ist fast schon trivial, so dass wir uns mit anderen Fragen beschäftigen können: wie zeigen wir die Gewinnmitteilung an und wie soll sich das Programm im Anschluss verhalten?

Allein schon für die Ausgabe gibt es unzählige Möglichkeiten: ein Label, ein Dialogfenster, ein von WorldView ausgegebener Text, um nur ein paar aufzuzählen.

Schön wäre es, wenn wir über das angezeigte Labyrinth eine halbtransparente Fläche legen könnten, während im Vordergrund die Gewinnmitteilung stünde. Ein Tastendruck könnte diese verschwinden und ein neues Spiel beginnen lassen.

Tatsächlich lässt sich das mit einem speziellen JPanel realisieren, das man über den Fensterinhalt legt. JPanel selbst funktioniert nicht, da nur opake (= undurchsichtige) Panels mit der Hintergrundfarbe gefüllt werden. Ein (halb-)transparentes Panel ist aber schnell implementiert:

Java:
import java.awt.Graphics;
import java.awt.LayoutManager;
import javax.swing.JPanel;

public class TransparentPanel extends JPanel {
    public TransparentPanel() {
        setOpaque(false);
    }

    public TransparentPanel(LayoutManager layout) {
        super(layout);
        setOpaque(false);
    }

    @Override
    protected void paintComponent(Graphics g) {
        g.setColor(getBackground());
        g.fillRect(0, 0, getWidth(), getHeight());
    }
}
In dieser Variante muss die Hintergrundfarbe Transparenz enthalten, damit das Panel durchsichtig wird. Die Komponenten des Panels werden dagegen "normal" angezeigt. Das reicht erstmal aus.

Dieses Panel kann nun als sog. "glass pane" des JFrame verwendet werden, das ein- und ausgeblendet werden kann und über dem Fensterinhalt angezeigt wird.

Java:
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import javax.swing.*;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;

public class App3 {

    private MazeGame game;
    private WorldView worldView;
    private TransparentPanel winnerMessage;

    private KeyListener dismissMessageByKeyboard = new KeyAdapter() {
        @Override
        public void keyPressed(KeyEvent e) {
            if (e.getKeyCode() == KeyEvent.VK_SPACE) {
                hideWinnerMessage();
                restart();
            }
        }
    };

    private KeyListener keyboardController = new KeyAdapter() {
        @Override
        public void keyPressed(KeyEvent e) {
            int dx = 0, dy = 0;

            switch (e.getKeyCode()) {
                case KeyEvent.VK_LEFT:  dx--; break;
                case KeyEvent.VK_RIGHT: dx++; break;
                case KeyEvent.VK_UP:    dy--; break;
                case KeyEvent.VK_DOWN:  dy++; break;
                default:
                    return;
            }

            game.movePlayer(dx, dy);
            worldView.repaint();

            if (game.isWin()) {
                showWinnerMessage();
            }
        }
    };

    public void run() {
        initWinnerMessage();

        newGame();
        worldView = new WorldView(game.getWorld(), 32, 32);
        WorldViewDefaults.configure(worldView);

        worldView.setFocusable(true);
        worldView.addKeyListener(keyboardController);

        JFrame frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.setGlassPane(winnerMessage);
        frame.add(worldView);
        frame.pack();
        frame.setVisible(true);
    }

    private void initWinnerMessage() {
        winnerMessage = new TransparentPanel(new BorderLayout());
        winnerMessage.setBackground(new Color(255, 255, 255, 200));

        String message = "<html><h1 style=\"text-align: center;\">Gewonnen!</h1>" +
            "<p>Weiter mit der Leertaste.</p>";
        winnerMessage.add(new JLabel(message, SwingConstants.CENTER));
        winnerMessage.setFocusable(true);
        winnerMessage.addKeyListener(dismissMessageByKeyboard);
    }

    private void showWinnerMessage() {
        if (winnerMessage.isVisible()) {
            return;
        }
        winnerMessage.setVisible(true);
        winnerMessage.requestFocus();
    }

    private void hideWinnerMessage() {
        winnerMessage.setVisible(false);
    }

    private void restart() {
        newGame();
        worldView.changeWorld(game.getWorld());
    }

    private void newGame() {
        game = new MazeGame(createWorld());
    }

    private MazeWorld createWorld() {
        String text =
            "wwwwwdw\n" +
            "wfddddw\n" +
            "wdddddd\n" +
            "ddddddw\n" +
            "wdddddw\n" +
            "wwwwwdw\n";
            
        MazeTextFormat fmt = new MazeTextFormat();
        try {
            Maze maze = fmt.read(new StringReader(text));
            return new MazeWorld(maze, new Position(4, 4));
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new App3().run());
    }
}
Damit haben wir in etwa den Stand erreicht, den wir bereits auf der Konsole hatten. Allerdings hat der Code in App3.java zwei wesentliche Nachteile. Der erste betrifft den Quelltext selbst. Der ist nämlich mittlerweile recht umfangreich, was wir im nächsten Schritt verbessern wollen. Bei der Gelegenheit wird sich auch der zweite Nachteil zeigen, der das Design betrifft.

Diese Dinge erscheinen mir relevant genug, um sie in eigenen Kommentaren zu behandeln.
 
mihe7

mihe7

Die "Gewinnbenachrichtigung" können wir in eine separate Klasse auslagern. An der Stelle wird oftmals ein eine völlig überflüssige Vererbung eingeführt wird, indem eine vorhandene Komponente wie z. B. JPanel erweitert wird, um ein aus meist mehreren Komponenten bestehendes Oberflächenelement zusammenzubauen.

Kein Mensch würde auf die Idee kommen, z. B. ArrayList zu erweitern, weil er eine Liste mit einem Standardelement braucht. Gerade in Swing-Frontends hat sich ein analoges Vorgehen jedoch als gängige Unsitte eingebürgert, bei der praktisch an jeder Stelle irgendeine Komponente erweitert wird.

Wenn man sich den bisherigen Code ansieht, kommen App1.java bis App3.java ohne eine Erweiterung einer UI-Komponente aus. Es wird einfach ein JFrame erstellt, auf das eine WorldView-Komponente platziert und mit einem TransparentPanel überlagert wird, dem ein JLabel hinzugefügt wurde.

Dass TransparentPanel und WorldView dagegen von UI-Komponenten abgeleitet sind, hat den einfachen Grund, dass die gewünschte Funktionalität mit dem Verhalten der Standardkomponenten nicht bzw. nicht ohne weiteres darstellbar ist. Wir haben also tatsächlich UI-Komponenten mit neuer Funktionalität erstellt und dafür ist Vererbung ein probates Mittel.

Was wir haben wollen, ist eine Benachrichtigung, in deren Folge etwas bestimmtes ausgeführt wird. Es gibt unzählige Möglichkeiten, so etwas umzusetzen. Theoretisch könnten wir den betreffenden Code aus App3.java direkt in eine separate Klasse setzen, aber ich bringe es nicht übers Herz, ganz ohne Abstraktion zu arbeiten ;) Trotzdem die Klasse recht konkret ist, sollen daher wenigstens der Text und die auszuführende Aktion anpassbar sein. Wichtig ist mir aber zu zeigen, dass es auch ohne Vererbung geht.

Java:
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.SwingConstants;

public class MessageOverlay {
    private final JFrame frame;

    private JComponent component;
    private Runnable runOnDismiss;

    private final KeyListener dismissByKeyboard = new KeyAdapter() {
        @Override
        public void keyPressed(KeyEvent e) {
            JComponent panel = (JComponent) e.getSource();
            if (e.getKeyCode() == KeyEvent.VK_SPACE) {
                panel.setVisible(false);
                if (runOnDismiss != null) {
                    runOnDismiss.run();
                }
            }
        }
    };

    public MessageOverlay(JFrame frame) {
        this.frame = frame;
    }

    public MessageOverlay display(String text) {
        return display(new JLabel(text, SwingConstants.CENTER));
    }

    public MessageOverlay display(JComponent component) {
        this.component = component;
        return this;
    }

    public MessageOverlay onDismiss(Runnable runnable) {
        runOnDismiss = runnable;
        return this;
    }

    public void show() {
        TransparentPanel panel = new TransparentPanel(new BorderLayout());
        panel.setFocusable(true);
        panel.setBackground(new Color(255, 255, 255, 200));
        panel.addKeyListener(dismissByKeyboard);
        panel.add(component);

        frame.setGlassPane(panel);
        panel.setVisible(true);
        panel.requestFocus();
    }

}
Die Klasse verfügt über ein sog. fluent interface, d. h. einer Menge öffentlicher Methoden, die entsprechende Objekte zurückgeben, so dass sich der Quelltext bei Verwendung dieser Klasse fast wie Prosa liest, wie unten zu sehen ist.

In MessageOverlay ist alles gekapselt: die Tatsache, dass ein TransparentPanel verwendet wird, ebenso wie das Layout, die Farben, auch dass die Leertaste verwendet werden muss, um die Nachricht ausuzblenden. Das ist alles andere als perfekt, aber für die Aufteilung des Codes reicht es allemal.

Ich stelle den Ansatz ausdrücklich zur Diskussion, ggf. kann man dann noch andere Möglichkeiten implementieren.

Hier noch die Verwendung in App4.java:
Java:
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import javax.swing.*;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;

public class App4 {

    private MazeGame game;
    private WorldView worldView;
    private MessageOverlay winnerMessage;

    private KeyListener keyboardController = new KeyAdapter() {
        @Override
        public void keyPressed(KeyEvent e) {
            int dx = 0, dy = 0;

            switch (e.getKeyCode()) {
                case KeyEvent.VK_LEFT:  dx--; break;
                case KeyEvent.VK_RIGHT: dx++; break;
                case KeyEvent.VK_UP:    dy--; break;
                case KeyEvent.VK_DOWN:  dy++; break;
                default:
                    return;
            }

            game.movePlayer(dx, dy);
            worldView.repaint();

            if (game.isWin()) {
                winnerMessage.show();
            }
        }
    };

    public void run() {
        newGame();
        worldView = new WorldView(game.getWorld(), 32, 32);
        WorldViewDefaults.configure(worldView);

        worldView.setFocusable(true);
        worldView.addKeyListener(keyboardController);

        JFrame frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.add(worldView);
        frame.pack();
        frame.setVisible(true);

        initWinnerMessage(frame);
    }

    private void initWinnerMessage(JFrame frame) {
        String message = "<html><h1 style=\"text-align: center;\">Gewonnen!</h1>" +
            "<p>Weiter mit der Leertaste.</p>";

        winnerMessage = new MessageOverlay(frame)
                .display(message)
                .onDismiss(this::restart);
    }

    private void restart() {
        newGame();
        worldView.changeWorld(game.getWorld());
    }

    private void newGame() {
        game = new MazeGame(createWorld());
    }

    private MazeWorld createWorld() {
        String text =
            "wwwwwdw\n" +
            "wfddddw\n" +
            "wdddddd\n" +
            "ddddddw\n" +
            "wdddddw\n" +
            "wwwwwdw\n";
            
        MazeTextFormat fmt = new MazeTextFormat();
        try {
            Maze maze = fmt.read(new StringReader(text));
            return new MazeWorld(maze, new Position(4, 4));
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new App4().run());
    }
}
Das war nun der Teil, bei dem primär die Aufteilung des Quelltexts im Fokus stand. Im nächsten Teil werden wir das fortführen und dabei auch gleich einen gewichtigen Nachteil des bisherigen Entwurfs sehen.
 
mihe7

mihe7

Kommen wir nun zum fast interessantesten Punkt der ganzen Geschichte. Wie angekündigt wollen wir den Quelltext weiter aufteilen. Dazu führen wir für die Implementierung von keyboardController eine separate Klasse KeyboardController ein.

Für sich genommen ist ein solches Refactoring nichts besonderes, allerdings wird dabei deutlich, dass die dabei entstehende Klasse Kenntnis von

  1. dem Spiel (MazeGame-Objekt),
  2. der WorldView-Komponente und
  3. dem MessageOverlay

haben müsste, damit der folgende Teil des verschobenen Codes überhaupt funktionieren kann:

Java:
game.movePlayer(dx, dy);
worldView.repaint();

if (game.isWin()) {
    winnerMessage.show();
}
Bei ganz genauer Betrachtung lässt sich erkennen, dass dort auch das Wissen implementiert ist, wie das Spiel im Kern logisch funktioniert, z. B. dass (nur) nach einem Tastendruck das Spiel enden kann.

Diese ganzen Abhängigkeiten stellen keinen Schönheitsfehler dar, sondern sorgen in größeren Projekten regelmäßig dafür, dass der Code innerhalb verhältnismäßig kurzer Zeit völlig unwartbar wird. Aus dem Grund verfolgt man im Entwurf das Ziel, möglichst lose gekoppelte, voneinander also weitgehend unabhängige Module zu erstellen.

Überlegen wir kurz, welche Aufgabe eine Klasse für die Steuerung per Tastatur hat: einen Tastendruck in einen Spielzug, also den Aufruf einer Methode eines MazeGame-Objekts zu übersetzen. Mehr nicht!

Der Code müsste in KeyboardController also mit game.movePlayer(dx, dy) enden. Damit wäre der Controller seiner Aufgabe entsprechend nur vom Spiel (MazeGame) abhängig. Ob und wie das UI die Änderung des Spielstands visualisiert oder den Gewinner benachrichtigt, braucht den Controller nicht zu interessieren.

Damit wird der Code sehr einfach und die Abhängigkeiten werden auf das notwendige Maß reduziert:

Java:
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;

public class KeyboardController extends KeyAdapter {
    private final MazeGame game;

    public KeyboardController(MazeGame game) {
        this.game = game;
    }

    @Override
    public void keyPressed(KeyEvent e) {
        int dx = 0, dy = 0;

        switch (e.getKeyCode()) {
            case KeyEvent.VK_LEFT:  dx--; break;
            case KeyEvent.VK_RIGHT: dx++; break;
            case KeyEvent.VK_UP:    dy--; break;
            case KeyEvent.VK_DOWN:  dy++; break;
            default:
                return;
        }

        game.movePlayer(dx, dy);
    }
}
Bei der Gelegenheit fällt mir noch ein weiterer Fehler auf, den ich für später auf die Todo-List setze (könnte man auch in den Bug-Tracker von Github eintragen): das Modell erlaubt es entgegen der Spielregeln, Felder zu überspringen. Aber zurück zum Thema.

Jetzt haben wir natürlich das Problem, dass die WorldView nicht mehr neu gezeichnet wird. Wie also erreichen wir es, dass Änderungen visualisiert werden können?

Hierfür gibt es ein einfaches, in verschiedenen Ausprägungen weit verbreitetes und sehr elegantes Entwurfsmuster: Beobachter (Observer). Tatsächlich verwenden wir dieses schon die ganze Zeit, denn jeder Listener ist nichts anderes als ein solcher Observer. Ein KeyListener "beobachtet" beispielsweise die Tastaturereignisse, die einer Komponente zuzuordnen sind.

Die Idee ist denkbar einfach: das MazeWorld-Objekt weiß, wenn sich etwas an ihm ändert und kann Beobachter, die zuvor bei ihm registriert wurden, über diese Änderung informieren. Da eine MazeWorld aber nicht weiß und insbesondere auch nicht wissen will, ob sie nun von einem Text-UI, einer für Swing erstellten MazeWorldView, einer JavaFX- oder Android-Komponente beobachtet wird, abstrahiert man von solchen Details und definiert einfach eine Schnittstelle, die jeder Beobachter implementieren muss.

Hierzu schauen wir uns in MazeWorld.java an, was sich im Laufe des Spiels ändern kann. Tatsächlich ist das nur die Position des Spielers (Anm.: somit kann maze in MazeWorld final sein, was unten entsprechend angepasst wurde). Wir können also eine Schnittstelle für die Beobachter wie folgt definieren:
Java:
public interface MazeWorldObserver {
    void playerMoved(Position fromPosition, Position toPosition);
}
Tatsächlich bräuchte man die Angabe der alten bzw. neuen Position nicht. Die Information kann aber sehr vorteilhaft genutzt werden, da das UI damit genau weiß, welche Teile der Anzeige aktualisiert werden müssen. Es muss also ggf. nicht das komplette Labyrinth neu gezeichnet werden. Außerdem sind diese Informationen von Interesse, wenn Spielzüge animiert dargestellt werden sollen.

In MazeWorld können wir nun anbieten, einen solchen MazeWorldObserver zu registrieren. Nicht unüblich ist es, beliebig viele solcher Beobachter zuzulassen, wie wir es von Swing kennen (z. B. addKeyListener), für unsere Zwecke reicht aktuell aber ein einzelner Observer aus.

Java:
public class MazeWorld {
    private final Maze maze;
    private Position player;
    private MazeWorldObserver observer;

    public MazeWorld(Maze maze, Position player) {
        this.maze = maze;
        this.player = player;
    }

    public void setObserver(MazeWorldObserver observer) {
        this.observer = observer;
    }

    public Maze getMaze() { return maze; }
    public Position getPlayer() { return player; }

    public void movePlayer(int dx, int dy) {
        Position newPos = player.move(dx, dy);
        if (isLegalMove(newPos)) {
            Position oldPos = player;
            player = newPos;
            movedFrom(oldPos);
        }
    }

    private void movedFrom(Position oldPos) {
        if (observer != null) {
            observer.playerMoved(oldPos, player);
        }
    }
    
    private boolean isLegalMove(Position pos) {
        return maze.contains(pos) &&
                !maze.hasTypeAt(FieldType.WALL, pos);
    }

    public boolean isPlayerOn(FieldType type) {
        return maze.hasTypeAt(type, player);
    }
}
Nach einer Bewegung des Spielers wird der ggf. registrierte Beobachter in Methode movedFrom über die Änderung informiert. In der Oberfläche muss nun lediglich eine entsprechende Implementierung bei MazeGame registriert werden. Das übernimmt die WorldView-Komponente selbst, da diese nach jeder Bewegung des Spielers auch immer neu gezeichnet werden muss.

Java:
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Insets;
import javax.swing.JComponent;

import java.util.EnumMap;
import java.util.Map;

public class WorldView extends JComponent {
    private final Size tileSize;

    private final Map<FieldType, Image> images = new EnumMap<>(FieldType.class);
    private Image player;

    private MazeWorld world;
    private MazeWorldObserver observer = new MazeWorldObserver() {
        @Override
        public void playerMoved(Position from, Position to) {
            repaint();
        }
    };
    
    public WorldView(MazeWorld world, int tileWidth, int tileHeight) {
        this(world, new Size(tileWidth, tileHeight));
    }

    public WorldView(MazeWorld world, Size tileSize) {
        this.world = world;
        this.tileSize = tileSize;
        installObserver();
    }

    @Override
    public Dimension getPreferredSize() {
        if (isPreferredSizeSet()) {
            return super.getPreferredSize();
        }
        Insets insets = getInsets();
        Size mazeSize = world.getMaze().getSize();
        int w = insets.left + insets.right + 
                tileSize.width * mazeSize.width;
        int h = insets.top + insets.bottom + 
                tileSize.height * mazeSize.height;
        return new Dimension(w, h);
    }

    public void changeWorld(MazeWorld world) {
        uninstallObserver();
        this.world = world;
        installObserver();
        repaint();
    }

    private void uninstallObserver() {
        if (world != null) {
            world.setObserver(null);
        }
    }

    private void installObserver() {
        if (world != null) {
            world.setObserver(observer);
        }
    }

    public void setImage(FieldType type, Image image) {
        images.put(type, requireCompatibleImage(image));
        repaint();
    }

    public void setPlayer(Image image) {
        player = requireCompatibleImage(image);
        repaint();
    }

    private Image requireCompatibleImage(Image image) {
        if (image.getWidth(null) != tileSize.width || 
                image.getHeight(null) != tileSize.height) {
            String fmt = "Given image doesn't match tile size (%d, %d)";
            String msg = String.format(fmt, tileSize.width, tileSize.height);
            throw new IllegalArgumentException(msg);
        }
        return image;
    }

    @Override
    protected void paintComponent(Graphics g) {
        paintMap(g);
        paintPlayer(g);
    }

    private void paintMap(Graphics g) {
        Maze maze = world.getMaze();
        Size mazeSize = maze.getSize();
        for (int y = 0; y < mazeSize.height; y++) {
            for (int x = 0; x < mazeSize.width; x++) {
                Image img = images.get(maze.get(x, y));
                if (img != null) {
                    paintImage(g, img, x, y);
                }
            }
        }
    }

    private void paintPlayer(Graphics g) {
        if (player == null) {
            return;
        }

        Position pos = world.getPlayer();
        paintImage(g, player, pos.x, pos.y);
    }

    private void paintImage(Graphics g, Image img, int xTile, int yTile) {
        g.drawImage(img, xTile * tileSize.width, yTile * tileSize.height, null);
    }
}
Der hier als anonyme innere Klasse implementierte Observer nutzt die Positionsangaben nicht. Das liegt vor allem daran, dass paintMap nicht auf Teilbereiche optimiert ist, die Änderungen im Code auch nachvollziehbar bleiben sollen und das UI in diesem kleinen Projekt nicht im Vordergrund steht (wer das einbauen will: repaint mit Parametern aufrufen, in paintMap mit Hilfe des Clip des Graphics-Objekt den neu zu zeichnenden Ausschnitt ermitteln und die for-Schleifen entsprechend anpassen). Auch haben wir keine Animation im Code.

Wir wollen das mit App5.java erst einmal testen, auch um den Kommentar nicht allzu lang werden zu lassen. Der Unterschied zur vorherigen App4.java besteht dabei lediglich in der Verwendung von KeyboardController.

Java:
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import javax.swing.*;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;

public class App5 {

    private MazeGame game;
    private WorldView worldView;
    private MessageOverlay winnerMessage;

    private KeyListener keyboardController;

    public void run() {
        newGame();

        worldView = new WorldView(game.getWorld(), 32, 32);
        WorldViewDefaults.configure(worldView);

        worldView.setFocusable(true);
        worldView.addKeyListener(keyboardController);

        JFrame frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.add(worldView);
        frame.pack();
        frame.setVisible(true);

        initWinnerMessage(frame); 
    }

    private void initWinnerMessage(JFrame frame) {
        String message = "<html><h1 style=\"text-align: center;\">Gewonnen!</h1>" +
            "<p>Weiter mit der Leertaste.</p>";

        winnerMessage = new MessageOverlay(frame)
                .display(message)
                .onDismiss(this::restart);
    }

    private void restart() {
        newGame();
        worldView.changeWorld(game.getWorld());
    }

    private void newGame() {
        game = new MazeGame(createWorld());
        keyboardController = new KeyboardController(game);
    }

    private MazeWorld createWorld() {
        String text = 
            "wwwwwdw\n" +
            "wfddddw\n" +
            "wdddddd\n" +
            "ddddddw\n" +
            "wdddddw\n" +
            "wwwwwdw\n";
             
        MazeTextFormat fmt = new MazeTextFormat();
        try {
            Maze maze = fmt.read(new StringReader(text));
            return new MazeWorld(maze, new Position(4, 4));
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new App5().run());
    }
}
Das Spiel funktioniert, allerdings nur einmal, da keine Gewinnbenachrichtigung und damit auch kein Neustart erfolgt. Darum kümmern wir uns aber erst im nächsten Schritt, das alles will erst einmal verdaut werden.

Zunächst nochmal eine Zusammenfassung. Der KeyboardController kümmert sich um die Eingaben, unser Modell um die Verarbeitung (Spiellogik) und dann haben wir noch Klassen (allen voran WorldView), die eine Sicht (View) auf das Modell darstellen und somit für die Ausgabe auf dem Bildschirm zuständig sind. Dieses Muster unter Verwendung von Beobachtern ist unter dem Namen Model-View-Controller, kurz MVC, sehr bekannt geworden. MVC ist eine objektorientierte Abbildung des EVA-Prinzips. Haben wir das auch...
 
mihe7

mihe7

Ganz ähnlich können wir nun mit MazeGame verfahren, um den Status des Spiels zu beobachten.

Mit
Java:
public interface GameStateObserver {
    void gameOver();
}
kann dann implementiert werden:
Java:
public class MazeGame {
    private MazeWorld world;
    private GameStateObserver observer;

    public MazeGame(MazeWorld world) {
        this.world = world;
    }

    public void setObserver(GameStateObserver observer) {
        this.observer = observer;
    }

    public MazeWorld getWorld() { return world; }

    public boolean isWin() {
        return world.isPlayerOn(FieldType.FINISH);
    }

    public void movePlayer(int dx, int dy) {
        if (isWin()) {
            return;
        }

        world.movePlayer(dx, dy);

        if (isWin()) {
            observer.gameOver();
        }
    }
}
Eingebaut in die Anwendung sieht das dann z. B. so aus:
Java:
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import javax.swing.*;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;

public class App6 {

    private MazeGame game;
    private WorldView worldView;
    private MessageOverlay winnerMessage;

    private KeyListener keyboardController;
    private GameStateObserver gameOverHandler = new GameStateObserver() {
        @Override
        public void gameOver() {
            winnerMessage.show();
        }
    };

    public void run() {
        newGame();

        worldView = new WorldView(game.getWorld(), 32, 32);
        WorldViewDefaults.configure(worldView);

        worldView.setFocusable(true);

        JFrame frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.add(worldView);
        frame.pack();
        frame.setVisible(true);

        initWinnerMessage(frame); 
        installListeners();
    }

    private void initWinnerMessage(JFrame frame) {
        String message = "<html><h1 style=\"text-align: center;\">Gewonnen!</h1>" +
            "<p>Weiter mit der Leertaste.</p>";

        winnerMessage = new MessageOverlay(frame)
                .display(message)
                .onDismiss(this::restart);
    }

    private void restart() {
        uninstallListeners();
        newGame();
        installListeners();
    }
    
    private void uninstallListeners() {
        game.setObserver(null);
        worldView.removeKeyListener(keyboardController);
    }

    public void installListeners() {
        worldView.changeWorld(game.getWorld());
        worldView.addKeyListener(keyboardController);
    }

    private void newGame() {
        game = new MazeGame(createWorld());
        game.setObserver(gameOverHandler);
        keyboardController = new KeyboardController(game);
    }

    private MazeWorld createWorld() {
        String text = 
            "wwwwwdw\n" +
            "wfddddw\n" +
            "wdddddd\n" +
            "ddddddw\n" +
            "wdddddw\n" +
            "wwwwwdw\n";
             
        MazeTextFormat fmt = new MazeTextFormat();
        try {
            Maze maze = fmt.read(new StringReader(text));
            return new MazeWorld(maze, new Position(4, 4));
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new App6().run());
    }
}
Der Code wurde ein klein wenig umgebaut: hinzugekommen sind die Methoden zum Installieren bzw. Entfernen der Beobachter. Letzteres ist wichtig, um Memory Leaks zu verhindern. Beispielsweise wird bei jeder Runde ein neuer KeyListener (für ein neues Spiel) registriert. Würde man den alten Listener nicht entfernen, würde dieser weiterhin existieren. Da dieser wiederum eine Referenz auf das Spiel hat, kann auch dieses vom Garbage Collector nicht abgeräumt werden. Tatsächlich würde der Listener auf jeden Tastendruck weiterhin reagieren und versuchen, im alten Spiel den Zug auszuführen. Daher den alten Listener sauber entfernen, dann kann nichts schief gehen.
 
Thema: 

Wie man einfache Spiele programmieren kann

Passende Stellenanzeigen aus deiner Region:
Anzeige

Neue Themen

Anzeige

Anzeige
Oben