article banner

Gra w węża, część 1: Daj mi okno na świat

Cześć! To jest fragment książki Python od podstaw, która ma pomóc w nauce programowania od zera. Znajdziesz ją na Allegro, w Empikach i w księgarniach internetowych.

W tym rozdziale zainstalujemy pakiet PyGame oraz wykorzystamy go, do narysowania elementów naszej gry.

Nadszedł czas, aby wykorzystać zdobytą wiedzę w praktyce: napiszemy razem grę Wąż (ang. Snake). Zasady są proste: Gracz kieruje wężem po planszy. Jego zadaniem jest zjadanie kulek. Gdy je zjada, to wąż rośnie. Gracz przegrywa, gdy wąż zderzy się z własnym ogonem.

Tak będzie wyglądała nasza gra.

Co ciekawe, posiadasz już większość umiejętności, niezbędnych by taką grę napisać. Brakuje nam tylko wiedzy, jak wyświetlać elementy widoku oraz jak reagować na naciśnięcie przycisków przez graczy. Reszta to właściwie praktyczne zastosowanie tego, czego nauczyliśmy się w poprzednich działach. Na start potrzebujemy tylko Pythona w wersji powyżej 3.6, ostatnio testowałem tę grę na wersji 3.9.6.

Jak działają gry?

Dla uproszczenia, grafika w grach sprowadza się do tego, że na obszarze, na którym jest wyświetlana, rysowane są kolejne obrazki. Podobnie jak w filmach, gdzie sceny są szybko zmieniającymi się kolejnymi klatkami — takie "ruchome obrazki". By odnieść wrażenie postaci w ruchu, wyświetlane są kolejno obrazki na następujących po sobie etapach tego ruchu. Do tego dodajemy przesunięcie. Na przykład, jeśli postać porusza się w prawo, to każdy kolejny obrazek powinien być narysowany nieco bardziej na prawo od poprzedniego. Należy też zetrzeć poprzedni obrazek, tak aby nie został po nim ślad. I tym samym odpowiednia ilość szybko wyświetlanych obrazków daje złudzenie płynnego poruszania się postaci.

Bohater gry klatka po klatce.

W naszym przypadku nie zajmiemy się animowaniem postaci, ale zamiast tego wyświetlimy węża oraz jego jedzenie.

Instalacja PyGame

Instalowanie pakietów przerabialiśmy w rozdziale Instalacja pakietów. W tym przypadku potrzebujemy zainstalować pygame. Jeśli korzystasz z pip w oknie poleceń, wpisz:

python -m pip install -U pygame --user

W PyCharm możemy w ustawieniach uruchomić okno zarządzania pakietami i tam przy pomocy plusa dodać pygame.

Zarządzanie zainstalowanymi pakietami w PyCharm. Aby dodać nowy, wciśnij plus, a otworzy się wyszukiwarka pakietów.

Stwórzmy okno na świat

Gdy już mamy pakiet PyGame, utwórzmy plik, w którym będziemy z niego korzystać. Możemy go nazwać game.py. Importowanie PyGame wygląda podobnie jak dla większości pakietów, a więc jest to po prostu import pygame. Następnie powinniśmy wywołać funkcję init(), która inicjuje wszystkie moduły pakietu. Niewiele pakietów ma podobne wymaganie, ale PyGame ma bardzo dużo funkcjonalności i niektóre z nich wymagają zainicjalizowania.

# game.py
import pygame

pygame.init()

W następnym kroku powinniśmy ustawić okno, w którym wyświetlana będzie nasza gra. Używamy do tego funkcji pygame.display.set_mode, do której przekazujemy tuple z wielkością okna. Okno posiada wysokość i szerokość, a więc tuple powinno mieć dwie wartości typu int. Jak duże powinno być to okno? Załóżmy, że plansza będzie rozmiarów 20 na 20, a każdy klocek niech będzie rozmiaru 251. Okno powinno być więc wysokości i szerokości 20 * 25. Ponieważ rysujemy kwadraty, ich wysokość i szerokość określimy tą samą współrzędną WIDTH.

