Python : Einführung in pygame


Einführung


Pygame ist eine Bibliothek in Python, die es erlaubt grafisch anspruchsvolle Spiele zu erstellen. Damit pygame effektiv eingesetzt werden kann, braucht es doch einiges an Basiswissen, das in diesem Blog vermittelt wird. Gerade für Spiele wird vielmals von Elementen der Künstlichen Intelligenz Gebrauch gemacht. Teilweise handelt es sich nur um offensichtliche KI-Routinen aber andererseits auch um hochkomplexe Algorithmen, die nicht so trivial zu verstehen sind. Vor etlichen Jahren habe ich ein Buch gekauft, mit dem ansprechenden Namen "AI for Game developers" von David M. Bourg und Glenn Seemann. Das Ziel war es, herauszufinden, wie einfach ein Spiel unter Java 5 realisiert werden könnte. Das ganze sollte ein Kundenprojekt werden, ist aber dann nie zustande gekommen, weil der Aufwand zu gross war, das Projekt mit den bestehenden Java Bibliotheken umzusetzen. Für meine weitere "Forschung" hinsichtlich KI habe ich dieses Buch wieder zur Hand genommen und es zeigt sich, dass mit pygame die dort vorgestellten Algorithmen erheblich leichter implementiert werden können, weil der Programmierer sich nicht mit komplexen Grafikroutinen, Gameloops, etc. herumschlagen muss. Ich hoffe, noch im Verlauf dieses Jahres einige in diesem Buch vorhandene Elemente auf meinem Blog zu veröffentlichen.

Übersicht pygame

Das Modul pygame hilft Entwicklern bei der Spieleentwicklung, indem es die Ausgabe von Grafiken und Musik erleichtert. Das Modul gehört nicht direkt zu Python, kann aber problemlos mit pip installiert werden. 

Hello World

Unten folgt das obligatorische "Hello World" Programm:
import pygame, sys
from pygame.locals import *


class HelloWorld:
    __windowsSurface = 0

    # color constants
    __BLACK = (0, 0, 0)
    __WHITE = (255, 255, 255)
    __RED = (255, 0, 0)
    __GREEN = (0, 255, 0)
    __BLUE = (0, 0, 255)

    # font
    __basicFont = 0

    # text to display
    __text = 'Hello world'

    # the rectangular text area
    __textRect = 0

    def __init__(self):
        pygame.init()
        # Einrichten der Schriftart
        self.__basicFont = pygame.font.SysFont(None, 48)
        self.__initWindow()
        self.__displayCaptions()
        self.__initText()

    def __initWindow(self):
        # Richtet das Fenster ein
        self.__windowsSurface = pygame.display.set_mode((500, 400), 0, 32)

    def __displayCaptions(self):
        # Setzt den Titel im Fenster
        pygame.display.set_caption(self.__text)

    def __initText(self):
        # Gibt ein gerendertes aber nicht visuell sichtbares textfeld zurueck mit
        # weisser Schriftfarbe und blauem Hintergrund
        t = self.__basicFont.render(self.__text, True, self.__WHITE, self.__BLUE)
        # Abfragen der Groesse des gerenderten Objekts
        self.__textRect = t.get_rect()
        # Umplatzieren in Mitte des Fensters
        self.__textRect.centerx = self.__windowsSurface.get_rect().centerx
        self.__textRect.centery = self.__windowsSurface.get_rect().centery
        # Ein zusätzliches Rechteck um den Text malen
        pygame.draw.rect(self.__windowsSurface,
                         self.__RED, (self.__textRect.left - 20,
                         self.__textRect.top - 20,
                         self.__textRect.width + 40,
                         self.__textRect.height + 40))
        # den Text auf die WindowsSurface malen
        self.__windowsSurface.blit(t, self.__textRect)

    def play(self):
        pygame.display.update()
        while True:
            for event in pygame.event.get():
                # bei Schliessen des Fensters Anwendung beenden
                if event.type == QUIT:
                    pygame.quit()
                    sys.exit()


helloWorld = HelloWorld()
helloWorld.play()

Im oberen Teil des Programms wird im Konstruktor die Game-Engine von pygame initialisiert. Dann werden einige Initialisierungsaufgaben durch den Konstruktor auf andere in der Klasse vorhandene Methoden delegiert. Das wichtigste Objekt ist das Surface Objekt, das die eigentliche Zeichenfläche darstellt auf das dann andere Objekte gezeichnet werden. Das Rendering des Textes findet Off-Screen statt, das heisst das Objekt wird vor dem Zeichnen schon mal gerendert. Mit der Methode blit der Surface wird dann das Textobjekt gezeichnet. Das Resultat sieht wie folgt aus:


