Einstieg in die Objektorientierung mit einer einfachen Planetensimulation in Pygame

https://oer-informatik.de/python_pygame_oop

tl/dr; (ca. 12 min Lesezeit): Eine Variable auf der einen Seite. Eine Funktion auf der anderen Seite. Viele Probleme lassen sich so darstellen. Manchmal ist es aber gerade die Verknüpfung von beidem (die Objektorientierung), die die Programmierung erstaunlich einfach macht. Am Beispiel einer Planetensimulation mit Hilfe von Pygame soll das Konzept der Objektorientierten Programmierung greifbar gemacht werden.

Der Ablauf einer Game-Engine und das Framework pygame

Bevor wir in die Objektorientierung abtauchen, wollen wir einen kurzen Blick auf den Aufbau einer Gameengine werfen. Ziel soll es schließlich sein, eine kleine objektorientierte Planetensimulation zu erstellen.

Gameengines nutzen häufig einen Ablauf, den man in zunächst zwei Bereiche gliedern kann: Die Initialisierung und darauf folgend eine Schleife, in der immer die gleichen Schritte wiederholt werden. Als Programmablaufplan sieht das etwa so aus:

Programmablaufplan einer Gameengine, mit den Unterprozessen init() setup() und der darauffolgenden Schleife von events() update() und draw().
Programmablaufplan einer Gameengine, mit den Unterprozessen init() setup() und der darauffolgenden Schleife von events() update() und draw().

Die einzelnen Unterprogramme haben häufig die folgenden Funktionen:

  • setup(): Alle nötigen Vorarbeiten werden geleistet, um das Programm zu starten: Defaultwerte werden gesetzt und alle Operationen werden ausgeführt, die genau einmal zu Beginn erledigt werden müssen.

  • Danach folgt die Schleife, die solange durchlaufen wird, bis das Programm beendet wird. Darin im Wesentlichen drei Arten von Operation, die sich in diesen drei Funktionen zusammenfassen lassen:

    • events(): Welche Ereignisse müssen ausgewertet werden? Hier wird z.B. nach Maus- und Tastertureingaben gesucht. Nach dem E.V.A.-Modell entspricht das der Eingabe.

    • update(): Hierin befindet sich die eigentliche Spiellogik: Änderung von Positionen werden berechnet, Wertungen vorgenommen. Nach dem E.V.A.-Modell also die Verarbeitung.

    • draw(): Hier werden schließlich die einzelnen Objekte gezeichnet. Folglich ist dies die Ausgabe gemäß E.V.A.-Modell.

Das Programm selbst in der Kurzübersicht besteht aus vier aufeinanderfolgende Phasen:

Wir importieren pygame,

deklarieren global genutzte Variablen,

implementieren die nötigen Funktionen

und erstellen unten schließlich den eigentlichen Programmaufruf.

Ein einfaches Programm, dass beispielsweise einen Ball an den Rändern des Fensters abprallen lässt, könnte so aussehen:

Beispiel 1 (ohne Objekte): Ein abprallender Ball mit pygame

Ein einfaches Beispiel bildet die Grundlage vieler Spiele: Abprallen an den Rändern:

Ein gelber, an den Rändern des Bilds abprallender Ball
Ein gelber, an den Rändern des Bilds abprallender Ball

Wir benötigen als globale Variablen zusätzlich zu unserem Beispiel oben noch die Koordinaten des Balls und die Geschwindigkeit in jede Richtung:

In der setup()-Funktion wird das pygame-Framework initialisiert und das eigentliche Spielfenster erstellt (screen). Weil wir auf screen auch außerhalb dieser Funktion zugreifen müssen, wird es als global deklariert (wir werden später noch elegantere Wege kennenlernen).

In der Methode events() sollen später Tastatur- und Mausereignisse überprüft werden. Vorerst langt uns, das Spiel zu beenden, wenn das Spielfenster geschlossen wird:

Jetzt kommt die eigentliche Logik: die update()-Methode berechnet neue Werte für unsere Koordinaten und Geschwindigkeiten - daher muss sie auf diese vier Variablen schreibend zugreifen. Wir lösen das wieder über global. Darauf folgt die Änderung der Position um den als speed angegebenen Wert (pos_x += speed_x). Anschließend wird geprüft, ob der Fensterrand erreicht wurde. Falls der Ball den Rand berührt wird die Bewegungsrichtung umgekehrt (speed_x *= -1).

Die draw()-Funktion zeichnet den Bildschirmhintergrund zunächst schwarz (fill()), zeichnet dann den Ball an die aktuellen Koordinaten (.circle()) und aktiviert schließlich das neue display (flip()).

