4 edycja
warsztatów zakończona

Pygame - biblioteka do tworzenia prostych gier w Pythonie, cz.2

Published: Tue 23 June 2015
tags: pygame python

Tak jak zostało zapowiedziane w poprzedniej części, w tym artykule skupię się na rozbudowaniu rozpoczętego remake’u “Space Invaders”. Dodamy kolejne funkcjonalności, takie jak: możliwość oddawania strzałów przez gracza, dodamy przeciwników oraz wprowadzimy ich w ruch, a także wyświetlimy aktualny wynik gry.

Poniżej przypominam ustalenia przyjęte w poprzednim artykule:

  • wysokość okna - 800 px
  • szerokość okna - 600 px
  • wielkość gracza 50x50 px
  • startowa pozycja gracza:
    • szerokość - szerokość okna/2 - 25 px
    • wysokość - wysokość okna - 75 px (50 px gracz + 25 px pozostawione na wyświetlenie wyniku i żyć gracza)
  • klawisz Enter - do wystartowania gry
  • klawisz Escape - do wyłączenia gry
  • gracz może oddać strzał co 0,5 sekundy
  • przeciwnicy 5 rzędów po 11 w każdym
    • pierwsze dwa rzędy dają po 10 punktów za każdego zabitego przeciwnika
    • dwa kolejne dają po 20 punktów za każdego zabitego przeciwnika
    • ostatni daje 30 punktów za każdego zabitego przeciwnika

Strzelanie gracza

Pierwszym elementem, który dodamy, będzie możliwość strzelania przez gracza. Do już istniejącej postaci gracza, którą możemy poruszać w prawo lub w lewo, dodamy tworzenie obiektu, który będzie naszym pociskiem. Wykorzystamy do tego klawisz spacji, a pocisk utworzymy po środku postaci. Ustaliliśmy wielkość postaci gracza jako kwadrat 50x50 px, co oznacza że obiekt pocisku powinien zostać utworzony na początku rysowania postaci gracza, tj. 75 px od dołu ekranu oraz w połowie jego szerokości plus 24 px. W trakcie tworzenia obiektu musimy również sprawdzić flagę, która sygnalizuje, czy taki strzał jest możliwy do oddania - stanem początkowym będzie oczywiście możliwość strzelania. Flaga będzie się zmieniała co 0,5 sekundy, do tego celu wykorzystamy wbudowaną w pygame’a obsługę zegara (pygame.time.Clock()).

Na samym początku zdefiniujmy sobie klasę, która będzie tworzyła obiekt pocisku, a także dodamy metodę obsługującą przemieszczanie pocisku na ekranie.

CLOCK = pygame.time.Clock()

# klasa pocisku
class Bullet:

    def __init__(self, surface, x_coord, y_coord):
        self.surface = surface
        self.x = x_coord + 24
        self.y = y_coord
        self.image = pygame.image.load('laser.png')
        return

    def update(self, y_amount=5):
        self.y -= y_amount
        self.surface.blit(self.image, (self.x, self.y))
        return

Listing 1. Klasa rysująca pocisk oraz deklaracja zegara

Na powyższym listingu dodaliśmy wywołanie zegara, które trzymamy w stałej o nazwie CLOCK. Następnie tworzymy klasę pocisku z początkową wartością pozycji na osi x i y, instancją okienka, a także dodajemy wczytanie grafiki. Następnie dodajemy metodę, która jest odpowiedzialną za aktualizację położenia obiektu pocisku. Ta prosta klasa pozwoli nam na utworzenie instancji pocisku oraz odpowiednie jego przemieszczenie. Funkcję self.surface.blit() rysujemy z aktualną pozycją pocisku.

Kolejnym krokiem dla zapewnienia obsługi strzelania, jest utworzenie listy z pociskami oraz obsługa klawisza spacji, która będzie tworzyć kolejne pociski.

# definicja pustej listy dla pocisków
self.bullets_array = []

# flaga oraz czas, które odpowiadają za możliwość strzelania
can_shoot = True
fire_wait = 500

# obsługa klawisza spacji oraz sprawdzenie flagi
if keys[K_SPACE] and can_shoot:
    bullet = Bullet(self.surface, self.player_x, self.player_y)
    self.bullets_array.append(bullet)
    can_shoot = False

if not can_shoot and fire_wait <= 0:
    can_shoot = True
    fire_wait = 500

fire_wait -= CLOCK.tick(60)

# pętla dla tablicy z utworzonymi pociskami
for bullet in self.bullets_array:
    bullet.update()
    if bullet.y < 0:
        self.bullets_array.remove(bullet)

Listing 2. Fragmenty kodu, które odpowiadają za obsługę strzelania

Powyższy listing przedstawia fragmenty kodu, które należy dodać, aby uzyskać efekt strzelania. Zatem tak:

  1. Deklaracja pustej listy powinna trafić do metody inicjalizującej klasę gry, tzn. def __init__() w class SpaceInvadersGame - pusta lista pozwoli nam na przetrzymywanie informacji o pocisku, jego położenie oraz ilości. Dzięki temu mam kontrolę nad tym, ile pocisków znajduje się na planszy oraz w jakiej pozycji.
  2. Kolejny fragment kodu - deklaracja flagi oraz czasu oczekiwania na możliwość strzelenia - powinny znaleźć się w głównej pętli gry przed deklaracją pętli while.
  3. Natomiast obsługa klawisza spacji oraz sprawdzenie flagi powinna się znaleźć się w pętli while. Tutaj musimy zauważyć, iż oczekiwanie na strzał wynosi 500 ms - dopóki dekrementacja tego licznika nie jest mniejsza lub równa zero, oddanie strzału będzie niemożliwe.
  4. Jeżeli chodzi o pętlę, która odpowiada za aktualizację pozycji pocisków musi ona zaleźć się za funkcjami aktualizującymi pozycję gracza. Jest to bardzo istotne, aby ten fragment kodu znalazł się w odpowiednim miejscu, ponieważ inaczej odświeżanie i rysowanie planszy zastąpi nam wyświetlanie przesuwającego się pocisku. Równocześnie podczas aktualizacji ekranu sprawdzamy, czy pocisk nie wyleciał poza ekran. Jeśli nastąpi taka sytuacja, musimy usunąć go z listy nabojów wystrzelonych przez gracza.

Przeciwnicy

Po dodaniu tych kilku funkcji, gracz będzie miał możliwość strzelania. Kolejnym etapem jest stworzenie przeciwników. Musimy ustawić im odpowiednią punktację oraz ich wielkość, a także odległość między nimi. Proponuję na początek dać wielkość przeciwników 35x35 px, odległość między nimi dać po 5 px, co daje nam łącznie 435 px szerokości, którą będą zajmować. Natomiast jeżeli chodzi o wysokość, to damy odstęp pomiędzy przeciwnikami na 15 px, co daje nam łączną wysokość zajmowaną przez przeciwników - 220 px. Powierzchnię, którą musimy przeznaczyć na przeciwników to rozmiar 435x220 px, umieścimy je w odległości 80 px od lewej krawędzi oraz 50 px od górnej krawędzi okna.

# klasa tworząca instancję przeciwnika
class Enemy:

    def __init__(self, x_coord, y_coord):
        self.x = x_coord
        self.y = y_coord
        self.image = pygame.image.load('enemy.png')
        self.speed = 3
        return

    def update(self, surface, dirx, y_amount=0):
        self.x += (dirx * self.speed)
        self.y += y_amount
        surface.blit(self.image, (self.x, self.y))
        return

# pętla generująca macierz przeciwników
def generate_enemies():
    matrix = []
    for y in range(5):
        enemies = [Enemy(80 + (40 * x), 50 + (50*y)) for x in range(11)]
        matrix.append(enemies)
    return matrix

# flaga odpowiadająca za poruszanie się przeciwników
moving = False

