© StonePictures/Shutterstock.com
Classic Games Reloaded

Neuronale Netzwerke spielen Tennis (Pong)


In diesem Artikel werden wir uns mit den Einsatzmöglichkeiten von diversen neuronalen Netzen im Rahmen des Spieleklassikers Pong auseinandersetzen und verschiedene Methoden evaluieren, mit deren Hilfe wir diese Netzwerke trainieren können. In diesem Zusammenhang richten wir unser Augenmerk einerseits auf die Steuerung der Schläger und andererseits auf die Wahrnehmung von bewegten und ruhenden Objekten innerhalb der Spielewelt.

Codebeispiel und Listings zum Download

Achtung: Das Beispielprojekt und die Listings zu diesem Artikel finden Sie in unserem GitHub Repository: https://github.com/EntwicklerMagazin/Entwickler-Magazin. Dort stehen auch die Codes zu den vorherigen Teilen der Serie bereit.

Der Autor stellt die Programmbeispiele außerdem unter www.graphics-and-physics-framework.spieleprogrammierung.net bereit.

Ungeachtet der Tatsache, dass es sich beim Spieleklassiker Pong bezüglich der Grafik und der Spielmechanik so ziemlich um das am einfachsten gestrickte Computerspiel handelt, besitzt es auch heutzutage noch zweifelsohne einen gewissen Unterhaltungswert. Gibt man sich, so wie in Abbildung 1 gezeigt, mit einer minimalistischen Grafik zufrieden, lässt sich besagtes Spiel beispielsweise in Form einer simplen Windows-Konsolenanwendung implementieren, die ohne zusätzliche Grafikbibliotheken lauffähig ist.

rudolph_spieleklassiker_1.tif_fmt1.jpgAbb. 1: Pong (Screenshot)

Ein einfacher Pong-Klon, der ohne großartigen Schnickschnack auskommt, kann selbst von einem Hobbyprogrammierer im Handumdrehen fertiggestellt werden. Auch die künstliche Intelligenz (KI), die für die Bewegungssteuerung eines der Schläger verantwortlich ist, lässt sich, wie nachfolgend demonstriert wird, mit Hilfe einiger weniger Source-Code-Zeilen implementieren:

  • Schläger in Richtung des Spielballs mitbewegen:

    PaddleVelocityX += PaddleAccelerationValue * (PlayBallPosX - PaddlePosX);
  • Geschwindigkeit begrenzen, mit der sich ein Schläger bewegen lässt:

    PaddleVelocityX = min(MaxAIPlayerPaddleVelocityX, PaddleVelocityX); PaddleVelocityX = max(MinAIPlayerPaddleVelocityX, PaddleVelocityX);
  • Bewegung des Schlägers ausklingen lassen:

    PaddleVelocityX *= PaddleDecelerationValue;
  • Neue Position des Schlägers berechnen:

    PaddlePosX += PaddleVelocityX;

Ein zweites Verfahren, mit dessen Hilfe sich die optimale Bewegungsrichtung eines Schlägers ermitteln lässt, können Sie Abbildung 2 entnehmen.

rudolph_spieleklassiker_2.tif_fmt1.jpgAbb. 2: Optimale Bewegungsrichtung des Schlägers ermitteln

Hierzu benötigen wir einerseits den Geschwindigkeitsvektor (v) des Spielballs und andererseits einen sogenannten Positionsdifferenzvektor (Ds), für dessen Berechnung wir die Position des Schlägermittelpunkts und die des Spielballs benötigen:

Ds = Position_PaddleCenter – Position_PlayBall;

Sofern der Winkel zwischen den Vektoren v und Ds gegen null geht, muss der Schläger nicht weiterbewegt werden, da sich der Spielball bereits auf den Schlägermittelpunkt zu bewegt. In allen anderen Fällen muss der Schläger in die Richtung bewegt werden, die zu einer Verkleinerung des besagten Winkels führt.

Aus der Perspektive eines KI-Entwicklers ist die Schlichtheit eines Spieleklassikers wie Pong gleich aus zweierlei Gründen von Vorteil: Zum einen bietet uns die extrem einfach strukturierte Spielewelt, die lediglich aus einem Ball und zwei Schlägern besteht, beste Voraussetzungen, um verschiedene Verfahren zur Erkennung von bewegten und ruhenden Objekten auszutesten, und zum anderen sollten selbst einfach gestrickte neuronale Netzwerke problemlos in der Lage sein, die jeweils bestmögliche Bewegungsrichtung eines Schlägers ermitteln zu können.

Gameplay

Da es sich bei Pong selbst im Vergleich zu anderen Klassikern um ein doch recht minimalistisches Spiel handelt, können wir den kompletten Spielablauf (Game Logic) wie gehabt innerhalb einer einfachen do-while-Spielschleife implementieren:

do // Beginn eines neuen Spiels { // [Game Logic und Visualisierung] } while (true); // Ende eines laufenden Spiels

Innerhalb der Spielschleife wird das Spielgeschehen 60-mal pro Sekunde aktualisiert. Oder anders ausgedrückt, für die Aktualisierung und Darstellung des Spielgeschehens stehen uns bei einer Framerate von 60 Bildern (Frames) pro Sekunde 16,7 Millisekunden (1000 ms/60 Frames pro Sekunde) bzw. 16 666 Mikrosekunden zur Verfügung. Um die Framerate auf 60 Hertz begrenzen zu können, müssen wir unmittelbar vor Beginn der Szenenberechnung eine erste Zeitmessung vornehmen:

auto last_timepoint = steady_clock::now();

Sobald Berechnung und Darstellung des aktuellen Frames abgeschlossen sind, müssen wir eine zweite Zeitmessung durchführen:

auto current_timepoint = steady_clock::now();

Die nun folgende while-Schleife fungiert als eine sogenannte Frame-Bremse. Die Programmausführung verharrt also so lange innerhalb der Schleife, bis die zeitliche Differenz zwischen den beiden Messpunkten current_timepoint und last_timepoint einen Wert von 16 666 Mikrosekunden überschreitet:

while (current_timepoint - last_timepoint < 16666us) { current_timepoint = steady_clock::now(); }

Kommen wir nun zur Aktualisierung des Spielgeschehens. Damit der KI-Spieler (AIPlayer) auf das Spielgeschehen reagieren kann, benötigt das für die Schlägersteuerung verantwortliche neuronale Netzwerk zunächst einmal Kenntnis über die Position des zu steuernden Schlägers sowie Informationen über die Position, Geschwindigkeit und Bewegungsrichtung des zu treffenden Spielballs:

AIPlayer.GameObjectDetection_And_VelocityCalculations(); Init_Or_Reset_fGameBoardArray();

Unter Zuhilfenahme der zuvor ermittelten Informationen können wir im nächsten Schritt die Position des vom menschlichen (HumanPlayerPaddle) und des vom KI-Spieler kontrollierten Schlägers aktualisieren:

HumanPlayerPaddle.HumanPlayer_SimpleMovement(...); AIPlayer.Calculate_NeuralNetPaddleMovement(...);

Im dritten Schritt wird die neue Position des Spielballs unter Berücksichtigung seiner momentanen Geschwindigkeit und Bewegungsrichtung berechnet (PlayBallPosX += PlayBallVelocityX; und PlayBallPosY += PlayBallVelocityY;):

PlayBall.Movement();

Im weiteren Verlauf müssen wir überprüfen, ob der Spielball von einem der beiden Schläger getroffen wird:

HumanPlayerPaddle.Handle_Possible_PlayBallCollision(...); AIPlayerPaddle.Handle_Possible_PlayBallCollision(...);

Im Fall einer Kollision berechnen wir die neue Geschwindigkeit und Flugrichtung des Spielballs unter Berücksichtigung des Kollisionspunkts mit Hilfe der beiden nachfolgend gezeigten Beziehungen:

  • Die vertikale Bewegungsrichtung kehrt sich um:

    PlayBallVelocityY *= -1.0f;
  • Sofern der Schläger nicht zentral getroffen wird (PlayBallPosX == PaddlePosX), wird der Spielball mit zunehmendem Ausmaß nach links oder rechts abgelenkt:

    PlayBallVelocityX += 0.3333f * (PlayBallPosX - PaddlePosX);

Hat der menschliche Spieler mit seinem Schläger den Ball verfehlt und dieser trifft auf die untere horizontale Spielfeldbegrenzung (Kollision, wenn PlayBallPosY > fConstGameBoardSizeYMinus1), wird die Spielwertung aktualisiert (Punkt für die KI) und ein neuer Ballwechsel initialisiert:

if (PlayBall.Check_Possible_LowerWallCollision() == true) { // [Spielwertung aktualisieren, Start eines neuen Ballwechsels] }

Wenn der KI-Spieler mit seinem Schläger den Ball verfehlt hat und dieser auf die obere horizontale Spielfeldbegrenzung trifft (Kollision, wenn PlayBallPosY < 1.0f), wird die Spielwertung aktualisiert (Punkt für den menschlichen Spieler) und ein neuer Ballwechsel initialisiert:

else if (PlayBall.Check_Possible_UpperWallCollision() == true) { // [Spielwertung aktualisieren, Start eines neuen Ballwechsels] }

Im Anschluss daran müssen wir noch überprüfen, ob sich eine Kollision des Spielballs mit einer der beiden seitlichen Spielfeldbegrenzungen ereignet hat, was im Fall der Fälle eine Umkehrung der horizontalen Bewegungsrichtung zur Folge hätte (PlayBallVelocityX *= -1.0f;):

PlayBall.Handle_Possible_WallCollisions();

Im nächsten Schritt treffen wir alle erforderlichen Vorbereitungen für die spätere Darstellung der Schläger und des Spielballs:

PlayBall.DrawIntoBuffer(); AIPlayerPaddle.DrawIntoBuffer(); HumanPlayerPaddle.DrawIntoBuffer();

Um sicherzustellen, dass der KI-Spieler die Position des Spielballs und die des eigenen Schlägers nachverfolgen kann, müssen wir die hierfür erforderlichen Daten überdies in einen zweiten Buffer (vom Typ float) schreiben:

PlayBall.fDrawIntoBuffer(); AIPlayerPaddle.fDrawIntoBuffer();

Im nächsten Schritt erfolgt zunächst die Darstellung des aktuellen Spielgeschehens (Frames) auf dem Bildschirm (Abb. 1):

Output_GameBoard(...);

I...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang