Zur Demonstration entwickeln wir ein Programm Schritt für Schritt. Ein geeignetes Projekt ist Conway's Game of Life, das genügend Komplexität besitzt und viele Fragen offen lässt, die sich durch Simulation beantworten lassen. Außerdem ist es grafisch nett anzusehen und bietet die Möglichkeit Benutzereingaben zu demonstrieren. Die Details des Game of Life sind in Wikipedia gut erklärt.
Der Programmierstil, den wir hier betreiben, ist Rapid Development. Wir werden einzelne Teile schnell entwickeln, so dass sofort ein aussagefähiges nd nutzbares Programm entsteht. Dieses Programm bauen wir dann Schritt für Schriit aus. Falls sich der erste Ansatz als ungenügend herausstellt, scheuen wir uns nicht, ihn komplett zu verwerfen und von Neuem anzufangen. Man braucht wegen der verlorenen Zeit keine Angst zu haben. Gewöhnlich ist der zweite Ansatz deutlich schneller erstellt und arbeitet viel besser.
Als erste Version erstellen wir zwei Java-Klassen Test und Situation. Letztere enthält die eigentliche Spielsituation und die Logik für die Berechnung des nächsten Lebenszyklus. Die Zellen werden durch boolean dargestellt, wobei true lebendig, und false tot bedeutet.
package rene.life; /** * Eine Situtation im Game of Life von Conway. * * Conway's Game of Life ist in Wikipedia gut erklärt. */ public class Situation { // Größe des Spielereichs public int n, m; // Der Spielbereich und ein Arbeitsbereich gleicher Größe public boolean[][] field; boolean[][] work; // Anzahl der lebenden Felder public int count; /** * Erzeuge den Spiel- und Arbeitsbereich. * * @param n * @param m */ public Situation (int n, int m) { this.n = n; this.m = m; field = new boolean[n][m]; work = new boolean[n][m]; } /** * Fülle den Bereich mit Felder, die mit Wahrscheinlichkeit density leben. * * @param density */ public void fillRandom (double density) { count = 0; for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) { field[i][j] = Math.random() < density; if (field[i][j]) count++; } } /** * Führe einen Schritt aus. */ public void run () { for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) { // Nachbarn zählen: int neighbors = 0; if (i < n - 1) { if (j > 0 && field[i + 1][j - 1]) neighbors++; if (field[i + 1][j]) neighbors++; if (j < m - 1 && field[i + 1][j + 1]) neighbors++; } if (i > 0) { if (j > 0 && field[i - 1][j - 1]) neighbors++; if (field[i - 1][j]) neighbors++; if (j < m - 1 && field[i - 1][j + 1]) neighbors++; } if (j > 0 && field[i][j - 1]) neighbors++; if (j < m - 1 && field[i][j + 1]) neighbors++; // Neues Feld gemäß den Regeln setzen: if (field[i][j]) work[i][j] = neighbors == 2 || neighbors == 3; else work[i][j] = neighbors == 3; } @Override public String toString () { String s = ""; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) s = s + (field[i][j] ? "X" : "-"); s = s + "\n"; } return s; } }
Ein kleines Detail ist, dass wir den Spielbereich nicht löschen. In Java ist garantiert, dass Arrays mit Default-Werten gefüllt werden. Es erscheint aber sinnvoll und logisch, die Bereiche dennoch zu löschen. Wir werden das später in den Code mitaufnehmen.
Beim Studium der Funktion run() fällt auf, dass es merkwürdig schwer ist einen Indexfehler zu vermeiden. Wir haben im obigen Code etlich if-Strukturen verwendet. Außerdem wird verwendet, dass && mit false abgebrochen wird, wenn die erste Bedingung schon falsch ist.
Dafür gibt es auch einen einfachen Trick, bei dem der Bereich an allen Rändern um eine Reihe vergrößert wird. Diese Zellen bleiben tot, also auf false gestellt. Es werden dann nur die inneren Felder verwendet. Diese Lösung ist zudem etwas schneller. Daher verwerfen wir unsere erste Lösung und ersetzen sie durch den folgenden Code. Die Logik ist nun viel einfacher zu verstehen.
package rene.life; /** * Eine Situtation im Game of Life von Conway. * * Conway's Game of Life ist in Wikipedia gut erklärt. */ public class Situation { // Größe des Spielereichs public int n, m; // Der Spielbereich und ein Arbeitsbereich gleicher Größe public boolean[][] field; boolean[][] work; // Anzahl der lebenden Felder public int count; /** * Erzeuge den Spiel- und Arbeitsbereich. Lösche den Spielbereich (nicht * unbedingt notwendig). * * @param n * @param m */ public Situation (int n, int m) { this.n = n; this.m = m; // Erzeuge Spielbereich mit einem Rand von Breite 1 field = new boolean[n + 2][m + 2]; work = new boolean[n + 2][m + 2]; } /** * Fülle den Bereich mit Felder, die mit Wahrscheinlichkeit density leben. * * @param density */ public void fillRandom (double density) { count = 0; for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) { field[i][j] = Math.random() < density; if (field[i][j]) count++; } } /** * Führe einen Schritt aus. */ public void run () { for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) { // Nachbarn zählen: int neighbors = 0; if (field[i + 1][j]) neighbors++; if (field[i][j + 1]) neighbors++; if (field[i - 1][j]) neighbors++; if (field[i][j - 1]) neighbors++; if (field[i + 1][j + 1]) neighbors++; if (field[i + 1][j - 1]) neighbors++; if (field[i - 1][j + 1]) neighbors++; if (field[i - 1][j - 1]) neighbors++; // Neues Feld gemäß den Regeln setzen: if (field[i][j]) work[i][j] = neighbors == 2 || neighbors == 3; else work[i][j] = neighbors == 3; } // Arbeitsbereich zurück kopieren und Lebende zählen: count = 0; for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) { field[i][j] = work[i][j]; if (field[i][j]) count++; } } @Override public String toString () { String s = ""; for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) s = s + (field[i][j] ? "X" : "-"); s = s + "\n"; } return s; } }
Man beachte, dass die Arrays nun um 2 größer sind. Außerdem laufen die Schleifen statt von 0 bis n-1 (bzw. m-1) nun von 1 bis n (bzw. m).
Die Funktion toString() überschreibt die Funktion, die jedes Objekt hat. Damit lässt sich eine Situation bequem auf der Konsole ausdrucken. Die Funktion fillRandom() füllt den Bereich zufällig mit einer vorgegebenen Dichte von lebenden Feldern.
Als Test im Hauptprgramm setzen wir ein 5x5-Feld zufällig mit Dichte 0.3 und testen die Logik in mehreren Beispielen.
package rene.life; import java.io.File; import java.io.PrintWriter; public class Main { public static void main (String args[]) { Situation s = new Situation(5, 5); s.fillRandom(0.3); System.out.println(s); s.run(); System.out.println(s); } }
Ein typtisches Beispiel sieht folgendermaßen aus.
---XX X--X- X---X -XX-- -XXXX
---XX ---X- X-XX- X---X -X-X-
Es gibt einige interessante Figuren, die auch im Wikipedia-Artikel erwähnt werden. Wir wollen Gleiter demonstrieren. Das sind Figuren, die sich nach einigen Iterationen selbst wiederherstellen, allerdings leicht versetzt. Um die Gleiter einsetzen zu können, schreiben wir eine set() Funktion und definieren statische Arrays, die wir in den Bereich einfügen können.
Die folgende Funktion wird in Situation eingefügt.
/** * Setze eine Figur an Stelle x,y im Bereich. * * @param figure * @param x * @param y */ public void set (boolean[][] figure, int x, int y) { for (int i = 0; i < figure.length; i++) for (int j = 0; j < figure[i].length; j++) field[(x + i) % n][(y + j) % m] = figure[i][j]; }
Im Hauptprogramm definieren wir den einfachen Gleiter und setzen ihn ein. Er reproduziert sich nach 4 Iterationen.
public class Main { public static void main (String args[]) { Situation s = new Situation(5, 5); s.clear(); s.set(glider, 1, 1); System.out.println(s); for (int i = 0; i < 4; i++) { s.run(); System.out.println(s); } } final static boolean[][] glider = { { false, true, false }, { false, false, true }, { true, true, true } }; }
Die Ausgabe zeigt, dass unser Code funktionert. Der Gleiter reproduziert sich um ein Feld nach rechts unten versetzt.
-X--- --X-- XXX-- ----- ----- ----- X-X-- -XX-- -X--- ----- ----- --X-- X-X-- -XX-- ----- ----- -X--- --XX- -XX-- ----- ----- --X-- ---X- -XXX- -----
Für weitere Experimente ist es sinnvoll, die Figuren vereinfacht darstellen zu können. Wir machen das mit Strings, die wir in boolean-Matrizen übersetzen. Damit definieren wir das Schiff, dass sich ebenfalls in 4 Iterationen reproduziert
public static void main (String args[]) { Situation s = new Situation(7, 12); s.clear(); s.set(translate(ship), 2, 2); System.out.println(s); for (int i = 0; i < 4; i++) s.run(); System.out.println(s); } final static String ship[] = { "-XXXXXX", "X-----X", "------X", "X----X-", "--XX---" }; public static boolean[][] translate (String[] figure) { boolean res[][] = new boolean[figure.length][]; for (int i = 0; i < res.length; i++) { res[i] = new boolean[figure[i].length()]; for (int j = 0; j < res[i].length; j++) res[i][j] = figure[i].charAt(j) == 'X'; } return res; }
------------ --XXXXXX---- -X-----X---- -------X---- -X----X----- ---XX------- ------------
------------ ----XXXXXX-- ---X-----X-- ---------X-- ---X----X--- -----XX----- ------------
Um nun zu zeigen, wie schnell unser Code ist, machen wir ein interessantes statitistisches Experiment. Wir lassen zufällige Startpositionen mitgegebener Dichte eine gewisse Zeit laufen. Es stellt sich heraus, dass die Dichte der Lebenden dabei im Trend abnimmt. Es stellt sich die Frage wieviele Lebenden im Schnitt nach einer gewissen Anzahl von Schritten übrig bleiben. Dazu müssen wir eine große Anzahl Läufe erzeugen und die Mittelwerte ermitteln.
Diesen Wert schreiben wir in eine Datei, die wir mit einem beliebigen Programm auslesen können. Das Format ist CSV (comma separated values). Es hat zeilenweise das Format "schritte, density". CSV kann von allen statistischen Programmen gelesen werden.
public class Main { public static void main (String args[]) { generateDecayStatistics(50, 50, 0.5, 1000, 1000); } public static void generateDecayStatistics (int n, int m, double fraction, int length, int repetitions) { double density[] = new double[length]; for (int i = 0; i < length; i++) density[i] = 0.0; SituationTorus situation = new SituationTorus(n, m); for (int i = 0; i < repetitions; i++) { situation.fillRandom(fraction); density[0] += (double) situation.count / (n * m); for (int k = 1; k < length; k++) { situation.run(); density[k] += (double) situation.count / (n * m); } } for (int i = 0; i < length; i++) density[i] /= repetitions; try { File file = new File( System.getProperty("user.home") + System.getProperty("file.separator") + "densities.dat"); PrintWriter out = new PrintWriter(file); for (int k = 0; k < length; k++) { out.println(k + ", " + density[k]); } out.close(); System.out .println("Densities written to " + file.getAbsolutePath()); } catch (Exception e) { System.out.println(e); } } }
Die erzeugte Datei habe ich in Euler Math Toolbox eingelesen und grafisch dargestellt. Aber es könnte auch Excel verwendet werden. Eine typische Ausgabe sieht so aus.
Offenbar gibt es einen Grenzwert. Am Ende besteht die Situation meist nur aus 2x2-Blocks, die SStabil sind oder 1x3-Blocks, die sich nach zwei Schritten reproduzieren.
Es ist interessant, Game of Life auf dem Torus laufen zu lassen. Dabei sind der rechte mit dem linken und der obere mit dem unteren Rand verklebt. Der Gleiter wandert also unten aus dem Bereich heraus und oben wieder hinein.
Vor dem Neuberechnen der Iteration muss man allerdings die Ränder mit den Werte füllen, die sich durch das Aneinanderkleben ergeben.
/** * Führe einen Schritt aus. */ public void run (boolean torus) { if (torus) { field[0][0] = field[n][m]; field[0][m + 1] = field[n][1]; field[n + 1][0] = field[1][m]; field[n + 1][m + 1] = field[1][1]; for (int i = 1; i <= n; i++) { field[i][0] = field[i][m]; field[i][m + 1] = field[i][1]; } for (int j = 1; j <= m; j++) { field[0][j] = field[n][j]; field[n + 1][j] = field[1][j]; } } for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) { // Nachbarn zählen: int neighbors = 0; if (field[i + 1][j]) neighbors++; if (field[i][j + 1]) neighbors++; if (field[i - 1][j]) neighbors++; if (field[i][j - 1]) neighbors++; if (field[i + 1][j + 1]) neighbors++; if (field[i + 1][j - 1]) neighbors++; if (field[i - 1][j + 1]) neighbors++; if (field[i - 1][j - 1]) neighbors++; // Neues Feld gemäß den Regeln setzen: if (field[i][j]) work[i][j] = neighbors == 2 || neighbors == 3; else work[i][j] = neighbors == 3; } // Arbeitsbereich zurück kopieren und Lebende zählen: count = 0; for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) { field[i][j] = work[i][j]; if (field[i][j]) count++; } }
Lässt man den Gleiter nun in einem Feld der Größe 5x6 laufen, befindet er sich nach 120 Schritten genau an derselben Stelle.
public static void main (String args[]) { Situation situation = new Situation(5, 6); situation.set(glider, 1, 1); System.out.println(situation); for (int i = 0; i < 120; i++) situation.run(true); System.out.println(situation); }
Wird dasselbe auf dem flachen Spielfeld ausgeführt, so entsteht nach11 Iterationen ein stabiler Block.
-X---- --X--- XXX--- ------ ------
------ ------ ------ ---XX- ---XX-
Eine interessante Frage ist, ab wann sich die Situation wiederholt. Eine Wiederholung festzustellen ist aufwändig, wenn man mit allen alten Versionen vergleicht, die man auch speichern muss.
Wir verwenden aber Hashtables. Dazu werden die Situationen in Bits von Integern komprimiert, damit sie schneller verglichen werden können und weniger Platz einnehmen. Hashtables speichern Datein unter einem Schlüssel. Man muss die Schlüssel nur miteinander durch equals() vergleichen können. Wir verwenden die gespeicherten Bits als Schlüssel, also die Daten selber. Dazu schreiben wir die equals()-Funktion selber. Jedes Objekt hat diese Funktion. Per Default würden einfach die Adressen im Speicher verglichen.
Hashtables benötigen auch einen Integer-Code, der möglichst breit gestreut aus dem Schlüssel erzeugt werden soll. Aber natürlich muss es immer derselbe Code für dieselben Daten sein. Wir erzeugen diesen Code aus den Bits, indem wir die Integer mit Exclusive-Or verknüpfen. Dazu überschreiben wir die Methode hashCode(), die auch jedes Objekt hat.
package rene.life;
/**
* Speichert Situation in Bits von Integern. Es wird equals() und hashCode()
* überschrieben.
*/
public class HashedSituation
{
int[] bits;
int iteration;
public HashedSituation (int[] bits, int iteration)
{
this.bits = bits;
this.iteration = iteration;
}
@Override
public int hashCode ()
{
int code = 0;
for (int i = 0; i < bits.length; i++)
code = code ^ bits[i];
return code;
}
@Override
public boolean equals (Object o)
{
HashedSituation other = (HashedSituation) o;
for (int i = 0; i < bits.length; i++)
if (bits[i] != other.bits[i]) return false;
return true;
}
}
Die folgende Funktion in der Klasse Situation erzeugt das Integer-Array, das die Bits enthält. Wir fügen noch eine Funktion dazu, die den Prozess umkehren kann, also aus Bit-Arrays Situationen macht. Dies ist für Testzwecke unerlässlich.
/** * Liest die boolean-Felder in ein Array von Integer als Bits. * * @return */ public int[] getBits () { int size = (n * m) / 32 + 1; int bits[] = new int[size]; int current = 0; int work = 0; int currentbit = 0; for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) { work = work << 1; if (field[i][j]) work = work | 1; currentbit++; if (currentbit == 32) { bits[current++] = work; currentbit = 0; work = 0; } } if (currentbit > 0) { work = work << (32 - currentbit); bits[current] = work; } return bits; } /** * Umkehrung von getBits() */ public void fromBits (int[] bits) { clear(); int current = 0; int currentbit = 0; int work = bits[current]; for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) { if (((work >> (31 - currentbit)) & 1) != 0) field[i][j] = true; currentbit++; if (currentbit == 32) { current++; currentbit = 0; work = bits[current]; } } }
Schließlich schreiben wir noch Funkionen in Main, die das Experiment durchführt und eine Datei mit den gefunden ersten Wiederholungen anlegt. Die aktuelle Situation wird immer wieder mit der gewählten Dichte aufgefüllt und dann laufen gelassen, bis die erste Wiederholung auftritt. Wir verwenden immer dieselbe Hashtable, löschen Sie aber immer wieder. Die Ergebnisse werden in einer Datei gespeichert, damit wir sie mit einem Statistikprogramm analysieren können.
static Hashtable<HashedSituation, HashedSituation> table = // new Hashtable<HashedSituation, HashedSituation>(300); public static int firstRepetition (Situation situation) { int iteration = 0; table.clear(); int[] bits = situation.getBits(); HashedSituation hashed = new HashedSituation(bits, iteration); table.put(hashed, hashed); while (true) { situation.run(true); bits = situation.getBits(); iteration++; hashed = new HashedSituation(bits, iteration); if (table.contains(hashed)) return iteration; table.put(hashed, hashed); if (iteration > 1000) break; } return iteration; } public static void generateRepeitionStatistics (Situation situation, double density, int times) { try { File file = new File( System.getProperty("user.home") + System.getProperty("file.separator") + "repetitions.dat"); PrintWriter out = new PrintWriter(file); for (int i = 0; i < times; i++) { situation.fillRandom(density); out.println(firstRepetition(situation)); } out.close(); System.out .println("Densities written to " + file.getAbsolutePath()); } catch (Exception e) { System.out.println(e); } }
Das alles ist natürlich nicht mehr ganz so einfach zu realisieren und zu verstehen. Daher sollte man solche Experimente zunächst nicht machen, oder ein einfacheres, aber wesentlich ineffektiveres Verfahren zur Detektion von Wiederholungen verwenden.
Hier ist eine Statistik über die ersten Wiederholungen in einen 20x20-Grid mit Torus, das zu 50% gefüllt wird. Der Mittelwert liegt ungefähr bei 200.
Natürlich macht Game of Life am meisten Spaß, wenn man die Iterationen animiert. Wir programmieren daher zunächst ein einfaches Panel, das eine Situation darstellen kann.
Ein Problem dabei ist, dass wir die Situationen bisher in den Koordinaten (zeile,spalte) verstanden haben. Die Konsolenausgabe erfolgte auch zeilenweise. Die Grafik von Java verwendet aber (Spalte,Breite). Man muss beim Zeichnen ein wenig aufpassen, wenn man die alte Vorstellung behalten will. Natürlich wird die Grafik doppelt gepuffert, da wir sie animieren wollen.
Wir initialisieren die Situation mit einer Füllung von 50%, damit wir schon gleich etwas sehen.
package rene.life; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Graphics; import java.awt.Graphics2D; import javax.swing.JPanel; public class SituationPanel extends JPanel { MainFrame main; Situation situation; int width, height; int fieldwidth; Color colorrectangle = Color.black; Color colorfield = Color.gray.darker(); Color colorback = Color.white; public SituationPanel (MainFrame main, Situation situation) { this.main = main; this.situation = situation; situation.fillRandom(0.5); setDoubleBuffered(true); } @Override public void paint (Graphics graphics) { Graphics2D g = (Graphics2D) graphics; width = getWidth(); height = getHeight(); g.setColor(colorback); g.fillRect(0,0,widht,height); fieldwidth = width / Math.max(situation.m + 10, situation.n + 10); int upperx = width / 2 - situation.m * fieldwidth / 2; int uppery = height / 2 - situation.n * fieldwidth / 2; g.setStroke(new BasicStroke(3)); g.setColor(colorrectangle); g.drawRect(upperx - fieldwidth / 2, uppery - fieldwidth / 2, situation.m * fieldwidth + fieldwidth, situation.n * fieldwidth + fieldwidth); g.setColor(colorfield); for (int i = 1; i <= situation.n; i++) for (int j = 1; j <= situation.m; j++) { if (situation.field[i][j]) { g.fillRect(upperx + (j - 1) * fieldwidth + 2, uppery + (i - 1) * fieldwidth + 2, fieldwidth - 2, fieldwidth - 2); } } } }
Das Panel wird mit den schon vorgestellten Techniken in ein Fenster eingebettet, das wir im Hauptprogramm einfach erzeugen. Das Fenster sorgt selbst für seine Darstellung und beendet das Programm, wenn es geschlossen wird.
package rene.life; import javax.swing.ImageIcon; import javax.swing.JFrame; public class MainFrame extends JFrame { Situation situation; SituationPanel panel; public MainFrame () { super("Game of Life"); setIconImage(new ImageIcon( getClass().getResource("hearts32.png")).getImage()); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); situation = new Situation(30, 50); panel = new SituationPanel(this, situation); add("Center", panel); setSize(800, 600); setLocationRelativeTo(null); setVisible(true); } }
Als nächsten Schritt bietet sich an, dem Benutzer zu erlauben, einzelne Iterationsschritte durchzuführen. Dazu verwenden wir einen Menüeintrag mit Keyboard-Shortcuts. Wir erzeugen auch gleich ein Menü Files, wo wir nachher Einträge zum Speichern und Laden haben wollen, sowie ein Option-Menü, das den Torus ein- und ausschalten kann.
Wir verwenden eigene Funktionen, um Menüeinträge zu erzeugen und einen kürzeren und übersichtlicheren Code zu erhalten. Diese Funktionen fügen den Frame als ActionListener zum Menüeintrag und setzen den Keyboard-Shortcut.
package rene.life; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyEvent; import javax.swing.ImageIcon; import javax.swing.JCheckBoxMenuItem; import javax.swing.JFrame; import javax.swing.JMenu; import javax.swing.JMenuBar; import javax.swing.JMenuItem; import javax.swing.KeyStroke; public class MainFrame extends JFrame implements ActionListener { Situation situation; SituationPanel panel; JMenuItem itemexit, itemstep; JCheckBoxMenuItem checktorus; public MainFrame () { super("Game of Life"); setIconImage(new ImageIcon( getClass().getResource("hearts32.png")).getImage()); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); JMenuBar menubar = new JMenuBar(); setJMenuBar(menubar); JMenu files = new JMenu("Files"); menubar.add(files); itemexit = additem(files, "Exit", KeyStroke.getKeyStroke(KeyEvent.VK_X, KeyEvent.CTRL_DOWN_MASK)); JMenu actions = new JMenu("Actions"); menubar.add(actions); itemstep = additem(actions, "One Step", KeyStroke.getKeyStroke(KeyEvent.VK_G, KeyEvent.CTRL_DOWN_MASK)); JMenu options = new JMenu("Options"); menubar.add(options); checktorus = addcheckboxitem(options, "Torus", KeyStroke.getKeyStroke(KeyEvent.VK_T, KeyEvent.ALT_DOWN_MASK)); checktorus.setState(true); situation = new Situation(30, 50); panel = new SituationPanel(this, situation); add("Center", panel); setSize(800, 600); setLocationRelativeTo(null); setVisible(true); } public JMenuItem additem (JMenu menu, String text, KeyStroke key) { JMenuItem item = new JMenuItem(text); item.addActionListener(this); if (key != null) item.setAccelerator(key); menu.add(item); return item; } public JCheckBoxMenuItem addcheckboxitem (JMenu menu, String text, KeyStroke key) { JCheckBoxMenuItem item = new JCheckBoxMenuItem(text); item.addActionListener(this); if (key != null) item.setAccelerator(key); menu.add(item); return item; } @Override public void actionPerformed (ActionEvent e) { if (e.getSource() == itemexit) { dispose(); System.exit(0); } else if (e.getSource() == itemstep) { situation.run(checktorus.getState()); repaint(); } else if (e.getSource() == checktorus) { System.out.println("Here"); repaint(); } } }
Der nächste Schritt ist die Animation der Iterationen. Der Nutzer soll die Animation über einen Menüeintrag oder seinen Keyboard-Shortcut starten und anhalten können. Die Animation hat zwischen den Generationen einen festgelegten Delay in Millisekunden, den wir per Keyboard rechts und links erhöhen und erniedrigen wollen. Während der Animation soll in der Darstellung ein Feedback sein, selbst wenn sich nichts bewegt. Dazu verdunkeln wir den Hintergrund ein klein wenig.
Die folgende Klasse enthält alles notwendige. Sie kann die Animation starten und sie kann gefragt werden, ob die Animation noch läuft. Wir machen einfach den Thread öffentlich, so dass jeder ihn unterbrechen kann.
package rene.life; public class Animation implements Runnable { MainFrame main; SituationPanel panel; // Delay in Millisekunden public int delay = 200; // The running thread public Thread thread; public Animation (MainFrame main, SituationPanel panel) { this.main = main; this.panel = panel; } public void start () { thread = new Thread(this); thread.start(); } public boolean isAlive () { return thread != null && thread.isAlive(); } @Override public void run () { while (!thread.isInterrupted()) { try { Thread.sleep(delay); } catch (InterruptedException e) { break; } panel.situation.run(main.checktorus.getState()); panel.repaint(); } } }
Im Hauptprogramm wird ein Menüeintrag für die Animation hinzugefügt und wie folgt abgehandelt.
else if (e.getSource() == itemrun) { if (animation.isAlive()) animation.thread.interrupt(); else animation.start(); }
Die Zeichnung des Hintergrundes wird ein wenig geändert, wobei animatedback ein helles Grau ist, das wir mit new Color(220,220,220) erhalten.
g.setColor(main.animation.isAlive() ? animatedback : colorback); g.fillRect(0, 0, width, height);
Um die Geschwindigkeit zu ändern, könnten wir einen KeyboardListener implementieren. Mit einem KeyAdapter würden wir dann die folgenden Zeilen in den Konstruktor von MainFrame einfügen.
addKeyListener(new KeyAdapter() { @Override public void keyPressed (KeyEvent e) { int key = e.getKeyCode(); switch (key) { case KeyEvent.VK_RIGHT: animation.delay /= 2; break; case KeyEvent.VK_LEFT: animation.delay *= 2; break; } } });
Der Nachteil ist, dass der Benutzer nicht im Menü nachschauen kann, wie die Keyboard-Kommandos lauten. Deswegen ist es vielleicht besser, Menüeinträge zu verwenden.
itemfaster = additem(actions, "Faster", KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0)); itemslower = additem(actions, "Slower", KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0));
Die Richtlinien für Benutzeroberflächen verbieten zwar nicht, einfache Keyboard-Kommandos in Menüs unterzubringen, aber es ist ein schechter Stil. Daher muss man im endültigen Code Ctrl-Rechts und Ctrl-Links drücken.
Das Programm läuft nun schon recht ordentlich. Nach wenigen Iterationen entwickelt sich schon eine stabile Dichte, und oft auch bald ein stablier Zustand.
Als erstes hätten wir gerne einen Eintrag in Actions, mit dem Benutzer das Feld mit einer von ihnen gewählten Dichte füllen können. Dazu öffnen wir einen der Default-Dialoge von Java, die Benutzereingaben entgegen nehmen. Der Ereignisbehandler sieht dann folgendermaßen aus.
if (e.getSource() == itemfill) { if (animation.isAlive()) animation.thread.interrupt();
String s = JOptionPane.showInputDialog(this, "Set Fill Density", "0.5"); if (s != null) { try { double x = Double.parseDouble(s); x = Math.max(Math.min(0.99, x), 0.01); situation.fillRandom(x); } catch (Exception ex) { JOptionPane.showMessageDialog(this, "Illegal number!"); } }
panel.repaint(); }
Der nächste Schritt wäre, mit der Maus einzelne Felder zu aktivieren oder zu deaktivieren. Dazu verwenden wir einen MouseMotionListener für Bewegungen der Maus über dem Bereich, und einen MouseListener für die Klicks. Mausbewegungen werden natürlich nur bearbeitet, wenn die Animation nicht läuft.
Man beachte, dass man Mausaktionen im Panel entgegennehmen sollte. Das vollständige Panel sieht nun folgendermaßen aus.
package rene.life;
import java.awt.BasicStroke; import java.awt.Color; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionAdapter;
import javax.swing.JPanel;
/**
* Enthält die Darstellung einer Situation im Game of Life.
*/
public class SituationPanel extends JPanel
{
// Enthalten in: MainFrame main; // Dargestellte Situation: Situation situation;
// Panelgröße: int width, height; // Linker oberer Eckpunkt der Felder int upperx, uppery; // Weite eines Feldes int fieldwidth;
// Soll ein Feld vorgehoben werden? boolean highlighted; // Vorgehobenes Feld int ihighlight, jhighlight;
// Verschiedene Farben für Felder, Hintergrund und Hervorhebung. Color colorrectangle = Color.black; Color colorfield = new Color(100, 100, 100); Color colorback = Color.white; Color animatedback = new Color(220, 220, 220); Color colorfieldhighlight = new Color(120, 50, 50); Color colorhighlight = new Color(220, 160, 160);
/** * Initialisierung mit Dichte 0.5. Einstellung der Listener für die Maus. * * @param main * @param situation */ public SituationPanel (MainFrame main, Situation situation) { this.main = main; this.situation = situation;
highlighted = false;
situation.fillRandom(0.5);
addMouseListener(new MouseAdapter() { @Override public void mouseClicked (MouseEvent e) { if (main.animation.isAlive()) { main.animation.thread.interrupt(); repaint(); } else mouseclicked(e.getX(), e.getY()); } });
addMouseMotionListener(new MouseMotionAdapter() { @Override public void mouseMoved (MouseEvent e) { if (main.animation.isAlive()) return; mousemoved(e.getX(), e.getY()); } });
setDoubleBuffered(true); }
@Override public void paint (Graphics graphics) { Graphics2D g = (Graphics2D) graphics;
width = getWidth(); height = getHeight();
// Lösche Hintergrund:
g.setColor(main.animation.isAlive() ? animatedback : colorback);
g.fillRect(0, 0, width, height);
// Berechne Feldgröße, so dass das Feld in das Panel passt.
fieldwidth = width / Math.max(situation.m + 10, situation.n + 10);
// Berechnung der linken oberen Ecke der Darstellung
upperx = width / 2 - situation.m * fieldwidth / 2;
uppery = height / 2 - situation.n * fieldwidth / 2;
// Zeichne ein Rechteck um die Felder
g.setStroke(new BasicStroke(3));
g.setColor(colorrectangle);
g.drawRect(upperx - fieldwidth / 2,
uppery - fieldwidth / 2,
situation.m * fieldwidth + fieldwidth,
situation.n * fieldwidth + fieldwidth);
// Zeichne Felder
g.setColor(colorfield);
for (int i = 1; i <= situation.n; i++) for (int j = 1; j <= situation.m; j++) { boolean highlight = (highlighted && i == ihighlight && j == jhighlight); if (situation.field[i][j]) // Besetztes Feld { if (highlight) g.setColor(colorfieldhighlight); g.fillRect(upperx + (j - 1) * fieldwidth + 2, uppery + (i - 1) * fieldwidth + 2, fieldwidth - 2, fieldwidth - 2); if (highlight) g.setColor(colorfield); } else if (highlight) // Leeres, aber hervorgehobenes Feld { g.setColor(colorhighlight); g.fillRect(upperx + (j - 1) * fieldwidth + 2, uppery + (i - 1) * fieldwidth + 2, fieldwidth - 2, fieldwidth - 2); if (highlight) g.setColor(colorfield); } } }
/**
* Der Benutzer hat an der Stelle x,y geklickt
*
* @param x
* @param y
*/
public void mouseclicked (int x, int y)
{
int i = (y - uppery) / fieldwidth + 1;
int j = (x - upperx) / fieldwidth + 1;
// Innerhalb des Bereichs?
if (i < 1 || i > situation.n || j < 1 || j > situation.m) return;
situation.field[i][j] = !situation.field[i][j]; repaint(); }
/**
* Der Benutzer hat die Maus über das Panel bewegt.
*
* @param x
* @param y
*/
public void mousemoved (int x, int y)
{
int i = (y - uppery) / fieldwidth + 1;
int j = (x - upperx) / fieldwidth + 1;
// Innerhalb des Bereichs? if (i < 1 || i > situation.n || j < 1 || j > situation.m) highlighted = false; else // ja: { highlighted = true; ihighlight = i; jhighlight = j; } repaint(); }
}
Interessant ist natürlich auch, die Breite und Höhe des Feldes ändern zu können. Man kann dazu den einfachen Dialog für die EIngabe einer Zeile verwenden. Der Benutzer müsste dann die beiden Werte dort eingeben. Professioneller ist ein eigener Dialog. Wir erstellen eine neue Klasse für diesen Dialog.
package rene.life;
import java.awt.FlowLayout; import java.awt.GridLayout; import java.awt.TextField; import java.awt.event.ActionEvent; import java.awt.event.ActionListener;
import javax.swing.JButton; import javax.swing.JDialog; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.border.EmptyBorder;
/**
* Eingabedialog for Weite und Höhe des Feldes.
*/
public class SizeInput extends JDialog implements ActionListener
{
TextField fieldwidth, fieldheight;
JButton buttonok, buttoncancel;
boolean okay;
public SizeInput (MainFrame main) { super(main, "Enter Field Size", true);
okay = false;
JPanel north = new JPanel(); north.setLayout(new FlowLayout()); north.add(new JLabel("Enter width and height between 5 and 200")); add("North", north);
JPanel center = new JPanel(); center.setLayout(new GridLayout(2, 2)); center.setBorder(new EmptyBorder(10, 10, 10, 10));
center.add(new JLabel("Width")); center.add(fieldwidth = new TextField("" + main.situation.m)); center.add(new JLabel("Height")); center.add(fieldheight = new TextField("" + main.situation.n));
add("Center", center);
JPanel south = new JPanel(); south.setLayout(new FlowLayout());
south.add(buttonok = new JButton("OK")); buttonok.addActionListener(this); south.add(buttoncancel = new JButton("Cancel")); buttoncancel.addActionListener(this);
add("South", south);
pack(); }
@Override public void actionPerformed (ActionEvent e) { if (e.getSource() == buttonok) { okay = true; dispose(); }
else if (e.getSource() == buttoncancel) { dispose(); }
} }
Ein weiteres Ziel war, die Situationen speichern und laden zu können. Dazu verwenden wir den FileSelector, den Java zur Verfügung stellt. Das Format der Dateien sind Zeilen wie "X---XXX---XX".
Der entsprechende Code für die Event-Handler ist der folgende. Man beachte, dass beim Lesen der Datei zuerst alle Zeilen zwischengespeichert werden. Sie müssen alle dieselbe Länge haben. Zeilen ohne "X" und "-" am Anfang werden ignoriert. Erst danach ist die Größe der Situation bekannt.
else if (e.getSource() == itemsave) { filechooser.setFileFilter( new FileNameExtensionFilter("Data Files", "dat")); if (filechooser .showSaveDialog(filechooser) == JFileChooser.APPROVE_OPTION) { File file = filechooser.getSelectedFile();
try { if (!file.getName().toLowerCase().endsWith(".dat")) { file = new File(file.getAbsolutePath() + ".dat"); }
PrintWriter out = new PrintWriter(file);
for (int i = 1; i <= situation.n; i++) { String s = ""; for (int j = 1; j <= situation.m; j++) if (situation.field[i][j]) s = s + "X"; else s = s + "-"; out.println(s); }
out.close(); } catch (Exception e1) { JOptionPane.showMessageDialog(this, "Error with file\n" + file.getAbsolutePath(), "Message", JOptionPane.INFORMATION_MESSAGE); } } }
else if (e.getSource() == itemload) { filechooser.setFileFilter( new FileNameExtensionFilter("Data Files", "dat")); if (filechooser .showOpenDialog(filechooser) == JFileChooser.APPROVE_OPTION) { File file = filechooser.getSelectedFile();
try { BufferedReader in = new BufferedReader( new FileReader(file));
Vector<String> lines = new Vector<String>();
int width = 0;
while (true) { String line = in.readLine(); if (line == null) break;
if (!line.startsWith("X") && !line.startsWith("-")) continue;
if (width == 0) width = line.length(); else if (width != line.length()) { in.close(); throw new Exception(); }
lines.add(line); }
in.close();
int height = lines.size();
situation = new Situation(height, width);
int i = 0;
for (String line : lines) { for (int j = 0; j < width; j++) { situation.field[i][j] = line.charAt(j) == 'X'; } i++; }
panel.situation = situation; panel.repaint();
} catch (Exception e1) { JOptionPane.showMessageDialog(this, "Error with file\n" + file.getAbsolutePath(), "Message", JOptionPane.INFORMATION_MESSAGE); } } }
Wir schreiben schließlich noch einen Dialog, der einen HTML-Text als Hilfe anzeigt.
package rene.life;
import java.awt.GridLayout; import java.io.IOException; import java.net.URL;
import javax.swing.JEditorPane; import javax.swing.JFrame; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.event.HyperlinkEvent; import javax.swing.event.HyperlinkListener;
public class Help extends JFrame { public Help () { super("Help");
setIconImage(new javax.swing.ImageIcon( getClass().getResource("hearts32.png")).getImage());
JPanel panel = new JPanel(); panel.setLayout(new GridLayout(1, 1));
JEditorPane jEditorPane = new JEditorPane(); jEditorPane.setEditable(false); URL url = Help.class.getResource("index.html");
jEditorPane.putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, Boolean.TRUE);
try { jEditorPane.setPage(url); } catch (IOException e) { jEditorPane.setContentType("text/html"); jEditorPane.setText("<html>Page not found.</html>"); }
JScrollPane jScrollPane = new JScrollPane(jEditorPane); panel.add(jScrollPane);
add(panel);
setSize(800, 800); setLocationRelativeTo(null);
revalidate();
jEditorPane.addHyperlinkListener(new HyperlinkListener() { @Override public void hyperlinkUpdate (HyperlinkEvent e) { if (e.getEventType() == HyperlinkEvent.EventType.ACTIVATED) { // System.out.println(e.getDescription()); try { java.awt.Desktop.getDesktop().browse( java.net.URI.create(e.getDescription())); } catch (IOException e1) { } } } });
setVisible(true); }
}
Die Datei index.html muss sich im Verzeichnis der Hilfe-Klasse befinden. Die Hilfe ist, ebenso wie das Programm, in Englisch geschrieben. Lokale Versionen können in Java erzeugt werden.
<h1>Help for Conway's Game of Life</h1>
<p>This program is part of my Java class (in German) at <a href="http://java.renegrothmann.de/">my homepage</a>. It is explained in all details in that class.</p>
<p>Rene Grothmann</p>
<h1>Rules for Conway's Game of Life</h1>
<p> The Game of Life consists of a matrix of cells which can be dead or alive. The matrix has either boundaries, or the left and right edge and the upper and lower edge are glued together to form a torus (donat shaped object). </p>
<ul> <li> An empty cell becomes alive if it has exactly 3 neighbors. </li> <li> A living cell survives only if it has 2 or 3 neighbors. </li> </ul>
<p> Neighbors are here the 8 neighboring cells on the torus, respectively 8 or less cells for matrix with boundaries. </p>
<p> In each generation, all cells are set dead or alive according to these rules. </p>
<h1>The Program</h1>
<p> This program can simulate the generations. A start position can be set by chance with a selected density, or using the mouse. </p>
<p> The defaut start position is filled with 50% cells alive randomly. Another density can be selected in the menu, as well as another matrix size. By clicking with the mouse, cells can be toggled from life to death. </p>
<p> An animation can be started in the menu. The animation can be sped up or slowed down. </p>
Das Projekt wird abschließend noch internationalisiert. Dazu wird in Main ein ResourceBundle geladen, dessen Strings von allen Klassen per statischer Methode Main.translate() übersetzt werden können.
public class Main { static ResourceBundle B; // verwendetes Bundle public static void initBundle () // Initialisiere B { try { B = ResourceBundle.getBundle("GOL"); } catch (Exception e) { B = null; } } public static String translate (String name) { try { return B.getString(name); } catch (Exception e) { return name; } } public static void main (String args[]) { initBundle(); new MainFrame(); } }
Falls keine Übersetzung existiert, wird der Originalname verwendet. Die detusche Übersetzung in GOL.de.properties sieht folgendermaßen aus.
helpfile=index_de.html Files=Dateien Save\ to\ File=Auf Datei Speichern Load\ from\ File=Von Datei Laden Fill\ Situation=Situation mit Dichte Füllen Clear\ Situation=Situation Löschen Set\ Size=Größe Ändern Exit=Exit Actions=Aktionen One\ Step=Ein Schritt Animation=Animation Faster=Schneller Slower=Langsamer Options=Optionen Torus=Auf dem Torus Help=Hilfe Set\ Fill\ Density=Dichte? Illegal\ number!=Falsche Zahleingabe! Enter\ Field\ Size=Größe? Width=Breite Height=Höhe fieldsize=Breite und Höhe zwischen 5 und 200 OK=OK Cancel=Abbruch
Die Datei GOL.properties enthält nur zwei "Übersetzungen", der Name der Hilfedate und ein String, der als Key zu lang erscheint.
helpfile=index.html fieldsize=Enter width and height between 5 and 200
Natürlich muss man nun auch index.html übersetzen und in index_de.html speichern.
Ein komplexes Programm ist eigentlich nie ganz fertig. Es finden sich immer Fehler oder Dinge die man einfach vergessen hat. Man sollte sich nicht scheuen, das Programm zu überarbeiten und beim Benutzer zu ersetzen. Das soll aber nicht heißen, dass unvollständige, fehlerhafte, oder einfach nur schlecht getestete Programme als Endprodukt ausgeliefert werden sollten. Jedoch ist es ein Merkmal von Rapid Development, dem Kunden Vorversionen zum Testen zu übergeben.
Natürlich sollte man gefundene Fehler, die behoben werden müssen, sofort beheben. Leider werden oft Programme nach der Auslieferung einfach weiter entwickelt. Entdeckt man dabei Fehler, so sollten diese an der ausgelieferten Version behoben werden, nicht an der neuen ungetesteten Version.
Das hiere entwickelte Programm steht als jar-Datei einschließlich der Sourcen zum Download zur Verfügung. Man kann diese Datei mit unzip entpacken um an die Sourcen zu kommen.