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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# coding=utf-8

import pygame
import pygame.locals


class Board(object):
    """
    Plansza do gry. Odpowiada za rysowanie okna gry.
    """

    def __init__(self, width, height):
        """
        Konstruktor planszy do gry. Przygotowuje okienko gry.

        :param width: szerokość w pikselach
        :param height: wysokość w pikselach
        """
        self.surface = pygame.display.set_mode((width, height), 0, 32)
        pygame.display.set_caption('Game of life')

    def draw(self, *args):
        """
        Rysuje okno gry

        :param args: lista obiektów do narysowania
        """
        background = (0, 0, 0)
        self.surface.fill(background)
        for drawable in args:
            drawable.draw_on(self.surface)

        # dopiero w tym miejscu następuje fatyczne rysowanie
        # w oknie gry, wcześniej tylko ustalaliśmy co i jak ma zostać narysowane
        pygame.display.update()


class GameOfLife(object):
    """
    Łączy wszystkie elementy gry w całość.
    """

    def __init__(self, width, height, cell_size=10):
        """
        Przygotowanie ustawień gry
        :param width: szerokość planszy mierzona liczbą komórek
        :param height: wysokość planszy mierzona liczbą komórek
        :param cell_size: bok komórki w pikselach
        """
        pygame.init()
        self.board = Board(width * cell_size, height * cell_size)
        # zegar którego użyjemy do kontrolowania szybkości rysowania
        # kolejnych klatek gry
        self.fps_clock = pygame.time.Clock()

    def run(self):
        """
        Główna pętla gry
        """
        while not self.handle_events():
            # działaj w pętli do momentu otrzymania sygnału do wyjścia
            self.board.draw()
            self.fps_clock.tick(15)

    def handle_events(self):
        """
        Obsługa zdarzeń systemowych, tutaj zinterpretujemy np. ruchy myszką

        :return True jeżeli pygame przekazał zdarzenie wyjścia z gry
        """
        for event in pygame.event.get():
            if event.type == pygame.locals.QUIT:
                pygame.quit()
                return True


# Ta część powinna być zawsze na końcu modułu (ten plik jest modułem)
# chcemy uruchomić naszą grę dopiero po tym jak wszystkie klasy zostaną zadeklarowane
if __name__ == "__main__":
    game = GameOfLife(80, 40)
    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
78
79
# magiczne liczby używane do określenia czy komórka jest żywa
DEAD = 0
ALIVE = 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
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
class Population(object):
    """
    Populacja komórek
    """

    def __init__(self, width, height, cell_size=10):
        """
        Przygotowuje ustawienia populacji

        :param width: szerokość planszy mierzona liczbą komórek
        :param height: wysokość planszy mierzona liczbą komórek
        :param cell_size: bok komórki w pikselach
        """
        self.box_size = cell_size
        self.height = height
        self.width = width
        self.generation = self.reset_generation()

    def reset_generation(self):
        """
        Tworzy i zwraca macierz pustej populacji
        """
        # w pętli wypełnij listę kolumnami
        # które także w pętli zostają wypełnione wartością 0 (DEAD)
        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
1
2
3
4
5
generation = [
    [DEAD, DEAD, DEAD, DEAD],
    [DEAD, DEAD, DEAD, DEAD],
    [DEAD, DEAD, DEAD, DEAD],
]

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
1
2
3
4
5
6
7
8
def reset_generation(self)
    generation = []
    for x in range(self.width):
        column = []
        for y in range(self.height)
            column.append(DEAD)
        generation.append(column)
    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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def handle_mouse(self):
    # pobierz stan guzików myszki z wykorzystaniem funcji pygame
    buttons = pygame.mouse.get_pressed()
    if not any(buttons):
        # ignoruj zdarzenie jeśli żaden z guzików nie jest wciśnięty
        return

    # dodaj żywą komórką jeśli wciśnięty jest pierwszy guzik myszki
    # będziemy mogli nie tylko dodawać żywe komórki ale także je usuwać
    alive = True if buttons[0] else False

    # pobierz pozycję kursora na planszy mierzoną w pikselach
    x, y = pygame.mouse.get_pos()

    # przeliczamy współrzędne komórki z pikseli na współrzędne komórki w macierz
    # gracz może kliknąć w kwadracie o szerokości box_size by wybrać komórkę
    x /= self.box_size
    y /= self.box_size

    # ustaw stan komórki na macierzy
    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
130
131
132
133
134
135
136
137
138
139
def draw_on(self, surface):
    """
    Rysuje komórki na planszy
    """
    for x, y in self.alive_cells():
        size = (self.box_size, self.box_size)
        position = (x * self.box_size, y * self.box_size)
        color = (255, 255, 255)
        thickness = 1
        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