CUBE_SIZE = 25
CUBES_NUM = 20
WIDTH = CUBE_SIZE * CUBES_NUM
screen = pygame.display.set_mode((WIDTH, WIDTH))

Wartości stałe (czyli nieulegające zmianom), takie jak CUBE_SIZE, CUBES_NUM czy WIDTH, zwyczajowo zapisujemy wielkimi literami, czyli zgodnie z konwencją SCREAMING_SNAKE_CASE.

W następnym kroku ustawimy kolor okna na biały. Użyjemy do tego funkcji fill na zwróconej referencji do okna. Jako wartość powinniśmy ponownie przekazać tuple, w którym zapisany będzie kolor przy pomocy zapisu RGB.

RGB to jeden z podstawowych zapisów dla kolorów. Kolejne liczby reprezentują kolejno wartości barwy czerwonej, zielonej i niebieskiej (stąd nazwa: Red, Green, Blue). Kolory z tych barw są ze sobą mieszane i w wyniku tego powstają inne kolory. Aby zrozumieć, jak powstają kolory z tych trzech barw, wyobraź sobie reflektory, które rzucają kolejno światło czerwone, zielone i niebieskie. Tam, gdzie nie padnie żadne z nich, jest idealnie ciemno. Tam, gdzie padną wszystkie z maksymalną mocą, mamy światło białe. Tam, gdzie padnie czerwony i zielony, zobaczymy żółty. Poprzez regulowanie intensywnością wszystkich trzech barw, możemy uzyskać właściwie dowolny kolor.

Ponieważ wartością maksymalną jest 255, kolor biały tworzymy przez (255, 255, 255).

WHITE = (255, 255, 255)
screen.fill(WHITE)

Dodajmy do naszej gry kilka przydatnych kolorów:

BLUE = (0, 0, 255)
GREEN = (0, 255, 0)
WHITE = (255, 255, 255)

Po każdej zmianie widoku powinniśmy wywołać pygame.display.update(), by pakiet wiedział, że zmianę tę należy wyświetlić.

pygame.display.update()

Gdybyśmy teraz uruchomili grę, to okno by się pokazało i natychmiast zamknęło. To dlatego, że nasz program doszedłby do końca i zakończył działanie. Tak działają programy: gdy dochodzimy do końca skryptu, kończymy ich działanie. Jak temu przeciwdziałać? Najłatwiej poprzez pętlę while. Na początek możemy użyć pętli nieskończonej:

while True:
    pass

Gdy odpalimy ten program, zobaczymy wreszcie okno z białym tłem. Program będzie więc uruchomiony, dopóki gra trwa. Niestety powstaje przeciwny problem: gdy ktoś zamknie okno, program wciąż będzie działał. Problem ten można rozwiązać na wiele sposobów. Często uzależnia się pętle while od zmiennej sygnalizującej, czy program wciąż powinien trwać. Pewniejszą jednak metodą jest wywołanie funkcji quit(), która natychmiast kończy program. Powinniśmy ją wywołać w sytuacji, gdy użytkownik zażąda zamknięcia gry, a więc w odpowiedzi na pewne zdarzenie. O zdarzeniach jeszcze pomówimy, na ten moment wystarczy powiedzieć, że w ciele naszej pętli nieskończonej powinniśmy dodać następujący kod:

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        quit()

Kod na tym etapie powinien wyglądać tak:

# game.py
import pygame

pygame.init()

CUBE_SIZE = 25
CUBES_NUM = 20
WIDTH = CUBE_SIZE * CUBES_NUM
screen = pygame.display.set_mode((WIDTH, WIDTH))
WHITE = (255, 255, 255)
screen.fill(WHITE)
pygame.display.update()

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            quit()

