Home > Informatik > Stufe EF > 9. zweidimensionale Arrays

9.3 Zelluläre Automaten - Workshop

Notenliste - Graphikanwendung - zelluläre Automaten

Workshop: Eine zweidimensionale Torus-Welt ↑

Wir entwickeln unseren zellulären Automaten Schritt für Schritt. Wie in Folge 9.2 beginnen wir nach dem Bottom-up-Prinzip mit der Klasse Feld, ergänzen anschließend das Spielbrett und bauen zuletzt eine grafische Anwendung mit einem Button.

Das Spielfeld enthält 100 × 100 Zellen. Eine Zelle ist entweder tot (0) oder lebendig (1). Wir verwenden die Vierernachbarschaft und verbinden die gegenüberliegenden Ränder zu einer Torus-Welt.

Bei unserem Projekt weichen wir etwas von Conways "Game of Life" - Regln ab. Unsere eigenen Regeln lauten:

Bei 0 oder 4 lebenden Nachbarn wird eine Zelle tot, bei 1, 2 oder 3 lebenden Nachbarn lebendig.

Allerdings könnte man diese Regeln leicht ändern; wenn Sie mit dem Workshop fertig sind, können Sie das ja mal versuchen ...

Legen Sie in BlueJ ein neues Projekt an. Sie benötigen drei Klassen: Feld, Spielbrett und ZAutomat.

Schritt 1 – Die Klasse Feld ↑

Aus Folge 9.2 kennen Sie die Klasse Feld. Wir behalten Position und Breite bei. Allerdings steht status jetzt für "tot" bzw. "lebendig". Lebende Zellen zeichnen wir grün. Die zusätzliche Methode lebt() liefert einen Wahrheitswert zurück.

import java.awt.Color;
import java.awt.Graphics;

public class Feld
{
	 private int status;  // 0 = tot, 1 = lebendig
	 private int x, y, breite;

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

	 public void setzeStatus(int neuerStatus)
	 {
		  status = neuerStatus;
	 }

	 public boolean lebt()
	 {
		  return status == 1;
	 }

	 public void anzeigen(Graphics g)
	 {
		  int px = x * breite;
		  int py = y * breite;

		  g.setColor(new Color(255, 255, 191));
		  g.fillRect(px, py, breite, breite);

		  if (lebt())
		  {
				g.setColor(new Color(0, 150, 0));
				g.fillOval(px, py, breite, breite);
		  }
	 }
}

Der Quelltext der Klasse Feld entspricht weitgehend dem aus der Folge 9.2; ein paar kleine Veränderungen waren aber notwendig. So verzichten wir beispielsweise auf einen breiten Rand um das Spielfeld, und die einzelnen Felder werden nicht mehr umrahmt.

Übernehmen Sie die Klasse in BlueJ und übersetzen Sie sie. Die Klasse lässt sich bereits kompilieren, zeigt aber allein noch kein Fenster.

Schritt 2 – Die erste Version der Klasse Spielbrett ↑

Das Spielbrett in dem folgenden Quelltext besteht aus 100 × 100 Zellen der Klasse Feld. Jedes Feld ist 4 Pixel breit; das ganze Spielfeld misst daher 400 × 400 Pixel. Eine Startposition für einen einzelnen Spielstein benötigen wir nicht mehr.

Auf einem großen Monitor erscheint dieses Spielbrett recht klein. Man kann die Feldbreite aber leicht ändern, mit 6 oder 8 Pixeln Breite sieht das Ganze schon etwas besser aus - wenn man einen großen Monitor hat.

Screenshot der Anwendung mit einer Feldbreite von 6 Pixeln.
Autor: Ulrich Helmich 2026, Lizenz: Public Domain

Auch die Anzahl der Felder kann man in dem Quelltext leicht verändern - probieren Sie es einfach,

Mit der Klasse Random legen wir die Anfangszustände fest. Der Befehl wuerfel.nextInt(10) liefert eine ganze Zahl zwischen 0 und 9. Bei 0, 1 oder 2 wird das Feld lebendig (Zustand 1), bei 3 bis 9 bleibt der Zustand 0.

import java.awt.Graphics;
import java.util.Random;

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

    public Spielbrett()
    {
        groesse = 100;
        breite = 4;
        altesFeld = 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);
                if (wuerfel.nextInt(10) < 3)
                {
                    altesFeld[x][y].setzeStatus(1);
                }
            }
        }
    }

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

    public int getPixelGroesse()
    {
        return groesse * breite;
    }
}

An sich ist nicht viel Neues zu sagen, der Quelltext entspricht dem aus der Folge 9.2. Neu hinzugekommen ist nur die Getter-Methode getPixelGroesse() , welche die Seitenlänge der Zeichenfläche zurückliefert. Diese Methode wird von der eigentlichen Java-Anwendung benötigt, auf die wir gleich zu sprechen kommen.

Erstellen Sie diese erste Version von Spielbrett und übersetzen Sie sie.

Schritt 3 – Ein Fenster mit einem Button ↑

Eine Graphikanwendung mit Buttons hatten wir bereits in der Folge 9.2 implementiert - Sie erinnern sich bestimmt noch an die kleine Krümelmonster-Anwendung. Die folgende - von ChatGPT generierte - Java-Anwendung enthält nur einen Button. Wenn dieser Button angeklickt wird, passiert noch nichts.

import java.awt.BorderLayout;
					import java.awt.Dimension;
					import java.awt.Graphics;
					import java.awt.event.ActionEvent;
					import java.awt.event.ActionListener;
					import javax.swing.JButton;
					import javax.swing.JFrame;
					import javax.swing.JPanel;

					public class ZAutomat extends JFrame implements ActionListener
					{
						 private Spielbrett brett;
						 private JButton weiter;
						 private JPanel zeichenflaeche;

						 public ZAutomat()
						 {
							  brett = new Spielbrett();

							  zeichenflaeche = new JPanel()
							  {
									protected void paintComponent(Graphics g)
									{
										 super.paintComponent(g);
										 brett.anzeigen(g);
									}
							  };
							  int pixel = brett.getPixelGroesse();
							  zeichenflaeche.setPreferredSize(new Dimension(pixel, pixel));

							  weiter = new JButton("Naechste Generation");
							  weiter.addActionListener(this);

							  setLayout(new BorderLayout());
							  add(zeichenflaeche, BorderLayout.CENTER);
							  add(weiter, BorderLayout.SOUTH);

							  setTitle("Ein zellulaerer Automat");
							  setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
							  pack();
							  setResizable(false);
							  setLocationRelativeTo(null);
							  setVisible(true);
						 }

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

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

Diese Java-Anwendung wurde von ChatGPT erzeugt, um die Einzelheiten müssen wir uns nicht weiter kümmern. Wer sich für die Details der Anwendung interessiert, übergibt diesen Quelltext einer KI und lässt ihn sich von dieser KI erläutern. Für Klausuren oder gar das Informatik-Abitur sind Java-Anwendungen völlig belanglos. Zweidimensionale Arrays und ihre Anwendungen sind dagegen höchst relevant!

Erstellen Sie ZAutomat, übersetzen Sie alle Klassen und starten Sie main(). Ein zufällig gefülltes Spielfeld sollte erscheinen. Beim Klicken verändert sich noch nichts.

Schritt 4 – Lebende Nachbarn zählen ↑

Bevor wir neue Zustände berechnen können, müssen wir für jede Zelle die Zahl der lebenden Nachbarn ermitteln. Zunächst betrachten wir nur die vier Nachbarn links, rechts, oben und unten. Ein direkter Zugriff auf altesFeld[x-1][y] würde am linken Rand allerdings einen Fehler auslösen: Der Index −1 existiert nicht.

In der Torus-Welt liegt links neben Spalte 0 die letzte Spalte. Dasselbe Prinzip gilt für die übrigen Ränder. Die vier Hilfsvariablen xl, xr, yo und yu enthalten die gültigen Nachbarpositionen.

    private int anzahlNachbarn(int x, int y)
    {
        int xl, xr, yo, yu;
        if (x == 0)           xl = groesse - 1;
        else                  xl = x - 1;
        if (x == groesse - 1) xr = 0;
        else                  xr = x + 1;
        if (y == 0)           yo = groesse - 1;
        else                  yo = y - 1;
        if (y == groesse - 1) yu = 0;
        else                  yu = y + 1;

        int nachbarn = 0;
        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;
    }

Statt der fest eingetragenen Zahl 99 verwenden wir groesse - 1. Dadurch funktioniert die Methode auch dann noch, wenn wir das Spielfeld später verkleinern oder vergrößern.

Ergänzen Sie die Methode in Spielbrett. Sie wird erst im nächsten Schritt verwendet.

Schritt 5 – Warum wir ein zweites Array brauchen ↑

Angenommen, ein bisher totes Feld erhält nach unseren Regeln den Zustand 1. Würden wir diesen neuen Zustand sofort in altesFeld speichern, könnten benachbarte Felder bei ihrer Berechnung bereits den neuen statt des alten Zustands sehen. Das Ergebnis würde dann von der Reihenfolge der Berechnung abhängen.

Beispiel für die Berechnung des Folgezustands zweier benachbarter Zellen

Warum Folgezustände zunächst zwischengespeichert werden müssen (ältere Abbildung).

Deshalb verwenden wir zusätzlich ein zweidimensionales int-Array. Es speichert nur die Zahlen 0 und 1; wir benötigen dafür keine weiteren 10.000 Feldobjekte.

    private int[][] neuesFeld;

    // Im Konstruktor ergänzen:
    neuesFeld = new int[groesse][groesse];

Wichtig: Die Deklaration allein erzeugt noch kein Array. Erst mit new int[groesse][groesse] wird der Speicher bereitgestellt.

Ergänzen Sie das Attribut und die Initialisierung im Konstruktor von Spielbrett.

Schritt 6 – Die nächste Generation berechnen ↑

Die Methode neueRunde() arbeitet in zwei Durchgängen. Im ersten Durchgang berechnen wir alle Folgezustände und schreiben sie in neuesFeld. Im zweiten Durchgang übertragen wir sie auf die Feldobjekte in altesFeld.

    public void neueRunde()
    {
        // Erst alle neuen Zustaende berechnen.
        for (int x = 0; x < groesse; x++)
        {
            for (int y = 0; y < groesse; y++)
            {
                int n = anzahlNachbarn(x, y);
                if (n >= 1 && n <= 3)
                    neuesFeld[x][y] = 1;
                else
                    neuesFeld[x][y] = 0;
            }
        }

        // Danach alle neuen Zustaende uebernehmen.
        for (int x = 0; x < groesse; x++)
        {
            for (int y = 0; y < groesse; y++)
            {
                altesFeld[x][y].setzeStatus(neuesFeld[x][y]);
            }
        }
    }

Die Bedingung n >= 1 && n <= 3 entspricht genau unseren Unterrichtsregeln. Bei 0 oder 4 Nachbarn ist sie falsch. Der bisherige Zustand der Zelle wird bewusst nicht geprüft.

Ergänzen Sie neueRunde() in Spielbrett.

Schritt 7 – Den Button aktivieren ↑

Nun fehlt nur noch eine Änderung in der Klasse ZAutomat. Ersetzen Sie die bisherige Methode actionPerformed() durch:

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

Ein Klick berechnet jetzt eine neue Generation und fordert anschließend die Zeichenfläche zum Neuzeichnen auf. Der Button und die Fensterleiste werden nicht überzeichnet, weil nur das JPanel seine Grafik zeichnet.

Starten Sie das Programm erneut und klicken Sie mehrfach auf "Naechste Generation". Beobachten Sie, wie sich die Verteilung der lebenden Zellen verändert.

Schritt 8 – Vollständige Klassen und Übungen ↑

Zur Kontrolle folgt die vollständige Endfassung der Klasse Spielbrett. Die Klassen Feld und ZAutomat stehen bereits vollständig in Schritt 1 bzw. Schritt 3; in Schritt 7 wurde lediglich die Button-Methode ergänzt.

import java.awt.Graphics;
import java.util.Random;

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

    public Spielbrett()
    {
        groesse = 100;
        breite = 4;
        altesFeld = new Feld[groesse][groesse];
        neuesFeld = new int[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);
                if (wuerfel.nextInt(10) < 3)
                {
                    altesFeld[x][y].setzeStatus(1);
                }
            }
        }
    }

    private int anzahlNachbarn(int x, int y)
    {
        int xl, xr, yo, yu;
        if (x == 0)           xl = groesse - 1;
        else                  xl = x - 1;
        if (x == groesse - 1) xr = 0;
        else                  xr = x + 1;
        if (y == 0)           yo = groesse - 1;
        else                  yo = y - 1;
        if (y == groesse - 1) yu = 0;
        else                  yu = y + 1;

        int nachbarn = 0;
        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;
    }

    public void neueRunde()
    {
        // 1. Neue Zustaende berechnen, ohne die alten zu aendern.
        for (int x = 0; x < groesse; x++)
        {
            for (int y = 0; y < groesse; y++)
            {
                int n = anzahlNachbarn(x, y);
                if (n >= 1 && n <= 3)
                    neuesFeld[x][y] = 1;
                else
                    neuesFeld[x][y] = 0;
            }
        }

        // 2. Erst jetzt die neuen Zustaende uebernehmen.
        for (int x = 0; x < groesse; x++)
        {
            for (int y = 0; y < groesse; y++)
            {
                altesFeld[x][y].setzeStatus(neuesFeld[x][y]);
            }
        }
    }

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

    public int getPixelGroesse()
    {
        return groesse * breite;
    }
}