Eine erste Animation

In einem nächsten Schritt definieren wir ein weiteres Objekt - einen grünen Kreis - und lassen diesen um den Schriftzug "Hello world" rotieren. Dieses Beispiel dient nur dazu zu demonstrieren, das mit pygame sehr einfach elementare Formen gezeichnet werden können. Der wichtige Teil ist aber sicherlich die Animation, das heisst das Drehen des Objekts um den Schriftzug.

Diese Animation kann mittels elementarer Grafik oder aber mit Sprites erreicht werden. Da Sprites eine Abstrahierung bewegter Objekte sind, sind diese sicher zu bevorzugen. Im Codebeispiel unten wird der eine Kreis elementar gezeichnet und der andere als Sprite. Insgesamt sind drei Python Dateien vonnöten, um den Code zu implementieren:

Datei: game1.py

import pygame, sys
from pygame.locals import *
import math
import Circle
import time


class HelloWorld:
    __windowsSurface = 0

    # color constants
    __BLACK = (0, 0, 0)
    __WHITE = (255, 255, 255)
    __RED = (255, 0, 0)
    __GREEN = (0, 255, 0)
    __BLUE = (0, 0, 255)

    # font
    __basicFont = 0

    # text to display
    __TEXT = 'Hello world'

    # the rectangular text area
    __textRect = 0

    # the radius of the circle
    __radius = 0

    # the off screen images
    __image1 = 0
    __image2 = 0

    def __init__(self):
        pygame.init()
        # Einrichten der Schriftart
        self.__basicFont = pygame.font.SysFont(None, 48)
        self.__initWindow()
        self.__displayCaptions()
        self.__initText()

    def __initWindow(self):
        # Richtet das Fenster ein
        self.__windowsSurface = pygame.display.set_mode((500, 400), 0, 32)

    def __displayCaptions(self):
        # Setzt den Titel im Fenster
        pygame.display.set_caption(HelloWorld.__TEXT)

    def __initText(self):
        # Gibt ein gerendertes aber nicht visuell sichtbares textfeld zurueck mit
        # weisser Schriftfarbe und blauem Hintergrund
        t = self.__basicFont.render(HelloWorld.__TEXT, True, HelloWorld.__WHITE, HelloWorld.__BLUE)
        # Abfragen der Groesse des gerenderten Objekts
        self.__textRect = t.get_rect()
        # Umplatzieren in Mitte des Fensters
        self.__textRect.centerx = self.__windowsSurface.get_rect().centerx
        self.__textRect.centery = self.__windowsSurface.get_rect().centery
        # Ein zusätzliches Rechteck um den Text malen
        pygame.draw.rect(self.__windowsSurface,
                         HelloWorld.__RED, (self.__textRect.left - 20,
                         self.__textRect.top - 20,
                         self.__textRect.width + 40,
                         self.__textRect.height + 40))
        # den Text auf die WindowsSurface malen
        self.__windowsSurface.blit(t, self.__textRect)

    def __assignImage(self, coluor):
        image = pygame.Surface((50, 50))
        image.fill(HelloWorld.__BLACK)
        image.set_colorkey(HelloWorld.__BLACK)
        pygame.draw.circle(image, coluor, (25, 25), 20, 0)
        image.convert()
        return image

    def __drawCircle(self, x, y, coluor, imageindex):
        image = 0
        if imageindex == 1:
            if self.__image1 == 0:
                image = self.__image1 = self.__assignImage(coluor)
            else:
                image = self.__image1
        if imageindex == 2:
            if self.__image2 == 0:
                image = self.__image2 = self.__assignImage(coluor)
            else:
                image = self.__image2

        draw_area = image.get_rect().move(x - 25, y - 25)
        return image, draw_area

    def play(self):
        # mal alles Zeichnen, was schon vorhanden ist
        pygame.display.update()
        # Variablen fuer die Kreisbewegung definieren
        theta = 0
        radius = 150
        steps = 500
        # Definition des Kreises als Sprite
        sprite = Circle.Circle(HelloWorld.__GREEN)
        oldx = oldy = -1
        while True:
            for event in pygame.event.get():
                # bei Schliessen des Fensters Anwendung beenden
                if event.type == QUIT:
                    pygame.quit()
                    sys.exit()
            # neue Position des Sprites berechnen
            sprite.update(radius, theta, (self.__textRect.centerx, self.__textRect.centery))

            # fuer den Kreis ohne Sprite die neue Position berechnen
            y = radius * math.cos(theta + math.pi) + self.__textRect.centery
            x = radius * math.sin(theta + math.pi) + self.__textRect.centerx

            # Bild des Kreises Offscreen zeichnen
            surface, area = self.__drawCircle(x, y, HelloWorld.__BLUE, 1)
            self.__windowsSurface.blit(surface, area)
            # Bild des Sprites Offscreen zeichnen
            self.__windowsSurface.blit(sprite.image, sprite.get_rect())
            # Offscreen Images darstellen
            pygame.display.update([area, sprite.get_rect()])
            pygame.display.flip()
            time.sleep(0.008)

            # fuer das Loeschen des gruenen Kreises kann ein neuer schwarzer Kreis gezeichnet werden
            # surface, area = self.__drawCircle(x, y, HelloWorld.__BLACK, 2)
            # self.__windowsSurface.blit(surface, area)
            # effizienter ist aber das Quadrat in dem der Kreis abgebildet ist zu loeschen

            self.__windowsSurface.fill([0, 0, 0], sprite.get_rect())
            self.__windowsSurface.fill([0, 0, 0], area)

            theta = (2 * math.pi/steps + theta) % (2*math.pi)