141
142
143
144
145
146
147
148
149
150
def alive_cells(self):
    """
    Generator zwracający współrzędne żywych komórek.
    """
    for x in range(len(self.generation)):
        column = self.generation[x]
        for y in range(len(column)):
            if column[y] == ALIVE:
                # jeśli komórka jest żywa zwrócimy jej współrzędne
                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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
class GameOfLife(object):
    """
    Łączy wszystkie elementy gry w całość.
    """

    def __init__(self, width, height, cell_size=10):
        """
        Przygotowanie ustawień gry
        :param width: szerokość planszy mierzona liczbą komórek
        :param height: wysokość planszy mierzona liczbą komórek
        :param cell_size: bok komórki w pikselach
        """
        pygame.init()
        self.board = Board(width * cell_size, height * cell_size)
        # zegar którego użyjemy do kontrolowania szybkości rysowania
        # kolejnych klatek gry
        self.fps_clock = pygame.time.Clock()
        self.population = Population(width, height, cell_size)

    def run(self):
        """
        Główna pętla gry
        """
        while not self.handle_events():
            # działaj w pętli do momentu otrzymania sygnału do wyjścia
            self.board.draw(
                self.population,
            )
            self.fps_clock.tick(15)

    def handle_events(self):
        """
        Obsługa zdarzeń systemowych, tutaj zinterpretujemy np. ruchy myszką

        :return True jeżeli pygame przekazał zdarzenie wyjścia z gry
        """
        for event in pygame.event.get():
            if event.type == pygame.locals.QUIT:
                pygame.quit()
                return True

            from pygame.locals import MOUSEMOTION, MOUSEBUTTONDOWN
            if event.type == MOUSEMOTION or event.type == MOUSEBUTTONDOWN:
                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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def neighbours(self, x, y):
    """
    Generator zwracający wszystkich okolicznych sąsiadów
    """
    for nx in range(x-1, x+2):
        for ny in range(y-1, y+2):
            if nx == x and ny == y:
                # pomiń współrzędne centrum
                continue
            if nx >= self.width:
                # sąsiad poza końcem planszy, bierzemy pierwszego w danym rzędzie
                nx = 0
            elif nx < 0:
                # sąsiad przed początkiem planszy, bierzemy ostatniego w danym rzędzie
                nx = self.width - 1
            if ny >= self.height:
                # sąsiad poza końcem planszy, bierzemy pierwszego w danej kolumnie
                ny = 0
            elif ny < 0:
                # sąsiad przed początkiem planszy, bierzemy ostatniego w danej kolumnie
                ny = self.height - 1

            # dla każdego nie pominiętego powyżej
            # przejścia pętli zwróć komórkę w tych współrzędnych
            yield self.generation[nx][ny]

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

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

    # nowa generacja staje się aktualną generacją
    self.generation = next_gen

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

Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class GameOfLife(object):
    """
    Łączy wszystkie elementy gry w całość.
    """

    def __init__(self, width, height, cell_size=10):
        """
        Przygotowanie ustawień gry
        :param width: szerokość planszy mierzona liczbą komórek
        :param height: wysokość planszy mierzona liczbą komórek
        :param cell_size: bok komórki w pikselach
        """
        pygame.init()
        self.board = Board(width * cell_size, height * cell_size)
        # zegar którego użyjemy do kontrolowania szybkości rysowania
        # kolejnych klatek gry
        self.fps_clock = pygame.time.Clock()
        self.population = Population(width, height, cell_size)

    def run(self):
        """
        Główna pętla gry
        """
        while not self.handle_events():
            # działaj w pętli do momentu otrzymania sygnału do wyjścia
            self.board.draw(
                self.population,
            )
            if getattr(self, "started", None):
                self.population.cycle_generation()
            self.fps_clock.tick(15)

    def handle_events(self):
        """
        Obsługa zdarzeń systemowych, tutaj zinterpretujemy np. ruchy myszką

        :return True jeżeli pygame przekazał zdarzenie wyjścia z gry
        """
        for event in pygame.event.get():
            if event.type == pygame.locals.QUIT:
                pygame.quit()
                return True

            from pygame.locals import MOUSEMOTION, MOUSEBUTTONDOWN
            if event.type == MOUSEMOTION or event.type == MOUSEBUTTONDOWN:
                self.population.handle_mouse()

            from pygame.locals import KEYDOWN, K_RETURN
            if event.type == KEYDOWN and event.key == K_RETURN:
                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:2022-05-22 o 19:52 w Sphinx 1.5.3
Autorzy:Patrz plik “Autorzy”