Home > Informatik > Stufe EF > 9. zweidimensionale Arrays

9.3 Zelluläre Automaten - Workshop

Notenliste - Graphikanwendung - zelluläre Automaten

Workshop

Eine zweidimensionale Torus-Welt

Wir gehen hier wieder nach dem bewährten Bottom-Up-Prinzip vor und erstellen zunächst eine Klasse für einzelnen Felder sowie eine Klasse für die gesamte Spielfläche, so ähnlich also, wie wir es bereits in der Folge 9.2 mit der einfachen Graphik-Anwendung gemacht haben. Im Grunde ist dieser Workshop eine Fortsetzung der Folge 9.2.

Die Klasse Feld

Vielleicht können wir sogar die Klasse Feld aus der Folge 9.2 übernehmen. Schauen wir sie uns doch noch einmal kurz an:

import java.awt.*;

public class Feld
{
    int status;
    int x,y;
    int breite;

    public Feld(int x, int y, int b)
    {
        status = 0;
        this.x = x;
        this.y = y;
        breite = b;
    }
    
    public void setzeStatus(int neuerStatus)
    {
       status = neuerStatus;
    }

    public void anzeigen(Graphics g)
    {
        g.setColor(new Color(255,255,191)); // hellgelb
        g.fillRect(50+x*breite,50+y*breite,breite,breite);
        g.setColor(new Color(0,0,0)); // schwarz
        g.drawRect(50+x*breite,50+y*breite,breite,breite);

        if (status == 1)
        {
            g.setColor(new Color(255,0,0)); // rot
            g.fillOval(50+x*breite,50+y*breite,breite,breite);
        }
    }

}

Eigentlich können wir die Klasse fast unverändert übernehmen. Das Attribut status steht jetzt allerdings nicht mehr für "frei" und "besetzt", sondern für "tot" und "lebendig". Die Farbe des Kreises, der bei status==1 gezeichnet wird, ändern wir auf Grün, das passt besser zum Status "lebendig".

⇒ Speichern Sie das letzte Projekt aus der Folge 9.2 unter einem neuen Namen ab und verändern Sie die Klasse Feld wie soeben besprochen.

Die Klasse Spielbrett

Auch diese Klasse muss leicht angepasst werden. Vor allem brauchen wir viel mehr als 10 x 10 Felder. Wir erweitern die Klasse auf 100 x 100 Felder. Die Felder dürfen dann natürlich nicht mehr 40 Pixel breit sein, sondern nur noch 4 Pixel. Und die vier Methoden zum Bewegen des Spielsteins benötigen wir nicht mehr, diese können wir löschen. So könnte dann der Quelltext dieser Klasse aussehen:

import java.awt.*;

public class Spielbrett
{   
    Feld[][] feld;
    int xpos, ypos;
    int groesse, breite;

    public Spielbrett(int xStart, int yStart)
    {   
        xpos    = xStart;
        ypos    = yStart;
        groesse = 100;
        breite  =   4;
        feld = new Feld[groesse][groesse];

        for (int x=0; x < groesse; x++)
            for (int y=0; y < groesse; y++)
                feld[x][y] = new Feld(x,y,breite);

        feld[xpos][ypos].setzeStatus(1);
    }

    public void anzeigen(Graphics g)
    {
        for (int x=0; x < groesse; x++)
            for (int y=0; y < groesse; y++)
                feld[x][y].anzeigen(g);
    }
}

Beim Betrachten dieser Version fällt auf, dass wir ja auch keine Startposition für "den" Spielstein angeben müssen. Vielmehr sollten wir den Status der Felder am Anfang per Zufall bestimmen lassen. Allerdings sollten nicht allzu viele Felder den Status 1 (lebend) haben, sondern vielleicht nur 1/4 der Felder.

Hier eine verbesserte Version der Klasse Spielbrett:

import java.awt.*;
import java.util.Random;

public class Spielbrett
{   
    Feld[][] feld;
    int groesse, breite;
    Random wuerfel;

    public Spielbrett()
    {   
        groesse = 100;
        breite  =   4;
        feld = new Feld[groesse][groesse];
        wuerfel = new Random();

        for (int x=0; x < groesse; x++)
            for (int y=0; y < groesse; y++)
            {
                feld[x][y] = new Feld(x,y,breite);
                if (wuerfel.nextInt(10) < 3)
                    feld[x][y].setzeStatus(1);
            }
    }

    public void anzeigen(Graphics g)
    {
        for (int x=0; x < groesse; x++)
            for (int y=0; y < groesse; y++)
                feld[x][y].anzeigen(g);
    }
}

Verstehen Sie, wie die Sache mit dem Zufallsgenerator funktioniert? Wenn nicht, dann hier eine kurze Erklärung:

Die Sache mit dem Zufallsgenerator

Ein Zufallsgenerator ist nichts anderes als eine Art Würfel, mit dem man Zahlen zwischen 1 und 6 auswürfeln kann. Nur kann ein Objekt der Klasse Random viel mehr. Wenn man will, kann man Zahlen zwischen 1 und 6 würfeln, genau wie mit einem echten Würfel. Man kann aber auch weit größere Zahlenbereiche abdecken. In unserem Programm würfelt der Zufallsgenerator Zahlen zwischen 0 und 9 aus.

Damit wir Zugriff auf die Klasse Random haben, müssen diese aus der Java-Bibliothek java.util importieren:

import java.util.Random;

Mit der Deklaration

Random wuerfel;

und der Initialisierung

wuerfel = new Random()

erzeugen wir ein Objekt der Klasse Random.

Wenn wir nun eine Zufallszahl zwischen 0 und 9 erzeugen wollen, geben wir ein:

z = wuerfel.nextInt(10);

Mit diesem Vorwissen können wir nun die if-Anweisung im Quelltext der Klasse Spielfeld verstehen:

if (wuerfel.nextInt(10) < 3) feld[x][y].setzeStatus(1);

Falls die gewürfelte Zahl den Wert 0, 1 oder 2 hat, dann wird der Status des Feldes auf 1 gesetzt und es wird von der Java-Anwendung mit einem grünen Kreis darauf gezeichnet. Trifft diese Bedingung nicht zu, wird der Status des Feldes nicht verändert, bleibt also 0.

Die Klasse ZAutomat

Die Klasse Graphikanwendung aus dem vorherigen Workshop 9.2 können wir erstmal übernehmen und dann etwas verändern:

import java.awt.*;
import javax.swing.*;
import java.awt.event.*;

public class ZAutomat extends JFrame implements ActionListener
{
    Spielbrett brett;
    Button   weiter;

    public ZAutomat()
    {
        initFelder();
        initButtons();

        setSize(500,580);
        setTitle("Ein zellulaerer Automat");
        setResizable(false);
        setVisible(true);
    }

    public void initFelder()
    {
        brett = new Spielbrett();
    }

    public void initButtons()
    {
        weiter = new Button("Hoch");
        add(weiter);
        setLayout(null);
        weiter.setBounds   (200,440,100,30);
        weiter.addActionListener(this);
    }

    public void actionPerformed(ActionEvent event)
    {
        repaint();
    }    

    public void paint(Graphics g)
    {
        super.paint(g);
        brett.anzeigen(g);
    }

    public static void main(String[] args) 
    {
        new ZAutomat();
    }
}

Wir benötigen nur noch einen Button um das Spielfeld zu aktualisieren. Eine Steuerung der Richtung durch die vier Buttons ist nicht mehr notwendig, der Zustand der Felder wird ja durch einen Algorithmus bestimmt, auf den wir noch kommen. Der einzige Button in dieser Version dient nur dazu, der Anwendung mitzuteilen, dass sie die Zustände der Felder aus den alten Zuständen der Felder neu berechnen soll.

Die Methode actionPerformed() muss noch entwickelt werden. Damit der Quelltext überhaupt kompiliert werden kann, muss aber eine leere Methode vorhanden sein.

Die Anwendung sieht nach erfolgreicher Kompilation dann so aus:

Der noch nicht funktionsfähige zelluläre Automat
Ersteller: Ulrich Helmich 2022, Lizenz: ---

Realisierung der Torus-Welt

Bisher tut sich noch nichts, wenn man auf den Button klickt. Nun soll Leben in das Spiel kommen. Dazu erst mal ein paar grundsätzliche Gedanken zu dem "Spiel des Lebens".

Eine Zelle des Spielfeldes ist entweder tot (status = 0) oder lebendig (status = 1). In der nächsten Generation hängt der neue Status dieser Zelle davon ab, wie viele lebende und tote Nachbarzellen existieren. Dabei gehen wir erst mal von einer Vierernachbarschaft aus: Nachbarn sind die Zellen links, rechts, oberhalb und unterhalb der betrachteten Zelle. Eine Zelle kann also 0, 1, 2, 3 oder 4 lebende Nachbarn haben.

Stellen wir jetzt mal folgende Regel auf:

0 lebende Nachbarn:

  • Die betrachtete Zelle wird absterben. Vier tote Zellen sind kein gutes Milieu für eine lebende Zelle, sie steckt sich an der Krankheit an und stirbt ebenfalls.

1, 2 oder 3 lebende Nachbarn:

  • Das ist ein gutes "Klima". Die betrachtete Zelle wird auf jeden Fall weiterleben, also auch in der nächsten Runde den Status 1 haben. Eine Zelle, die den Status 0 hatte, wird in der nächsten Runde ebenfalls auf Status = 1 gesetzt. Es ist quasi eine neue lebende Zelle entstanden, die Nachbarzellen haben Nachwuchs bekommen.

4 lebende Nachbarn:

  • Das wiederum ist nicht gut für die betrachtete Zelle. Sie wird von den vielen Nachbarn quasi erdrückt oder erstickt und stirbt in der nächsten Runde.

Das sind sozusagen die "Spielregeln" des "Spiel des Lebens". Natürlich kann man diese Spielregeln leicht abändern. Das ist sogar eine der Hauptaufgaben bei der Anwendung des Spiels, die Regeln so abzuändern, dass sich im Laufe der Zeit interessante Muster herausbilden.

Wir wollen nun diese Regeln umsetzen. Als erstes benötigen wir eine sondierende Methode, die feststelle, wie viele lebende Nachbarn eine Zelle überhaupt hat. Dazu schreiben wir uns einfach eine Methode

Eine erste Version der Methode könnte so aussehen:

    public int anzahlNachbarn(int x, int y)
    {
        int nachbarn = 0;
        
        if (feld[x-1]  [y].lebt()) nachbarn++;    
        if (feld[x+1][  y].lebt()) nachbarn++; 
        if (feld[  x][y-1].lebt()) nachbarn++;       
        if (feld[  x][y+1].lebt()) nachbarn++;            
        return nachbarn;
    }

Dazu müssen wir die Klasse Feld mit einer sondierenden Methode

public boolean lebt()

ausstatten, die true zurückliefert, falls der Status des betrachteten Feldes = 1 ist.

Die Toruswelt

Im Theorieteil hatten wir bereits gesagt, dass wir unser Spielfeld als Toruswelt realisieren wollen. Der linke Nachbar einer Zelle am linken Rand ist also die Zelle, die sich ganz rechts in der gleichen Zeile befindet. Entsprechendes gilt für die Zellen am rechten Rand. Für eine Zelle der oberen Reihe ist der obere Nachbar die Zelle in der untersten Reihe und umgekehrt. Diese Tatsache müssen wir in unserer Methode berücksichtigen.

Betrachten wir nun folgenden Quelltext:

   public int anzahlNachbarn(int x, int y)
    {
        int nachbarn = 0;
        int xl, xr, yo, yu;
        
        if (x ==  0) xl = 99; else xl = x-1;
        if (x == 99) xr =  0; else xr = x+1;
        if (y ==  0) yo = 99; else yo = y-1;
        if (y == 99) yu =  0; else yu = y+1;
                
        if (feld[xl][ y].lebt()) nachbarn++;    
        if (feld[xr][ y].lebt()) nachbarn++; 
        if (feld[ x][yo].lebt()) nachbarn++;       
        if (feld[ x][yu].lebt()) nachbarn++;            
        return nachbarn;
    }

Durch die vier neuen Hilfsvariablen xl, xr, yo, yu können wir nun die Koordinaten des linken, rechten, oberen bzw. unteren Nachbarn zuverlässig auch unter den Bedingungen einer Torus-Welt berechnen.

⇒ Ändern Sie den Quelltext der Klasse Spielbrett entsprechend!

Berechnung der nächsten Runde

Wir könnten jetzt für jedes der 100 x 100 Felder des Spielbrettes den Status berechnen, den das Feld in der nächsten Generation hat. Dabei gibt es allerdings ein kleines Problem: Wir dürfen den Folgestatus zwar berechnen, das Feld muss aber seinen alten Status noch behalten. Warum? Der alte Status wird ja benötigt, um den Folgestatus des der beiden Nachbarfelder rechts und unten zu berechnen.

Berechnung des Folgestatus
Autor: Ulrich Helmich 2022, Lizenz: siehe Seitenende

Betrachten wir zunächst das rot markierte leere Feld C3 in der obigen Abbildung. Dieses Feld besitzt einen lebenden Nachbarn und hat daher in der nächsten Generation den Status 1.

Das leere Feld C4 befindet sich unter dem Feld C3, es hat in der aktuellen Generation keinen lebenden Nachbarn, hätte also in der Folgegeneration ebenfalls den Status 0.

Würden wir nun sofort nach der Berechnung den Status von Feld C3 auf 1 setzen, dann hätte Feld C4 plötzlich einen lebenden Nachbarn, nämlich auf Feld C3. Der Folgestatus von C4 wäre dann nicht 0, sondern 1.

Wir dürfen den Folgestatus von C3 zwar berechnen, das Feld muss aber seinen alten Status 0 noch behalten, damit wir den Folgestatus von D3 und C4 korrekt berechnen können.

Daraus folgt nun, dass wir die neuen Zustände der bereits berechneten Felder irgendwo zwischenlagern müssen. In der nächsten Runde werden sie dann ja gebraucht, aber noch nicht jetzt. Wir müssen also 100 x 100 Folgezustände zwischenspeichern, und dazu bietet sich natürlich wieder ein zweidimensionaler int-Array an.

import java.awt.*;
import java.util.Random;

public class Spielbrett
{   
    Feld[][] altesFeld, neuesFeld;
    
    int groesse, breite;
    Random wuerfel;

    public Spielbrett()
    {   
        groesse = 100;
        breite  =   4;
        altesFeld = new Feld[groesse][groesse];
        neuesFeld = new Feld[groesse][groesse];
        wuerfel = new Random();

        for (int x=0; x < groesse; x++)
            for (int y=0; y < groesse; y++)
            {
                altesFeld[x][y] = new Feld(x,y,breite);
                neuesFeld[x][y] = new Feld(x,y,breite);
                if (wuerfel.nextInt(10) < 3)
                    altesFeld[x][y].setzeStatus(1);
            }
    }

Hier sehen sie die ersten Zeilen der neu angepassten Klasse Spielbrett. Die Klasse verwaltet nun zwei int-Arrays der Größe 100 x 100, nämlich altesFeld und neuesFeld.

Beide Felder müssen korrekt deklariert und initialisiert werden, und das leistet dieser Quelltext.

Ich hatte bei der Erstellung dieser Klasse eine ganze Zeit einen lästigen Laufzeitfehler, der nicht verschwinden wollte, einen Initialisierungsfehler. Bis ich dann merkte, dass ich die Zeile

 neuesFeld = new Feld[groesse][groesse];

vergessen hatte. Ich hatte zwar das neue Feld korrekt deklariert und initialisiert, aber diese wichtige Zeile, durch die der Array erst "richtig" erzeugt wird, hatte ich vergessen. Nach dem Ergänzen dieser Anweisung lief das Programm ohne Laufzeitfehler.

Die Methode zur Berechnung der Anzahl lebender Nachbarn musste etwas angepasst werden:

    private int anzahlNachbarn(int x, int y)
    {
        int nachbarn = 0;
        int xl, xr, yo, yu;
        
        if (x ==  0) xl = 99; else xl = x-1;
        if (x == 99) xr =  0; else xr = x+1;
        if (y ==  0) yo = 99; else yo = y-1;
        if (y == 99) yu =  0; else yu = y+1;
                
        if (altesFeld[xl][ y].lebt()) nachbarn++;    
        if (altesFeld[xr][ y].lebt()) nachbarn++; 
        if (altesFeld[ x][yo].lebt()) nachbarn++;       
        if (altesFeld[ x][yu].lebt()) nachbarn++;            
        return nachbarn;
    }

Eigentlich wurde nur der Name des Bezeichners von feld geändert, jetzt steht hier altesFeld.

Der neue Status eines einzelnen Feldes wird mit dieser Methode berechnet:

    private void neuerStatusFeld(int x, int y)
    {
       int n = anzahlNachbarn(x,y);
       
       if (n <= 0) neuesFeld[x][y].setzeStatus(0); else
       if (n <= 3) neuesFeld[x][y].setzeStatus(1); else
       if (n >= 4) neuesFeld[x][y].setzeStatus(0);
    }

Der Status wird mit Hilfe der Informationen des Arrays altesFeld berechnet und dann in dem Array neuesFeld gespeichert.

Beide Methoden, anzahlNachbarn() und neuerStatusFeld() wurden übrigens als private deklariert. Es handelt sich um zwei Hilfsmethoden, die den außenstehenden Betrachter nicht interessieren. Von öffentlichem Interesse ist eigentlich nur die nächste Methode, die daher als public deklariert wurde:

    public void neueRunde()
    {
        for (int x=0; x < groesse; x++)
            for (int y=0; y < groesse; y++)
               neuerStatusFeld(x,y);
        
        for (int x=0; x < groesse; x++)
            for (int y=0; y < groesse; y++)
               altesFeld[x][y].setzeStatus(neuesFeld[x][y].gibStatus());
    }

Die erste doppelte for-Schleife berechnet den Status jedes einzelnen Feldes und speichert den neuen Status in dem Array neuesFeld.

Die zweite doppelte for-Schleife wird erst ausgeführt, wenn die Status aller Felder berechnet worden sind. Jetzt werden die Status, die in neuesFeld zwischengespeichert wurden, in den Array altesFeld übertragen. Wenn dann also die anzeigen()-Methoden der Klassen aufgerufen werden, werden auch die neuen Status angezeigt.

    public void anzeigen(Graphics g)
    {
        for (int x=0; x < groesse; x++)
            for (int y=0; y < groesse; y++)
                altesFeld[x][y].anzeigen(g);
    }

Hier sehen wir die anzeigen()-Methode der Klasse Spielbrett. Zugegriffen wird auf die Status des Arrays altesFeld.

Die Anwendung, aktuelle Version

Schauen wir uns jetzt noch einmal den Quelltext der aktuellen Version der Anwendung an:

import java.awt.*;
import javax.swing.*;
import java.awt.event.*;

public class ZAutomat extends JFrame implements ActionListener
{
    Spielbrett brett;
    Button   weiter;

    public ZAutomat()
    {
        initFelder();
        initButtons();

        setSize(500,580);
        setTitle("Ein zellulaerer Automat");
        setResizable(false);
        setVisible(true);
    }

    public void initFelder()
    {
        brett = new Spielbrett();
    }

    public void initButtons()
    {
        weiter = new Button("Hoch");
        add(weiter);
        setLayout(null);
        weiter.setBounds(200,440,100,30);
        weiter.addActionListener(this);
    }

    public void actionPerformed(ActionEvent event)
    {
        brett.neueRunde();
        repaint();
    }    

    public void paint(Graphics g)
    {
        super.paint(g);
        brett.anzeigen(g);
    }

    public static void main(String[] args) 
    {
        new ZAutomat();
    }
}

Die Anwendung müsste jetzt eigentlich fehlerfrei laufen.

Auf manchen Windows-Rechnern meiner Schule gab es gelegentlich Probleme, wenn man eine Java-Anwendung mit einer paint()-Methode startete, die gleichzeitig Buttons hatte. Dann wurden die Buttons oft doppelt gezeichnet. Der Fehler trat aber nicht mehr auf, wenn man in der paint()-Methode als erstes die paint()-Methode der Mutterklasse aufrief, also super.paint().

Alter Zustand und neuer Zustand des Spielbretts
Autor: Ulrich Helmich 2022, Lizenz: siehe Seitenende

Auf diesem Bild sehen wir, was passiert, wenn man auf den Button geklickt hat. Die neuen Status werden berechnet und angezeigt.

Schauen wir uns die linken oberen Ecken der beiden Felder mal genauer an:

Überprüfung der Berechnung
Autor: Ulrich Helmich 2022, Lizenz: siehe Seitenende

Eine stichprobenartige Überprüfung ergibt, dass die Folgestatus korrekt berechnet worden sind.

Kleine Info, bevor Sie meinen, ich hätte hier einen Fehler gemacht: Der Plural von Status ist ebenfalls Status.

Damit wären wir mit diesem Workshop am Ende. Es steht Ihnen aber frei, das Programm auszubauen. Man könnte die Regeln ändern, mit denen sich der Status eines Feldes berechnet. Man könnte auch statt einer Vierernachbarschaft eine Achternachbarschaft einführen. Oder ein Feld könnte mehr als zwei Zustände haben, zum Beispiel "lebendig", "krank" und "tot".

Ich hoffe, dieser Workshop hat Ihnen etwas Spaß gemacht und Sie motiviert, weiter zu programmieren und Java zu lernen.