Wie man einfache Spiele programmieren kann

mihe7

Top Contributor
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

Top Contributor
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

Top Contributor
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

Top Contributor
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

  • desert.png
    desert.png
    1,5 KB · Aufrufe: 1
  • finish.png
    finish.png
    1,3 KB · Aufrufe: 1
  • player.png
    player.png
    618 Bytes · Aufrufe: 0
  • wall.png
    wall.png
    1,6 KB · Aufrufe: 0

mrBrown

Super-Moderator
Mitarbeiter
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

Top Contributor
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:

mihe7

Top Contributor
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

Top Contributor
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

Gast
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

Top Contributor
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.
 

mihe7

Top Contributor
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

Top Contributor
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

Top Contributor
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

Top Contributor
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

Top Contributor
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.
 

mihe7

Top Contributor
Zur Abwechslung mal einen

BUG-REPORT

Beim Testen mit z. B. Test3 fällt auf, dass es beim Erreichen des Ziels eine NullPointerException gibt. Grund ist, dass in Methode movePlayer der Klasse MazeGame nicht geprüft wird, ob überhaupt ein Observer registriert wurde.

Den Fehler bitte beheben. Das Ende der Methode movePlayer in MazeGame bitte so abändern:

Java:
        if (observer != null && isWin()) {
            observer.gameOver();
        }
 

mihe7

Top Contributor
Allmählich wird es Zeit, eine "fertige" Anwendung zu bauen.

Zuvor kümmern wir uns aber noch um das Problem, dass das Spiel unzulässige Züge ermöglicht. Das lässt sich strukturell lösen, indem wir bei der Bewegung nur noch eine Richtungsangabe zulassen. Da sich der Spieler nur in eine der vier Himmelsrichtungen bewegen können soll, führen wir für diese eine Aufzählung ein.

Java:
enum Direction { 
    NORTH(0,-1), EAST(1,0), SOUTH(0,1), WEST(-1,0);

    private int dx;
    private int dy;

    Direction(int dx, int dy) {
        this.dx = dx;
        this.dy = dy;
    }

    public Position move(Position pos) {
        return pos.move(dx, dy);
    }
}

Die movePlayer-Methoden in MazeGame und MazeWorld können nun als Parameter eine Direction statt zweier int-Werte erhalten. Direction enthält auch eine move-Methode, die eine neue in die jeweilige Richtung verschobene Position zurückgibt. Damit werden die int-Werte vollständig gekapselt, der Rest der Anwendung kann diese nicht einmal mehr sehen. Cool, oder?

In MazeWorld muss dann lediglich noch die neue Position über die Richtung bestimmt werden:
Java:
    public void movePlayer(Direction dir) {
        Position newPos = dir.move(player);
        if (isLegalMove(newPos)) {
            Position oldPos = player;
            player = newPos;
            movedFrom(oldPos);
        }
    }

Nochmal zur Verdeutlichung: wir haben eben ein logisches Problem durch die Einführung eines Datentyps gelöst. Es ist nun praktisch unmöglich, dass der Spieler eine unzulässige Bewegung durchführen kann, denn jetzt haben wir den Compiler auf unserer Seite, der bereits zur Übersetzungszeit falsche Angaben verhindert. Natürlich könnte die Methode movePlayer noch null als Richtungsangabe erhalten, was zur Laufzeit aber zu einer NullPointerException führen würde. Der Zug würde also nicht ausgeführt.

Natürlich müssen wir jetzt die Klassen, die movePlayer aufrufen, entsprechend anpassen. Details bzgl. der bestehenden "Testklassen" finden sich im Repository. Hier möchte ich nur den KeyboardController zeigen:

Java:
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.Map;
import java.util.HashMap;

public class KeyboardController extends KeyAdapter {
    private static final Map<Integer, Direction> KEY_MAPPING =
            new HashMap<>();
    static {
        KEY_MAPPING.put(KeyEvent.VK_LEFT, Direction.WEST);
        KEY_MAPPING.put(KeyEvent.VK_RIGHT, Direction.EAST);
        KEY_MAPPING.put(KeyEvent.VK_UP, Direction.NORTH);
        KEY_MAPPING.put(KeyEvent.VK_DOWN, Direction.SOUTH);
    }