# główna pętla odpowiadająca za mechanikę przeciwników
for enemies in self.enemies_matrix:
    for enemy in enemies:
        if enemies[-1].x > 765:
            dirx = -1
            moving = True
            enemy.update(self.surface, 0, 5)
        elif enemies[0].x < 0:
            dirx = 1
            moving = True
            enemy.update(self.surface, 0, 5)
        elif not moving:
            dirx = 1
        enemy.update(self.surface, dirx)

Listing 3. Kod odpowiadający za mechanikę oraz tworzenie przeciwników

  1. Następnie tworzymy podobną klasę, którą utworzyliśmy dla pocisku. Jest kilka drobnych różnic, ale najważniejsza z nich to zmiana w metodzie update(). Polega ona na dodaniu parametru poruszania się obiektów przeciwnika. Przeciwnicy muszą poruszać się po osi x, ale również po osi y. Dlatego też dodajemy opcjonalną możliwość poruszania się po osi y, gdyż poruszanie się po tej osi następuje dopiero, gdy skrajni przeciwnicy dotkną krawędzi okna - ten dodatkowy ruch zaimplementowany jest w głównej pętli mechaniki przeciwników.
  2. Kolejnym etapem podczas tworzenia przeciwników jest utworzenie metody generującej przeciwników i wypełnienie głównej listy (macierz). Wykorzystamy do tego pętle for, która w głównym obiegu tworzy wiersze. Następnie wykorzystujemy list comperhension, aby kolejno utworzyć kolumny. Po uzupełnieniu wszystkich kolumn w wierszu, dodajemy tak utworzoną listę do głównej listy. W ten sposób otrzymam macierz wypełnioną obiektami przeciwników. W funkcji inicjalizującej grę musimy w obiekcie self wygenerować macierz, robimy to używając self.enemies_matrix = generate_enemies().
  3. W głównej pętli gry dodajemy nową flagę o nazwie moving, która początkowo ustawiona jest na False. Oznacza to, że w czasie wczytania gry przeciwnicy nie będą się poruszać.
  4. Najważniejszym etapem podczas tworzenia przeciwników, jest ich wyświetlenie oraz mechanika. W głównej pętli gry po wyświetlaniu graczu dodajemy nową pętle, która będzie odpowiedzialna za wczytanie wszystkich przeciwników znajdujących się w głównej liście (macierzy). Wczytywanie dokonujemy poprzez przejście po wierszach, a następnie po kolumnach. W trakcie obiegu pętli sprawdzamy czy pierwszy lub ostatni element wiersza ma styczność z którąś z krawędzi okna. Dla prawej krawędzi musimy sprawdzić czy pozycja przeciwnika na osi x jest większa niż szerokość ekranu minus szerokość przeciwnika, czyli 800 px - 35 px daje nam 765 px. Natomiast dla lewej krawędzi sprawdzimy, czy pierwszy element z wiersza jest mniejszy niż 0 px. Zmiana kierunku następuje poprzez przesłanie wartości 1 lub -1. 1 - oznacza ruch w kierunku prawej krawędzi, natomiast -1 - oznacza ruch w przeciwnym kierunku. W trakcie sprawdzania, czy obiekt przeciwnika ma styczność z krawędzią, ustawiamy prędkość chwilową na 0, a następnie wracamy do ustalonej prędkości.

W tym momencie posiadamy już poruszającą się postać gracza, która może strzelać, a także mamy już utworzonych wszystkich przeciwników, którzy potrafią się poruszać. Pozostałe rzeczy do dodania to ilość punktów w odpowiednich wierszach, zliczanie wyniku, możliwość strzelania przez przeciwników, zliczanie żyć gracza, restartowanie planszy oraz koniec gry.

Kolizje, zliczanie punktów

Następny krok to potrzeba obsługi kolizji pomiędzy przeciwnikami a pociskami wystrzelonymi przez gracza. Do tego celu wykorzystamy pętlę, która przechodzi po liście pocisków w głównej pętli gry. Dodamy tam sprawdzanie, czy dany nabój wchodzi w kolizję z przeciwnikiem oraz dodamy aktualizację wyniku.

# deklaracja zmiennej wyniku
SCORE = 0

# funkcja sprawdzająca kolizję
def check_collision(object1_x, object1_y, object2_x, object2_y):
    return (
        (object1_x > object2_x) and (object1_x < object2_x + 35) and 
        (object1_y > object2_y) and (object1_y < object2_y + 35)
    )

# pętla przechodząca po macierzy przeciwników i sprawdzająca kolizje
for enemies in self.enemies_matrix:
    for enemy in enemies:
        if (check_collision(bullet.x, bullet.y, enemy.x, enemy.y) 
            and bullet in self.bullets_array
        ):
            self.score += enemy.points
            enemies.remove(enemy)
            self.bullets_array.remove(bullet)

# dodawanie napisu wyniku i aktualizacja
score_label = myfont.render("Score: {}".format(self.score), 1, YELLOW)
self.surface.blit(score_label, (25, 575))

Listing 4. fragmenty kodu, które odpowiadają za kolizję oraz za aktualizację wyniku i jego wypisanie

Jak widać na powyższym listingu, najpierw dodajemy stałą SCORE o wartości 0. Po jej dodaniu musimy ją przypisać do zmiennej, w której będziemy otrzymywać i aktualizować wynik gracza. Zmienna ta musi znaleźć się w funkcji inicjalizującej grę jako opcjonalny parametr - def __init__(self, score=SCORE). Powinniśmy ją przypisać do self, aby móc ją aktualizować. Kod wygląda następująco self.score = score. Kolejną czynnością jest zdefiniowianie metody sprawdzającej kolizję. Do tego celu potrzebujemy metodę, która przyjmuje 4 wartości, a są to: x i y pierwszego obiektu oraz x i y drugiego obiektu. Kolizja dotyczy pocisków gracza oraz przeciwników, a więc musimy sprawdzić, czy pocisk znajduje się na wysokości lub szerokości przeciwnika, dlatego też do odpowiednich parametrów dodajemy po 35 px. Jest to boolowska operacja and a więc funkcja zwróci True lub False. Funkcję tę wykorzystamy w pętli, która odpowiedzialna jest za przejście po liście pocisków wystrzelonych przez gracza. W pętli tej dodajemy kolejną pętlę, która przechodzi przez macierz przeciwników, w niej sprawdzimy czy występuje kolizja pocisku z przeciwnikiem i czy pocisk znajduje się na liście pościsków gracza. Jeżeli dojdzie do kolizji, to do wyniku dodajemy wartość punktów za przeciwnika, 10, 20 lub 30 punktów. Dodatkowo usuwamy z wiersza odpowiedniego przeciwnika oraz pocisk z listy pocisków. Na koniec musimy wyświetlić aktualny wynik. Do tego potrzebujemy ustawić czcionkę w głównej pętli (podobnie jak to ma miejsce przy tekście powitalnym) - myfont = pygame.font.Font(None, 20). Następnie dodajemy renderowanie czcionki oraz tekstu i tak utworzony napis wyświetlamy w odpowiednim miejscu na ekranie. Wyświetlanie rozpoczynamy od 25 px z lewej strony oraz 575 px od góry ekranu, co wpasuje nam się w pozostawioną odległość gracza od dołu ekranu. Do funkcji generującej macierz przeciwników musimy dodać warunki sprawdzające oraz dodające odpowiednią ilość punktów, dlatego też modyfikujemy ją w sposób pokazany na poniższym listingu.

def generate_enemies():
    matrix = []
    for y in range(5):
        if y == 0:
            points = 30
        elif y == 1 or y == 2:
            points = 20
        else:
            points = 10

        enemies = [Enemy(80 + (40 * x), 50 + (50 * y), points) for x in range(11)]
        matrix.append(enemies)
    return matrix

Listing 5. Zmodyfikowana funkcja generująca przeciwników

Modyfikujemy metodę __init__ w klasie przeciwników. Dodajemy do metody kolejny argument points (def __init__(self, x_coord, y_coord, points)) oraz dodajemy linijkę, która do obiektu przeciwnika doda odpowiednią ilość punktów - self.points = points. W ten sposób każdy przeciwnik będzie miał odpowiednią ilość punktów. Musimy pamiętać, że tworzymy przeciwników od góry, tj. wiersz 0 to wiersz najbardziej oddalony od przeciwnika, czyli za zabicie przeciwnika z tego wiersza otrzymujemy 30 punktów.

Strzelanie przeciwników

# klasa tworząca pociski przeciwnika
class EnemyBullet:

    def __init__(self, surface, x_coord, y_coord):
        self.surface = surface
        self.x = x_coord + 12
        self.y = y_coord
        self.image = pygame.image.load('laser.png')
        return

    def update(self, y_amount=5):
        self.y += y_amount
        self.surface.blit(self.image, (self.x, self.y))
        return

# strzelanie przeciwników
if enemy_can_shoot:
    flat_list = [enemy for enemies in self.enemies_matrix for enemy in enemies]
    random_enemy = random.choice(flat_list)
    enemy_bullet = EnemyBullet(self.surface, random_enemy.x, random_enemy.y)
    self.enemies_bullets.append(enemy_bullet)
    enemy_can_shoot = False

if not enemy_can_shoot and enemy_fire_wait <= 0:
    enemy_fire_wait = 1500
    enemy_can_shoot = True

for enemy_bullet in self.enemies_bullets:
    enemy_bullet.update()
    if enemy_bullet > 600:
        self.enemies_bullets.remove(enemy_bullet)

    if (check_collision(enemy_bullet.x, enemy_bullet.y, self.player_x, self.player_y) and
        enemy_bullet in self.enemies_bullets
    ):
          self.enemies_bullets.remove(enemy_bullet)
          self.life -= 1

life_label = myfont.render("Life: {}".format(self.life), 1, YELLOW)
self.surface.blit(life_label, (750, 575))

Listing 6. Kod obsługujący strzelanie przeciwników, zliczanie życia gracza

Strzelanie przez przeciwników jest bardzo podobne do strzelania przez gracza, występuje tylko kilka drobnych różnic. Podczas tworzenia pocisku przeciwnika, ustawiamy pozycję x przesuniętą o 12 px, a nie jak w przypadku gracza o 24 px, natomiast drugą istotną zmianą jest kierunek poruszania się pocisku. Pociski przeciwnika muszą poruszać się z góry ekranu w dół, czyli zamiast odejmować od parametru y, tym razem dodajemy. Jeżeli chodzi o sprawdzenie kolizji z graczem czy możliwością strzelania, wygląda to prawie identycznie, jak w przypadku gracza, z tą różnicą, że gdy wystąpi kolizja musimy odjąć jedno życie od ilości żyć gracza. Najciekawsza zmiana natomiast występuje, gdy przeciwnik może strzelić. W tym przypadku zmniejszamy macierz do jednego wiersza, losujemy przeciwnika i ten losowo wybrany przeciwnik wykonuje strzał w kierunku gracza. Aby można było wybrać losowo, potrzebujemy zaimportować wbudowaną bibliotekę random (import random). Prócz zmian, które zaprezentowane są powyżej, jak zapewne domyślacie się, trzeba ustawić zmienne lokalne, którymi się posługujemy, a są to:

  • self.enemies_bullets = [] - w funkcji __init__
  • self.life = life - w funkcji __init__
  • enemy_can_shoot = True - w głównej pętli gry
  • enemy_fire_wait = 1500 - w głównej pętli gry
  • oraz modyfikacja funkcji __init__ - def __init__(self, score=SCORE, life=LIFE)

Klasa tworzenia pocisku przeciwnika znajduje się poza główną klasą gry, natomiast cała reszta kodu musi znaleźć się w głównej pętli gry.

Przegrana, wygrana - czyli koniec gry i restart

# game over screen
def game_over_screen(self):
    myfont = pygame.font.Font(None, 15)
    label = myfont.render("Press Y to restart game, N to exit", 1, YELLOW)
    score = myfont.render("You finished with score: {}".format(self.score), 1, YELLOW)
    self.surface.fill(BLACK)
    self.surface.blit(label, (100, 100))
    self.surface.blit(score, (100, 120))
    pygame.display.flip()
    while True:
        for event in pygame.event.get():
            if event.type == KEYDOWN and event.key == K_y:
                SpaceInvadersGame()
            if (event.type == QUIT or 
                (event.type == KEYDOWN and event.key == K_ESCAPE) or
                (event.type == KEYDOWN and event.key == K_n)
            ):
                exit()

# przeładowanie planszy
def continue_screen(self):
    myfont = pygame.font.Font(None, 15)
    label = myfont.render("Press ENTER to continue game", 1, YELLOW)
    score = myfont.render("Your score: {}".format(self.score), 1, YELLOW)
    self.surface.fill(BLACK)
    self.surface.blit(label, (100, 100))
    self.surface.blit(score, (100, 120))
    pygame.display.flip()
    while True:
        for event in pygame.event.get():
            if event.type == KEYDOWN and event.key == K_RETURN:
                SpaceInvadersGame(score=self.score, life=self.life)
            if (event.type == QUIT or 
                (event.type == KEYDOWN and event.key == K_ESCAPE) or
                (event.type == KEYDOWN and event.key == K_n)
            ):
                exit()

# brak żyć
if not self.life:
    self.gamestate = 0

# sprawdzenie czy na planszy są jeszcze przeciwnicy
if not any(self.enemies_matrix):
    self.continue_screen()

Listing 7. Funkcje końca gry oraz przeładowania poziomu

Jeżeli chodzi o koniec gry lub przeładowanie planszy, funkcje te są bardzo podobne do siebie. Różnią się wyświetlanymi tekstami oraz ponownym wywołaniem gry. W przypadku, gdy gracz stracił wszystkie życia i przegrał grę, wczytujemy bez parametrów. Natomiast w przypadku zniszczenia wszystkich przeciwników, grę wczytujemy z podaniem parametrów: aktualnego wyniku oraz pozostałej ilość żyć gracza. Sprawdzenie czy wszystkie wiersze w macierzy przeciwników są puste powoduje przeładowanie gry, a gry gracz straci wszystkie życia zmieniamy stan gry na 0 i wychodzimy z głównej pętli while. Dlatego po tej pętli zmieniamy linijkę self.game_exit() na self.game_over_screen(), co spowoduje wyświetlenie się ekranu przegranej. Pozostałe dwie funkcje dodajemy w głównej klasie gry.

Podsumowanie

Gra, która została stworzona w obu częściach jest grywalna. Stworzyliśmy w niej postać gracza i postacie przeciwników. Dodaliśmy im możliwość strzelania oraz zdobywania punktów przez gracza czy utratę punktów życia, czyli mamy możliwość wygrania bądź przegrania. Oczywiście gra ta nie jest pozbawiona wad i nie zawiera wszystkich elementów, które opisaliśmy w pierwszej części, ale jest gotowa do dalszych optymalizacji czy ulepszeń. Kod z grą jest dostępny w repozytorium kryjącym się pod linkiem: https://github.com/lukaszjagodzinski/space-invaders-pygame/.
Możecie dowolnie modyfikować i usprawniać znajdujący się tam kod źródłowy. Pamiętajcie, aby uruchomić grę trzeba stworzyć 3 pliki z grafiką, tj. grafikę dla strzału (laser.png), statku gracza (ship.png) oraz przeciwników (enemy.png). W trakcie tworzenia można by skorzystać z gotowych funkcji w pygame takich jak Sprite i wykorzystać kolizje, które są tam zaimplementowane. Zachęcam do dalszego poznawania i pogłębienia wiedzy na temat biblioteki pygame i pythona.

Łukasz Jagodziński Łukasz Jagodziński

Na co dzień zajmuje się tworzeniem aplikacji webowych w Pythonie. Od kilku lat współpracuje z STX Next w Poznaniu. Po godzinach interesuję się tworzeniem gier od A do Z czyli od etapu projektowania, poprzez kodowanie, aż do gotowego produktu na sprzedaż.

comments powered by Disqus
Współpraca: programista