helloWorld = HelloWorld()
helloWorld.play()
Datei Circle.py

import pygame
import math

class Circle(pygame.sprite.Sprite):

    def __init__(self, color):
        super().__init__()

        self.image = pygame.Surface([50, 50])
        self.image.fill((0, 0, 9))
        self.image.set_colorkey((0, 0, 0))
        pygame.draw.circle(self.image, color, (25, 25), 20, 0)
        self.image.convert()
        self.rect = self.image.get_rect()

    def get_rect(self):
        return self.rect

    def update(self, *args):
        radius = args[0]
        theta = args[1]
        centerx, centery = args[2]

        y = radius * math.cos(theta) + centery
        x = radius * math.sin(theta) + centerx
        self.rect = self.image.get_rect().move(x-25, y-25)
Datei __init__.py

from .Circle import Circle

Damit das ganze Beispiel lauffähig ist sollten sie für __init__.py und Circle.py in PyCharm ein eigenes Package mit dem Namen Circle definieren.

Das Resultat sieht dann wie folgt aus. Leider sieht man unschön, dass die Bewegungen nicht ganz optimal ablaufen. Das kann aber darauf zurückgeführt werden, dass ich die Aufnahme in einer Windows XSession gemacht habe. Die Übertragung der Bilddaten auf den Arbeitscomputer sind in einer XSession nie ideal.



Noch beinhalteten unsere Beispiele überhaupt keine Künstliche Intelligenz. Ziel ist es hier auch nicht ein vollständiges Spiel zu entwickeln, sondern in den weiteren Betrachtungen, nach und nach Elemente der KI in die Programmierung einfliessen zu lassen.

Manche Spielprogrammierer reden schon von KI, wenn es um die Detektion von Kollisionen zwischen Objekte geht. Nachfolgender Abschnitt befasst sich genau mit diesem Thema. Es wird eine eigene Methode - nicht allgemein - und eine Methode, die pygame zur Verfügung stellt, vorgestellt. In einem einfachen Spiel kann eine Kollision als der Punkt im Spiel bezeichnet werden, wo das Spiel endet, indem zum Beispiel der Gnome den Ritter gefressen hat. Kollisionserkennung ist sicher wichtig in einem Spiel, hat aber noch nichts mit intelligentem Verhalten zu tun. Erst beim sogenannten Chasing and Evading, wo der Gegner aktiv auf die eigene Figur zusteuert, deutet der Computer so etwas wie intelligentes Verhalten an. Der Gegner benutzt nämlich ein mehr oder weniger komplexes Suchmuster, um das Spiel zu seiner eigener Gunst zu entscheiden. Daher setzen wir uns nach der Kollisionserkennung intensiv mit dem Thema Chasing and Evading auseinander.

Kollisionserkennung

In diesem Beispiel kehren wir dem vorherigen Beispiel den Rücken und lassen uns vorerst durch folgende Animation begeistern.



Damit die Kugeln reibungslos voneinander abprallen, überprüfen wir bei jeder Neudarstellung des Bildschirminhalts, ob die Kugeln kollidieren. Wenn dies der Fall ist, drehen wir die Richtung um 180 Grad um. Pygame hat vom Umfang her, eine eigene Kollisionserkennung integriert und von dieser kann dann Gebrauch gemacht werden. Rein didaktisch ist es aber trotzdem illustrativ, eine eigene Kollisionserkennung zu implementieren. Vom Prinzip her, wählt man ein Objekt (Target) aus, welches sich im Raum bewegt. Dann wird überprüft, ob sich die anderen Objekte bei einem nächsten Bewegungsschritt in das Objekt hineinbewegen. Unabhängig davon, ob es sich um Kreise, Quadrate oder Rechtecke handelt, werden in Pygame die Objekte als Rechtecke dargestellt. Daher genügt es, die Kollisionserkennung für Rechtecke zu schreiben. In folgender Grafik bewegen sich vier Objekte auf das Target zu. Es muss dann für jedes Objekt überprüft werden, ob das Target gemeinsame x- oder y- Koordinaten mit dem anderen Objekt hat. Normalerweise genügt das für Objekte gleicher Grösse. Haben aber die Objekte alle eine unterschiedliche Grösse, so ist es manchmal notwendig, dass Target mit dem Objekt zu wechseln, um tatsächlich eine Kollision festzustellen.



Objekte bewegen sich auf das Target zu 
Unten sehen wir einen Spezialfall, wo die normale Kollisionserkennung versagen würde. Das Target-Objekt in Gelb überprüft, ob die Koordinaten des anderen Objektes in Blau in ihm vorhanden sind. Das ist aber nicht der Fall. Kehrt man die Rollen zwischen Target- und Objekt um, so wird sofort ersichtlich, dass die Objekte kollidieren.
Kehren der Rolle von Target und Objekt

Eine eigene Kollisionserkennung implementieren

Zuerst generieren wir alle Objekte und platzieren sie links oben in der Windows-Oberfläche (Surface):

    def __createImages(self):
        i = 0
        counter = random.randint(5, 15)
        while i < counter:
            size = random.randint(18, 60)
            color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
            image = self.__drawCircle(color, (size, size))
            self.__images.append(image)
            self.__areas.append(image.get_rect())
            i += 1

In einem nächsten Schritt verteilen wir alle Objekte auf der Surface und zwar so, dass sie sich nicht schneiden.

    def __randomPaintImages(self):

        i = 0
        while i < len(self.__images):
            surface = self.__images[i]
            area = self.__areas[i]
            pos = (random.randint(area.width, 500 - area.width), random.randint(area.height, 400 - area.height))
            area.centerx = pos[0]
            area.centery = pos[1]
            self.__areas[i] = area
            indexList = self.__detectCollision(surface, area)

            while len(indexList) > 0:
                pos = (random.randint(area.width, 500 - area.width), random.randint(area.height, 400 - area.height))
                area.centerx = pos[0]
                area.centery = pos[1]
                self.__areas[i] = area

                indexList = self.__detectCollision(surface, area)

            i += 1
            # area = surface.get_rect().move(pos)

            self.__windowsSurface.blit(surface, area)

Der Algorithmus dies zu tun - wie oben angegeben - ist sicherlich nicht optimal. Es wird ein Objekt genommen (aus der Objektliste) und dann ein Zufallsposition innerhalb des sichtbaren Windows-Bereichs zugeordnet. Mit der Methode detectCollision wird überprüft, ob ein Objekt mit einem anderen Objekt "zusammenstösst". Ist dies der Fall, so wird dem Objekt eine neue Zufallsposition zugeordnet und zwar so lange, bis es nicht mehr mit anderen Objekten überlappt.

Unten der Algorithmus, um Kollisionen zu erkennen. Der Methode detectCollision wird das Target und dessen aktuelle Position übergeben. Um auch Sonderfälle abzudecken, wird die eigentliche Kollisionserkennung zweimal ausgeführt. Einmal in der Rolle Target - Objekt und das andere Mal in vertauschter Rolle.

Die Methode gibt eine Liste von Indices zurück, mit Angabe der Objektpositionen innerhalb der Objektliste. Somit wissen wir, welches Objekt mit welchem Objekt kollidiert.

    def __detectCollision(self, image, rect):
        area = rect.copy()
        result = []
        index1 = self.__images.index(image)
        j = 0
        i = 0
        while i < len(self.__areas):
            drawarea = self.__areas[i]
            index2 = j
            t = 0
            while t < 2:
                if area.right in range(drawarea.left, drawarea.right + 1):
                    if area.top in range(drawarea.top, drawarea.bottom + 1):

                        if index1 != index2:
                            result.append((index1, index2))

                    if area.bottom in range(drawarea.top, drawarea.bottom + 1):

                        if index1 != index2:
                            result.append((index1, index2))
                if area.left in range(drawarea.left, drawarea.right + 1):
                    if area.top in range(drawarea.top, drawarea.bottom + 1):

                        if index1 != index2:
                            result.append((index1, index2))

                    if area.bottom in range(drawarea.top, drawarea.bottom + 1):

                        if index1 != index2:
                            result.append((index1, index2))

                temparea = area.copy()
                area = drawarea.copy()
                drawarea = temparea
                tempIndex = index2
                index2 = index1
                index1 = tempIndex
                t += 1

            i += 1
            j += 1

        return result

Unten der Algorithmus, um die Bilder zu bewegen. Im Prinzip wird ein Richtungsvektor innerhalb des Einheitskreises festgelegt. Danach wird der Vektor noch skaliert. Mit der Methode move des Rect Objekts kann dann eine hochaufgelöste Pixelbewegung erzielt werden.

    def __moveImages(self):
        if len(self.__dvectors) == 0:
            count = len(self.__images)

            i = 0
            while i < count:
                x = y = 0
                theta = random.random() * math.pi * 2
                if math.cos(theta) > 0:
                    x = round(math.cos(theta) + 1)
                else:
                    x = round(math.cos(theta) - 1)
                if math.sin(theta) > 0:
                    y = round(math.sin(theta) + 1)
                else:
                    y = round(math.sin(theta) - 1)
                self.__dvectors.append((x, y))

                i += 1
        i = 0
        while i < len(self.__areas):
            area = self.__areas[i]
            vector = self.__dvectors[i]
            done = False
            while area.top <= 0:
                if not done:
                    self.__dvectors[i] = (vector[0], -vector[1])
                    vector = self.__dvectors[i]
                    done = True

                area = area.move(vector[0], 2)

            while area.bottom >= 400:
                if not done:
                    self.__dvectors[i] = (vector[0], -vector[1])
                    vector = self.__dvectors[i]
                    done = True

                area = area.move(vector[0], -2)

            while area.left <= 0:
                if not done:
                    self.__dvectors[i] = (-vector[0], vector[1])
                    vector = self.__dvectors[i]
                    done = True

                area = area.move(2, vector[1])

            while area.right >= 500:
                if not done:
                    self.__dvectors[i] = (-vector[0], vector[1])
                    vector = self.__dvectors[i]
                    done = True
                area = area.move(-2, vector[1])

            if done:
                self.__windowsSurface.blit(self.__images[i], area)
                self.__areas[i] = area
                continue

            vector = self.__dvectors[i]
            area = area.move(vector[0], vector[1])
            indexes = self.__detectCollision(self.__images[i], area)
            if len(indexes) > 0:
                vector = self.__dvectors[i]
                self.__dvectors[i] = (-vector[0], -vector[1])
                area = area.move(-vector[0], -vector[1])
                self.__areas[i] = area

            self.__windowsSurface.blit(self.__images[i], area)
            self.__areas[i] = area
            i += 1

Unten noch der Play-Alogorithmus:

  def play(self):
        # mal alles Zeichnen, was schon vorhanden ist
        self.__randomPaintImages()
        pygame.display.update()
        while True:

            for event in pygame.event.get():
                # bei Schliessen des Fensters Anwendung beenden
                if event.type == QUIT:
                    pygame.quit()
                    sys.exit()
            self.__moveImages()

            pygame.display.flip()
            self.__windowsSurface.fill([0, 0, 0])
            time.sleep(0.008)


game = CollisionDetection()
game.play()

Verwenden der Kollisionserkennung von Pygame

Die Animation oben basierte auf der eigenen Kollisionserkennung. Pygame stellt aber eine eigene Kollisionserkennung zur Verfügung. Diese diskutieren wir rasch:




<<to be continued>>



Keine Kommentare:

Kommentar veröffentlichen