    private final MazeGame game;

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

    @Override
    public void keyPressed(KeyEvent e) {
        Direction dir = KEY_MAPPING.get(e.getKeyCode());
        if (dir != null) {
            game.movePlayer(dir);
        }
    }
}

Damit haben wir switch gekillt. Wer sich fragt, was das bringen soll: die Einträge einer Map sind zur Laufzeit änderbar. D. h. theoretisch ließe sich mit ein paar Änderungen eine konfigurierbare Tastaturbelegung erreichen...
 

mihe7

Top Contributor
Was noch fehlt, ist das Laden eines Labyrinths aus einer Datei, die der Anwender wählt. Viele Beispiele sind derart gestrickt, dass einfach in einem ActionListener - in der Regel implementiert als anonyme innere Klasse - ein JFileChooser verwendet wird und dort auch alles andere zu finden ist.

Das kann man durchaus machen und der Ansatz hat den Vorteil, dass er sehr einfach zu verstehen ist: Datei auswählen, einlesen, fertig. Die bisherige Methode createWorld derart umzuschreiben ist fast trivial, so dass ich mir den Code dazu spare. Vielmehr möchte ich etwas over-engineering betreiben, um einen anspruchsvolleren Weg zu gehen, der für ähnliche aber ggf. komplexere Probleme eingeschlagen werden kann.

Egal welchen Ansatz man wählt, müssen wir uns zunächst um etwas anderes kümmern: wir haben ein Format für das Labyrinth, nicht aber für die Spielwelt definiert. Das Problem dabei ist, dass wir den Startpunkt des Spielers nicht kennen, so dass dieser in createWorld fest auf (4,4) gesetzt wurde. Das funktioniert natürlich bei beliebigen Labyrinthen nicht mehr.

Die Frage ist jetzt, ob der Startpunkt geladen oder zufällig gewählt werden soll. Für das Laden könnte man unter Zuhilfenahme von MazeTextFormat ein eigenes MazeWorldTextFormat definieren. Dabei könnte die Datei einfach mit der Spielerposition beginnen. Das ist auch nicht viel mehr Aufwand als das, was wir umsetzen wollen: den Spieler zufällig positionieren.

Nachdem das geklärt ist, fangen wir mal mit dem over-engineering an :) Dazu müssen wir uns klarmachen, dass beim Laden verschiedene Dinge zusammenkommen.
  1. Eine Aktion des Benutzers, die an verschiedenen Stellen im Programm (z. B. Menü und Toolbar) auftreten kann
  2. Die Auswahl einer Datei durch den Benutzer
  3. Das Einlesen der Datei in ein Objekt (hier: Maze)
  4. Im Erfolgsfall: das Verwenden des eingelesenen Objekts in einer von der Anwendung definierten Art und Weise
  5. Im Fehlerfall: die Behandlung des Fehlers in einer von der Anwendung definierten Art und Weise

Sehr viel Verantwortung für eine einzige Klasse. Dabei sind die einzelnen Punkte scharf voneinander abgrenzbar und müssen lediglich passend entkoppelt werden. Wie das funktioniert, haben wir schon mehrfach gesehen: eigene Klassen und Schnittstellen definieren.

Die ersten beiden Punkte betreffen ausschließlich die Oberfläche, die vom Einlesen selbst in keinster Weise berührt wird. Die letzten Punkte sind nicht ganz so einfach: die Unterscheidung nach Erfolgs- und Fehlerfall hat mit der Oberfläche nichts zu tun - die Behandlung der Fälle kann dagegen das UI betreffen. Eben diese Behanldung muss aber nicht in der Aktion selbst stattfinden.

Konkret: man könnte nach dem JFileChooser versuchen, die Datei einzulesen, und im Falle eines Fehlers ein Dialogfenster anzeigen. Was aber, wenn die Anwendung an der Stelle gar kein Dialogfenster sondern ein TransparentPanel anzeigen will? Noch schlimmer: was, wenn beim Aufruf über das Menü ein Dialogfenster, beim Aufruf über die Toolbar dagegen ein TransparentPanel angezeigt werden soll? Und für den Erfolgsfall: wer sagt denn, dass nach dem Laden eines Labyrinths immer dasselbe passieren muss?

Um noch eins draufzusetzen, muss die Datei denn immer vom Anwender mit einem JFileChooser gewählt werden? Wohl kaum.

Der langen Rede kurzer Sinn: wir erstellen uns ein "Lademodul", das nur die Aufgabe hat, beim Einlesen Erfolgs- und Fehlerfall zur Behandlung an entsprechende Objekte weiterzuleiten.

Java:
import java.io.File;
import java.io.FileReader;
import java.io.IOException;

public class MazeLoader {
    private static final MazeTextFormat FORMAT = new MazeTextFormat();

    private MazeHolder holder;
    private ExceptionHandler exceptionHandler;

    public void setMazeHolder(MazeHolder holder) {
        this.holder = holder;
    }

    public void setExceptionHandler(ExceptionHandler handler) {
        exceptionHandler = handler;
    }

    public void load(File file) {
        try(FileReader reader = new FileReader(file)) {
            Maze maze = FORMAT.read(reader);
            if (holder != null) {
                holder.setMaze(maze);
            }
        } catch (IllegalArgumentException | IOException ex) {
            if (exceptionHandler != null) {
                exceptionHandler.handleException(ex);
            } else {
                ex.printStackTrace();
            }
        }
    }
}

Dabei sind MazeHolder und ExceptionHandler zwei Interfaces, die jeweils die betreffende Methode enthalten. Die Benutzeraktion lagern wir ebenfalls in eine Klasse aus, die mit einem "Lademodul" arbeiten kann:

Java:
import java.awt.Window;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;

import java.io.File;

import javax.swing.Action;
import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.SwingUtilities;
import javax.swing.filechooser.FileNameExtensionFilter;

public class LoadMazeAction extends AbstractAction {
    private final MazeLoader loader;
    private final JFileChooser chooser;

    public LoadMazeAction(MazeLoader loader) {
        super("Labyrinth laden...");
        this.loader = loader;

        putValue(Action.MNEMONIC_KEY, KeyEvent.VK_L);
        putValue(Action.DISPLAYED_MNEMONIC_INDEX_KEY, 10);

        chooser = new JFileChooser();
        chooser.setAcceptAllFileFilterUsed(true);
        chooser.addChoosableFileFilter(new FileNameExtensionFilter(
                "Maze Text File", "mtf"));
    }

    @Override
    public void actionPerformed(ActionEvent e) {
        File mazeFile = selectFile(getWindow(e));
        if (mazeFile != null) {
            return;
        }
        loader.load(mazeFile);
    }

    private File selectFile(Window parent) {
        int result = chooser.showOpenDialog(parent);
        if (result == JFileChooser.APPROVE_OPTION) {
            return chooser.getSelectedFile();
        }
        return null;
    }

    private Window getWindow(ActionEvent e) {
        Object src = e.getSource();
        if (src instanceof JComponent) {
            return SwingUtilities.getWindowAncestor((JComponent) src);
        }
        return null;
    }
}

Die Klasse implementiert nicht ActionListener sondern erweitert AbstractAction, eine Swing-Klasse, die es ermöglicht, eine Aktion anwendungsweit zu de-/aktivieren, wobei die Steuerelemente entsprechend reagieren. D. h. man kann eine Aktion für mehrere Steuerelemente verwenden und diese werden "ausgegraut", sobald man die Aktion deaktiviert. Auf diese Möglichkeit wollte ich durch die Verwendung von AbstractAction nur einmal hinweisen.

Viel interessanter ist aber, dass die Aktion nur das Lademodul kennen muss und keine Ahnung davon hat, was mit der gewählten Datei passiert. Schon gar nicht, was nach dem Einlesen mit den Daten geschieht. Auf diese Weise haben wir eine vollständige Entkopplung von Aktion, Ausgabe (UI) und Verarbeitung erreicht.

