4.6. Życie Conwaya (obj)
Gra w życie zrealizowana z użyciem biblioteki PyGame.

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.
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.
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.
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:
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.
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:
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
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ą:
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.
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
.
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.
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
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ę:
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ę
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.
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
TODO
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: