Das Leben gestalten Conways Spiel des Lebens

Manchmal können selbst einfache Grundregeln sehr interessante Ergebnisse liefern. In diesem Tutorial werden wir die Kernmaschine von Conways Game of Life von Grund auf entwickeln.

Hinweis: Obwohl dieses Tutorial mit C # und XNA geschrieben wurde, sollten Sie in der Lage sein, in fast jeder 2D-Entwicklungsumgebung dieselben Techniken und Konzepte anzuwenden.


Einführung

Conways Game of Life ist ein zellulärer Automat, der in den 70er Jahren von einem britischen Mathematiker namens John Conway entwickelt wurde.

Angesichts eines zweidimensionalen Gitters von Zellen, bei dem einige "ein" oder "lebendig" und andere "aus" oder "tot" sind, und einer Reihe von Regeln, die bestimmen, wie sie lebendig werden oder sterben, können wir eine interessante "Lebensform" haben "entfalten Sie sich direkt vor uns. Indem wir einfach einige Muster auf unser Raster zeichnen und dann die Simulation starten, können wir beobachten, wie sich grundlegende Lebensformen entwickeln, ausbreiten, absterben und sich schließlich stabilisieren. Laden Sie die endgültigen Quelldateien herunter oder sehen Sie sich die Demo unten an:

Nun, dieses "Spiel des Lebens" ist nicht unbedingt ein "Spiel" - es ist eher eine Maschine, hauptsächlich weil es keinen Spieler und kein Ziel gibt, es entwickelt sich einfach auf der Grundlage seiner Anfangsbedingungen. Trotzdem macht es viel Spaß, damit zu spielen, und es gibt viele Prinzipien des Spieldesigns, die bei seiner Erstellung angewendet werden können. Also lass uns ohne weiteres anfangen!

Für dieses Tutorial habe ich alles in XNA eingebaut, weil ich damit am wohlsten bin. (Falls Sie Interesse haben, finden Sie hier eine Anleitung zum Einstieg in XNA.) Sie sollten jedoch in der Lage sein, jede 2D-Spieleentwicklungsumgebung zu verfolgen, mit der Sie vertraut sind.


Die Zellen erstellen

Das grundlegendste Element in Conways Game of Life sind die Zellen, Welches sind die "Lebensformen", die die Grundlage der gesamten Simulation bilden. Jede Zelle kann sich in einem von zwei Zuständen befinden: "lebend" oder "tot". Aus Gründen der Konsistenz bleiben wir für den Rest dieses Tutorials bei diesen beiden Namen für die Zellzustände.

Zellen bewegen sich nicht, sie beeinflussen einfach ihre Nachbarn basierend auf ihrem aktuellen Zustand.

Was die Programmierung ihrer Funktionalität angeht, gibt es drei Verhaltensweisen, die wir ihnen geben müssen:

  1. Sie müssen ihre Position, Grenzen und ihren Status nachverfolgen, damit sie richtig angeklickt und gezeichnet werden können.
  2. Sie müssen beim Klicken zwischen lebendig und tot wechseln, wodurch der Benutzer tatsächlich interessante Dinge verwirklichen kann.
  3. Sie müssen als weiß oder schwarz gezeichnet werden, wenn sie tot oder lebendig sind.

All dies kann durch Erstellen von a erreicht werden Zelle Klasse, die den folgenden Code enthält:

Klasse Zelle public Point Position get; privates Set;  public Rectangle Bounds get; privates Set;  public bool IsAlive get; einstellen;  public Cell (Punktposition) Position = Position; Bounds = neues Rechteck (Position.X * Game1.CellSize, Position.Y * Game1.CellSize, Game1.CellSize, Game1.CellSize); IsAlive = falsch;  public void Update (MouseState mouseState) if (Bounds.Contains (Neuer Punkt (mouseState.X, mouseState.Y)))) // Mit Linksklick Zellen zum Leben erwecken oder mit Rechtsklick beenden. if (mouseState.LeftButton == ButtonState.Pressed) IsAlive = true; else if (mouseState.RightButton == ButtonState.Pressed) IsAlive = false;  public void Draw (SpriteBatch SpriteBatch) if (IsAlive) SpriteBatch.Draw (Game1.Pixel, Bounds, Color.Black); // Zeichne nichts, wenn es tot ist, da die Standardhintergrundfarbe weiß ist. 

Das Gitter und seine Regeln

Jetzt, da sich jede Zelle korrekt verhält, müssen wir ein Raster erstellen, das alle enthalten wird, und die Logik implementieren, die jeder Zelle mitteilt, ob sie lebendig werden, am Leben bleiben, sterben oder tot bleiben soll (keine Zombies!)..

Die Regeln sind ziemlich einfach:

  1. Jede lebende Zelle mit weniger als zwei lebenden Nachbarn stirbt, als ob sie von einer unterbevölkerung betroffen wäre.
  2. Jede lebende Zelle mit zwei oder drei lebenden Nachbarn lebt bis zur nächsten Generation.
  3. Jede lebende Zelle mit mehr als drei lebenden Nachbarn stirbt wie durch Überfüllung.
  4. Jede tote Zelle mit genau drei lebenden Nachbarn wird wie durch Reproduktion zu einer lebenden Zelle.

Im folgenden Bild sehen Sie eine kurze visuelle Anleitung zu diesen Regeln. Jede durch einen blauen Pfeil hervorgehobene Zelle wird durch die entsprechende nummerierte Regel oben beeinflusst. Mit anderen Worten, Zelle 1 wird sterben, Zelle 2 wird am Leben bleiben, Zelle 3 wird sterben und Zelle 4 wird lebendig.

Während die Spielsimulation in konstanten Zeitintervallen ein Update ausführt, überprüft das Raster jede dieser Regeln für alle Zellen im Raster. Dies kann erreicht werden, indem der folgende Code in eine neue Klasse eingefügt wird, die ich anrufe Gitter:

Klassenraster öffentliche Punktgröße get; privates Set;  private Zelle [,] Zellen; public Grid () Größe = neuer Punkt (Game1.CellsX, Game1.CellsY); Zellen = neue Zelle [Size.X, Size.Y]; für (int i = 0; i < Size.X; i++) for (int j = 0; j < Size.Y; j++) cells[i, j] = new Cell(new Point(i, j));  public void Update(GameTime gameTime)  (… ) // Loop through every cell on the grid. for (int i = 0; i < Size.X; i++)  for (int j = 0; j < Size.Y; j++)  // Check the cell's current state, and count its living neighbors. bool living = cells[i, j].IsAlive; int count = GetLivingNeighbors(i, j); bool result = false; // Apply the rules and set the next state. if (living && count < 2) result = false; if (living && (count == 2 || count == 3)) result = true; if (living && count > 3) Ergebnis = falsch; if (! living && count == 3) Ergebnis = wahr; Zellen [i, j] .IsAlive = Ergebnis;  (…)

Das einzige, was uns hier fehlt, ist die Magie GetLivingNighbors Diese Methode zählt einfach, wie viele Nachbarn der aktuellen Zelle gerade am Leben sind. Also, lasst uns diese Methode zu unserer hinzufügen Gitter Klasse:

public int GetLivingNeighbors (int x, int y) int count = 0; // Zelle rechts prüfen. if (x! = Size.X - 1) if (Zellen [x + 1, y] .IsAlive) count ++; // Überprüfen Sie die Zelle unten rechts. if (x! = Size.X - 1 && y! = Size.Y - 1) if (Zellen [x + 1, y + 1] .IsAlive) count ++; // Zelle unten prüfen. if (y! = Size.Y - 1) if (Zellen [x, y + 1] .IsAlive) count ++; // Kontrolliere die Zelle unten links. if (x! = 0 && y! = Größe.Y - 1) if (Zellen [x - 1, y + 1] .IsAlive) count ++; // Kontrolliere die Zelle links. if (x! = 0) if (Zellen [x - 1, y] .IsAlive) count ++; // Kontrolliere die Zelle oben links. if (x! = 0 && y! = 0) if (Zellen [x - 1, y - 1] .IsAlive) count ++; // Überprüfen Sie die Zelle oben. if (y! = 0) if (Zellen [x, y - 1] .IsAlive) count ++; // Überprüfen Sie die Zelle oben rechts. if (x! = Size.X - 1 && y! = 0) if (Zellen [x + 1, y - 1] .IsAlive) count ++; zurück zählen 

Beachten Sie, dass im obigen Code der erste ob Eine Aussage jedes Paares überprüft einfach, dass wir uns nicht am Rand des Gitters befinden. Wenn wir diese Überprüfung nicht hätten, würden wir mehrere Exceptions durchlaufen, wenn wir die Grenzen des Arrays überschreiten. Auch da dies zu führen wird Anzahl niemals erhöht werden, wenn wir über die Ränder hinaus schauen, das bedeutet, dass die Kanten des Spiels "tot" sind. Dies entspricht einer permanenten Umrandung von weißen, toten Zellen um unsere Spielfenster herum.


Aktualisierung des Rasters in diskreten Zeitschritten

Bis jetzt ist die gesamte Logik, die wir implementiert haben, solide, aber sie wird sich nicht richtig verhalten, wenn wir nicht darauf achten, dass unsere Simulation in diskreten Zeitschritten ausgeführt wird. Dies ist nur eine schicke Art zu sagen, dass alle unsere Zellen zur gleichen Zeit aktualisiert werden, um die Konsistenz zu gewährleisten. Wenn wir das nicht implementieren würden, würden wir ein merkwürdiges Verhalten bekommen, weil die Reihenfolge, in der die Zellen geprüft wurden, eine Rolle spielen würde, so dass die strengen Regeln, die wir gerade aufgestellt haben, auseinanderfallen würden und ein Mini-Chaos entstehen würde.

Zum Beispiel überprüft unsere obige Schleife alle Zellen von links nach rechts. Wenn also die Zelle links, die wir gerade geprüft haben, lebendig wird, würde dies die Anzahl der Zellen in der Mitte, die wir jetzt überprüfen, ändern und möglicherweise zum Leben erwecken . Wenn wir stattdessen von rechts nach links prüfen, ist die Zelle rechts möglicherweise tot, und die Zelle links ist noch nicht lebendig, sodass unsere mittlere Zelle tot bleibt. Das ist schlecht, weil es inkonsistent ist! Wir sollten in der Lage sein, die Zellen in beliebiger Reihenfolge (wie eine Spirale!) Zu überprüfen, und der nächste Schritt sollte immer identisch sein.

Glücklicherweise lässt sich dies sehr einfach in Code implementieren. Alles, was wir brauchen, ist ein zweites Gitter von Zellen im Speicher für den nächsten Zustand unseres Systems. Jedes Mal, wenn wir den nächsten Zustand einer Zelle bestimmen, speichern wir ihn in unserem zweiten Raster für den nächsten Zustand des Gesamtsystems. Wenn wir dann den nächsten Zustand jeder Zelle gefunden haben, wenden wir sie alle gleichzeitig an. Wir können also ein 2D-Array von Booleans hinzufügen nextCellStates als private Variable und fügen Sie diese Methode dann der hinzu Gitter Klasse:

public void SetNextState () für (int i = 0; i < Size.X; i++) for (int j = 0; j < Size.Y; j++) cells[i, j].IsAlive = nextCellStates[i, j]; 

Vergessen Sie nicht, Ihr Problem zu beheben Aktualisieren Methode oben, so dass das Ergebnis dem nächsten Status und nicht dem aktuellen Status zugewiesen wird SetNextState ganz am Ende des Aktualisieren Methode, direkt nach Abschluss der Schleifen.


Das Gitter zeichnen

Jetzt, da wir die komplizierteren Teile der Logik des Gitters fertiggestellt haben, müssen wir es in der Lage sein, es auf den Bildschirm zu zeichnen. Das Raster zeichnet jede Zelle, indem sie ihre Zeichenmethoden nacheinander aufruft, sodass alle lebenden Zellen schwarz und die toten Zellen weiß sind.

Das eigentliche Raster nicht brauchen Alles zeichnen, aber aus Sicht des Benutzers ist es viel klarer, wenn wir einige Rasterlinien hinzufügen. Dies ermöglicht es dem Benutzer, Zellgrenzen leichter zu erkennen und vermittelt ein Skalierungsgefühl, also erstellen wir eine Zeichnen Methode wie folgt:

public void Draw (SpriteBatch SpriteBatch) foreach (Zelle in Zellen) cell.Draw (SpriteBatch); // Zeichne vertikale Gitternetzlinien. für (int i = 0; i < Size.X; i++) spriteBatch.Draw(Game1.Pixel, new Rectangle(i * Game1.CellSize - 1, 0, 1, Size.Y * Game1.CellSize), Color.DarkGray); // Draw horizontal gridlines. for (int j = 0; j < Size.Y; j++) spriteBatch.Draw(Game1.Pixel, new Rectangle(0, j * Game1.CellSize - 1, Size.X * Game1.CellSize, 1), Color.DarkGray); 

Beachten Sie, dass wir im obigen Code ein einzelnes Pixel nehmen und es strecken, um eine sehr lange und dünne Linie zu erzeugen. Ihre spezielle Spiel-Engine könnte eine einfache Lösung bieten DrawLine Eine Methode, bei der Sie zwei Punkte angeben und eine Linie zwischen ihnen zeichnen können, was es noch einfacher machen würde als die obige.


Hinzufügen einer High-Level-Spielelogik

An diesem Punkt haben wir alle grundlegenden Elemente, die wir benötigen, um das Spiel zum Laufen zu bringen. Wir müssen nur alles zusammenbringen. Zu Beginn müssen wir in der Hauptklasse Ihres Spiels (der, die alles beginnt) einige Konstanten wie die Abmessungen des Rasters und der Framerate (wie schnell es aktualisiert wird) und all die anderen Dinge, die wir brauchen, hinzufügen das Einzelpixelbild, die Bildschirmgröße usw..

Wir müssen auch viele dieser Dinge initialisieren, z. B. das Gitter erstellen, die Fenstergröße für das Spiel festlegen und sicherstellen, dass die Maus sichtbar ist, damit wir auf Zellen klicken können. Aber all diese Dinge sind motorspezifisch und nicht sehr interessant, also überspringen wir es und kommen zu den guten Sachen. (Wenn Sie in XNA mitarbeiten, können Sie natürlich den Quellcode herunterladen, um alle Details zu erhalten.)

Nun, da wir alles vorbereitet haben und loslegen können, sollten wir das Spiel einfach ausführen können! Aber nicht so schnell, denn es gibt ein Problem: Wir können nichts tun, weil das Spiel immer läuft. Es ist grundsätzlich nicht möglich, bestimmte Formen zu zeichnen, da diese beim Zeichnen auseinander brechen. Daher müssen wir das Spiel wirklich unterbrechen können. Es wäre auch schön, wenn wir das Raster löschen könnten, wenn es zu einem Chaos wird, da unsere Kreationen oft außer Kontrolle geraten und ein Chaos hinterlassen.

Fügen Sie also etwas Code hinzu, um das Spiel anzuhalten, wenn Sie die Leertaste drücken, und löschen Sie den Bildschirm, wenn Sie die Rücktaste drücken:

geschütztes überschreiben void Update (GameTime gameTime) keyboardState = Keyboard.GetState (); if (GamePad.GetState (PlayerIndex.One) .Buttons.Back == ButtonState.Pressed) this.Exit (); // Pause umschalten, wenn Leertaste gedrückt wird. if (keyboardState.IsKeyDown (Keys.Space) && lastKeyboardState.IsKeyUp (Keys.Space)) Paused =! Paused; // Den Bildschirm löschen, wenn die Rücktaste gedrückt wird. if (keyboardState.IsKeyDown (Keys.Back) && lastKeyboardState.IsKeyUp (Keys.Back)) grid.Clear (); base.Update (gameTime); grid.Update (gameTime); lastKeyboardState = keyboardState; 

Es wäre auch hilfreich, wenn wir deutlich machen würden, dass das Spiel pausiert war, also schreiben wir unser Zeichnen Methode, fügen wir etwas Code hinzu, damit der Hintergrund rot wird, und schreiben Sie "Paused" in den Hintergrund:

protected override void Draw (GameTime gameTime) if (Paused) GraphicsDevice.Clear (Color.Red); else GraphicsDevice.Clear (Color.White); spriteBatch.Begin (); if (paused) Zeichenfolge pausiert = "pausiert"; spriteBatch.DrawString (Schriftart, Pause, ScreenSize / 2, Color.Gray, 0f, Font.MeasureString (Pause)) / 2, 1f, SpriteEffects.None, 0f);  grid.Draw (spriteBatch); spriteBatch.End (); base.Draw (gameTime); 

Das ist es! Jetzt sollte alles funktionieren, also kannst du es drehen, einige Lebensformen zeichnen und sehen, was passiert! Entdecken Sie interessante Muster, die Sie erstellen können, indem Sie erneut auf die Wikipedia-Seite verweisen. Sie können auch mit der Framerate, der Zellengröße und den Abmessungen des Rasters spielen, um es nach Ihren Wünschen anzupassen.


Verbesserungen hinzufügen

Zu diesem Zeitpunkt ist das Spiel voll funktionsfähig und es ist keine Schande, es einen Tag zu nennen. Ein Ärger, den Sie vielleicht bemerkt haben, ist, dass Ihre Mausklicks nicht immer registriert werden, wenn Sie versuchen, eine Zelle zu aktualisieren. Wenn Sie also mit der Maus klicken und über das Raster ziehen, wird eine gepunktete Linie hinterlassen, statt eines Volumens ein. Dies geschieht, weil die Geschwindigkeit, mit der die Zellen aktualisiert werden, auch die Geschwindigkeit ist, mit der die Maus geprüft wird, und sie ist viel zu langsam. Daher müssen wir einfach die Rate, mit der das Spiel aktualisiert wird, und die Rate, mit der die Eingabe gelesen wird, entkoppeln.

Definieren Sie zunächst die Aktualisierungsrate und die Bildrate in der Hauptklasse:

public const int UPS = 20; // Updates pro Sekunde public const int FPS = 60;

Wenn Sie nun das Spiel initialisieren, legen Sie mithilfe der Framerate (FPS) fest, wie schnell die Mauseingaben gelesen und gezeichnet werden. Dies sollte zumindest eine angenehme 60 FPS sein:

IsFixedTimeStep = true; TargetElapsedTime = TimeSpan.FromSeconds (1.0 / FPS);

Dann fügen Sie einen Timer hinzu Gitter Klasse, so dass es nur bei Bedarf aktualisiert wird, unabhängig von der Bildrate:

public void Update (GameTime gameTime) (…) updateTimer + = gameTime.ElapsedGameTime; if (updateTimer.TotalMilliseconds> 1000f / Game1.UPS) updateTimer = TimeSpan.Zero; (…) // Aktualisieren Sie die Zellen und wenden Sie die Regeln an. 

Jetzt sollten Sie in der Lage sein, das Spiel mit beliebiger Geschwindigkeit auszuführen, selbst mit sehr langsamen 5 Aktualisierungen pro Sekunde. So können Sie die Entwicklung Ihrer Simulation sorgfältig beobachten und gleichzeitig bei einer festen Bildfrequenz schöne glatte Linien zeichnen.


Fazit

Sie haben jetzt ein reibungsloses und funktionales Game of Life in der Hand, aber wenn Sie es näher erkunden möchten, gibt es immer mehr Optimierungen, die Sie hinzufügen können. Zum Beispiel geht das Gitter derzeit davon aus, dass über die Ränder hinaus alles tot ist. Sie könnten es so modifizieren, dass sich das Gitter dreht, sodass ein Segelflugzeug für immer fliegen würde! An Variationen dieses beliebten Spiels mangelt es nicht, lassen Sie Ihrer Fantasie freien Lauf.

Danke fürs Lesen, und ich hoffe, Sie haben heute einige nützliche Dinge gelernt!