Wir könnten den Code nun in eine weitere Testklasse einbauen, jedoch würde das bereits sehr unübersichtlich werden. Wir brauchen immerhin eine Implementierung für das Exception-Handling sowie für die Verarbeitung des geladenen Labyrinths. Da muss ein wenig Struktur rein. Die folgt im nächsten Kommentar; der Code findet sich bis dahin nicht im Repository.
 

mihe7

Top Contributor
Zunächst werden wir den bisherigen Code ein wenig verbessern. Würde man z. B. nicht nach jeder "Runde" eine neue MazeGame-Instanz erstellen sondern einfach die MazeWorld austauschen, müsste man diverse Listener nur einmal erstellen und registrieren. Der GameStateObserver könnte dann auch diese Änderung beobachten, eine mögliche Implementierung:

Java:
    private final GameStateObserver gameStateHandler = new GameStateObserver() {
        @Override
        public void gameOver() {
            winnerMessage.show();
        }
        @Override
        public void worldChanged(MazeWorld newWorld) {
            worldView.changeWorld(newWorld);
        }
    };
In App6.java muss der Observer an die geänderte Schnittstelle angepasst werden. Ebenso muss MazeGame über das betreffende Ereignis informieren, sprich die worldChanged-Methode aufrufen. Beides ist trivial, so dass ich den Code hier nicht zeige (im Repository ist der Code natürlich geändert).

Als nächstes gliedern wir das UI aus der Anwendungsklasse aus - wir erstellen eine Klasse für das "Hauptfenster". Dieses bekommt auch gleich eine Menüzeile spendiert, um ein anderes Labyrinth zu laden. In Verbindung mit LoadMazeAction und dem MazeLoader könnte der Code dann wie folgt aussehen:

Java:
import javax.swing.*;

public class MainWindow {

    private final MazeGame game;
    private final LoadMazeAction loadMazeAction;

    private JFrame frame;
    private MessageOverlay winnerMessage;
    private final WorldView worldView;

    private final GameStateObserver gameStateHandler = new GameStateObserver() {
        @Override
        public void gameOver() {
            winnerMessage.show();
        }
        @Override
        public void worldChanged(MazeWorld newWorld) {
            worldView.changeWorld(newWorld);
            frame.pack();
        }
    };

    public MainWindow(MazeGame game, MazeLoader loader) {
        this.game = game;
        game.setObserver(gameStateHandler);
        loadMazeAction = new LoadMazeAction(loader);

        worldView = new WorldView(game.getWorld(), 32, 32);
        WorldViewDefaults.configure(worldView);
        worldView.setFocusable(true);
        worldView.addKeyListener(new KeyboardController(game));
    }

    public void show() {
        frame = new JFrame("maze");
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.setJMenuBar(createMenuBar());
        frame.add(worldView);
        frame.pack();
        frame.setVisible(true);

        initWinnerMessage(frame);
    }

    private JMenuBar createMenuBar() {
        JMenuItem loadMazeItem = new JMenuItem(loadMazeAction);
        JMenuItem exitItem = new JMenuItem("Beenden");
        exitItem.setMnemonic('e');
        exitItem.addActionListener(e -> System.exit(0));
        JMenu gameMenu = new JMenu("Spiel");
        gameMenu.setMnemonic('S');
        gameMenu.add(loadMazeItem);
        gameMenu.addSeparator();
        gameMenu.add(exitItem);

        JMenuBar menuBar = new JMenuBar();
        menuBar.add(gameMenu);
        return menuBar;
    }

    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);
    }
}

Das sieht doch schon wesentlich aufgeräumter aus. Man beachte, dass hier ausschließlich Themen des UIs behandelt werden. Das Spiel und das Lademodul kommen von außen, woher interessiert das UI nicht. Somit könnten wir z. B. eine von MazeGame abgeleitete Klasse mit anderen Spielregeln verwenden, ohne am UI auch nur eine Zeile ändern zu müssen. Vorstellbar wäre beispielsweise, dass es "Teleporter"-Felder gibt, die den Spieler auf ein anderes freies, zufällig gewähltes Feld "beamen".

Das Lademodul (MazeLoader) kümmert sich nur um das Labyrinth, für das Spiel müssen wir aber eine Spielwelt erstellen.

Java:
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;

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

public class MazeWorldFactory {

    public MazeWorld createInitialWorld() {
        String text = "f";
             
        MazeTextFormat fmt = new MazeTextFormat();
        try {
            Maze maze = fmt.read(new StringReader(text));
            return new MazeWorld(maze, new Position(0, 0));
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    public MazeWorld createWorld(Maze maze) {
        Position pos = determineStartPosition(maze);
        if (pos == null) {
            return createInitialWorld(); 
        }
        return new MazeWorld(maze, pos);
    }

    private Position determineStartPosition(Maze maze) {
        List<Position> free = new ArrayList<>();
        for (int y = 0, height = maze.getHeight(); y < height; y++) {
            for (int x = 0, width = maze.getWidth(); x < width; x++) {
                if (maze.get(x,y) == FieldType.DESERT) {
                    free.add(new Position(x,y));
                }
            }
        }

        if (free.isEmpty()) {
            return null;
        }
        return free.get((int)(Math.random() * free.size()));
    }
}

Die Anwendung hält den Spaß letztlich zusammen:
Java:
import javax.swing.SwingUtilities;

public class App {

    private final MazeGame game;
    private final MazeWorldFactory factory;

    public App() {
        factory = new MazeWorldFactory();
        game = new MazeGame(factory.createInitialWorld());
    }

    public void run() {
        MazeLoader loader = new MazeLoader();
        loader.setMazeHolder(maze -> game.setWorld(factory.createWorld(maze)));
        new MainWindow(game, loader).show();
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> new App().run());
    }
}

Im Repository finden sich unter maze-samples auch Beispiele für Labyrinthe.

Damit soll das Ganze an der Stelle auch im Wesentlichen ein Ende finden, auch wenn das Spiel nicht fertig ist. Es wird nicht alles 100 %-ig funktionieren, z. B. müsste die Glasspane Tastatur- und Mausereignisse abfangen. Darum ging es hier aber nie, vielmehr sollte ein Ansatz skizziert werden, welche Überlegungen angestellt werden usw. Aus dem Grund sind einige Dinge für ein so kleines Projekt auch etwas übertrieben, auf der anderen Seite fehlt z. B. die Strukturierung des Quellcodes mit Paketen. Evtl. hole ich das später einmal nach.

Kritik und Fragen einfach posten.
 
Ähnliche Java Themen
  Titel Forum Antworten Datum
K Einfache Engine für einfaches 3D gesucht Spiele- und Multimedia-Programmierung 10
Taschentuch9 Einfache fertige Schach AI gesucht Spiele- und Multimedia-Programmierung 3
D einfache 2D Grafik in JAVA. absoluter Anfänger Spiele- und Multimedia-Programmierung 5
D Einfache Physik (Beschleunigung) Spiele- und Multimedia-Programmierung 6
B Einfache Animationen darstellen Spiele- und Multimedia-Programmierung 12
N Seltsame Exception bei Code eines Spiele-Tutorials Spiele- und Multimedia-Programmierung 6
N Escape the Room - Spiele Apps programmieren Spiele- und Multimedia-Programmierung 14
Noahscript 3D-Spiele Spiele- und Multimedia-Programmierung 7
N Spiele-Menü in Java Spiele- und Multimedia-Programmierung 9
T Neuronale Netze und Spiele Spiele- und Multimedia-Programmierung 4
H KI für Spiele Spiele- und Multimedia-Programmierung 1
H 3D Spiele mit Java - Diskussion Spiele- und Multimedia-Programmierung 35
D 2d Spiele Bibliothek Spiele- und Multimedia-Programmierung 9
M Spiele Tutorial.. Findet Bilddateien nicht Spiele- und Multimedia-Programmierung 6
K Casino Spiele UI Spiele- und Multimedia-Programmierung 7
J 2D Spiele - Inwiefern Vektorrechnung nötig? Spiele- und Multimedia-Programmierung 7
S Aufbau für 2D Spiele Spiele- und Multimedia-Programmierung 7
A Drawable und Moveable in Quaxli 2D Spiele Tutorial Spiele- und Multimedia-Programmierung 7
Gossi Probleme beim Laden der Images aus dem "Tutorial für Java-Spiele" Spiele- und Multimedia-Programmierung 4
X Gutes 2D Spiele Tutorial? Spiele- und Multimedia-Programmierung 9
B Spiele Tutorials Spiele- und Multimedia-Programmierung 9
F wie richtig spiele programmieren ? Spiele- und Multimedia-Programmierung 19
C Java für große Spiele geeignet ? Spiele- und Multimedia-Programmierung 101
B Spiele programmieren für ein Fenster? Spiele- und Multimedia-Programmierung 14
V Online-Spiele syncronisieren. Spiele- und Multimedia-Programmierung 5
L 2D-Spiele ruckeln auf JPanel Spiele- und Multimedia-Programmierung 7
J Suche Java Spiele Editor! Spiele- und Multimedia-Programmierung 2
Developer_X Java3D-Ungeeignet für 3D Spiele wegen Heap Space=? Spiele- und Multimedia-Programmierung 23
D Spiele Wuerfel 3D aber wie Spiele- und Multimedia-Programmierung 6
D Problem mit dem Spiele TUT Spiele- und Multimedia-Programmierung 16
H 2d- Spiele Entwicklung Spiele- und Multimedia-Programmierung 11
D sehr simple Java Spiele Platformübergreifend für Handys/PDAs Spiele- und Multimedia-Programmierung 3
Quaxli Welche Grafiksoftware nutzt Ihr für 2D-Spiele? Spiele- und Multimedia-Programmierung 6
D Welcher Image Typ am besten für 2D-Spiele geeignet? Spiele- und Multimedia-Programmierung 5
X JPCT 3d-Spiele Programmierung Tutorial Spiele- und Multimedia-Programmierung 40
X 3D Spiele Tutorial gewünscht? Spiele- und Multimedia-Programmierung 14
G Bekannte Spiele in Java programmiert Spiele- und Multimedia-Programmierung 9
R Spiele für den DVD Player Spiele- und Multimedia-Programmierung 6
F 2d Spiele Spiele- und Multimedia-Programmierung 4
J soundlösung zu langsam für spiele Spiele- und Multimedia-Programmierung 16
T Größeres Spiele Projekt - einige Fragen zur Umsetzung Spiele- und Multimedia-Programmierung 3
O soundlösung für spiele (mit lautstärke) Spiele- und Multimedia-Programmierung 4
Landei Entwicklungsumgebung für "Pseudo-3D"-Spiele? Spiele- und Multimedia-Programmierung 17
A Spiele kommentieren Spiele- und Multimedia-Programmierung 4
M Images/Sounds für Spiele Spiele- und Multimedia-Programmierung 3
A Werden "große Spiele" mal in Java programmiert? Spiele- und Multimedia-Programmierung 43
H Große Spiele in welcher Sprache Spiele- und Multimedia-Programmierung 33
R eure programmierten Spiele Spiele- und Multimedia-Programmierung 53
M BlueJ Schach Steuerung programmieren Spiele- und Multimedia-Programmierung 28
ItundMathe1994 TicTacToe Spiel programmieren Spiele- und Multimedia-Programmierung 2
Laaalo Tic tac toe programmieren Spiele- und Multimedia-Programmierung 4
M Brauche Hilfe was zu Programmieren Spiele- und Multimedia-Programmierung 4
N Minecraft Spigot-Plugin | Schusswaffe programmieren Spiele- und Multimedia-Programmierung 3
A Programmieren eines Memorys mit Java (in Eclipse) Spiele- und Multimedia-Programmierung 5
A DoodleJump programmieren: Kollisionsabfrage Spiele- und Multimedia-Programmierung 6
I Vier gewinnt programmieren, Klick-Reihenfolge Spiele- und Multimedia-Programmierung 2
B Programmieren wie der Befehl /ban in Minecraft geblockt wird aber nicht /ban mit einem Argument Spiele- und Multimedia-Programmierung 1
K Android Spiel Programmieren Spiele- und Multimedia-Programmierung 6
P Tennis- Spielstand- Zähler für Schule programmieren Spiele- und Multimedia-Programmierung 6
J HDMI Ausgänge mit Java programmieren? Spiele- und Multimedia-Programmierung 18
L Hörtest programmieren und implementieren Spiele- und Multimedia-Programmierung 2
E Möchte Jump and Run programmieren Spiele- und Multimedia-Programmierung 2
E Möchte Spiel Programmieren Spiele- und Multimedia-Programmierung 7
M Gesellschaftsspiel Mühle in Java programmieren Spiele- und Multimedia-Programmierung 3
M Textbasiertes Spiel programmieren Spiele- und Multimedia-Programmierung 4
M Logitech G15/G510 Applets programmieren Spiele- und Multimedia-Programmierung 3
F Spiel ähnlich wie SimCity/o.ä programmieren Spiele- und Multimedia-Programmierung 5
O Rundenbasiertes strategiespiel programmieren Spiele- und Multimedia-Programmierung 2
K 2D Blockade Programmieren Spiele- und Multimedia-Programmierung 3
wolfgang63 Mit JavaFX einfaches Game programmieren Spiele- und Multimedia-Programmierung 5
J Mod Loader programmieren Spiele- und Multimedia-Programmierung 11
I Hitpoints/Lifepoints programmieren, wie? Spiele- und Multimedia-Programmierung 7
L Minecraft Minecraft Plugin programmieren (Craftbukkit 1.7.2) Problem Spiele- und Multimedia-Programmierung 4
B Spiel Programmieren, die Anfänge Spiele- und Multimedia-Programmierung 6
wolfgang63 Einfachen Soundgenerator programmieren Spiele- und Multimedia-Programmierung 1
J UNO Programmieren Spiele- und Multimedia-Programmierung 4
F Bot Programmieren Spiele- und Multimedia-Programmierung 10
N Game GUI Programmieren Spiele- und Multimedia-Programmierung 16
1 Minecraft Minecraft Plugins programmieren Spiele- und Multimedia-Programmierung 6
T MiniCraft - selbst nach Programmieren Spiele- und Multimedia-Programmierung 25
A Klickgame ala "Harveys neue Augen" oder "Edna bricht aus" in Java programmieren... Fragen zu Kleinig Spiele- und Multimedia-Programmierung 8
S Spiel Programmieren (Kreise treffen) Spiele- und Multimedia-Programmierung 5
C Hinterteil von Snake programmieren Spiele- und Multimedia-Programmierung 11
B Kartenspiel Leben und Tod programmieren Spiele- und Multimedia-Programmierung 11
T Vier gewinnt programmieren Spiele- und Multimedia-Programmierung 9
N Stimme programmieren Spiele- und Multimedia-Programmierung 11
Sebi Mit Java Online games programmieren ? Spiele- und Multimedia-Programmierung 8
N Labyrinth programmieren/Denkhilfe Spiele- und Multimedia-Programmierung 3
data89 Spiel mit JMonkey programmieren Spiele- und Multimedia-Programmierung 6
O Programmieren von "Familienduell" Spiele- und Multimedia-Programmierung 3
A Wie JAVA Webcam Client programmieren? Spiele- und Multimedia-Programmierung 11
T vier gewinnt programmieren - aber wie Spiele- und Multimedia-Programmierung 19
N In einem Kasten springende Bälle programmieren...Hilfe Spiele- und Multimedia-Programmierung 7
B Problem beim Programmieren von 4Gewinnt Spiele- und Multimedia-Programmierung 5
A Programmieren eines Bruchrechners Spiele- und Multimedia-Programmierung 3
K Malefiz programmieren - Frage zu den einzelnen Spielfeldern Spiele- und Multimedia-Programmierung 5
G wer möchte mit mir risiko programmieren? Spiele- und Multimedia-Programmierung 7
B Problem beim Programmieren Von Mühle Spiele- und Multimedia-Programmierung 6
N Sinus Welle programmieren! Spiele- und Multimedia-Programmierung 4
J Gesellschaftsspiele programmieren Spiele- und Multimedia-Programmierung 8

Ähnliche Java Themen

Neue Themen


Oben