Gdy go uruchomimy, zobaczymy białe okno. To w nim będzie nasza gra. Gdy to okno zamkniemy, nasz program również się zakończy.

Jest to bardzo dobry początek naszej przygody. Teraz pomówmy o rysowaniu węża i jego jedzenia.

Kwadrat

Aby narysować kwadrat w PyGame, używamy funkcji pygame.draw.rect z argumentami:

  • referencja do okna, czyli u nas screen,
  • kolor (tuple RGB),
  • tuple z kolejno współrzędnymi lewego górnego rogu oraz wielkością kwadratu.

Określenie współrzędnych punktu.

Kwadraty o wielkości 25 na 25, o współrzędnych (25, 25) i (175, 75).

W naszym przypadku kwadraty posłużą nam do narysowania węża. Za kolor możemy przyjąć zielony (0, 255, 0). Jego wysokość i szerokość określiliśmy jako CUBE_SIZE. Jeśli chodzi o umiejscowienie, to kawałki muszą się mieścić w siatce o wielkości CUBES_NUM. Umiejscowienie kawałka wolelibyśmy więc określać poprzez współrzędne na tej siatce. To umiejscowienie moglibyśmy określić poprzez dwa parametry xy.

def draw_snake_part(x, y):
    position = (x * CUBE_SIZE,
                y * CUBE_SIZE,
                CUBE_SIZE,
                CUBE_SIZE)
    pygame.draw.rect(screen, GREEN, position)
    pygame.display.update()

Użyjmy tej funkcji do narysowania kilku kawałków węża w różnych miejscach:

draw_snake_part(0, 0)
draw_snake_part(13, 16)
draw_snake_part(10, 10)
draw_snake_part(12, 8)
draw_snake_part(19, 19)

Rysowanie koła

Rysowanie koła jest bardzo podobne do rysowania kwadratu. Używamy do tego funkcji pygame.draw.circle, do której podajemy kolejno:

  • referencja do okna, czyli u nas screen,
  • kolor (tuple RGB),
  • pozycję środka koła (tuple ze współrzędnymi),
  • wielkość koła (promień).

Ponieważ chcemy, by nasze koło idealnie wpisywało się w kwadraty, jego promień powinien wynosić dokładnie połowę CUBE_SIZE. Ponieważ określamy pozycję środka koła, ta również powinna być przesunięta o połowę CUBE_SIZE względem pozycji, w jakiej zaczynałby się kwadrat.

Siatka współrzędnych na płótnie.

def draw_food(x, y):
    radius = float(CUBE_SIZE) / 2
    position = (x * CUBE_SIZE + radius,
                y * CUBE_SIZE + radius)
    pygame.draw.circle(screen, BLUE, position, radius)
    pygame.display.update()

Użyjmy tej funkcji do narysowania kawałków jedzenia w różnych miejscach planszy:

draw_food(0, 0)
draw_food(13, 16)
draw_food(10, 10)
draw_food(12, 8)
draw_food(19, 19)

Rysowanie węża

Węża możemy reprezentować jako po prostu listę jego elementów. Elementy zaś są reprezentowane w pełni przez swoje umiejscowienie. Moglibyśmy stworzyć listę tupli, ale wygodniej będzie nam zdefiniować klasę do reprezentowania współrzędnych punktu. Tutaj przypomnimy sobie klasę Position z rozdziału Operatory. W naszym przykładzie użyjemy wariantu, w którym przechowuje ona współrzędne punktu, jak również pozwala na porównanie wartości i zamianę obiektu na string. Porównywanie punktów okaże się przydatne do sprawdzania, czy nie nastąpiliśmy na ogon lub jedzenie. Wypisywanie ich przyda nam się przy pracy nad grą. Poniższą klasę dodaj do projektu, w osobnym pliku o nazwie position.py.

# position.py
class Position:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        return isinstance(other, Position) and \
               self.x == other.x and \
               self.y == other.y
    
    def __str__(self):
        return f"({self.x}, {self.y})"

    __repr__ = __str__

W naszym pliku z grą pamiętaj zaimportować tę klasę:

from position import Position

Jako współrzędne podawać będziemy numer kwadratu na osi poziomej lub pionowej. Jeśli wielkość naszej planszy to 20, to pozycja opisana jest przez liczby od 0 do 19. Przykładowy wąż mógłby wyglądać następująco:

snake = [
    Position(2, 2),
    Position(3, 2),
    Position(4, 2),
    Position(5, 2),
    Position(5, 1),
]

Teraz wyświetlenie węża, to po prostu wyświetlenie kolejnych jego części. Możemy użyć do tego pętli for:

def draw_snake(snake):
    for part in snake:
        draw_snake_part(part.x, part.y)

Miejsce, w którym znajduje się pożywienie również wygodnie będzie nam reprezentować przy użyciu klasy Position:

food = Position(11, 14)

Przerobię więc funkcje do rysowania części węża oraz jedzenia tak, by przyjmowały Position jako argumenty:

def draw_snake_part(pos):
    position = (pos.x * CUBE_SIZE,
                pos.y * CUBE_SIZE,
                CUBE_SIZE,
                CUBE_SIZE)
    pygame.draw.rect(screen, GREEN, position)
    pygame.display.update()
def draw_food(pos):
    radius = float(CUBE_SIZE) / 2
    position = (pos.x * CUBE_SIZE + radius,
                pos.y * CUBE_SIZE + radius)
    pygame.draw.circle(screen, BLUE, position, radius)
    pygame.display.update()


def draw_snake(snake):
    for part in snake:
        draw_snake_part(part)

Czyszczenie płótna

Brakuje nam już tylko jednej funkcji do rysowania na płótnie. Przed narysowaniem elementów w nowym miejscu, musimy wcześniej zmazać te narysowane poprzednio. Bez tego nasze płótno wypełni się nieaktualnymi rysunkami. Do wyczyszczenia płótna użyjemy znanej nam już funkcji fill, która wypełnia całe płótno określonym kolorem.

screen.fill(WHITE)

Optymalizacja

Aktualnie każda funkcja wprowadzająca zmianę w widoku, osobno woła funkcję pygame.display.update(), by tę zmianę wyświetlić. Gdy jednak stan naszej gry zacznie się zmieniać, może to prowadzić do tego, że niektóre elementy będą mrugać2. Nie jest to także najwydajniejsze rozwiązanie, gdyż przerysowywujemy elementy znacznie częściej niż potrzeba. Znacznie lepiej byłoby najpierw określić wszystkie zmiany, a następnie jednocześnie je wyświetlić. Aby to zrobić, pozbądźmy się update z poszczególnych funkcji, a zamiast tego wprowadźmy funkcję draw, która narysuje wszystkie interesujące nas elementy.

def draw_snake_part(pos):
    position = (pos.x * CUBE_SIZE,
                pos.y * CUBE_SIZE,
                CUBE_SIZE,
                CUBE_SIZE)
    pygame.draw.rect(screen, GREEN, position)


def draw_snake(snake):
    for part in snake:
        draw_snake_part(part)


def draw_food(pos):
    radius = float(CUBE_SIZE) / 2
    position = (pos.x * CUBE_SIZE + radius,
                pos.y * CUBE_SIZE + radius)
    pygame.draw.circle(screen, BLUE, position, radius)


def fill_bg():
    screen.fill(WHITE)


def draw(snake, food):
    fill_bg()
    draw_snake(snake)
    draw_food(food)
    pygame.display.update()
1:

Jednostką miary wysokości i szerokości w PyGame są piksele. Oczywiście, niewiele to mówi na temat tego, ile to jest 25. Najłatwiej jest dobrać odpowiednie wartości "na oko".

2:

Szybko pojawiać się i znikać.