4.6. Życie Conwaya (obj)

Gra w życie zrealizowana z użyciem biblioteki PyGame.

../../_images/screen1.png

4.6.1. Przygotowanie

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

~/python101$ git checkout -f life/z1

4.6.2. Okienko gry

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

Informacja

Ten przykład zakłada wcześniejsze zrealizowanie przykładu: Pong (obj), opisy niektórych cech wspólnych zostały tutaj wyraźnie pominięte. W tym przykładzie wykorzystujemy np. podobne mechanizmy do tworzenia okna i zarządzania główną pętlą 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: szerokość w pikselach
17        :param height: wysokość w pikselach
18        """
19        self.surface = pygame.display.set_mode((width, height), 0, 32)
20        pygame.display.set_caption('Game of life')
21
22    def draw(self, *args):
23        """
24        Rysuje okno gry
25
26        :param args: lista obiektów do narysowania
27        """
28        background = (0, 0, 0)
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
38class GameOfLife(object):
39    """
40    Łączy wszystkie elementy gry w całość.
41    """
42
43    def __init__(self, width, height, cell_size=10):
44        """
45        Przygotowanie ustawień gry
46        :param width: szerokość planszy mierzona liczbą komórek
47        :param height: wysokość planszy mierzona liczbą komórek
48        :param cell_size: bok komórki w pikselach
49        """
50        pygame.init()
51        self.board = Board(width * cell_size, height * cell_size)
52        # zegar którego użyjemy do kontrolowania szybkości rysowania
53        # kolejnych klatek gry
54        self.fps_clock = pygame.time.Clock()
55
56    def run(self):
57        """
58        Główna pętla gry
59        """
60        while not self.handle_events():
61            # działaj w pętli do momentu otrzymania sygnału do wyjścia
62            self.board.draw()
63            self.fps_clock.tick(15)
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
77# Ta część powinna być zawsze na końcu modułu (ten plik jest modułem)
78# chcemy uruchomić naszą grę dopiero po tym jak wszystkie klasy zostaną zadeklarowane
79if __name__ == "__main__":
80    game = GameOfLife(80, 40)
81    game.run()

W powyższym kodzie mamy podstawy potrzebne do uruchomienia gry:

~/python101$ python games/life.py

4.6.3. Tworzymy matrycę życia

Nasza gra polega na ułożenia komórek na planszy i obserwacji jak w kolejnych generacjach życie się zmienia, które komórki giną, gdzie się rozmnażają i wywołują efektowną wędrówkę oraz tworzenie się ciekawych struktur.

Zacznijmy od zadeklarowania zmiennych które zastąpią nam tzw. magiczne liczby. W kodzie zamiast wartości 1 dla określenia żywej komórki i wartości 0 dla martwej komórki wykorzystamy zmiennie ALIVE oraz DEAD. W innych językach takie zmienne czasem są określane jako stała.

Kod nr
77# magiczne liczby używane do określenia czy komórka jest żywa
78DEAD = 0
79ALIVE = 1

Podstawą naszego życia będzie klasa Population która będzie przechowywać stan gry, a także realizować funkcje potrzebne do zmian stanu gry w czasie. W przeciwieństwie do gry w Pong nie będziemy dzielić odpowiedzialności pomiędzy większą liczbę klas.

Kod nr
 82class Population(object):
 83    """
 84    Populacja komórek
 85    """
 86
 87    def __init__(self, width, height, cell_size=10):
 88        """
 89        Przygotowuje ustawienia populacji
 90
 91        :param width: szerokość planszy mierzona liczbą komórek
 92        :param height: wysokość planszy mierzona liczbą komórek
 93        :param cell_size: bok komórki w pikselach
 94        """
 95        self.box_size = cell_size
 96        self.height = height
 97        self.width = width
 98        self.generation = self.reset_generation()
 99
100    def reset_generation(self):
101        """
102        Tworzy i zwraca macierz pustej populacji
103        """
104        # w pętli wypełnij listę kolumnami
105        # które także w pętli zostają wypełnione wartością 0 (DEAD)
106        return [[DEAD for y in range(self.height)] for x in range(self.width)]

Poza ostatnią linią nie ma tutaj wielu niespodzianek, ot konstruktor __init__ zapamiętujący wartości konfiguracyjne w instancji naszej klasy, tj. w self.

W ostatniej linii budujemy macierz dla komórek. Tablicę dwuwymiarową, którą będziemy adresować przy pomocy współrzędnych x i y. Jeśli plansza miałaby szerokość 4, a wysokość 3 komórek to zadeklarowana ręcznie nasza tablica wyglądałaby tak:

Kod nr
1generation = [
2    [DEAD, DEAD, DEAD, DEAD],
3    [DEAD, DEAD, DEAD, DEAD],
4    [DEAD, DEAD, DEAD, DEAD],
5]

Jednak ręczne zadeklarowanie byłoby uciążliwe i mało elastyczne, wyobraźmy sobie macierz 40 na 80 — strasznie dużo pisania! Dlatego posłużymy się pętlami i wyliczymy sobie dowolną macierz na podstawie zadanych parametrów.

Kod nr
1def reset_generation(self)
2    generation = []
3    for x in range(self.width):
4        column = []
5        for y in range(self.height)
6            column.append(DEAD)
7        generation.append(column)
8    return generation

Powyżej wykorzystaliśmy 2 pętle (jedna zagnieżdżona w drugiej) oraz funkcję range która wygeneruje listę wartości od 0 do zadanej wartości - 1. Dzięki temu nasze pętle uzyskają self.width i self.height przebiegów. Jest lepiej.

Przykład kodu powyżej to konstrukcja którą w taki lub podobny sposób wykorzystuje się co chwila w każdym programie — to chleb powszedni programisty. Każdy program musi w jakiś sposób iterować po elementach list przekształcając je w inne listy.

W linii 113 mamy przykład zastosowania tzw. wyrażeń listowych (ang. list comprehensions). Pomiędzy znakami nawiasów kwadratowych [ ] mamy pętlę, która w każdym przebiegu zwraca jakiś element. Te zwrócone elementy napełniają nową listę która zostanie zwrócona w wyniku wyrażenia.

Sprawę komplikuje dodaje fakt, że chcemy uzyskać tablicę dwuwymiarową dlatego mamy zagnieżdżone wyrażenie listowe (jak 2 pętle powyżej). Zajrzyjmy najpierw do wewnętrznego wyrażenia:

Kod nr
1[DEAD for y in range(self.height)]

W kodzie powyżej każdym przebiegu pętli uzyskamy DEAD. Dzięki temu zyskamy kolumnę macierzy od wysokości self.height, w każdej z nich będziemy mogli się dostać do pojedynczej komorki adresując ją listę wartością y o tak kolumna[y].

Teraz zajmijmy się zewnętrznym wyrażeniem listowym, ale dla uproszczenia w każdym jego przebiegu zwracajmy nowa_kolumna

Kod nr
1[nowa_kolumna for x in range(self.width)]

W kodzie powyżej w każdym przebiegu pętli uzyskamy nowa_kolumna. Dzięki temu zyskamy listę kolumn. Do każdej z nich będziemy mogli się dostać adresując listę wartością x o tak generation[x], w wyniku otrzymamy kolumnę którą możemy adresować wartością y, co w sumie da nam macierz w której do komórek dostaniemy się o tak: generation[x][y].

Zamieniamy nowa_kolumna wyrażeniem listowym dla y i otrzymamy 1 linijkę zamiast 7 z przykładu z podwójną pętlą:

Kod nr
1[[DEAD for y in range(self.height)] for x in range(self.width)]

4.6.4. Układamy żywe komórki na planszy

Teraz przygotujemy kod który dzięki wykorzystaniu myszki umożliwi nam ułożenie planszy, będziemy wybierać gdzie na planszy będą żywe komórki. Dodajmy do klasy Population metodę handle_mouse którą będziemy później wywoływać w metody GameOfLife.handle_events za każdym razem gdy nasz program otrzyma zdarzenie dotyczące myszki.

Chcemy by myszka z naciśniętym lewym klawiszem ustawiała pod kursorem żywą komórkę. Jeśli jest naciśnięty inny klawisz to usuniemy żywą komórkę. Jeśli żaden z klawiszy nie jest naciśnięty to zignorujemy zdarzenie myszki.

Zdarzenia są generowane w przypadku naciśnięcia klawiszy lub ruchu myszką, nie będziemy nic robić jeśli gracz poruszy myszką bez naciskania klawiszy.

Kod nr
108def handle_mouse(self):
109    # pobierz stan guzików myszki z wykorzystaniem funcji pygame
110    buttons = pygame.mouse.get_pressed()
111    if not any(buttons):
112        # ignoruj zdarzenie jeśli żaden z guzików nie jest wciśnięty
113        return
114
115    # dodaj żywą komórką jeśli wciśnięty jest pierwszy guzik myszki
116    # będziemy mogli nie tylko dodawać żywe komórki ale także je usuwać
117    alive = True if buttons[0] else False
118
119    # pobierz pozycję kursora na planszy mierzoną w pikselach
120    x, y = pygame.mouse.get_pos()
121
122    # przeliczamy współrzędne komórki z pikseli na współrzędne komórki w macierz
123    # gracz może kliknąć w kwadracie o szerokości box_size by wybrać komórkę
124    x /= self.box_size
125    y /= self.box_size
126
127    # ustaw stan komórki na macierzy
128    self.generation[int(x)][int(y)] = ALIVE if alive else DEAD

Następnie dodajmy metodę draw_on która będzie rysować żywe komórki na planszy. Tą metodę wywołamy w metodzie GameOfLife.draw.

Kod nr
130def draw_on(self, surface):
131    """
132    Rysuje komórki na planszy
133    """
134    for x, y in self.alive_cells():
135        size = (self.box_size, self.box_size)
136        position = (x * self.box_size, y * self.box_size)
137        color = (255, 255, 255)
138        thickness = 1
139        pygame.draw.rect(surface, color, pygame.locals.Rect(position, size), thickness)

Powyżej wykorzystaliśmy nie istniejącą metodę alive_cells która jak wynika z jej użycia powinna zwrócić kolekcję współrzędnych dla żywych komórek. Po jednej parze x, y dla każdej żywej komórki. Każdą żywą komórkę narysujemy jako kwadrat w białym kolorze.

Utwórzmy metodę alive_cells która w pętli przejdzie po całej macierzy populacji i zwróci tylko współrzędne żywych komórek.

Kod nr
141def alive_cells(self):
142    """
143    Generator zwracający współrzędne żywych komórek.
144    """
145    for x in range(len(self.generation)):
146        column = self.generation[x]
147        for y in range(len(column)):
148            if column[y] == ALIVE:
149                # jeśli komórka jest żywa zwrócimy jej współrzędne
150                yield x, y

W kodzie powyżej mamy przykład dwóch pętli przy pomocy których sprawdzamy zawartość stan życia komórek dla wszystkich możliwych współrzędnych x i y w macierzy. Na uwagę zasługują dwie rzeczy. Nigdzie tutaj nie zadeklarowaliśmy listy żywych komórek — którą chcemy zwrócić — oraz instrukcję yield.

Instrukcja yield powoduje, że nasza funkcja zamiast zwykłych wartości zwróci generator. W skrócie w każdym przebiegu wewnętrznej pętli zostaną wygenerowane i zwrócone na zewnątrz wartości x, y. Za każdym razem gdy for x, y in self.alive_cells() poprosi o współrzędne następnej żywej komórki, alive_cells wykona się do instrukcji yield.

Wskazówka

Działanie generatora najlepiej zaobserwować w debugerze, będziemy mogli to zrobić za chwilę.

4.6.5. Dodajemy populację do kontrolera gry

Czas by rozwinąć nasz kontroler gry, klasę GameOfLife o instancję klasy Population

Kod nr
38class GameOfLife(object):
39    """
40    Łączy wszystkie elementy gry w całość.
41    """
42
43    def __init__(self, width, height, cell_size=10):
44        """
45        Przygotowanie ustawień gry
46        :param width: szerokość planszy mierzona liczbą komórek
47        :param height: wysokość planszy mierzona liczbą komórek
48        :param cell_size: bok komórki w pikselach
49        """
50        pygame.init()
51        self.board = Board(width * cell_size, height * cell_size)
52        # zegar którego użyjemy do kontrolowania szybkości rysowania
53        # kolejnych klatek gry
54        self.fps_clock = pygame.time.Clock()
55        self.population = Population(width, height, cell_size)
56
57    def run(self):
58        """
59        Główna pętla gry
60        """
61        while not self.handle_events():
62            # działaj w pętli do momentu otrzymania sygnału do wyjścia
63            self.board.draw(
64                self.population,
65            )
66            self.fps_clock.tick(15)
67
68    def handle_events(self):
69        """
70        Obsługa zdarzeń systemowych, tutaj zinterpretujemy np. ruchy myszką
71
72        :return True jeżeli pygame przekazał zdarzenie wyjścia z gry
73        """
74        for event in pygame.event.get():
75            if event.type == pygame.locals.QUIT:
76                pygame.quit()
77                return True
78
79            from pygame.locals import MOUSEMOTION, MOUSEBUTTONDOWN
80            if event.type == MOUSEMOTION or event.type == MOUSEBUTTONDOWN:
81                self.population.handle_mouse()

Gotowy kod możemy wyciągnąć komendą:

~/python101$ git checkout -f life/z2

4.6.6. Szukamy żyjących sąsiadów

Podstawą do określenia tego czy w danym miejscu na planszy (w współrzędnych x i y macierzy) powstanie nowe życie, przetrwa lub zginie istniejące życie; jest określenie liczby żywych komórek w bezpośrednim sąsiedztwie. Przygotujmy do tego metodę:

Kod nr
159def neighbours(self, x, y):
160    """
161    Generator zwracający wszystkich okolicznych sąsiadów
162    """
163    for nx in range(x-1, x+2):
164        for ny in range(y-1, y+2):
165            if nx == x and ny == y:
166                # pomiń współrzędne centrum
167                continue
168            if nx >= self.width:
169                # sąsiad poza końcem planszy, bierzemy pierwszego w danym rzędzie
170                nx = 0
171            elif nx < 0:
172                # sąsiad przed początkiem planszy, bierzemy ostatniego w danym rzędzie
173                nx = self.width - 1
174            if ny >= self.height:
175                # sąsiad poza końcem planszy, bierzemy pierwszego w danej kolumnie
176                ny = 0
177            elif ny < 0:
178                # sąsiad przed początkiem planszy, bierzemy ostatniego w danej kolumnie
179                ny = self.height - 1
180
181            # dla każdego nie pominiętego powyżej
182            # przejścia pętli zwróć komórkę w tych współrzędnych
183            yield self.generation[nx][ny]

Następnie przygotujmy funkcję która będzie tworzyć nową populację

Kod nr
185def cycle_generation(self):
186    """
187    Generuje następną generację populacji komórek
188    """
189    next_gen = self.reset_generation()
190    for x in range(len(self.generation)):
191        column = self.generation[x]
192        for y in range(len(column)):
193            # pobieramy wartości sąsiadów
194            # dla żywej komórki dostaniemy wartość 1 (ALIVE)
195            # dla martwej otrzymamy wartość 0 (DEAD)
196            # zwykła suma pozwala nam określić liczbę żywych sąsiadów
197            count = sum(self.neighbours(x, y))
198            if count == 3:
199                # rozmnażamy się
200                next_gen[x][y] = ALIVE
201            elif count == 2:
202                # przechodzi do kolejnej generacji bez zmian
203                next_gen[x][y] = column[y]
204            else:
205                # za dużo lub za mało sąsiadów by przeżyć
206                next_gen[x][y] = DEAD
207
208    # nowa generacja staje się aktualną generacją
209    self.generation = next_gen

Jeszcze ostatnie modyfikacje kontrolera gry tak by komórki zaczęły żyć po wciśnięciu klawisza enter.

Kod nr
 1class GameOfLife(object):
 2    """
 3    Łączy wszystkie elementy gry w całość.
 4    """
 5
 6    def __init__(self, width, height, cell_size=10):
 7        """
 8        Przygotowanie ustawień gry
 9        :param width: szerokość planszy mierzona liczbą komórek
