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.
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
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:
publicenumFieldType{ 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:
publicclassMaze{privatefinalFieldType[][] fields;publicMaze(FieldType[][] fields){int height = fields.length;int width = height ==0?0: fields[0].length;this.fields =newFieldType[height][width];for(int y =0; y < height; y++){for(int x =0; x < width; x++){this.fields[y][x]= fields[y][x];}}}publicintgetHeight(){return fields.length;}publicintgetWidth(){returngetHeight()==0?0: fields[0].length;}publicFieldTypeget(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:
importjava.io.BufferedReader;importjava.io.IOException;importjava.io.Reader;importjava.util.List;importjava.util.stream.Collectors;publicclassMazeTextFormat{publicMazeread(Reader reader)throwsIOException{List<String> rows =readLines(reader);returncreateMaze(rows);}privateList<String>readLines(Reader reader)throwsIOException{try(BufferedReader buffered =newBufferedReader(reader)){return buffered.lines().collect(Collectors.toList());}}privateMazecreateMaze(List<String> rows){int height = rows.size();if(height ==0){returnnewMaze(newFieldType[0][0]);}FieldType[][] fields =newFieldType[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]);}}returnnewMaze(fields);}privateFieldTypegetFieldType(char ch){switch(ch){case'w':returnFieldType.WALL;case'd':returnFieldType.DESERT;case'f':returnFieldType.FINISH;default:thrownewIllegalArgumentException(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:
importjava.io.StringReader;importjava.util.StringJoiner;publicclassTest1{privatefinalMaze maze;publicTest1(Maze maze){this.maze = maze;}publicvoidshowMap(){System.out.println(mapToString());}privateStringmapToString(){StringJoiner sj =newStringJoiner("\n");for(int y =0, n = maze.getHeight(); y < n; y++){StringBuilder line =newStringBuilder();for(int x =0, m = maze.getWidth(); x < m; x++){
line.append(getTypeChar(maze.get(x,y)));}
sj.add(line.toString());}return sj.toString();}privatechargetTypeChar(FieldType type){switch(type){case WALL:return'W';case DESERT:return'.';case FINISH:return'F';default:return'?';}}publicstaticvoidmain(String[] args)throwsException{String text ="wwwwwww\n"+"wfddddw\n"+"wdddddw\n"+"wdddddw\n"+"wdddddw\n"+"wwwwwww\n";MazeTextFormat fmt =newMazeTextFormat();Maze maze = fmt.read(newStringReader(text));Test1 app =newTest1(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.
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:
publicclassPosition{publicfinalint x;publicfinalint y;publicPosition(int x,int y){this.x = x;this.y = y;}publicPositionmove(int dx,int dy){returnnewPosition(x + dx, y + dy);}publicPositionmove(Position delta){returnnewPosition(x + delta.x, y + delta.y);}@Overridepublicbooleanequals(Object o){if(o ==null|| o ==this||!(o instanceofPosition)){return o ==this;}Position p =(Position) o;return x == p.x && y == p.y;}@OverridepublicinthashCode(){return5*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.
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:
importjava.io.StringReader;importjava.util.Scanner;importjava.util.StringJoiner;publicclassTest2{privatefinalMazeWorld world;publicTest2(MazeWorld world){this.world = world;}publicvoidrun(){Scanner sc =newScanner(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.");}}}publicvoidshowMap(){System.out.println(mapToString());}privateStringmapToString(){Maze maze = world.getMaze();Position player = world.getPlayer();StringJoiner sj =newStringJoiner("\n");for(int y =0, n = maze.getHeight(); y < n; y++){StringBuilder line =newStringBuilder();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();}privatechargetTypeChar(FieldType type){switch(type){case WALL:return'W';case DESERT:return'.';case FINISH:return'F';default:return'?';}}publicstaticvoidmain(String[] args)throwsException{String text ="wwwwwdw\n"+"wfddddw\n"+"wdddddd\n"+"ddddddw\n"+"wdddddw\n"+"wwwwwdw\n";MazeTextFormat fmt =newMazeTextFormat();Maze maze = fmt.read(newStringReader(text));MazeWorld world =newMazeWorld(maze,newPosition(4,4));Test2 app =newTest2(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.
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:
So, nun lässt sich das ganze Spiel wunderbar auf der Konsole testen:
Java:
importjava.io.StringReader;importjava.util.Scanner;importjava.util.StringJoiner;publicclassTest3{privatefinalMazeGame game;publicTest3(MazeGame game){this.game = game;}publicvoidrun(){Scanner sc =newScanner(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");}publicvoidshowMap(){System.out.println(mapToString());}privateStringmapToString(){MazeWorld world = game.getWorld();Maze maze = world.getMaze();Position player = world.getPlayer();StringJoiner sj =newStringJoiner("\n");for(int y =0, n = maze.getHeight(); y < n; y++){StringBuilder line =newStringBuilder();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();}privatechargetTypeChar(FieldType type){switch(type){case WALL:return'W';case DESERT:return'.';case FINISH:return'F';default:return'?';}}publicstaticvoidmain(String[] args)throwsException{String text ="wwwwwdw\n"+"wfddddw\n"+"wdddddd\n"+"ddddddw\n"+"wdddddw\n"+"wwwwwdw\n";MazeTextFormat fmt =newMazeTextFormat();Maze maze = fmt.read(newStringReader(text));MazeWorld world =newMazeWorld(maze,newPosition(4,4));Test3 app =newTest3(newMazeGame(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.
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:
importjava.awt.Dimension;importjava.awt.Graphics;importjava.awt.Image;importjava.awt.Insets;importjavax.swing.JComponent;importjava.util.EnumMap;importjava.util.Map;publicclassWorldViewextendsJComponent{privatefinalint tileWidth;privatefinalint tileHeight;privatefinalMap<FieldType,Image> images =newEnumMap<>(FieldType.class);privateImage player;privateMazeWorld world;publicWorldView(MazeWorld world,int tileWidth,int tileHeight){this.world = world;this.tileWidth = tileWidth;this.tileHeight = tileHeight;}@OverridepublicDimensiongetPreferredSize(){if(isPreferredSizeSet()){returnsuper.getPreferredSize();}Insets insets =getInsets();int w = insets.left + insets.right +
tileWidth * world.getMaze().getWidth();int h = insets.top + insets.bottom +
tileHeight * world.getMaze().getHeight();returnnewDimension(w, h);}publicvoidchangeWorld(MazeWorld world){this.world = world;repaint();}publicvoidsetImage(FieldType type,Image image){
images.put(type,requireCompatibleImage(image));repaint();}publicvoidsetPlayer(Image image){
player =requireCompatibleImage(image);repaint();}privateImagerequireCompatibleImage(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);thrownewIllegalArgumentException(msg);}return image;}@OverrideprotectedvoidpaintComponent(Graphics g){paintMap(g);paintPlayer(g);}privatevoidpaintMap(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);}}}}privatevoidpaintPlayer(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.
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:
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.
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
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)
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)
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.
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:
publicPositionmove(Position delta){returnnewPosition(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:
publicclassSize{publicfinalint width;publicfinalint height;publicSize(int width,int height){this.width = width;this.height = height;}@Overridepublicbooleanequals(Object o){if(o ==null|| o ==this||!(o instanceofSize)){return o ==this;}Size s =(Size) o;return width == s.width && height == s.height;}@OverridepublicinthashCode(){return7*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().
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.
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):
publicclassMazeimplementsIterable<MazeField>{// ...@OverridepublicIterator<MazeField>iterator(){returnnewIterator<MazeField>(){Position pos =newPosition(0,0);@OverridepublicbooleanhasNext(){returncontains(pos);}@OverridepublicMazeFieldnext(){if(!hasNext()){thrownewNoSuchElementException();}MazeField result =newMazeField(pos,get(pos));int index = pos.x + pos.y * size.width +1;
pos =newPosition(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:
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
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.
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. 😊
Das will ich hoffen, für alle anderen gibts ein Pong-Tutorial 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.
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.
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...
Um den Stand von Test3.java mit einer GUI zu erreichen, müssen wir lediglich zwei Dinge ändern:
Die Anwendung muss ein MazeGame statt einer MazeWorld verwalten.
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:
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.
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.
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.
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.
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.
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
dem Spiel (MazeGame-Objekt),
der WorldView-Komponente und
dem MessageOverlay
haben müsste, damit der folgende Teil des verschobenen Codes überhaupt funktionieren kann:
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:
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:
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.
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.
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:
importjava.awt.BorderLayout;importjava.awt.Color;importjava.awt.event.KeyAdapter;importjava.awt.event.KeyListener;importjava.awt.event.KeyEvent;importjava.awt.event.MouseAdapter;importjava.awt.event.MouseEvent;importjavax.swing.*;importjava.io.IOException;importjava.io.StringReader;importjava.io.UncheckedIOException;publicclassApp5{privateMazeGame game;privateWorldView worldView;privateMessageOverlay winnerMessage;privateKeyListener keyboardController;publicvoidrun(){newGame();
worldView =newWorldView(game.getWorld(),32,32);WorldViewDefaults.configure(worldView);
worldView.setFocusable(true);
worldView.addKeyListener(keyboardController);JFrame frame =newJFrame();
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
frame.add(worldView);
frame.pack();
frame.setVisible(true);initWinnerMessage(frame);}privatevoidinitWinnerMessage(JFrame frame){String message ="<html><h1 style=\"text-align: center;\">Gewonnen!</h1>"+"<p>Weiter mit der Leertaste.</p>";
winnerMessage =newMessageOverlay(frame).display(message).onDismiss(this::restart);}privatevoidrestart(){newGame();
worldView.changeWorld(game.getWorld());}privatevoidnewGame(){
game =newMazeGame(createWorld());
keyboardController =newKeyboardController(game);}privateMazeWorldcreateWorld(){String text ="wwwwwdw\n"+"wfddddw\n"+"wdddddd\n"+"ddddddw\n"+"wdddddw\n"+"wwwwwdw\n";MazeTextFormat fmt =newMazeTextFormat();try{Maze maze = fmt.read(newStringReader(text));returnnewMazeWorld(maze,newPosition(4,4));}catch(IOException ex){thrownewUncheckedIOException(ex);}}publicstaticvoidmain(String[] args){SwingUtilities.invokeLater(()->newApp5().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...
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.
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:
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.
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:
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:
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...
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.
Eine Aktion des Benutzers, die an verschiedenen Stellen im Programm (z. B. Menü und Toolbar) auftreten kann
Die Auswahl einer Datei durch den Benutzer
Das Einlesen der Datei in ein Objekt (hier: Maze)
Im Erfolgsfall: das Verwenden des eingelesenen Objekts in einer von der Anwendung definierten Art und Weise
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.
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:
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.
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:
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:
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:
importjava.io.IOException;importjava.io.StringReader;importjava.io.UncheckedIOException;importjava.util.ArrayList;importjava.util.List;publicclassMazeWorldFactory{publicMazeWorldcreateInitialWorld(){String text ="f";MazeTextFormat fmt =newMazeTextFormat();try{Maze maze = fmt.read(newStringReader(text));returnnewMazeWorld(maze,newPosition(0,0));}catch(IOException ex){thrownewUncheckedIOException(ex);}}publicMazeWorldcreateWorld(Maze maze){Position pos =determineStartPosition(maze);if(pos ==null){returncreateInitialWorld();}returnnewMazeWorld(maze, pos);}privatePositiondetermineStartPosition(Maze maze){List<Position> free =newArrayList<>();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(newPosition(x,y));}}}if(free.isEmpty()){returnnull;}return free.get((int)(Math.random()* free.size()));}}
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.