Ein Jump and Run (Mario Style) in Pygame und Pygbag (Stage 1)

Pygame
Pygbag
Spieleprogrammierung
Retrogaming
Autor:in

Jörg Kantel

Veröffentlichungsdatum

17. Juni 2023

Ich habe es getan! Genauer gesagt, ich habe damit angefangen. Wie hier schon angedroht, habe ich mich durch die ersten Video-Tutorials der Playlist »Platformer« von Jonathan Cooper gewühlt und davon inspiriert angefangen, mein eigenes Jump ‘n’ Run in Pygame zu schreiben. Der Sinn der Übung ist, eben keine sklavische Kopie des Spiels von Jonathan Cooper zu programmieren, sondern es sollte meiner Vorstellung von objektorientierter Programmierung folgen, mithilfe von Pygbag auch im Browser zu spielen sein und last but not least die freien (CC0) Assets von Kenneys niedlichem Pixel Platformer (Farm Expansion, Industrial Expansion) verwenden. Diese basieren zwar auf ein unübliches Raster von 18x18 Pixeln, aber man wächst ja mit seinen Aufgaben1.

Beim derzeitigen Stand der Entwicklung habe ich noch recht wenige Bildchen verwendet, aber ich habe ja noch vor, das Spiel ganz gewaltig aufzuhübschen. Aber es ist selbst in diesem frühen Stadium schon (dank Itch.io) im Browser zu spielen2:

Da die Browser in ihrer Gier nach Input die Pfeiltasten und auch die Leertaste in Beschlag genommen haben, wird das Spiel wie in den 80er Jahren üblich mit den Tasten a, d und w gesteuert: a bewegt die Spielerfigur (das kleine, grüne Alien) nach links, d nach rechts und bei w hüpft sie nach oben.

Was habe ich nun angestellt? Ich habe mir das hier vorgestellte Template geschnappt und es behutsam ausgebaut. Damit das Alien nicht ins Bodenlose fällt, habe ich als erstes eine Klasse Platform erstellt:

class Platform(pg.sprite.Sprite):

    def __init__(self, _x, _y, _image):
        super().__init__()
        self.image = _image
        self.rect = self.image.get_rect()
        self.rect.x = _x*GRIDSIZE
        self.rect.y = _y*GRIDSIZE

Da die Klasse von pygame.sprite.Sprite erbt, ist die Initialisierung alles, was sie benötigt. Für die Positionierung wird die Konstante GRIDSIZE verwendet (hier 18 Pixel), die es erlaubt, die Sprites im Raster zu positionieren. In meinem Fall ist das Raster 40 Einheiten weit und 15 Einheiten hoch. Das ist leichter auszuzählen, als die Pixel (720x290).

Um den Boden für das Alien zu zeichnen, habe ich erst einmal das Bild geladen

        grass_image = pg.image.load(os.path.join(IMAGEPATH, "grass_02.png"))
        .convert_alpha()

und die Tiles dann positioniert:

        grass_locations = []
        for i in range(GRID_WIDTH):
            grass_loc = (i, GRID_HEIGHT - 1)
            grass_locations.append(grass_loc)
        self.all_sprites = pg.sprite.Group()
        self.platforms = pg.sprite.Group()
        for loc in grass_locations:
            x = loc[0]
            y = loc[1]
            p = Platform(x, y, grass_image)
            self.platforms.add(p)
            self.all_sprites.add(p)

Da alle Sprites in der Klasse GameWorld meines Templates gezeichnet werden, mußte ich mir über die draw()-Methode keine Gedanken machen, das erledigt Pygame für mich.

Dann habe ich eine Klasse Player spezifiziert:

class Player(pg.sprite.Sprite):

    def __init__(self, _x, _y):
        super().__init__()
        self.img = []
        for i in range(2):
            player_image = pg.image.load(os.path.join(IMAGEPATH,
            "alien_green_0" + str(i) + ".png")).convert_alpha()
            self.img.append(player_image)
        self.image = self.img[0]
        self.rect = self.image.get_rect()
        self.rect.x = _x*GRIDSIZE
        self.rect.bottom = _y*GRIDSIZE
        # self.rect.topleft = (self.rect.x, self.rect.y)
        self.speed = PLAYER_SPEED
        self.jump_power = JUMP_POWER
        self.vx = 0
        self.vy = 0
        self.gems = 0

Das zweite Bild benötige ich beim derzeitigen Stand der Entwicklung noch nicht, aber alles andere wird benötigt. Die Klasse hat diverse Hilfsmethoden, die in Summe alle in der Methode update() aufgerufen werden. Für das Verständnis der Implementierung notwendig ist die (Hilfs-) Methode move(), die auf die Tastatursteuerung reagiert und eine Kollisionserkennung besitzt:

    def move(self):
        keys = pg.key.get_pressed()
        if keys[pg.K_a]:        # LEFT
            if self.rect.x > 0:
                self.vx = -1*self.speed
        elif keys[pg.K_d]:      # RIGHT
            if self.rect.x < WIDTH - PLAYER_WIDTH:
                self.vx = self.speed
        elif keys[pg.K_w]:      # JUMP
            self.jump()
        else:
            self.vx = 0
        # Horizonfale Kollision
        self.rect.x += self.vx
        hits = pg.sprite.spritecollide(self, world.platforms, False)
        for hit in hits:
            if self.vx > 0:
                self.rect.right = hit.rect.left
            elif self.vx < 0:
                self.rect.left = hit.rect.right
        # Vertikale Kollision
        self.rect.y += self.vy
        hits = pg.sprite.spritecollide(self, world.platforms, False)
        for hit in hits:
            if self.vy > 0:
                self.rect.bottom = hit.rect.top
            elif self.vy < 0:
                self.rect.top = hit.rect.bottom
            self.vy = 0

Die Geschwindigkeit des Spielers wird mit den Variablen für Velocity (vx, vy) gesteuert, die unter anderem nur dann zu der x- oder y-Position des Spielers aufaddiert wird, wenn keine Kollision registriert wird. Kollisionen werden, je nachdem ob eine horizontale (vx != 0) oder vertikale (vy != 0) Kollision vorliegt, separat abgehandelt. Trifft der Spieler horizontal von rechts auf ein Hindernis gilt self.rect.right = hit.rect.left und umgekehrt (self.rect.left = hit.rect.right). Ähnliches gilt für die vertikale Kollision von oben (self.rect.bottom = hit.rect.top) oder unten (self.rect.top = hit.rect.bottom).

Damit eine vertikale Kollision überhaupt stattfinden kann, müssen natürlich noch ein paar Plattformen installiert werden. Wie gewohnt wird erst das Bildchen geladen

        block_image = pg.image.load(os.path.join(IMAGEPATH, "block_00.png"))
        .convert_alpha()

und dann die Blöcke positioniert:

        block_locations = [(18, 4), (19, 4), (20, 4), (21, 4),                         
                           (11, 7), (12, 7), (13, 7), (14, 7),
                           (25, 7), (26, 7), (27, 7),
                           (17, 10), (18, 10), (19, 10),
                           (38, 12), (38, 13)]
        for loc in block_locations:
            x = loc[0]
            y = loc[1]
            p = Platform(x, y, block_image)
            self.platforms.add(p)
            self.all_sprites.add(p)

Als letztes habe ich die Klasse Gem implementiert, die der Klasse Platform sehr ähnelt, aber zusätzlich noch die Methode apply() besitzt:

class Gem(pg.sprite.Sprite):

    def __init__(self, _x, _y, _image):
        super().__init__()
        self.image = _image
        self.rect = self.image.get_rect()
        self.rect.x = _x*GRIDSIZE
        self.rect.y = _y*GRIDSIZE
    
    def apply(self, character):
        character.gems += 1

Die Methode apply in Gem und nicht in Player zu implementieren, war eine Designentscheidung, damit in zukünftigen Versionen die Klasse auch auf andere Spielfiguren, wie zum Beispiel Gegner des Spielers, reagieren kann.

Dazu braucht die Klasse Player aber auch eine Methode, die auf eine Kollision mit den Edelsteinen reagiert. Ich habe sie check_items() genannt:

    def check_items(self):
        hits = pg.sprite.spritecollide(self, world.items, True)
        for item in hits:
            item.apply(self)

Hier ist der letzte Parameter von pygame.sprite.spritecollide() zum ersten Mal auf True gesetzt. Das bedeutet, das bei einer Kollision das Item aus allen sprite.Group gelöscht wird und im Spiel nicht mehr existiert.

Bevor ich Euch komplett verwirre, hier der vollständige Quellcode dieser Version 0.2, damit Ihr alles nachvollziehen und auch nachprogrammieren könnt:

import pygame as pg
import asyncio
from pygame.locals import *
import os, sys