Übungen

  1. Ändern Sie die Wahrscheinlichkeit für lebendige Startzellen von 30 % auf 10 % und anschließend auf 50 %. Welche Unterschiede beobachten Sie?
  2. Ändern Sie die Regel so, dass eine Zelle nur bei genau zwei lebenden Nachbarn lebendig wird. Wie entwickelt sich das Spielfeld?
  3. Verkleinern Sie das Spielfeld auf 50 × 50 Zellen und erhöhen Sie die Feldbreite auf 8 Pixel. Welche Anpassungen sind nötig?
  4. Zusatzaufgabe: Erweitern Sie die Nachbarzählung auf die acht Nachbarn der Moore-Nachbarschaft. Überlegen Sie anschließend, wie sich die Übergangsregeln ändern müssten.
Kleine Hilfe zu Teilaufgabe 2

Wir ersetzen die if-else-Anweisung durch eine switch-case-Anweisung. Dann können Sie leichter mit der Übergangsfunktion experimentieren:

int n = anzahlNachbarn(x, y);

switch (n)
{
   case 0: neuesFeld[x][y] = 0; break;
   case 1: neuesFeld[x][y] = 1; break;
   case 2: neuesFeld[x][y] = 1; break;
   case 3: neuesFeld[x][y] = 1; break;
   case 4: neuesFeld[x][y] = 0; break;
}

Hinweis: Der klassische Automat "Game of Life" von Conway verwendet acht Nachbarn und berücksichtigt zusätzlich den bisherigen Zustand der Zelle. Unser Beispiel ist ein eigenständiger, vereinfachter Automat.