4.2. Pong (obj)

Klasyczna gra w odbijanie piłeczki zrealizowana z użyciem biblioteki PyGame. Wersja obiektowa. Biblioteka PyGame ułatwia tworzenie aplikacji multimedialnych, w tym gier.

../../_images/pong_0.png

4.2.1. Przygotowanie

Do rozpoczęcia pracy z przykładem pobieramy szczątkowy kod źródłowy:

~/python101$ git checkout -f pong/z1

4.2.2. Okienko gry

Na wstępie w pliku ~/python101/games/pong.py otrzymujemy kod który przygotuje okienko naszej gry:

Kod nr
 1# coding=utf-8
 2
 3import pygame
 4import pygame.locals
 5
 6
 7class Board(object):
 8    """
 9    Plansza do gry. Odpowiada za rysowanie okna gry.
10    """
11
12    def __init__(self, width, height):
13        """
14        Konstruktor planszy do gry. Przygotowuje okienko gry.
15
16        :param width:
17        :param height:
18        """
19        self.surface = pygame.display.set_mode((width, height), 0, 32)
20        pygame.display.set_caption('Simple Pong')
21
22    def draw(self, *args):
23        """
24        Rysuje okno gry
25
26        :param args: lista obiektów do narysowania
27        """
28        background = (230, 255, 255)
29        self.surface.fill(background)
30        for drawable in args:
31            drawable.draw_on(self.surface)
32
33        # dopiero w tym miejscu następuje fatyczne rysowanie
34        # w oknie gry, wcześniej tylko ustalaliśmy co i jak ma zostać narysowane
35        pygame.display.update()
36
37
38board = Board(800, 400)
39board.draw()

W powyższym kodzie zdefiniowaliśmy klasę Board z dwiema metodami:

  1. konstruktorem __init__, oraz

  2. metodą draw posługującą się biblioteką PyGame do rysowania w oknie.

Na końcu utworzyliśmy instancję klasy Board i wywołaliśmy jej metodę draw na razie bez żadnych elementów wymagających narysowania.

Informacja

Każdy plik skryptu Python jest uruchamiany w momencie importu — plik/moduł główny jest importowany jako pierwszy.

Deklaracje klas są faktycznie instrukcjami sterującymi mówiącymi, by w aktualnym module utworzyć typy zawierające wskazane definicje.

Możemy mieszać deklaracje klas ze zwykłymi instrukcjami sterującymi takimi jak print, czy przypisaniem wartości zmiennej board = Board(800, 400) i następnie wywołaniem metody na obiekcie board.draw().

Nasz program możemy uruchomić komendą:

~/python101$ python games/pong.py

Mrugnęło? Program się wykonał i zakończył działanie :). Żeby zobaczyć efekt na dłużej, możemy na końcu chwilkę uśpić nasz program:

Kod nr
39import time
40time.sleep(5)

Jednak zamiast tego, dla lepszej kontroli powinniśmy zadeklarować klasę kontrolera gry, usuńmy kod od linii 37 do końca i dodajmy klasę kontrolera:

Kod nr
38class PongGame(object):
39    """
40    Łączy wszystkie elementy gry w całość.
41    """
42
43    def __init__(self, width, height):
44        pygame.init()
45        self.board = Board(width, height)
46        # zegar którego użyjemy do kontrolowania szybkości rysowania
47        # kolejnych klatek gry
48        self.fps_clock = pygame.time.Clock()
49
50    def run(self):
51        """
52        Główna pętla programu
53        """
54        while not self.handle_events():
55            # działaj w pętli do momentu otrzymania sygnału do wyjścia
56            self.board.draw()
57            self.fps_clock.tick(30)
58
59    def handle_events(self):
60        """
61        Obsługa zdarzeń systemowych, tutaj zinterpretujemy np. ruchy myszką
62
63        :return True jeżeli pygame przekazał zdarzenie wyjścia z gry
64        """
65        for event in pygame.event.get():
66            if event.type == pygame.locals.QUIT:
67                pygame.quit()
68                return True
69
70
71# Ta część powinna być zawsze na końcu modułu (ten plik jest modułem)
72# chcemy uruchomić naszą grę dopiero po tym jak wszystkie klasy zostaną zadeklarowane
73if __name__ == "__main__":
74    game = PongGame(800, 400)
75    game.run()

Informacja

Prócz dodania kontrolera zmieniliśmy także sposób, w jaki gra jest uruchamiana — nie mylić z uruchomieniem programu.

Na końcu dodaliśmy instrukcję warunkową if __name__ == "__main__":, w niej sprawdzamy, czy nasz moduł jest modułem głównym programu, jeśli nim jest, gra zostanie uruchomiona.

Dzięki temu, jeśli nasz moduł zostałby zaimportowany gdzieś indziej instrukcją import pong, deklaracje klas wykonałyby się, ale sama gra nie zostanie uruchomiona.

Gotowy kod możemy pobrać komendą:

~/python101$ git checkout -f pong/z2

4.2.3. Piłeczka

Czas dodać piłkę do gry. Piłeczką będzie kolorowe kółko, które z każdym przejściem naszej pętli przesuniemy o kilka punktów w osi X i Y, zgodnie wektorem prędkości.

Wcześniej jednak zdefiniujemy wspólną klasę bazową dla obiektów, które będziemy rysować w oknie naszej gry:

Kod nr
71class Drawable(object):
72    """
73    Klasa bazowa dla rysowanych obiektów
74    """
75
76    def __init__(self, width, height, x, y, color=(0, 255, 0)):
77        self.width = width
78        self.height = height
79        self.color = color
80        self.surface = pygame.Surface([width, height], pygame.SRCALPHA, 32).convert_alpha()
81        self.rect = self.surface.get_rect(x=x, y=y)
82
83    def draw_on(self, surface):
84        surface.blit(self.surface, self.rect)

Następnie dodajmy klasę samej piłeczki dziedzicząc z Drawable:

Kod nr
 87class Ball(Drawable):
 88    """
 89    Piłeczka, sama kontroluje swoją prędkość i kierunek poruszania się.
 90    """
 91    def __init__(self, width, height, x, y, color=(255, 0, 0), x_speed=3, y_speed=3):
 92        super(Ball, self).__init__(width, height, x, y, color)
 93        pygame.draw.ellipse(self.surface, self.color, [0, 0, self.width, self.height])
 94        self.x_speed = x_speed
 95        self.y_speed = y_speed
 96        self.start_x = x
 97        self.start_y = y
 98
 99    def bounce_y(self):
100        """
101        Odwraca wektor prędkości w osi Y
102        """
103        self.y_speed *= -1
104
105    def bounce_x(self):
106        """
107        Odwraca wektor prędkości w osi X
108        """
109        self.x_speed *= -1
110
111    def reset(self):
112        """
113        Ustawia piłeczkę w położeniu początkowym i odwraca wektor prędkości w osi Y
114        """
115        self.rect.move(self.start_x, self.start_y)
116        self.bounce_y()
117
118    def move(self):
119        """
120        Przesuwa piłeczkę o wektor prędkości
121        """
122        self.rect.x += self.x_speed
123        self.rect.y += self.y_speed

W przykładzie powyżej wykonaliśmy dziedziczenie oraz przesłanianie konstruktora, ponieważ rozszerzamy Drawable i chcemy zachować efekt działania konstruktora na początku konstruktora Ball wywołujemy konstruktor klasy bazowej:

super(Ball, self).__init__(width, height, x, y, color)

Teraz musimy naszą piłeczkę zintegrować z resztą gry:

Kod nr
38class PongGame(object):
39    """
40    Łączy wszystkie elementy gry w całość.
41    """
42
43    def __init__(self, width, height):
44        pygame.init()
45        self.board = Board(width, height)
46        # zegar którego użyjemy do kontrolowania szybkości rysowania
47        # kolejnych klatek gry
48        self.fps_clock = pygame.time.Clock()
49        self.ball = Ball(20, 20, width/2, height/2)
50
51    def run(self):
52        """
53        Główna pętla programu
54        """
55        while not self.handle_events():
56            # działaj w pętli do momentu otrzymania sygnału do wyjścia
57            self.ball.move()
58            self.board.draw(
59                self.ball,
60            )
61            self.fps_clock.tick(30)

Informacja

Metoda Board.draw oczekuje wielu opcjonalnych argumentów, choć na razie przekazujemy tylko jeden. By zwiększyć czytelność potencjalnie dużej listy argumentów — kto wie co jeszcze dodamy :) — podajemy każdy argument w osobnej linii zakończonej przecinkiem ,.

Python nie traktuje takich osieroconych przecinków jako błąd, jest to ukłon w stronę programistów, którzy często zmieniają kod, kopiują i wklejają kawałki.

Dzięki temu możemy wstawiać nowe i zmieniać kolejność bez zwracania uwagi, czy na końcu jest przecinek, czy go brakuje, czy go należy usunąć. Zgodnie z konwencją powinien być tam zawsze.

Gotowy kod możemy pobrać komendą:

~/python101$ git checkout -f pong/z3

4.2.4. Odbijanie piłeczki

Uruchommy naszą „grę” ;)

~/python101$ python games/pong.py
../../_images/pong_3.png

Efekt nie jest powalający, ale mamy już jakiś ruch na planszy. Szkoda, że piłka spada z planszy. Może mogła by się odbijać od krawędzi okienka? Możemy wykorzystać wcześniej przygotowane metody do zmiany kierunku wektora prędkości, musimy tylko wykryć moment w którym piłeczka będzie dotykać krawędzi.

W tym celu piłeczka musi być świadoma istnienia planszy i pozycji krawędzi, dlatego zmodyfikujemy metodę Ball.move tak by przyjmowała board jako argument i na jego podstawie sprawdzimy, czy piłeczka powinna się odbijać:

Kod nr
122def move(self, board):
123    """
124    Przesuwa piłeczkę o wektor prędkości
125    """
126    self.rect.x += self.x_speed
127    self.rect.y += self.y_speed
128
129    if self.rect.x < 0 or self.rect.x > board.surface.get_width():
130        self.bounce_x()
131
132    if self.rect.y < 0 or self.rect.y > board.surface.get_height():
133        self.bounce_y()

Jeszcze zmodyfikujmy wywołanie metody move w naszej pętli głównej:

Kod nr
51def run(self):
52    """
53    Główna pętla programu
54    """
55    while not self.handle_events():
56        self.ball.move(self.board)
57        self.board.draw(
58            self.ball,
59        )
60        self.fps_clock.tick(30)

Ostrzeżenie

Powyższe przykłady mają o jedno wcięcie za mało. Poprawnie wcięte przykłady straciłyby kolorowanie w tej formie materiałów. Ze względu na czytelność kodu zdecydowaliśmy się na taki drobny błąd. Kod po ewentualnym wklejeniu należy poprawić dodając jedno wcięcie (4 spacje).

Sprawdzamy, czy piłka się odbija, uruchamiamy nasz program:

~/python101$ python games/pong.py

Gotowy kod możemy pobrać komendą:

~/python101$ git checkout -f pong/z4

4.2.5. Odbijamy piłeczkę rakietką

Dodajmy „rakietkę”, przy pomocy której będziemy mogli odbijać piłeczkę. Będzie to zwykły prostokąt, który będziemy przesuwać za pomocą myszki.

Kod nr
136class Racket(Drawable):
137    """
138    Rakietka, porusza się w osi X z ograniczeniem prędkości.
139    """
140
141    def __init__(self, width, height, x, y, color=(0, 255, 0), max_speed=10):
142        super(Racket, self).__init__(width, height, x, y, color)
143        self.max_speed = max_speed
144        self.surface.fill(color)
145
146    def move(self, x):
147        """
148        Przesuwa rakietkę w wyznaczone miejsce.
149        """
150        delta = x - self.rect.x
151        if abs(delta) > self.max_speed:
152            delta = self.max_speed if delta > 0 else -self.max_speed
153        self.rect.x += delta

Informacja

W tym przykładzie zastosowaliśmy operator warunkowy, który ogranicza prędkość poruszania się rakietki:

delta = self.max_speed if delta > 0 else -self.max_speed

Zmienna delta otrzyma wartość max_speed ze znakiem + lub - w zależności od znaku jaki ma aktualnie.

Następnie „pokażemy” rakietkę piłeczce, tak by mogła się od niej odbijać. Wiemy że rakietek będzie więcej, dlatego od razu tak zmodyfikujemy metodę Ball.move, by przyjmowała kolekcję rakietek:

Kod nr
122def move(self, board, *args):
123    """
124    Przesuwa piłeczkę o wektor prędkości
125    """
126    self.rect.x += self.x_speed
127    self.rect.y += self.y_speed
128
129    if self.rect.x < 0 or self.rect.x > board.surface.get_width():
130        self.bounce_x()
131
132    if self.rect.y < 0 or self.rect.y > board.surface.get_height():
133        self.bounce_y()
134
135    for racket in args:
136        if self.rect.colliderect(racket.rect):
137            self.bounce_y()

Tak jak w przypadku dodawania piłeczki, rakietkę też trzeba dodać do „gry”, dodatkowo musimy ją pokazać piłeczce:

Kod nr
38class PongGame(object):
39    """
40    Łączy wszystkie elementy gry w całość.
41    """
42
43    def __init__(self, width, height):
44        pygame.init()
45        self.board = Board(width, height)
46        # zegar którego użyjemy do kontrolowania szybkości rysowania
47        # kolejnych klatek gry
48        self.fps_clock = pygame.time.Clock()
49        self.ball = Ball(width=20, height=20, x=width/2, y=height/2)
50        self.player1 = Racket(width=80, height=20, x=width/2, y=height/2)
51
52    def run(self):
53        """
54        Główna pętla programu
55        """
56        while not self.handle_events():
57            # działaj w pętli do momentu otrzymania sygnału do wyjścia
58            self.ball.move(self.board, self.player1)
59            self.board.draw(
60                self.ball,
61                self.player1,
62            )
63            self.fps_clock.tick(30)
64
65    def handle_events(self):
66        """
67        Obsługa zdarzeń systemowych, tutaj zinterpretujemy np. ruchy myszką
68
69        :return True jeżeli pygame przekazał zdarzenie wyjścia z gry
70        """
71        for event in pygame.event.get():
72            if event.type == pygame.locals.QUIT:
73                pygame.quit()
74                return True
75
76            if event.type == pygame.locals.MOUSEMOTION:
77                # myszka steruje ruchem pierwszego gracza
78                x, y = event.pos
79                self.player1.move(x)

Gotowy kod możemy pobrać komendą:

~/python101$ git checkout -f pong/z5

Informacja

W tym miejscu można się pobawić naszą grą. Zmodyfikuj ją według uznania i podziel się rezultatem z innymi. Jeśli kod przestanie działać, można szybko cofnąć zmiany poniższą komendą.

~/python101$ git reset --hard

4.2.6. Gramy przeciwko komputerowi

Dodajemy przeciwnika, nasz przeciwnik będzie mistrzem, będzie dokładnie śledził piłeczkę i zawsze starał się utrzymać rakietkę gotową do odbicia piłeczki.

Kod nr
167
168class Ai(object):
169    """
170    Przeciwnik, steruje swoją rakietką na podstawie obserwacji piłeczki.
171    """
172    def __init__(self, racket, ball):
173        self.ball = ball
174        self.racket = racket
175
176    def move(self):
177        x = self.ball.rect.centerx
178        self.racket.move(x)

Tak jak w przypadku piłeczki i rakietki dodajemy nasze Ai do gry, a wraz nią drugą rakietkę. Rakietki ustawiamy na przeciwległych brzegach planszy.

Trzeba pamiętać, by pokazać drugą rakietkę piłeczce, tak by mogła się od niej odbijać.

Kod nr
38class PongGame(object):
39    """
40    Łączy wszystkie elementy gry w całość.
41    """
42
43    def __init__(self, width, height):
44        pygame.init()
45        self.board = Board(width, height)
46        # zegar którego użyjemy do kontrolowania szybkości rysowania
47        # kolejnych klatek gry
48        self.fps_clock = pygame.time.Clock()
49        self.ball = Ball(width=20, height=20, x=width/2, y=height/2)
50        self.player1 = Racket(width=80, height=20, x=width/2 - 40, y=height - 40)
51        self.player2 = Racket(width=80, height=20, x=width/2 - 40, y=20, color=(0, 0, 0))
52        self.ai = Ai(self.player2, self.ball)
53
54    def run(self):
55        """
56        Główna pętla programu
57        """
58        while not self.handle_events():
59            # działaj w pętli do momentu otrzymania sygnału do wyjścia
60            self.ball.move(self.board, self.player1, self.player2)
61            self.board.draw(
62                self.ball,
63                self.player1,
64                self.player2,
65            )
66            self.ai.move()
67            self.fps_clock.tick(30)

4.2.7. Pokazujemy punkty

Dodajmy klasę sędziego, który patrząc na poszczególne elementy gry będzie decydował, czy graczom należą się punkty i będzie ustawiał piłkę w początkowym położeniu.

Kod nr
184
185
186class Judge(object):
187    """
188    Sędzia gry
189    """
190
191    def __init__(self, board, ball, *args):
192        self.ball = ball
193        self.board = board
194        self.rackets = args
195        self.score = [0, 0]
196
197        # Przed pisaniem tekstów, musimy zainicjować mechanizmy wyboru fontów PyGame
198        pygame.font.init()
199        font_path = pygame.font.match_font('arial')
200        self.font = pygame.font.Font(font_path, 64)
201
202    def update_score(self, board_height):
203        """
204        Jeśli trzeba przydziela punkty i ustawia piłeczkę w początkowym położeniu.
205        """
206        if self.ball.rect.y < 0:
207            self.score[0] += 1
208            self.ball.reset()
209        elif self.ball.rect.y > board_height:
210            self.score[1] += 1
211            self.ball.reset()
212
213    def draw_text(self, surface,  text, x, y):
214        """
215        Rysuje wskazany tekst we wskazanym miejscu
216        """
217        text = self.font.render(text, True, (150, 150, 150))
218        rect = text.get_rect()
219        rect.center = x, y
220        surface.blit(text, rect)
221
222    def draw_on(self, surface):
223        """
224        Aktualizuje i rysuje wyniki
225        """
226        height = self.board.surface.get_height()
227        self.update_score(height)
228
229        width = self.board.surface.get_width()
230        self.draw_text(surface, "Player: {}".format(self.score[0]), width/2, height * 0.3)
231        self.draw_text(surface, "Computer: {}".format(self.score[1]), width/2, height * 0.7)

Jak zwykle dodajemy instancję nowej klasy do gry:

Kod nr
38class PongGame(object):
39    """
40    Łączy wszystkie elementy gry w całość.
41    """
42
43    def __init__(self, width, height):
44        pygame.init()
45        self.board = Board(width, height)
46        # zegar którego użyjemy do kontrolowania szybkości rysowania
47        # kolejnych klatek gry
48        self.fps_clock = pygame.time.Clock()
49        self.ball = Ball(width=20, height=20, x=width/2, y=height/2)
50        self.player1 = Racket(width=80, height=20, x=width/2 - 40, y=height - 40)
51        self.player2 = Racket(width=80, height=20, x=width/2 - 40, y=20, color=(0, 0, 0))
52        self.ai = Ai(self.player2, self.ball)
53        self.judge = Judge(self.board, self.ball, self.player2, self.ball)
54
55    def run(self):
56        """
57        Główna pętla programu
58        """
59        while not self.handle_events():
60            # działaj w pętli do momentu otrzymania sygnału do wyjścia
61            self.ball.move(self.board, self.player1, self.player2)
62            self.board.draw(
63                self.ball,
64                self.player1,
65                self.player2,
66                self.judge,
67            )
68            self.ai.move()
69            self.fps_clock.tick(30)
70

4.2.8. Zadania dodatkowe

  1. Piłeczka „odbija się” po zewnętrznej prawej i dolnej krawędzi. Można to poprawić.

  2. Metoda Ball.move otrzymuje w argumentach planszę i rakietki. Te elementy można piłeczce przekazać tylko raz w konstruktorze.

  3. Komputer nie odbija piłeczkę rogiem rakietki.

  4. Rakietka gracza rusza się tylko, gdy gracz rusza myszką, ruch w stronę myszki powinien być kontynuowany także, gdy myszka jest bezczynna.

  5. Gdy piłeczka odbija się od boków rakietki, powinna odbijać się w osi X.

  6. Gra dwuosobowa z użyciem komunikacji po sieci.


Licencja Creative Commons Materiały Python 101 udostępniane przez Centrum Edukacji Obywatelskiej na licencji Creative Commons Uznanie autorstwa-Na tych samych warunkach 4.0 Międzynarodowa.

Utworzony:

2025-04-12 o 10:21 w Sphinx 7.3.7

Autorzy:

Patrz plik „Autorzy”