## Settings
GRIDSIZE = 18
GRID_WIDTH = 40
GRID_HEIGHT = 15
WIDTH, HEIGHT = GRID_WIDTH*GRIDSIZE, GRID_HEIGHT*GRIDSIZE
TITLE = "Simple Platformer"
FPS = 60                     # Frames per second
PLAYER_WIDTH = 24
PLAYER_HEIGHT = 24
PLAYER_START_X, PLAYER_START_Y = 5, 1
PLAYER_SPEED = 3

# Physikalische Konstanten
GRAVITY = 0.5
JUMP_POWER = 10

## Hier wird der Pfad zum Verzeichnis der Assets gesetzt
IMAGEPATH = os.path.join(os.getcwd(), "data/images")

## Farben
BG_COLOR = (65, 166, 246)     # Himmelblau

## Class GameWorld
class GameWorld:

    def __init__(self):
        # Pygame und das Fenster initialisieren
        pg.init()
        self.screen = pg.display.set_mode((WIDTH, HEIGHT))
        pg.display.set_caption(TITLE)

        self.clock = pg.time.Clock()
        self.keep_going = True

    def reset(self):
        # Neustart oder Status zurücksetzen
        # Hier werden alle Elemente der GameWorld initialisiert
        ## Load Assets
        grass_image = pg.image.load(os.path.join(IMAGEPATH, "grass_02.png")).convert_alpha()
        block_image = pg.image.load(os.path.join(IMAGEPATH, "block_00.png")).convert_alpha()
        gem_image   = pg.image.load(os.path.join(IMAGEPATH, "gem.png")).convert_alpha()

        grass_locations = []
        for i in range(GRID_WIDTH):
            grass_loc = (i, GRID_HEIGHT - 1)
            grass_locations.append(grass_loc)
        self.all_sprites = pg.sprite.Group()
        self.platforms = pg.sprite.Group()
        for loc in grass_locations:
            x = loc[0]
            y = loc[1]
            p = Platform(x, y, grass_image)
            self.platforms.add(p)
            self.all_sprites.add(p)
    
        block_locations = [(18, 4), (19, 4), (20, 4), (21, 4),                         
                           (11, 7), (12, 7), (13, 7), (14, 7),
                           (25, 7), (26, 7), (27, 7),
                           (17, 10), (18, 10), (19, 10),
                           (38, 12), (38, 13)]
        for loc in block_locations:
            x = loc[0]
            y = loc[1]
            p = Platform(x, y, block_image)
            self.platforms.add(p)
            self.all_sprites.add(p)

        gem_locations = [(20,3), (12, 6), (26,6), (36, 13)]
        self.items = pg.sprite.Group()
        for loc in gem_locations:
            x = loc[0]
            y = loc[1]
            g = Gem(x, y, gem_image)
            self.items.add(g)
            self.all_sprites.add(g)

        self.player = Player(PLAYER_START_X, PLAYER_START_Y)
        self.player_sprite_group = pg.sprite.GroupSingle()
        self.player_sprite_group.add(self.player)
        self.all_sprites.add(self.player)
  
    def events(self):
        for event in pg.event.get():
            if ((event.type == pg.QUIT)
                or (event.type == pg.KEYDOWN
                and event.key == pg.K_ESCAPE)):
                self.keep_going = False
                self.game_over()

    def update(self):
        self.all_sprites.update()

    def draw(self):
        self.screen.fill(BG_COLOR)
        self.all_sprites.draw(self.screen)
        pg.display.flip()

    def start_screen(self):
        pass
    
    def win_screen(self):
        pass
    
    def loose_screen(self):
        pass

    def game_over(self):
        # print("Bye, Bye, Baby!")
        pg.quit()
        sys.exit()
## Ende Class GameWorld

## Class Platform
class Platform(pg.sprite.Sprite):

    def __init__(self, _x, _y, _image):
        super().__init__()
        self.image = _image
        self.rect = self.image.get_rect()
        self.rect.x = _x*GRIDSIZE
        self.rect.y = _y*GRIDSIZE
## End Class Platform

class Gem(pg.sprite.Sprite):

    def __init__(self, _x, _y, _image):
        super().__init__()
        self.image = _image
        self.rect = self.image.get_rect()
        self.rect.x = _x*GRIDSIZE
        self.rect.y = _y*GRIDSIZE
    
    def apply(self, character):
        character.gems += 1
## End Class Gem

## Class Player
class Player(pg.sprite.Sprite):

    def __init__(self, _x, _y):
        super().__init__()
        self.img = []
        for i in range(2):
            player_image = pg.image.load(os.path.join(IMAGEPATH, "alien_green_0" + str(i) + ".png")).convert_alpha()
            self.img.append(player_image)
        self.image = self.img[0]
        self.rect = self.image.get_rect()
        self.rect.x = _x*GRIDSIZE
        self.rect.bottom = _y*GRIDSIZE
        self.speed = PLAYER_SPEED
        self.jump_power = JUMP_POWER
        self.vx = 0
        self.vy = 0
        self.gems = 0
    
    def jump(self):
        self.rect.y += 2
        hits = pg.sprite.spritecollide(self, world.platforms, False)
        self.rect.y -= 2
        if len(hits) > 0:
            self.vy = -1*self.jump_power

    def apply_gravity(self):
        self.vy += GRAVITY

    def move(self):
        keys = pg.key.get_pressed()
        if keys[pg.K_a]:        # LEFT
            if self.rect.x > 0:
                self.vx = -1*self.speed
        elif keys[pg.K_d]:      # RIGHT
            if self.rect.x < WIDTH - PLAYER_WIDTH:
                self.vx = self.speed
        elif keys[pg.K_w]:      # JUMP
            self.jump()
        else:
            self.vx = 0
        # Horizonfale Kollision
        self.rect.x += self.vx
        hits = pg.sprite.spritecollide(self, world.platforms, False)
        for hit in hits:
            if self.vx > 0:
                self.rect.right = hit.rect.left
            elif self.vx < 0:
                self.rect.left = hit.rect.right
        # Vertikale Kollision
        self.rect.y += self.vy
        hits = pg.sprite.spritecollide(self, world.platforms, False)
        for hit in hits:
            if self.vy > 0:
                self.rect.bottom = hit.rect.top
            elif self.vy < 0:
                self.rect.top = hit.rect.bottom
            self.vy = 0

    def check_edges(self):
        if self.rect.left < 0:
            self.rect.left = 0
        elif self.rect.right > WIDTH:
            self.rect.right = WIDTH
    
    def check_items(self):
        hits = pg.sprite.spritecollide(self, world.items, True)
        for item in hits:
            item.apply(self)

    def update(self):
        self.apply_gravity()
        self.move()
        self.check_edges()
        self.check_items()

## End Class Player

# Hauptprgramm
world = GameWorld()
world.start_screen()
world.reset()

# Hauptschleife
async def main():
    while world.keep_going:
        world.clock.tick(FPS)
        world.events()
        world.update()
        world.draw()
        await asyncio.sleep(0)  # Very important, and keep it 0

asyncio.run(main())

Das Spiel habe ich auf Itch.io hochgeladen, Ihr könnt es also auch dort spielen. Allerdings wird es dort immer nur die letzte, gerade aktuelle Version geben – jede neue Version überschreibt die vorherige Version. Ich will schließlich meinen Account nicht vollmüllen.

Und wie immer gibt es den Quellcode mit allen Assets auch in meinem Github-Repositorium. Aber auch hier wird es immer nur die aktuelle Fassung geben, frühere Versionen könnt Ihr Euch ja notfalls aus der History ziehen (wozu hat man denn eine Versionskontrolle?).

Als nächstes werde ich erst einmal ein Refactoring des bisher implmentierten vornehmen. Es sind mir nämlich zuviel Doppelungen im Quellcode vorhanden und auch ein paar Zeilen sind von früheren Versuchen noch funktions- und nutzlos im Code verblieben. Ich lasse Euch an den Fortschritten weiter teilhaben. Still digging!

Fußnoten

  1. Ich wollte ein recht kleines Pixelraster verwenden, damit das Spielfeld – zumindest in den ersten Versionen – auch problemlos in ein Browserfenster paßt. Das 64x64 große Pixelraster des Simplified Platformer Pack (ebenfalls von Kenney.nl) schien mir dafür zu wuchtig (bei aller Sympathie, die ich für die kleine, animiert und gelbe Spielekonsole des Packs hege).↩︎

  2. Wenn man, nachdem das Spiel gestartet ist, die Seite neu lädt oder verläßt, gibt der Browser eine Warnmeldung heraus, daß eventuelle Änderungen nicht gespeichert werden. Ich weiß nicht, ob das gewollt ist und ob oder wie man das abstellen kann. Momentan müßt Ihr und muß ich damit leben.↩︎