10        :param height: wysokość planszy mierzona liczbą komórek
11        :param cell_size: bok komórki w pikselach
12        """
13        pygame.init()
14        self.board = Board(width * cell_size, height * cell_size)
15        # zegar którego użyjemy do kontrolowania szybkości rysowania
16        # kolejnych klatek gry
17        self.fps_clock = pygame.time.Clock()
18        self.population = Population(width, height, cell_size)
19
20    def run(self):
21        """
22        Główna pętla gry
23        """
24        while not self.handle_events():
25            # działaj w pętli do momentu otrzymania sygnału do wyjścia
26            self.board.draw(
27                self.population,
28            )
29            if getattr(self, "started", None):
30                self.population.cycle_generation()
31            self.fps_clock.tick(15)
32
33    def handle_events(self):
34        """
35        Obsługa zdarzeń systemowych, tutaj zinterpretujemy np. ruchy myszką
36
37        :return True jeżeli pygame przekazał zdarzenie wyjścia z gry
38        """
39        for event in pygame.event.get():
40            if event.type == pygame.locals.QUIT:
41                pygame.quit()
42                return True
43
44            from pygame.locals import MOUSEMOTION, MOUSEBUTTONDOWN
45            if event.type == MOUSEMOTION or event.type == MOUSEBUTTONDOWN:
46                self.population.handle_mouse()
47
48            from pygame.locals import KEYDOWN, K_RETURN
49            if event.type == KEYDOWN and event.key == K_RETURN:
50                self.started = True

Gotowy kod możemy wyciągnąć komendą:

~/python101$ git checkout -f life/z3

4.6.7. Zadania dodatkowe

  1. TODO


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”