Das eigentliche Hauptprogramm ist unverändert zum obigen Codebeispiel:

Damit ist der Grundaufbau erstellt. Jetzt kann es daran gehen, in unser Spiel Objekte aufzunehmen.

Beispiel 2 - jetzt wird’s objektorientiert: Die pygame-Planetensimulation

Werfen wir zunächst einen Blick auf die fertige Simulation und versuchen zu beschreiben, was wir sehen.

Planetensimulation, bei der sich ein blauer Kreis (die Erde) um einen gelben Kreis (die Sonne) dreht
Planetensimulation, bei der sich ein blauer Kreis (die Erde) um einen gelben Kreis (die Sonne) dreht

Das es eine einfache Planetensimulation wird, hatte ich ja bereits gesagt. Welche Eigenschaften hat der blaue Planet - die Erde? Ein paar Dinge lassen sich leicht beobachten:

  • Der Planet hat einen Radius.

  • Die Umlaufbahn des Planeten hat einen Radius.

  • Er bewegt sich auf der Umlaufbahn.

  • Er bewegt sich mit einer bestimmten Geschwindigkeit.

  • Er hat eine definierte Farbe.

  • Er hat eine definierte Form.

Was wir hier beschrieben haben ist zweierlei: Zustand auf der einen Seite und Verhalten auf der anderen Seite. Das Verhalten ändert oder nutzt jeweils einen bestimmten (zugehörigen) Zustand - move() verändert z.B. die Position eines konkreten Planeten. Wir verknüpfen die Variablen, die den Zustand beschreiben, und die Funktionen, die einen konkreten Zustand nutzen oder anpassen in einer neuen Einheit: einer Klasse.

Eine Klasse gibt nur die Struktur vor, wie eine konkrete Instanz gebaut werden muss - im Planetenbeispiel:

Planet
Planet

Dieser Bauplan eines Planeten ist allgemein formuliert und nicht für einen spezifischen Planeten. Diesen Bauplan nennt man in der Objektorientierung Klasse. Aus Klassen lassen sich konkrete, identifizierbare Instanzen erstellen: die Planeten erde, uranus und mars etwa. Diese Instanzen nennt man in der Objektorientierung Objekte. Jedes Objekt verfügt über einen eigenen Zustand (eine eigene Identität). Alle Objekte einer Klasse verfügen aber über das gleiche Verhalten (nutzen die gleichen Funktionen).

Die Variablen einer Klasse, die den Objektzustand speichern, nennt man Attribute. Sie werden in der zweiten Sektion des UML-Klassendiagramms notiert.

Die Funktionen einer Klasse, die die Attribute nutzen und ändern, nennt man Methoden. Sie werden in der untersten Sektion des UML-Klassendiagramms notiert.

Die Implementierung der Klasse Planet

Eine Instanz einer Klasse wird erstellt, in dem ich den Konstruktor aufrufe, und ihm alle gewünschten Parameter übergebe. In den meisten Programmiersprachen wird der Konstruktor aufgerufen mit einem Methodennamen, der wie die Klasse heißt. In Python wir ein neuer Planet z.B. über den folgenden aufruf instanziiert:

Natürlich müssen wir die Klasse selbst noch implementieren. Wir schreiben die relevanten Methoden der Klasse in eine eigene Suite, die wir mit class Planet überschreiben (Klassennamen werden i.d.R. groß geschrieben):

Es fällt auf, dass alle drei Methoden als ersten Parameter self übergeben bekommen: über die self-Refernz erhalten Methoden in Python Zugriff auf den Objektzustand. Alle Attribute (also Variablen des Objektzustands) werden über diese self-Referenz angesprochen.

Die Methode __init__() ist der Konstruktor der Klasse (in vielen anderen Programmiersprachen würde sie einfach Planet() heißen). Sie setzt den Ausgangszustand und legt fest, welche Parameter bei Objekterzeugung übergeben werden müssen.

Den Rumpf des Programms

Am Programmrumpf öndert sich im Vergleich zum ersten Beispiel nicht viel. Für die Kreisbewegung brauchen wir Sinus- und Cosinusfunktionen aus der Mathe-Bibliothek:

Darauf folgt wieder die Größe des Darstellungsfensters, danach die Planetenklasse (Quelltext siehe oben):

Ab jetzt werden nur noch Details angepasst: in der setup()-Methode werden die Planeten erzeugt:

Events haben wir zunächst noch gar keine weiteren - wir können also die Methode von oben übernehmen:

In der Update-Methode muss der Planet bewegt werden:

Und in der draw()-Methode wird die Sonne ins Zentrum und die erde an der jeweiligen Position gezeichnet:

Am Ende wird alles in gewohnter Weise aufgerufen:

Wir sind nicht allein im Weltall… neue Planeten in Listen speichern

Wenn das Programm erstmal läuft ist es ein leichtes, neben der Erde noch Merkur, Venus, Mars, Jupiter, Saturn, Uranus, Neptun… um die Erde kreisen zu lassen. Natürlich muss man beim Maßstab etwas mogeln, sonst sieht man nur schwarz…

Planetensimulation, diesmal mit vielen Planeten) um einen gelben Kreis (die Sonne) dreht
Planetensimulation, diesmal mit vielen Planeten) um einen gelben Kreis (die Sonne) dreht

Wenn viele Planeten erstellt werden sollen ist ganz schön nervig, wenn man alle einzeln aufrufen muss bei der Initialisierung, beim Updaten und beim Zeichnen.

Das lässt sich alles enrom vereinfachen, wenn wir alle Planeten in einer Liste (planeten) speichern, über die wir dann iterieren können. Die Liste erstellen wir zu Beginn einmal - also in der setup()-Funktion:

Fehlt nur noch die Anpassung der update()- und draw()-Funktion, die jeweils die entsprechenden Methoden aller Objekte in der Liste aufrufen müssen. Und schon können wir davon profitieren, dass wir Listen haben, durch die iteriert werden kann:

Kapselung der Attribute

Es ist guter Stil, nur die Attribute von Außen zugreifbar zu machen, die dort auch benötigt werden. Außerdem greift man nicht direkt auf die Attribute zu, sondern nutzt Getter- und Settermethoden. Der Weg der Wahl, dies in Python zu tun sind Properties (weitere Infos dazu finden sich hier).

Die Attribute werden in Python zur Kapselung versteckt (nur über den Klassennamen zugreifbar gemacht). Dazu muss jedem Attribut bei jedem Vorkommen ein doppelter Unterstrich vorangestellt werden. Zum Beispiel im Konstruktor sieht das so aus:

(in den anderen Methoden entsprechend.)

Zusätzlich müssen bei den Attributen, auf die von Außen zugegriffen werden soll, noch Properties erzeugt werden:

Das langt für einen lesenden Zugriff (und entspricht Getter-Methoden, ohne Setter-Methoden)

Monde erzeugen: Objektbeziehungen zwischen Planeten

Objekte können zueinander in Beziehungen stehen. Beispielsweise kann ein Planet um einen anderen kreisen (Astronomen mögen mir verzeihen, dass wir hier keine gesonderte Klasse Mond einführen). Die Klasse benötigt dazu im Konstruktor ein zusätzliches Attribut centerobject, das als Defaultwert die Mitte des Ausgabefensters hat:

Wenn das Zentrum des jeweiligen Planeten ein anderer Planet ist, müssen dessen Koordinaten jedes Mal zur Berechnung der neuen Position ausgelesen werden.

Schließlich muss noch ein neues Objekt erzeugt werden, dass um ein zweites kreist:

Fazit

Mithilfe einer Planetensimulation haben wir uns die Möglichkeit angesehen, mit Pygame eine einfache graphische Ausgabe zu erstellen. Mit der Gameloop haben wir nun alles an der Hand, um uns an die Entwicklung einfacher Spiele zu wagen.

  • Sehr lesenswert hierzu sind die Beiträge in Jörg Kantels Blog Schockwellenreiter, der in der Objektorientierung noch einen Schritt weiter geht, und auch die Oberfläche selbst zum Objekt macht (und andere interessante Artikel hat)

  • Die Website vom Framework PyGame

Quellen und offene Ressourcen (OER)

Die Ursprungstexte (als Markdown), Grafiken und zugrunde liegende Diagrammquelltexte finden sich (soweit möglich in weiterbearbeitbarer Form) in folgendem git-Repository:

https://gitlab.com/oer-informatik/python-basics/erste-schritte-mit-python

Sofern nicht explizit anderweitig angegeben sind sie zur Nutzung als Open Education Resource (OER) unter Namensnennung (H. Stein, oer-informatik.de) freigegeben gemäß der Creative Commons Namensnennung 4.0 International Lizenz (CC BY 4.0).

Creative Commons Lizenzvertrag


Hinweis zur Nachnutzung

Dieses Werk und dessen Inhalte sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Erste Schritte mit Python” von Hannes Stein, Lizenz: CC BY 4.0. Die Quellen dieses Werks sind verfügbar auf GitLab.

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: