22  Kugelspiel

Lasst uns ein Spiel spielen! Die Grundelemente einer Programmiersprache zu kennen, heisst noch lange nicht, dass man in der Lage ist, sie einzusetzen. Daher möchten wir uns in dieser Übung einer Anwendung widmen.

Vorbereitung:

  1. Einen leeren Ordner erstellen
  2. Eine virtuelle Umgebung .venv erstellen und sicherstellen, dass sie aktiv ist
  3. Package pygame installieren pip install pygame
  4. Erstellen eines neuen Skripts “CircleApp.py” mit nachfolgendem Inhalt.
  5. Code verstehen!
import pygame
import sys

# Initialisierung von Pygame
pygame.init()

# Fenstergröße und Farben (RBG) 
WIDTH, HEIGHT = 800, 600
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)

# Fenster erstellen
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Circles, everywhere are circles...")

# Spiel-Loop
running = True

while running:
    # Ereignisse überprüfen (Tasten, Mouse ...)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Bildschirm leeren 
    screen.fill(BLACK)

    # Objekte platzieren
    # ...
    pygame.draw.circle(screen, WHITE, (100, 100), 30)

    # Aktualisieren
    pygame.display.flip()
    
    # Begrenze die Bildrate
    pygame.time.Clock().tick(60)

# Pygame beenden
pygame.quit()
sys.exit()

22.1 Bringen wir Bewegung ins Spiel!

Als erstes möchten wir das Template erweitern, damit sich die Kugel vertikale bewegt und die Richtung jeweils am Rand ändert. Für diese Erweiterung ist folgender Codeausschnitt interessant:

    # Objekte platzieren
    # ...
    pygame.draw.circle(screen, WHITE, (100, 100), 30)

(100,100) ist ein Tupel und beschreibt die X und Y Position einer Kugel auf dem Screen. Die Position 0,0 liegt dabei in der linken, oberen Ecke. Betrachten wir das Template etwas näher, stellen wir fest, dass der Inhalt im Screen immer wieder neu gezeichnet wird.

    # Bildschirm leeren
    screen.fill(BLACK)

    # Objekte platzieren
    # ...
    # ... 
    # ...

    # Aktualisieren
    pygame.display.flip()
    
    # Begrenze die Bildrate
    pygame.time.Clock().tick(60)

Diesen Aufbau machen wir uns für die Bewegung zu nutzen, in dem wir jeden Tick die Kugel löschen und etwas weiter erneut zeichnen.

22.1.1 Vertikale Bewegung

  1. Ersetzen wir die Y Komponente im Tupel durch eine Variable und inkrementieren sie jedesmal, wenn der Screen neu gezeichnet wird –> (100, y). Der Wert des Inkrements beschreibt dabei die Veränderung pro Tick, was der Geschwindigkeit entspricht.
  2. Vor dem Loop definieren wir die Positionsvariable y mit Startwert 100 und vy als Geschwindigkeit mit 3.
  3. Im Loop updaten wir die Variablen dementsprechend
# Variablen vor dem Loop
y = 100
vy = 3

while running:
    # Ereignisse überprüfen (Tasten, Mouse ...)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Bildschirm leeren 
    screen.fill(BLACK)

    # Objekte platzieren
    # ...
    y = y + vy
    pygame.draw.circle(screen, WHITE, (100, y), 30)

    # Aktualisieren
    pygame.display.flip()
    
    # Begrenze die Bildrate
    pygame.time.Clock().tick(60)

22.1.2 Kollission erkennen

Damit die Kugel nicht den Screen verlässt, möchten wir sicherstellen, dass sie am Rand jeweils die Richtung ändert. Dafür prüfen wir, ob die Position plus dem Kugelradius, die Screenhöhe übersteigt. Ist dies der Fall, invertieren wir die Geschwindigkeitskomponente.

    # Objekte platzieren
    # ...
    y = y + vy

    # Kollision Y erkennen
    if y + 30 >= HEIGHT:
        vy = vy * -1
    elif y <= 30:
        vy = vy * -1

    pygame.draw.circle(screen, WHITE, (100, y), 30)

22.1.3 Integration von X

Nach dem gleichen Prinzip kann die X Komponente integriert werden. Führen wir also analog eine x und vx Variable ein und ergänzen die Kollisionserkennung.

# Variablen vor dem Loop
y = 100
x = 100
vy = 3
vx = 1

while running:
    # Ereignisse überprüfen (Tasten, Mouse ...)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Bildschirm leeren 
    screen.fill(BLACK)

    # Objekte platzieren
    # ...
    y = y + vy
    x = x + vx

    # Kollision Y erkennen
    if y + 30 >= HEIGHT:
        vy = vy * -1
    elif y <= 30:
        vy = vy * -1

    # Kollision X erkennen
    if x + 30 >= WIDTH:
        vx = vx * -1
    elif x <= 30:
        vx = vx * -1

    pygame.draw.circle(screen, WHITE, (x, y), 30)
Figure 22.1: Circle Game X,Y

22.2 Eine zweite Kugel

Unsere Anwendung kann eine Kugel bewegen, soweit so gut. Aber wie sieht es mit einer weiteren aus? Selbstverständlich möchten wir unsere Logik nicht unnötig kopieren, um auch eine 3,4 oder 100ste Kugel zu bewegen.

22.2.1 Nutzen von Funktionen

Im Beispiel einer einzelnen Kugel gibt es zwei interessante Code Bereiche. Das Positionsupdate und die Kollisionserkennung mit dem Rand. Damit wir die minimale Logik nur einmal programmieren müssen, packen wir diese in die Funktionen update_pos und impact. Dies könnte wie folgt aussehen:

# Variablen vor dem Loop
y = 100
x = 100
vy = 3
vx = 1

def update_pos(current_pos, current_speed):
    return current_pos + current_speed

def impact(current_pos, max_screen, circle_r):
    return current_pos + circle_r >= max_screen or current_pos <= circle_r

while running:
    # Ereignisse überprüfen (Tasten, Mouse ...)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Bildschirm leeren 
    screen.fill(BLACK)

    # Objekte platzieren
    # ...
    y = update_pos(y, vy)
    x = update_pos(x, vx)

    # Kollision Y erkennen
    if impact(y, HEIGHT, 30):
        vy = vy * -1

    # Kollision X erkennen
    if impact(x, WIDTH, 30):
        vx = vx * -1

    pygame.draw.circle(screen, WHITE, (x, y), 30)

22.2.2 Implementierung einer 2. Kugel

Das nachfolgende Code Segment zeigt die relevanten Änderungen für eine 2. Kugel. Die Implementierung ist sehr einfach, da jede Funktion für sich abgeschlossen ist. Das bedeutet, dass jede Funktion alle nötigen Informationen bekommt, um darauf das neue Resultat zu berechnen. Dementsprechend muss das Resultat ausserhalb gespeichert werden. Was in unserem Beispiel durch die Variablen vor dem Loop passiert. Wirklich einfacher, wirkt unsere Anpassung nicht, speziell wenn man an 5 weitere Kugeln denkt. Es ist aber einfach zu verstehen (Prinzip Funktionaler Programmierung).

# Variablen vor dem Loop
y = 100
x = 100
vy = 3
vx = 1

y2 = 200
x2 = 200
vy2 = 2
vx2 = 3

def update_pos(current_pos, current_speed):
    return current_pos + current_speed

def impact(current_pos, max_screen, circle_r):
    return current_pos + circle_r >= max_screen or current_pos <= circle_r

while running:
    # Ereignisse überprüfen (Tasten, Mouse ...)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Bildschirm leeren 
    screen.fill(BLACK)

    # Objekte platzieren
    # ...
    y = update_pos(y, vy)
    x = update_pos(x, vx)

    y2 = update_pos(y2, vy2)
    x2 = update_pos(x2, vx2)

    # Kollision Y erkennen
    if impact(y, HEIGHT, 30):
        vy = vy * -1

    # Kollision X erkennen
    if impact(x, WIDTH, 30):
        vx = vx * -1

    # Kollision Y2 erkennen
    if impact(y2, HEIGHT, 30):
        vy2 = vy2 * -1

    # Kollision X2 erkennen
    if impact(x2, WIDTH, 30):
        vx2 = vx2 * -1    

    pygame.draw.circle(screen, WHITE, (x, y), 30)
    pygame.draw.circle(screen, WHITE, (x2, y2), 30)

22.2.3 Klasse und Objekt Kugel

Betrachten wir die angelegten Variablen im vorangegangenen Beispiel etwas genauer, stellen wir fest, dass diese eine Kugel beschreiben. Die Position, Geschwindigkeit und Radius sind demnach Eigenschaften (Properties), die zum Nomen Kugel gehören, die aber für jede Kugel anders sein können. Genauso gibt es Funktionen die sich auch immer auf eine spezifische Kugel beziehen, wie das Updaten der Position oder das Prüfen der Kollision.

Zusammengefasst: Die Variablen vor dem Loop sind der Speicher der Eigenschaften, die eine spezifische Kugel beschreiben. Die wiederverwendbaren Funktionen erlauben das definierte verändern der Kugel-Eigenschaften. Beides macht ohne das andere keinen Sinn, demnach stehen diese in Abhängigkeit zu einander.

Hinweis: Als Beispiel einer anderen Funktion wäre die Sinus Funktion (Math.sin(x)) - Diese berechnet unabhängig von allem anderen, den Sinus der Variable x.

Diesen Zusammenhang möchten wir in einem Schema (Klasse) festhalten. Dazu legen wir in unserer App die Klasse Circle an und definieren die ersten Eigenschaften:

# Klasse circle
class Circle:

    def __init__(self, x, y, vx, vy, r = 1):
        """ 
        Die Methode __init__ wird aufgerufen, wenn eine Instanz ins Leben gerufen wird. In diesem Beispiel, wenn eine Kugel "gebaut" wird.

        self - Beschreibt dabei die neue Instanz
        Die Argumente x, y, vx, vy, r sind "Startwerte" die der Instanz übergeben werden.

        Die Übergebenen Argumente sollen in der entsprechenden Instanz gespeichert werden, daher self._x
        Der Unterstrich "_" ist dabei eine empohlene Konvention von Variablen, die innerhalb der Klasse bleiben sollen.

        Argumente können bei der Übergabe vorbelegt werden. Dadurch erhalten sie einen Default Wert und werden zu Optionalen Argumenten.
        In diesem Beispiel ist der Radius 'r' optional - Wird er nicht angegeben, wird er als 1 angenommen.
        
        x, y = Position
        vx, vy = Geschwindigkeit
        r = Radius
        """
        self._x = x
        self._y = y
        self._vx = vx
        self._vy = vy 
        self._r = r

Die Kugel enthält die aktuelle Position wie auch die Geschwindigkeit. Demnach müssen wir der Kugel mitteilen, wann sie die Position updaten soll. Dazu erweitern wir die Klasse mit update_pos. Self beschreibt dabei wieder, dass die Methode an die Instanz gebunden ist. Habe ich also eine Kugel k1 und k2, bezieht sich der Aufruf von k1.update_pos() nur auf die Kugel k1.

def update_pos(self):
        """
        Ein Positionstick
        """
        self._x = self._x + self._vx
        self._y = self._y + self._vy

Mit dieser Methode können wir die Position updaten, haben aber noch keine Kugel an der neuen Position gezeichnet. Für dies gibt es mehrere Wege, man könnte z.B. eine Screenverwalter Klasse bauen, die immer alle Kugeln einzeichnet und auch die Kollisionen mit dem Rand prüft. In diesem Beispiel möchten wir dies aber direkt in der Klasse Kugel erledigen. Somit müsste folgende Zeile in die Klasse Kugel integriert werden. Die Paramter x, y und Radius sind in der Klasse bereits verfügbar, der Screen und die Farbe WHITE aber noch nicht.

pygame.draw.circle(screen, WHITE, (x, y), 30)

Der Screen ist keine Variable, die jeder Kreis bereits bei der Erstellung kennen muss. Es reicht, wenn diese beim Update der Position vorhanden ist. Daher können wir unsere update_pos() Methode wie folgt erweitern:

def update_pos(self, screen):
    """
    Ein Positionstick
    """
    self._x = self._x + self._vx
    self._y = self._y + self._vy

    pygame.draw.circle(screen, WHITE, (self._x, self._y), self._r)

Für den gleichen Funktionsumfang fehlt noch die Kollisionserkennung mit dem Rand. Da die Screen Variable bereits bekannt ist, könnte man eine interne Methode _screen_collission() anlegen, die ebenfalls im update_pos() berücksichtigt wird. Dieser Methode ist ein “_” vorangestellt, um anzuzeigen, dass diese Methode Klassen intern verwendet wird und nicht für die Benützung von ausserhalb gedacht ist.

Hinweis: Es gibt ebenfalls “__” um interne Methoden zu machen. Dies verhindert aber die Verwendung gänzlich, was für Tests oder kleine Anpassungen mühsam sein kann.

def update_pos(self, screen):
    """
    Ein Positionstick
    """
    self._x = self._x + self._vx
    self._y = self._y + self._vy

    self._screen_collision(screen)

    pygame.draw.circle(screen, WHITE, (self._x, self._y), self._r)

def _screen_collision(self, screen):
    """
    Check for Screen collisions and invert speeds
    """
    if (self._x + self._r >= screen.get_width() or
       self._x <= self._r):
        self._vx = self._vx * -1

    if (self._y + self._r >= screen.get_height() or
       self._y <= self._r):
        self._vy = self._vy * -1

Unsere Klasse sollte nun den gleichen Funktionsumfang haben wie vorhin. Passen wir also die Ausführung an und legen zwei Kugeln an. Bereits jetzt ist ersichtlich, dass der Hauptaufruf deutlich übersichtlicher geworden ist.

# Erstellen von zwei Objektinstanzen k1,k2 von der Klasse Circle
k1 = Circle(100, 100, 3, 2, 30)
k2 = Circle(200, 200, 1, 3, 30)

while running:
    # Ereignisse überprüfen (Tasten, Mouse ...)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    # Bildschirm leeren 
    screen.fill(BLACK)

    # Objekte platzieren
    # ...
    k1.update_pos(screen)
    k2.update_pos(screen)

    # Aktualisieren
    pygame.display.flip()
    
    # Begrenze die Bildrate
    pygame.time.Clock().tick(60)

22.3 Kugeln, überall Kugeln (Events)

Das Grundgerüst steht, aber es fehlen noch Kugeln, vieeeeeele Kugeln. Bei jedem Mausklick soll eine weitere Kugel in den Screen integriert werden. Dazu wird die bestehende Eventstruktur wie folgt erweitert:

# Ereignisse überprüfen (Tasten, Mouse ...)
for event in pygame.event.get():
    if event.type == pygame.QUIT:
        running = False
    elif event.type == pygame.MOUSEBUTTONUP:
        # Kugel hinzufügen
        print("Clicked!")

Bei jedem Klick wird nun “Clicked” in der Konsole angezeigt. An dieser Stelle soll nun jeweils eine neue Kugel eingefügt werden. Das Problem ist nun, dass wir für das Update des Screens alle Kugeln kennen müssen. Aktuell wird k1.update_pos(..) und k2.update_pos(..) hart codiert aufgerufen, dies müsste sich aber bei jeder neuen Kugel erweitern zu k3, k4, k5 usw. Damit dies möglich ist, wird ein gemeinsames Gefäss benötigt, eine Liste. Die beiden zuerst erstellen Objektinstanzen k1 und k2 werden dieser hinzugefügt, wie auch jede weitere durch den Klick-Event.

# k1 und k2 in die Liste einfügen
k_list = [k1, k2]

while running:
    # Ereignisse überprüfen (Tasten, Mouse ...)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.MOUSEBUTTONUP:
            # Kugel hinzufügen
            neue_kugel = Circle(100,100,3,2,30)
            k_list.append(neue_kugel)

Was nun noch fehlt, ist das update der Position. Daher iterieren wir durch die Liste und ersetzen damit die hart codierten k1/k2 Aufrufe:

# Objekte platzieren
# ...
for k in k_list:
    k.update_pos(screen)

22.3.1 Random Startgeschwindigkeit

Voila, wir können durch Klicken neue Kugeln hinzufügen. Damit es aber etwas spannender wird, möchten wir Random Startgeschwindigkeiten verwenden. Dazu importieren wir das Modul random und ergänzen unseren Event mit der Methode randint. Diese pickt “random” aus dem angegebenen Bereiche eine Integer Variable:

elif event.type == pygame.MOUSEBUTTONUP:
    # Kugel hinzufügen
    vx = random.randint(-5,5)
    vy = random.randint(-5,5)
    neue_kugel = Circle(100,100,vx,vy,30)
    k_list.append(neue_kugel)

22.3.2 Definierter Zugriff auf Eigenschaften

Langsam nimmt unsere Klasse Form an. Bisher haben wir interne Eigenschaften festgelegt und diese über Methoden (Funktionen die an ein Objekt gebunden sind) verändert. Aus Umgebungen wie Java kennt man Getter und Setter um auf interne Eigenschaften zuzugreifen. In unserem Fall würde das bedeuten, dann wir z.B. eine Methode get_pos_x(self) anlegen, die die aktuelle X Position self._x zurückgibt. Methoden sind aber eigentlich “Aktionen” (Verben) und daher weniger sinnvoll für einfache Variablenzugriffe. Ein Konstrukt, dass dies aber ermöglicht sind Properties (Gleich wie in C#). Dabei wird ähnlich einer Methode, eine Property in der Klasse angelegt, um einen definierten Variablenzugriff zu ermöglichen. Als Beispiel im Folgenden die Eigenschaften unserer Klasse:

@property
def x(self):
    return self._x

@property
def y(self):
    return self._y

@property
def vx(self):
    return self._vx

@property
def vy(self):
    return self._vy

@property
def r(self):
    return self._r

Damit haben wir für x,y,vx,vy und r definiert, dass diese von ausserhalb genutzt werden können. Ausserdem könnte man so auch ein Log-Statement einfügen, um den Zugriff auf festzuhalten. In der aktuellen Definition liegen die Eigenschaften als Read-Only vor. Für unsere Eigenschaften macht dies vermutlich auch Sinn. Aber nehmen wir an, wir möchten an einem bestehenden Objekt den Radius oder die Geschnwidkeit ändern. Dafür müssten wir eine Set-Property definieren (Nur möglich, wenn die Get-Property bereits vorhanden ist):

@property
def vx(self):
    return self._vx

@vx.setter
def vx(self, value):
    self._vx = value

@property
def vy(self):
    return self._vy

@vy.setter
def vy(self, value):
    self._vy = value

@property
def r(self):
    return self._r

@r.setter
def r(self, value):
    self._r = value

Damit können wir den z.B. Radius wie folgt verwenden:

k1 = Circle(...)

if k1.r == 15:
  ....
  
k1.r = 99

22.4 Kollisionserkennung

Interessanter wird das Spiel, wenn es auch Kollisionen zwischen Kugeln geben kann. Dies ist eher ein physikalische als ein Programmierproblem. Dennoch versuchen wir es mal!

22.4.1 Einbinden von Hilfsfunktionen

Für die Kollision gehen wir von einem idealen, elastischen Stoss aus. Das bedeutet, dass die Energie komplett erhalten bleibt und bei der Bewegung auch keine Reibungs oder Trägheitseffekte wirken.

Auf nachfolgender Seite, findet man die Herleitung der einzelnen Formeln:

https://hermann-baum.de/bouncing-balls/

Für unseren Fall, möchten wir ein eigenes Modul nutzen. Wir legen also ein Skript im selben Ordner wie unsere App ab und nennen es “circle_util.py”:

def impact(diff_x, diff_y, c1_r, c2_r):
    """
    Prüft ob sich zwei Kugeln berühren
    """
    return (diff_x**2 + diff_y**2) <= (c1_r + c2_r)**2

def determine_angle_between_circles(c1_x, c1_y, c2_x, c2_y):
    """
    Bestimmt den Winkel vom Schwerpunkt zweier Kugeln
    """
    import math
    if c2_x == c1_x:
        if c2_y > c1_y:
            return math.pi/2
        else:
            return -math.pi/2
    else:
        return math.atan((c2_y-c1_y)/(c2_x-c1_x))

def determine_collision_speeds(c1_vx, c1_vy, c2_vx, c2_vy, angle):
    """
    Berechnet die neuen Geschwindigkeiten nach einer Kollision zwischen 2 Kugeln unter
    einem Winkel angle.
    """
    (new_c1_vx, new_c1_vy) = calculate_object_speed_xy_after_collision(c1_vx, c1_vy, c2_vx, c2_vy, angle)
    (new_c2_vx, new_c2_vy) = calculate_object_speed_xy_after_collision(c2_vx, c2_vy, c1_vx, c1_vy, angle)

    return (new_c1_vx, new_c1_vy, new_c2_vx, new_c2_vy)

def calculate_object_speed_xy_after_collision(v1x, v1y, v2x, v2y, angle):
    import math
    calc_self_x =  ((v1x * math.sin(angle)) + (v1y * math.cos(angle))) * math.sin(angle)
    calc_other_x = ((v2x * math.cos(angle)) + (v2y * math.sin(angle))) * math.cos(angle)
    speed_x = calc_self_x + calc_other_x

    calc_self_y =  ((-v1x * math.sin(angle)) + (v1y * math.cos(angle))) * math.cos(angle)
    calc_other_y = ((v2x * math.cos(angle)) + (v2y * math.sin(angle))) * math.sin(angle)
    speed_y = (calc_self_y + calc_other_y) 

    return (speed_x, speed_y)

In unserem App importieren wir dieses Modul, um alle Funktionen nutzen zu können:

import circle_util

# Beispiel
circle_util.determine_angle_between_circles(..)

22.4.2 Kollisionserkennung

Die Kollisionerkennung zwischen Kugeln, kann nur sehr mühsam in die Kugel Klasse integriert werden, da eine Kollision immer mehr als eine Kugel betrifft. Aus diesem Grund prüfen wir nach dem Positionsupdate aller Kugeln, ob sich zwei Kugeln berühren. Dafür wird geprüft, ob die Distanz zwischen beiden Kugelnmittelpunkten kleiner ist, als die Summe beider Radien.

Hinweis: Diese Funktion steht uns bereits in circle_util.py zur Verfügung.

In der Umsetzung müssen wir nun jeden Tick, jede Kugel mit jeder anderen Prüfen, solange die andere nicht die gleiche ist. Klingt etwas kompliziert, aber kurz um, loopen wir zwei mal durch die Kugelliste:

# Prüfe Kollision
for k1 in k_list:
    for k2 in k_list:
        # Prüfen ob K1 = K2, dann Iteration überspringen
        if k is k2:
            continue

        diff_x = k2.x - k1.x
        diff_y = k2.y - k1.y

        impact = circle_util.impact(diff_x, diff_y, k1.r, k2.r)

Wird eine Kollision erkannt, wird der Aufprallwinkel bestimmt, um die neuen Geschwidigkeiten nach dem Aufprall zu bestimmen:

# Objekte platzieren
# ...
for k in k_list:
    k.update_pos(screen)

# Prüfe Kollision
for c1 in k_list:
    for c2 in k_list:
        # Prüfen ob K1 = K2, dann Iteration überspringen
        if c1 is c2:
            continue

        diff_x = c2.x - c1.x
        diff_y = c2.y - c1.y

        impact = circle_util.impact(diff_x, diff_y, c1.r, c2.r)

        if impact:
            angle = circle_util.determine_angle_between_circles(c1.x, c1.y, c2.x, c2.y)
            (new_c1_vx, new_c1_vy, new_c2_vx, new_c2_vy) = circle_util.determine_collision_speeds(c1.vx, c1.vy, c2.vx, c2.vy, angle)
            c1.vx = new_c1_vx
            c1.vy = new_c1_vy
            c2.vx = new_c2_vx
            c2.vy = new_c2_vy
            c1.update_pos(screen)
            c2.update_pos(screen)

Die Kollisionserkennung ist zwar nicht über alles erhaben, aber aktuell sollte die meisten Kollisionen erkannt werden.

22.4.3 Problem Schrittweite

Der aktuelle Ansatz prüft ob eine Kollision stattgefunden hat und wenn ja, wird eine neue Geschwindigkeit bestimmt. Wenn man sich aber nun die Schrittweite einer Kugel überlegt, ist dies gar nicht so simpel. Im aktuellen Beispiel, wird der Screen 60 Mal pro Sekunde erneuert. Somit bewegt sich eine Kugel alle 1/60 um den Geschwindigkeitswert vx, vy weiter. Somit kann es vorkommen, dass zwei Kugeln sich nicht berühren und 1/60 s später sich aber bereits überschneiden. Die obige Kollisionserkennung reagiert und passt die Geschwindigkeit an. Nun kann es aber sein, dass wieder 1/60s später diese Überschneidung immernoch vorhanden ist, und die Geschwindigkeit wird erneut berechnet. Das sieht dann in der Übersicht sehr komisch aus. Man könnte das schöner Berechnen, aber ein simpler Trick, der die Situation etwas verbessert ist eine Schätzung. Wir führen zwei geschätze Positionen nach weiteren 1/60s ein (x_est und y_est). Somit wird eine Kollision eher zu früh, als zu spät erkannt. Es ist aber sichergestellt, dass es keine Überschneidungen gibt:

# Prüfe Kollision
for c1 in k_list:
    for c2 in k_list:
        # Prüfen ob K1 = K2, dann Iteration überspringen
        if c1 is c2:
            continue

        diff_x = c2.x_est - c1.x_est
        diff_y = c2.y_est - c1.y_est

        impact = circle_util.impact(diff_x, diff_y, c1.r, c2.r)

        if impact:
            angle = circle_util.determine_angle_between_circles(c1.x_est, c1.y_est, c2.x_est, c2.y_est)
            (new_c1_vx, new_c1_vy, new_c2_vx, new_c2_vy) = circle_util.determine_collision_speeds(c1.vx, c1.vy, c2.vx, c2.vy, angle)
            c1.vx = new_c1_vx
            c1.vy = new_c1_vy
            c2.vx = new_c2_vx
            c2.vy = new_c2_vy
            c1.update_pos(screen)
            c2.update_pos(screen)

Ebenso können die geschätzten Positionen bei der Randerkennung benutzt werden:

def _screen_collision(self, screen):
    """
    Check for Screen collisions and invert speeds
    """
    if (self.x_est + self._r >= screen.get_width() or
       self.x_est <= self._r):
        self._vx = self._vx * -1

    if (self.y_est + self._r >= screen.get_height() or
       self.y_est <= self._r):
        self._vy = self._vy * -1

22.5 Übungen / Erweiterungen

  1. Durch Klick-Events könnte eine Kugel die verschwinden oder die Richtung oder Farbe ändern.
  2. Mit Pfeiltasten könnten alle Kugeln schneller oder langsamer gemacht werden
  3. Mit Klicken kann eine Kugel ausgewählt werden, die dann per Pfeiltasten gesteuert werden kann.
  4. Es erscheint eine Anzahl Kugeln mit unterschiedlichen Grössen und man hat eine gewisse Zeit sie anzuklicken.
  5. Schwerkraft: Die Schwerkraft zieht die Kugeln nach unten und sie Stapeln sich langsam auf.
  6. Kugeln die sich treffen verschwinden
  7. Kugeln zeigen ihre aktuelle Geschwindigkeit als Wert oder Vektor
  8. usw.